Sign web release manifests (#1128)

* feat(release): sign web release manifests

* ci(doctor): skip non-ui pull requests
This commit is contained in:
Tommaso Casaburi
2026-05-10 14:59:28 +07:00
committed by GitHub
parent cbc50888d5
commit e7a6c377c9
6 changed files with 166 additions and 0 deletions
+17
View File
@@ -76,9 +76,26 @@ jobs:
- name: Verify forge config
run: node -e "import('./forge.config.js').then(c => console.log('Config loaded, makers:', c.default.makers?.length || 0)).catch(e => { console.error(e); process.exit(1); })"
- name: Detect React UI changes
id: react-ui-changes
if: github.event_name == 'pull_request'
run: |
git diff --name-only "${{ github.event.pull_request.base.sha }}" HEAD > changed-files.txt
cat changed-files.txt
if grep -Eq '^(src/(components|views|hooks|stores)/|src/app\.tsx|src/main\.tsx)' changed-files.txt; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Run React Doctor
if: github.event_name != 'pull_request' || steps.react-ui-changes.outputs.changed == 'true'
run: yarn doctor
- name: Skip React Doctor
if: github.event_name == 'pull_request' && steps.react-ui-changes.outputs.changed != 'true'
run: echo "Skipping React Doctor because this pull request did not change React UI source."
- name: Install Chromium for smoke tests
run: npx playwright install --with-deps chromium
+6
View File
@@ -95,11 +95,17 @@ jobs:
cp "$APPIMAGE_PATH" "release-assets/5chan-${VERSION}-${{ matrix.arch }}.AppImage"
- name: Create static HTML release archive
if: matrix.arch == 'x64'
env:
RELEASE_MANIFEST_PRIVATE_KEY_PEM: ${{ secrets.RELEASE_MANIFEST_PRIVATE_KEY_PEM }}
RELEASE_MANIFEST_KEY_ID: 5chan-release-p256-2026-05
run: |
VERSION=$(node -e "console.log(require('./package.json').version)")
HTML_ARCHIVE_DIR="5chan-$VERSION-html"
rm -rf "$HTML_ARCHIVE_DIR"
yarn release:manifest --build-dir build --out-dir release-assets
cp -R build "$HTML_ARCHIVE_DIR"
cp release-assets/5chan-release-manifest.json "$HTML_ARCHIVE_DIR/5chan-release-manifest.json"
cp release-assets/5chan-release-manifest.sig.json "$HTML_ARCHIVE_DIR/5chan-release-manifest.sig.json"
zip -r "release-assets/${HTML_ARCHIVE_DIR}.zip" "$HTML_ARCHIVE_DIR"
rm -rf "$HTML_ARCHIVE_DIR"
- name: List release assets
+2
View File
@@ -3,6 +3,7 @@
bin
.env
.deploy-env
.release-manifest-*.pem
# Logs
logs
@@ -154,6 +155,7 @@ dev-dist
# production
/build
/release-assets
# misc
.DS_Store
+2
View File
@@ -53,6 +53,8 @@
},
"scripts": {
"generate:assets": "node scripts/generate-asset-manifest.js",
"release:manifest": "node scripts/generate-release-manifest.mjs",
"release:manifest:keygen": "node scripts/generate-release-manifest-keypair.mjs",
"sync:directories": "node scripts/sync-directories.js",
"postinstall": "node scripts/patch-capacitor-cli-tar.cjs",
"prebuild": "corepack yarn sync:directories && corepack yarn generate:assets",
@@ -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
View 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, 'release-assets');
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 = '5chan-release-p256-2026-05';
const MANIFEST_FILE = '5chan-release-manifest.json';
const SIGNATURE_FILE = '5chan-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: '5chan',
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');
}