mirror of
https://github.com/plebbit/seedit.git
synced 2026-05-25 00:57:02 -04:00
feat(release): add signed release manifest
This commit is contained in:
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
@@ -42,6 +42,22 @@ jobs:
|
||||
- name: Build Electron app for Linux (x64)
|
||||
if: ${{ matrix.arch == 'x64' }}
|
||||
run: yarn electron:build:linux:x64
|
||||
- name: Create signed release manifest
|
||||
if: ${{ matrix.arch == 'x64' }}
|
||||
env:
|
||||
RELEASE_MANIFEST_PRIVATE_KEY_PEM: ${{ secrets.RELEASE_MANIFEST_PRIVATE_KEY_PEM }}
|
||||
RELEASE_MANIFEST_KEY_ID: seedit-release-p256-2026-05
|
||||
run: |
|
||||
VERSION=$(node -e "console.log(require('./package.json').version)")
|
||||
HTML_ARCHIVE_DIR="seedit-html-$VERSION"
|
||||
rm -rf "$HTML_ARCHIVE_DIR"
|
||||
yarn release:manifest --build-dir build --out-dir dist
|
||||
cp -R build "$HTML_ARCHIVE_DIR"
|
||||
cp dist/seedit-release-manifest.json "$HTML_ARCHIVE_DIR/seedit-release-manifest.json"
|
||||
cp dist/seedit-release-manifest.sig.json "$HTML_ARCHIVE_DIR/seedit-release-manifest.sig.json"
|
||||
rm -f "dist/${HTML_ARCHIVE_DIR}.zip"
|
||||
zip -r "dist/${HTML_ARCHIVE_DIR}.zip" "$HTML_ARCHIVE_DIR"
|
||||
rm -rf "$HTML_ARCHIVE_DIR"
|
||||
- name: List dist directory
|
||||
run: ls dist
|
||||
|
||||
@@ -49,8 +65,17 @@ jobs:
|
||||
- name: Generate release body
|
||||
run: node scripts/release-body > release-body.txt
|
||||
- uses: ncipollo/release-action@v1
|
||||
if: ${{ matrix.arch == 'x64' }}
|
||||
with:
|
||||
artifacts: 'dist/seedit*.AppImage,dist/seedit*-arm64.AppImage,dist/seedit-html*.zip'
|
||||
artifacts: 'dist/seedit*.AppImage,dist/seedit-html*.zip,dist/seedit-release-manifest.json,dist/seedit-release-manifest.sig.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
replacesArtifacts: true
|
||||
omitBody: true
|
||||
allowUpdates: true
|
||||
- uses: ncipollo/release-action@v1
|
||||
if: ${{ matrix.arch == 'arm64' }}
|
||||
with:
|
||||
artifacts: 'dist/seedit*-arm64.AppImage'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
replacesArtifacts: true
|
||||
omitBody: true
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
"electron:build:mac:arm64": "yarn build && yarn build:preload && electron-forge make --platform=darwin --arch=arm64",
|
||||
"electron:before": "yarn electron:before:delete-data",
|
||||
"electron:before:delete-data": "rimraf .plebbit",
|
||||
"release:manifest": "node scripts/generate-release-manifest.mjs",
|
||||
"release:manifest:keygen": "node scripts/generate-release-manifest-keypair.mjs",
|
||||
"android:build:icons": "cordova-res android --skip-config --copy --resources /tmp/plebbit-react-android-icons --icon-source ./android/icons/icon.png --splash-source ./android/icons/splash.png --icon-foreground-source ./android/icons/icon-foreground.png --icon-background-source '#ffffee'",
|
||||
"prettier": "oxfmt src/**/*.{js,ts,tsx} electron/**/*.{js,mjs}",
|
||||
"lint": "oxlint src electron",
|
||||
|
||||
10
scripts/generate-release-manifest-keypair.mjs
Normal file
10
scripts/generate-release-manifest-keypair.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
});
|
||||
|
||||
console.log('# Store this PEM as the GitHub secret RELEASE_MANIFEST_PRIVATE_KEY_PEM.');
|
||||
console.log(privateKey.export({ format: 'pem', type: 'sec1' }).toString().trim());
|
||||
console.log('\n# Commit this public JWK in the verifier that pins the release signing key.');
|
||||
console.log(JSON.stringify(publicKey.export({ format: 'jwk' }), null, 2));
|
||||
129
scripts/generate-release-manifest.mjs
Normal file
129
scripts/generate-release-manifest.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash, createSign } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
||||
const DEFAULT_BUILD_DIR = path.join(REPO_ROOT, 'build');
|
||||
const DEFAULT_OUT_DIR = path.join(REPO_ROOT, 'dist');
|
||||
const MANIFEST_SCHEMA = 'bitsocial.release-manifest.v1';
|
||||
const SIGNATURE_SCHEMA = 'bitsocial.release-manifest-signature.v1';
|
||||
const SIGNATURE_ALGORITHM = 'ECDSA-P256-SHA256';
|
||||
const DEFAULT_KEY_ID = 'seedit-release-p256-2026-05';
|
||||
const MANIFEST_FILE = 'seedit-release-manifest.json';
|
||||
const SIGNATURE_FILE = 'seedit-release-manifest.sig.json';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
const key = process.argv[index];
|
||||
const value = process.argv[index + 1];
|
||||
if (!key?.startsWith('--') || !value) {
|
||||
throw new Error(`Invalid argument near ${key ?? '<empty>'}`);
|
||||
}
|
||||
args.set(key, value);
|
||||
}
|
||||
|
||||
const buildDir = path.resolve(args.get('--build-dir') ?? DEFAULT_BUILD_DIR);
|
||||
const outDir = path.resolve(args.get('--out-dir') ?? DEFAULT_OUT_DIR);
|
||||
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
||||
const version = packageJson.version;
|
||||
const releaseTag = process.env.GITHUB_REF_NAME?.startsWith('v') ? process.env.GITHUB_REF_NAME : `v${version}`;
|
||||
const privateKeyPem = process.env.RELEASE_MANIFEST_PRIVATE_KEY_PEM;
|
||||
const keyId = process.env.RELEASE_MANIFEST_KEY_ID || DEFAULT_KEY_ID;
|
||||
|
||||
if (!privateKeyPem) {
|
||||
throw new Error('RELEASE_MANIFEST_PRIVATE_KEY_PEM is required to sign the release manifest');
|
||||
}
|
||||
|
||||
const files = await listFiles(buildDir);
|
||||
const manifest = {
|
||||
schema: MANIFEST_SCHEMA,
|
||||
appName: 'Seedit',
|
||||
version,
|
||||
releaseTag,
|
||||
verificationScope: 'web-release-all-files',
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceCommit: process.env.GITHUB_SHA || readGitCommit(),
|
||||
files,
|
||||
};
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const manifestSha256 = sha256Hex(manifestBytes);
|
||||
const signer = createSign('sha256');
|
||||
signer.update(manifestBytes);
|
||||
signer.end();
|
||||
const signature = signer.sign({ key: privateKeyPem, dsaEncoding: 'ieee-p1363' });
|
||||
const signaturePayload = {
|
||||
schema: SIGNATURE_SCHEMA,
|
||||
algorithm: SIGNATURE_ALGORITHM,
|
||||
keyId,
|
||||
manifestSha256,
|
||||
signature: base64Url(signature),
|
||||
};
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(path.join(outDir, MANIFEST_FILE), manifestBytes);
|
||||
await writeFile(path.join(outDir, SIGNATURE_FILE), `${JSON.stringify(signaturePayload, null, 2)}\n`);
|
||||
|
||||
console.log(`Wrote ${path.relative(REPO_ROOT, path.join(outDir, MANIFEST_FILE))}`);
|
||||
console.log(`Wrote ${path.relative(REPO_ROOT, path.join(outDir, SIGNATURE_FILE))}`);
|
||||
|
||||
async function listFiles(rootDir) {
|
||||
const entries = [];
|
||||
|
||||
async function walk(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolutePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = normalizePath(path.relative(rootDir, absolutePath));
|
||||
if (relativePath === MANIFEST_FILE || relativePath === SIGNATURE_FILE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = await readFile(absolutePath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
bytes: fileStat.size,
|
||||
sha256: sha256Hex(bytes),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDir);
|
||||
return entries.sort((first, second) => first.path.localeCompare(second.path));
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function readGitCommit() {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function base64Url(bytes) {
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
Reference in New Issue
Block a user