From 91235120c99c7b15e91faad1867c9b7de49b23b8 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Thu, 9 Jul 2026 19:55:01 -0700 Subject: [PATCH] Close #43 --- .github/workflows/release.yml | 19 +++++++++++++++++++ docs/release-playbook.md | 23 +++++++++++++++++++++++ scripts/e2e-release-packaging.test.mjs | 25 +++++++++++++++++++++++++ scripts/firefox-webext.js | 15 +++++++++++++++ scripts/sign-firefox-listed.mjs | 12 ++++++------ scripts/sign-firefox-unlisted.mjs | 7 ++----- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0c1916..83e0f78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,25 @@ jobs: node-version-file: .nvmrc cache: npm + - name: Validate Firefox signing credentials + env: + AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} + AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} + run: | + missing=0 + case "$AMO_JWT_ISSUER" in + (*[![:space:]]*) ;; + (*) echo "::error title=Missing Firefox signing secret::AMO_JWT_ISSUER is not configured for the protected release workflow."; missing=1 ;; + esac + case "$AMO_JWT_SECRET" in + (*[![:space:]]*) ;; + (*) echo "::error title=Missing Firefox signing secret::AMO_JWT_SECRET is not configured for the protected release workflow."; missing=1 ;; + esac + if [ "$missing" -ne 0 ]; then + echo "Configure both protected AMO Actions secrets before rerunning the release." + exit 1 + fi + - name: Install dependencies run: npm ci diff --git a/docs/release-playbook.md b/docs/release-playbook.md index 73bcee1..3915296 100644 --- a/docs/release-playbook.md +++ b/docs/release-playbook.md @@ -27,6 +27,29 @@ - Publish browser store listings and verify installed updates as required by the target browser. - The protected release workflow signs the Firefox unlisted XPI with AMO credentials and uploads it with the other release assets. +### Firefox unlisted-signing credentials + +The protected release workflow requires two GitHub Actions secrets before it +starts the release build: + +- `AMO_JWT_ISSUER` +- `AMO_JWT_SECRET` + +Create the AMO API credential pair in Mozilla Add-ons, then store the values as +repository or protected release-environment secrets with these exact names. +Keep them unavailable to pull-request workflows, do not put them in source, +local release artifacts, or logs, and grant only the permissions required for +Firefox signing. The workflow checks only that each value is present and +non-blank; it never prints either value. + +Rotate both secrets through Mozilla and GitHub when the credential expires or +is suspected to be exposed. After updating them, use +`workflow_dispatch` with `force=true` to rerun the protected release. The +workflow must fail before dependency installation when either secret is absent; +do not bypass signing or publish an unsigned XPI. If the preflight passes but +signing fails, inspect the protected workflow's AMO/web-ext error, correct the +credential or AMO configuration, and rerun the forced release. + ## Firefox Verification Test Plan Use this plan before declaring Firefox support release-ready or bumping the diff --git a/scripts/e2e-release-packaging.test.mjs b/scripts/e2e-release-packaging.test.mjs index 5c8d03e..963440a 100644 --- a/scripts/e2e-release-packaging.test.mjs +++ b/scripts/e2e-release-packaging.test.mjs @@ -23,6 +23,7 @@ const { const { detectManifestVersionChange, } = require('./detect-manifest-version-change.js'); +const { validateAmoCredentials } = require('./firefox-webext.js'); function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -82,6 +83,30 @@ function initGitRepo(repoRoot) { execFileSync('git', ['config', 'user.name', 'Codex'], { cwd: repoRoot }); } +test('e2e: Firefox signing credentials reject missing values', () => { + assert.throws( + () => validateAmoCredentials({}), + /AMO_JWT_ISSUER is required/ + ); +}); + +test('e2e: Firefox signing credentials identify a missing secret', () => { + assert.throws( + () => validateAmoCredentials({ + AMO_JWT_ISSUER: 'issuer', + AMO_JWT_SECRET: ' ', + }), + /AMO_JWT_SECRET is required/ + ); +}); + +test('e2e: Firefox signing credentials accept a complete pair', () => { + assert.doesNotThrow(() => validateAmoCredentials({ + AMO_JWT_ISSUER: 'issuer', + AMO_JWT_SECRET: 'secret', + })); +}); + test('e2e: release version sync fails on source manifest mismatch', () => { const repoRoot = makeRepoFixture(); writeJson(path.join(repoRoot, 'manifest.json'), { diff --git a/scripts/firefox-webext.js b/scripts/firefox-webext.js index 6be18de..a5f93d6 100644 --- a/scripts/firefox-webext.js +++ b/scripts/firefox-webext.js @@ -19,6 +19,20 @@ function ensureFirefoxBuild(repoRoot) { return manifestPath; } +function validateAmoCredentials(env = process.env) { + const apiKey = env.AMO_JWT_ISSUER; + const apiSecret = env.AMO_JWT_SECRET; + + if (typeof apiKey !== 'string' || !/\S/.test(apiKey)) { + throw new Error('AMO_JWT_ISSUER is required to sign a Firefox package.'); + } + if (typeof apiSecret !== 'string' || !/\S/.test(apiSecret)) { + throw new Error('AMO_JWT_SECRET is required to sign a Firefox package.'); + } + + return { apiKey, apiSecret }; +} + function runWebExt(args, options = {}) { const repoRoot = options.repoRoot || path.resolve(__dirname, '..'); const { command, args: prefixArgs } = resolveWebExtCommand(); @@ -49,6 +63,7 @@ function updateReleaseManifest(repoRoot, mutate) { module.exports = { ensureFirefoxBuild, + validateAmoCredentials, runWebExt, updateReleaseManifest, }; diff --git a/scripts/sign-firefox-listed.mjs b/scripts/sign-firefox-listed.mjs index 1715992..ed61282 100644 --- a/scripts/sign-firefox-listed.mjs +++ b/scripts/sign-firefox-listed.mjs @@ -4,18 +4,18 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); -const { ensureFirefoxBuild, runWebExt } = require('./firefox-webext.js'); +const { + ensureFirefoxBuild, + validateAmoCredentials, + runWebExt, +} = require('./firefox-webext.js'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); function main() { ensureFirefoxBuild(repoRoot); - const apiKey = process.env.AMO_JWT_ISSUER; - const apiSecret = process.env.AMO_JWT_SECRET; - if (!apiKey || !apiSecret) { - throw new Error('AMO_JWT_ISSUER and AMO_JWT_SECRET are required to sign the Firefox listed package.'); - } + const { apiKey, apiSecret } = validateAmoCredentials(); const metadataPath = path.join(repoRoot, 'amo', 'metadata', 'listed.json'); if (!fs.existsSync(metadataPath)) { diff --git a/scripts/sign-firefox-unlisted.mjs b/scripts/sign-firefox-unlisted.mjs index 9702fc3..b7407a2 100644 --- a/scripts/sign-firefox-unlisted.mjs +++ b/scripts/sign-firefox-unlisted.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const { ensureFirefoxBuild, + validateAmoCredentials, runWebExt, updateReleaseManifest, } = require('./firefox-webext.js'); @@ -21,11 +22,7 @@ function findSignedArtifact(dir) { function main() { ensureFirefoxBuild(repoRoot); - const apiKey = process.env.AMO_JWT_ISSUER; - const apiSecret = process.env.AMO_JWT_SECRET; - if (!apiKey || !apiSecret) { - throw new Error('AMO_JWT_ISSUER and AMO_JWT_SECRET are required to sign the Firefox unlisted XPI.'); - } + const { apiKey, apiSecret } = validateAmoCredentials(); const artifactsDir = path.join(repoRoot, 'release', 'firefox-unlisted'); fs.mkdirSync(artifactsDir, { recursive: true });