From b69bd764ec8dd6ea5f5f06459bbbbd2223d75de8 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 12 May 2026 16:58:33 +0700 Subject: [PATCH] feat(release): add signed release manifest --- .github/workflows/release.yml | 27 +++- package.json | 2 + scripts/generate-release-manifest-keypair.mjs | 10 ++ scripts/generate-release-manifest.mjs | 129 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-release-manifest-keypair.mjs create mode 100644 scripts/generate-release-manifest.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5251a9e4..5819eadc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index d1655435..371999d2 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-release-manifest-keypair.mjs b/scripts/generate-release-manifest-keypair.mjs new file mode 100644 index 00000000..4233be3d --- /dev/null +++ b/scripts/generate-release-manifest-keypair.mjs @@ -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)); diff --git a/scripts/generate-release-manifest.mjs b/scripts/generate-release-manifest.mjs new file mode 100644 index 00000000..2ec121fd --- /dev/null +++ b/scripts/generate-release-manifest.mjs @@ -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 ?? ''}`); + } + 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'); +}