From 68ce64adadd94832a34d13b19a4c31008704083f Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 28 Apr 2026 11:23:30 +0200 Subject: [PATCH 1/2] feat(sync-releases): show artifact details, verify GPG, allow custom rollout (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync-releases): show artifact details, verify GPG, allow custom rollout The previous production prompt was a single y/N with no artifact context, no way to override the hardcoded 10% rollout, and no signature verification. An operator confirming a release this way had to trust that the right files were in S3 and that the .sig was issued by the OTA root key — neither was visible. Changes: * Print full artifact summary before the prompt: URL, sha256, compatibleSkus, and signature status for each artifact. * Verify each .sig file with gpg --status-fd=1 and check the primary key fingerprint against OTA_ROOT_KEY_FPR (mirrored from rv1106-system's release_r2.sh). Reports valid / wrong-root / invalid / missing-pubkey / gpg-unavailable / absent, with a loud WARNING line for wrong-root and invalid signatures so the operator cannot miss them. * Print the latest already-synced release of the same type before the prompt so the operator can confirm this is the next expected version. * Add an interactive rollout-percentage prompt with 10% default, validated to 0-100, replacing the hardcoded 10. * Add an `a`/`abort` answer alongside y/N so operators can stop a multi- release sync mid-run when they spot something wrong, instead of having to N through every remaining version. * Print the DB target and bucket as the first line of main() so a wrong .env.production selection is obvious before any prompts fire. * Print a final run summary with counters: created / skipped-by-user / already-synced / no-artifacts, plus an "aborted at " line when the run was cut short. * Add `npm run sync-releases:production` script and ignore .env.production. Verification path runs only when NODE_ENV=production, so non-prod runs and the test suite never spawn gpg or download artifacts. All 52 existing tests still pass; tsc build is clean. * refactor: hoist objectKeyFromArtifactUrl into helpers Both src/releases.ts and scripts/sync-releases.ts had their own copy of the same URL-to-S3-key conversion. Moved into src/helpers.ts and imported from both call sites so a future change (e.g. CDN path prefix handling) only needs to land once. * fix(sync-releases): only treat ERRSIG rc=9 as missing pubkey GnuPG's ERRSIG line carries an `rc` reason code. rc=9 is the only one that means "we don't have the signer's key" — rc=4 (unsupported algorithm) and other codes are real verification failures. The previous implementation collapsed every ERRSIG into noPubkey, which would have falsely told the operator the OTA root key was missing when the actual problem was e.g. an unsupported pubkey algorithm. Now parses the rc field and surfaces non-9 ERRSIG codes as `invalid` with a human reason (rc=4 → "unsupported algorithm", others → "gpg error code N"). --- .gitignore | 1 + package.json | 1 + scripts/sync-releases.ts | 461 ++++++++++++++++++++++++++++++++++++++- src/helpers.ts | 8 + src/releases.ts | 6 +- 5 files changed, 461 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index f34b126..2b000c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist/ .env .env.development +.env.production diff --git a/package.json b/package.json index a8648af..4cdeb56 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prisma-migrate": "prisma migrate deploy", "seed": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/seed.ts", "sync-releases": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/sync-releases.ts", + "sync-releases:production": "NODE_ENV=production node -r ts-node/register --env-file=.env.production ./scripts/sync-releases.ts", "build": "tsc", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/sync-releases.ts b/scripts/sync-releases.ts index 8ae0624..30e965a 100644 --- a/scripts/sync-releases.ts +++ b/scripts/sync-releases.ts @@ -1,3 +1,13 @@ +import { spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { stdin, stdout } from "node:process"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createInterface } from "node:readline/promises"; + import { GetObjectCommand, HeadObjectCommand, @@ -7,7 +17,9 @@ import { import { PrismaClient } from "@prisma/client"; import semver from "semver"; -import { streamToString } from "../src/helpers"; +import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers"; + +const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95"; type ReleaseType = "app" | "system"; @@ -41,6 +53,372 @@ function legacyCompatibleSkus(): string[] { return [DEFAULT_SKU]; } +const DEFAULT_ROLLOUT_PERCENTAGE = 10; + +type ReleaseOutcome = + | "created" + | "already-synced" + | "no-artifacts" + | "skipped" + | "aborted"; + +type ReleaseDecision = + | { kind: "create"; rolloutPercentage: number } + | { kind: "skip" } + | { kind: "abort" }; + +interface LatestExistingRelease { + version: string; + rolloutPercentage: number; +} + +type SignatureStatus = + | { kind: "absent" } + | { kind: "valid"; signingFpr: string; rootFpr: string } + | { kind: "wrong-root"; signingFpr: string; rootFpr: string } + | { kind: "invalid"; reason: string } + | { kind: "missing-pubkey"; rootFpr?: string } + | { kind: "gpg-unavailable" }; + +interface ArtifactDisplayInfo { + artifact: ReleaseArtifactInput; + signature: SignatureStatus; +} + +function shortFpr(fpr: string): string { + // Keep the leading 16 hex chars (8 bytes) — enough to be unambiguous in a log + // line while staying readable. The full fingerprint is what we actually + // compare against; this is just for display. + return fpr.slice(0, 16); +} + +function describeSignature(status: SignatureStatus): string { + switch (status.kind) { + case "absent": + return "NO (no .sig file in S3)"; + case "valid": + return `yes (root ${shortFpr(status.rootFpr)})`; + case "wrong-root": + return `WRONG ROOT (got ${shortFpr(status.rootFpr)}, expected ${shortFpr(OTA_ROOT_KEY_FPR)})`; + case "invalid": + return `INVALID (${status.reason})`; + case "missing-pubkey": + return `cannot verify (OTA root key ${shortFpr(OTA_ROOT_KEY_FPR)} not in local GPG keyring)`; + case "gpg-unavailable": + return "cannot verify (gpg not installed)"; + } +} + +async function downloadObjectToFile( + s3Client: S3Client, + bucketName: string, + key: string, + destPath: string, +): Promise { + const response = await s3Client.send( + new GetObjectCommand({ Bucket: bucketName, Key: key }), + ); + if (!response.Body) { + throw new Error(`Empty body from S3 for key ${key}`); + } + await pipeline(response.Body as Readable, createWriteStream(destPath)); +} + +function runGpgVerify( + sigPath: string, + artifactPath: string, +): Promise<{ exitCode: number; statusOutput: string; stderrOutput: string }> { + return new Promise((resolve, reject) => { + const proc = spawn( + "gpg", + ["--batch", "--status-fd=1", "--verify", sigPath, artifactPath], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let statusOutput = ""; + let stderrOutput = ""; + proc.stdout.on("data", chunk => (statusOutput += chunk.toString())); + proc.stderr.on("data", chunk => (stderrOutput += chunk.toString())); + proc.on("error", reject); + proc.on("close", exitCode => { + resolve({ exitCode: exitCode ?? -1, statusOutput, stderrOutput }); + }); + }); +} + +interface GpgStatus { + validSig?: { signingFpr: string; rootFpr: string }; + noPubkey?: boolean; + // ERRSIG `rc` field. GnuPG documents rc=4 (unsupported algorithm), + // rc=9 (missing public key); other codes are possible and we leave + // them as raw strings for the caller to format. + errSigRc?: string; + badSig?: boolean; +} + +const ERRSIG_RC_REASONS: Record = { + "4": "unsupported algorithm", + "9": "missing public key", +}; + +function describeErrSigRc(rc: string): string { + return ERRSIG_RC_REASONS[rc] ?? `gpg error code ${rc}`; +} + +function parseGpgStatus(statusOutput: string): GpgStatus { + const result: GpgStatus = {}; + for (const rawLine of statusOutput.split("\n")) { + const line = rawLine.replace(/^\[GNUPG:\]\s+/, "").trim(); + + if (line.startsWith("VALIDSIG ")) { + // VALIDSIG + // + // Fields are space-separated; index 10 is the primary key fingerprint. + const parts = line.split(/\s+/); + if (parts.length >= 11) { + result.validSig = { signingFpr: parts[1], rootFpr: parts[10] }; + } + } else if (line.startsWith("NO_PUBKEY ")) { + result.noPubkey = true; + } else if (line.startsWith("ERRSIG ")) { + // ERRSIG