From 9670076e606c02a15db0c938f687eb230027093a Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:34:57 +0800 Subject: [PATCH 01/11] Harden GitHub Actions manual store submission with secret-free preflight Manual dispatches should validate release artifacts without invoking publish-browser-extension's authenticated dry-run path. Run workflow_dispatch as a read-only GitHub Actions preflight job with no persisted checkout credentials. Keep real store submission on tag pushes only. Cover artifact, manifest shape, process environment, and credential boundaries with targeted tests. --- .github/workflows/tagged-release.yml | 160 ++++---- package.json | 1 + scripts/submit-stores.mjs | 84 +++-- tests/unit/release/submit-stores.test.mjs | 434 +++++++++++++++++++++- 4 files changed, 576 insertions(+), 103 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index e36931d65..a547b6cc9 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -3,62 +3,64 @@ on: workflow_dispatch: inputs: submit_stores: - description: "Run Chrome, Firefox, and Edge store submission preflight" + description: 'Run store submission artifact preflight without store credentials' required: false - default: "false" + default: 'false' type: choice options: - - "false" - - "true" - dry_run: - description: "Validate store submission without uploading artifacts" - required: false - default: "true" - type: choice - options: - - "true" - - "false" + - 'false' + - 'true' push: tags: - - "v*" - -permissions: - id-token: "write" - contents: "write" -env: - GH_TOKEN: ${{ github.token }} + - 'v*' jobs: - build_and_release: + manual_preflight: + if: github.event_name == 'workflow_dispatch' runs-on: macos-14 + permissions: + contents: read steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'push' && 'master' || github.ref_name }} + ref: ${{ github.ref }} + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - - uses: actions/setup-python@v7 + - run: npm ci + + - run: npm run build + + - run: npm run release:firefox-sources + + - name: Submit stores preflight + if: inputs.submit_stores == 'true' + run: npm run release:submit:preflight + + release: + if: github.event_name == 'push' + runs-on: macos-14 + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 with: - python-version: '3.10' # for appdmg - - uses: maxim-lobanov/setup-xcode@v1 + ref: master + fetch-depth: 0 + persist-credentials: true + + - uses: actions/setup-node@v7 with: - xcode-version: 16.2 - - run: npm ci + node-version: 22 - name: Resolve release version - run: | - if [ "${{ github.event_name }}" = "push" ]; then - echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - else - version="$(node -p "require('./src/manifest.json').version")" - echo "VERSION=${version}" >> $GITHUB_ENV - fi + run: printf 'VERSION=%s\n' "${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - name: Update manifest.json version - if: github.event_name == 'push' uses: jossef/action-set-json-field@v2.2 with: file: src/manifest.json @@ -66,7 +68,6 @@ jobs: value: ${{ env.VERSION }} - name: Update manifest.v2.json version - if: github.event_name == 'push' uses: jossef/action-set-json-field@v2.2 with: file: src/manifest.v2.json @@ -74,46 +75,69 @@ jobs: value: ${{ env.VERSION }} - name: Push files - if: github.event_name == 'push' - continue-on-error: true run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" - git commit -am "release v${{ env.VERSION }}" - git push + git add src/manifest.json src/manifest.v2.json + if git diff --cached --quiet; then + echo "No release version changes to commit" + else + git commit -m "release v${VERSION}" + git fetch origin master + git rebase FETCH_HEAD + git push origin HEAD:master + fi - - if: github.event_name == 'push' - run: | - gh release create ${{github.ref_name}} -d -F CURRENT_CHANGE.md -t ${{github.ref_name}} + - name: Checkout release tag for artifacts + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + persist-credentials: false + + - uses: actions/setup-python@v7 + with: + python-version: '3.10' # for appdmg + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.2 + - run: npm ci + + - name: Update release artifact manifest.json version + uses: jossef/action-set-json-field@v2.2 + with: + file: src/manifest.json + field: version + value: ${{ env.VERSION }} + + - name: Update release artifact manifest.v2.json version + uses: jossef/action-set-json-field@v2.2 + with: + file: src/manifest.v2.json + field: version + value: ${{ env.VERSION }} - - if: github.event_name == 'push' - run: npm run build:safari + - run: | + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} - - if: github.event_name != 'push' - run: npm run build + - run: npm run build:safari - run: npm run release:firefox-sources - - if: github.event_name == 'push' - run: | - gh release upload ${{github.ref_name}} build/chromium.zip - gh release upload ${{github.ref_name}} build/firefox.zip - gh release upload ${{github.ref_name}} build/safari.dmg - gh release upload ${{github.ref_name}} build/chromium-without-katex-and-tiktoken.zip - gh release upload ${{github.ref_name}} build/firefox-without-katex-and-tiktoken.zip + - run: | + gh release upload "$RELEASE_TAG" build/chromium.zip + gh release upload "$RELEASE_TAG" build/firefox.zip + gh release upload "$RELEASE_TAG" build/safari.dmg + gh release upload "$RELEASE_TAG" build/chromium-without-katex-and-tiktoken.zip + gh release upload "$RELEASE_TAG" build/firefox-without-katex-and-tiktoken.zip + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} - name: Submit stores - if: github.event_name == 'push' || inputs.submit_stores == 'true' - run: | - args=() - if [ "${{ github.event_name }}" != "push" ]; then - if [ "${{ inputs.dry_run }}" != "true" ]; then - echo "::error::Manual store submission only supports dry_run=true. Push a v* tag for a real submission." - exit 1 - fi - args+=(--dry-run) - fi - npm run release:submit -- "${args[@]}" + run: npm run release:submit env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} @@ -131,6 +155,8 @@ jobs: EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }} EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }} - - if: github.event_name == 'push' - run: | - gh release edit ${{github.ref_name}} --draft=false + - run: | + gh release edit "$RELEASE_TAG" --draft=false + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} diff --git a/package.json b/package.json index a75f48586..5912cb391 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "release:firefox-sources": "node scripts/create-firefox-sources-zip.mjs", "release:submit": "node scripts/submit-stores.mjs", "release:submit:dry-run": "node scripts/submit-stores.mjs --dry-run", + "release:submit:preflight": "node scripts/submit-stores.mjs --preflight-only", "release:update-firefox-metadata": "node scripts/update-firefox-metadata.mjs", "release:check-edge-api-key": "node scripts/check-edge-api-key-expiry.mjs" }, diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index 63f71ff69..9d6beb658 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -2,13 +2,15 @@ import fs from 'fs-extra' import { spawn } from 'node:child_process' -import path from 'node:path' import { randomUUID } from 'node:crypto' +import { createRequire } from 'node:module' +import path from 'node:path' import { fileURLToPath } from 'node:url' import { signHs256Jwt } from '../src/utils/hs256-jwt.mjs' const REQUIRED_ARTIFACTS = ['build/chromium.zip', 'build/firefox.zip', 'build/firefox-sources.zip'] const AMO_BASE_URL = 'https://addons.mozilla.org' +const require = createRequire(import.meta.url) export const FIREFOX_COMPATIBILITY = { firefox: { min: '58.0', @@ -36,6 +38,7 @@ const REQUIRED_ENV = [ export function parseArgs(args) { return { dryRun: args.includes('--dry-run'), + preflightOnly: args.includes('--preflight-only'), } } @@ -158,18 +161,29 @@ export async function updateFirefoxVersionNotes({ } function resolvePublishExtensionBin() { - const command = process.platform === 'win32' ? 'publish-extension.cmd' : 'publish-extension' - return path.join(process.cwd(), 'node_modules', '.bin', command) + return require.resolve('publish-browser-extension/cli') +} + +function buildPublishExtensionEnv(env, baseEnv = process.env) { + const merged = { ...baseEnv, ...(env ?? {}) } + return Object.fromEntries( + Object.entries(merged) + .filter(([, value]) => value !== undefined && value !== null) + .map(([name, value]) => [name, String(value)]), + ) } -async function runPublishExtension(args) { - const command = resolvePublishExtensionBin() +export async function runPublishExtension( + args, + { env, baseEnv = process.env, spawnImpl = spawn } = {}, +) { + const childArgs = [resolvePublishExtensionBin(), ...args] await new Promise((resolve, reject) => { - const child = spawn(command, args, { + const child = spawnImpl(process.execPath, childArgs, { stdio: 'inherit', shell: false, - env: process.env, + env: buildPublishExtensionEnv(env, baseEnv), }) child.once('error', reject) @@ -183,34 +197,62 @@ async function runPublishExtension(args) { }) } -export async function submitStores({ argv = process.argv.slice(2), env = process.env } = {}) { - const { dryRun } = parseArgs(argv) - const missingArtifacts = await findMissingArtifacts() - const missingEnv = findMissingEnv(env) +export async function submitStores({ + argv = process.argv.slice(2), + env: envInput, + exists = fs.pathExists, + readJson = fs.readJson, + runPublishExtensionImpl = runPublishExtension, + updateFirefoxVersionNotesImpl = updateFirefoxVersionNotes, + logger = console.log, + errorLogger = console.error, +} = {}) { + const { dryRun, preflightOnly } = parseArgs(argv) + const env = envInput ?? process.env + const missingArtifacts = await findMissingArtifacts({ exists }) + const missingEnv = preflightOnly ? [] : findMissingEnv(env) if (missingArtifacts.length > 0 || missingEnv.length > 0) { if (missingArtifacts.length > 0) { - console.error(`Missing release artifacts: ${missingArtifacts.join(', ')}`) + errorLogger(`Missing release artifacts: ${missingArtifacts.join(', ')}`) } if (missingEnv.length > 0) { - console.error(`Missing store submission environment variables: ${missingEnv.join(', ')}`) + errorLogger(`Missing store submission environment variables: ${missingEnv.join(', ')}`) } throw new Error('Store submission preflight failed') } - const manifest = await fs.readJson('build/firefox/manifest.json') - const args = buildPublishExtensionArgs({ dryRun }) + let manifest + try { + manifest = await readJson('build/firefox/manifest.json') + } catch (error) { + errorLogger('Missing or invalid Firefox manifest: build/firefox/manifest.json') + throw new Error('Store submission preflight failed', { cause: error }) + } + + if (!manifest || typeof manifest.version !== 'string' || manifest.version.trim().length === 0) { + errorLogger('Missing Firefox manifest version: build/firefox/manifest.json') + throw new Error('Store submission preflight failed') + } + const firefoxReleaseNotes = buildFirefoxReleaseNotes(manifest.version) + const mode = preflightOnly ? 'preflight' : dryRun ? 'dry-run' : 'submit' + + logger(`${preflightOnly ? 'Checking' : 'Submitting'} ChatGPTBox ${manifest.version}`) + logger(`Mode: ${mode}`) + logger(`Artifacts: ${REQUIRED_ARTIFACTS.join(', ')}`) + logger(`Firefox version notes: ${firefoxReleaseNotes}`) - console.log(`Submitting ChatGPTBox ${manifest.version} to Chrome, Firefox, and Edge`) - console.log(`Mode: ${dryRun ? 'dry-run' : 'submit'}`) - console.log(`Artifacts: ${REQUIRED_ARTIFACTS.join(', ')}`) - console.log(`Firefox version notes: ${firefoxReleaseNotes}`) + if (preflightOnly) { + logger('Store authentication, upload, and submission are skipped in preflight mode') + return + } - await runPublishExtension(args) + const args = buildPublishExtensionArgs({ dryRun }) + await runPublishExtensionImpl(args, { env }) if (!dryRun) { - await updateFirefoxVersionNotes({ + await updateFirefoxVersionNotesImpl({ extensionId: env.FIREFOX_EXTENSION_ID, version: manifest.version, jwtIssuer: env.FIREFOX_JWT_ISSUER, diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index a6e580957..abe083aca 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -1,15 +1,20 @@ import assert from 'node:assert/strict' import { Buffer } from 'node:buffer' import { createHmac } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import process from 'node:process' import test from 'node:test' import { buildFirefoxReleaseNotes, buildPublishExtensionArgs, + runPublishExtension, FIREFOX_COMPATIBILITY, findMissingArtifacts, findMissingEnv, parseArgs, stripFirefoxExtensionId, + submitStores, updateFirefoxVersionNotes, } from '../../../scripts/submit-stores.mjs' @@ -30,11 +35,34 @@ function verifyHs256Token(token, secret) { } } +const require = createRequire(import.meta.url) +const publishExtensionCli = require.resolve('publish-browser-extension/cli') + test('parseArgs detects dry run', () => { - assert.deepEqual(parseArgs(['--dry-run']), { dryRun: true }) - assert.deepEqual(parseArgs([]), { dryRun: false }) + assert.deepEqual(parseArgs(['--dry-run']), { dryRun: true, preflightOnly: false }) + assert.deepEqual(parseArgs(['--preflight-only']), { dryRun: false, preflightOnly: true }) + assert.deepEqual(parseArgs(['--dry-run', '--preflight-only']), { + dryRun: true, + preflightOnly: true, + }) + assert.deepEqual(parseArgs([]), { dryRun: false, preflightOnly: false }) }) +function createStoreEnv() { + return { + CHROME_EXTENSION_ID: 'chrome-id', + CHROME_CLIENT_ID: 'chrome-client', + CHROME_CLIENT_SECRET: 'chrome-secret', + CHROME_REFRESH_TOKEN: 'chrome-refresh', + FIREFOX_EXTENSION_ID: 'chatgptbox', + FIREFOX_JWT_ISSUER: 'firefox-issuer', + FIREFOX_JWT_SECRET: 'firefox-secret', + EDGE_PRODUCT_ID: 'edge-product', + EDGE_CLIENT_ID: 'edge-client', + EDGE_API_KEY: 'edge-key', + } +} + test('findMissingEnv reports all required secrets', () => { const missing = findMissingEnv({}) assert.deepEqual(missing, [ @@ -52,20 +80,14 @@ test('findMissingEnv reports all required secrets', () => { }) test('findMissingEnv accepts required secrets', () => { - const env = { - CHROME_EXTENSION_ID: 'chrome-id', - CHROME_CLIENT_ID: 'chrome-client', - CHROME_CLIENT_SECRET: 'chrome-secret', - CHROME_REFRESH_TOKEN: 'chrome-refresh', - FIREFOX_EXTENSION_ID: 'chatgptbox', - FIREFOX_JWT_ISSUER: 'firefox-issuer', - FIREFOX_JWT_SECRET: 'firefox-secret', - EDGE_PRODUCT_ID: 'edge-product', - EDGE_CLIENT_ID: 'edge-client', - EDGE_API_KEY: 'edge-key', - } + assert.deepEqual(findMissingEnv(createStoreEnv()), []) +}) + +test('findMissingEnv rejects blank required secrets', () => { + const env = createStoreEnv() + env.FIREFOX_JWT_SECRET = ' ' - assert.deepEqual(findMissingEnv(env), []) + assert.deepEqual(findMissingEnv(env), ['FIREFOX_JWT_SECRET']) }) test('findMissingEnv treats whitespace-only secrets as missing', () => { @@ -108,6 +130,108 @@ test('buildPublishExtensionArgs includes all stores and dry run', () => { ]) }) +test('runPublishExtension merges env overrides before spawning publish-extension', async () => { + const child = new EventEmitter() + const spawnCalls = [] + + await runPublishExtension(['--dry-run'], { + baseEnv: { PATH: 'parent-path', CHROME_EXTENSION_ID: 'parent-chrome-id' }, + env: { CHROME_EXTENSION_ID: 'override-chrome-id' }, + spawnImpl: (command, args, options) => { + spawnCalls.push({ command, args, options }) + queueMicrotask(() => child.emit('exit', 0)) + return child + }, + }) + + assert.equal(spawnCalls.length, 1) + assert.equal(spawnCalls[0].command, process.execPath) + assert.equal(spawnCalls[0].args[0], publishExtensionCli) + assert.deepEqual(spawnCalls[0].args.slice(1), ['--dry-run']) + assert.equal(spawnCalls[0].options.shell, false) + assert.equal(spawnCalls[0].options.env.PATH, 'parent-path') + assert.equal(spawnCalls[0].options.env.CHROME_EXTENSION_ID, 'override-chrome-id') +}) + +test('runPublishExtension omits nullish env values before spawning publish-extension', async () => { + const child = new EventEmitter() + const spawnCalls = [] + + await runPublishExtension([], { + baseEnv: { + PATH: 'parent-path', + CHROME_EXTENSION_ID: 'parent-chrome-id', + EMPTY_VALUE: 'parent-empty', + }, + env: { + CHROME_EXTENSION_ID: undefined, + FIREFOX_JWT_SECRET: null, + EMPTY_VALUE: '', + NUMERIC_VALUE: 123, + BOOLEAN_VALUE: false, + }, + spawnImpl: (command, args, options) => { + spawnCalls.push({ command, args, options }) + queueMicrotask(() => child.emit('exit', 0)) + return child + }, + }) + + assert.equal(spawnCalls[0].options.env.PATH, 'parent-path') + assert.equal(spawnCalls[0].options.env.EMPTY_VALUE, '') + assert.equal(spawnCalls[0].options.env.NUMERIC_VALUE, '123') + assert.equal(spawnCalls[0].options.env.BOOLEAN_VALUE, 'false') + assert.equal('CHROME_EXTENSION_ID' in spawnCalls[0].options.env, false) + assert.equal('FIREFOX_JWT_SECRET' in spawnCalls[0].options.env, false) +}) + +test('runPublishExtension invokes publish-extension through node', async () => { + const child = new EventEmitter() + const spawnCalls = [] + + await runPublishExtension(['--dry-run'], { + spawnImpl: (command, args, options) => { + spawnCalls.push({ command, args, options }) + queueMicrotask(() => child.emit('exit', 0)) + return child + }, + }) + + assert.equal(spawnCalls[0].command, process.execPath) + assert.equal(spawnCalls[0].args[0], publishExtensionCli) + assert.deepEqual(spawnCalls[0].args.slice(1), ['--dry-run']) + assert.equal(spawnCalls[0].options.shell, false) +}) + +test('runPublishExtension rejects when publish-extension exits with non-zero code', async () => { + const child = new EventEmitter() + + await assert.rejects( + runPublishExtension(['--dry-run'], { + spawnImpl: () => { + queueMicrotask(() => child.emit('exit', 1)) + return child + }, + }), + /publish-extension exited with code 1/, + ) +}) + +test('runPublishExtension rejects when publish-extension cannot start', async () => { + const child = new EventEmitter() + const error = new Error('spawn failed') + + await assert.rejects( + runPublishExtension(['--dry-run'], { + spawnImpl: () => { + queueMicrotask(() => child.emit('error', error)) + return child + }, + }), + (actual) => actual === error, + ) +}) + test('buildFirefoxReleaseNotes returns the fixed GitHub release URL', () => { assert.equal( buildFirefoxReleaseNotes('2.6.1'), @@ -115,6 +239,286 @@ test('buildFirefoxReleaseNotes returns the fixed GitHub release URL', () => { ) }) +test('submitStores preflight skips store env and publish-extension', async () => { + const publishCalls = [] + + await submitStores({ + argv: ['--preflight-only'], + env: {}, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, []) +}) + +test('submitStores preflight takes precedence over dry run', async () => { + const publishCalls = [] + const metadataCalls = [] + + await submitStores({ + argv: ['--dry-run', '--preflight-only'], + env: {}, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args) => publishCalls.push(args), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, []) + assert.deepEqual(metadataCalls, []) +}) + +test('submitStores preflight fails on missing artifacts before publishing', async () => { + const publishCalls = [] + let manifestRead = false + + await assert.rejects( + submitStores({ + argv: ['--preflight-only'], + env: {}, + exists: async (file) => file !== 'build/firefox-sources.zip', + readJson: async () => { + manifestRead = true + return { version: '2.6.1' } + }, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.equal(manifestRead, false) + assert.deepEqual(publishCalls, []) +}) + +test('submitStores preflight fails when Firefox manifest cannot be read', async () => { + const publishCalls = [] + + await assert.rejects( + submitStores({ + argv: ['--preflight-only'], + env: {}, + exists: async () => true, + readJson: async () => { + throw new Error('ENOENT') + }, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.deepEqual(publishCalls, []) +}) + +test('submitStores preflight fails when Firefox manifest version is missing or invalid', async () => { + for (const manifest of [ + null, + {}, + { version: '' }, + { version: ' ' }, + { version: 123 }, + { version: null }, + ]) { + const publishCalls = [] + + await assert.rejects( + submitStores({ + argv: ['--preflight-only'], + env: {}, + exists: async () => true, + readJson: async () => manifest, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.deepEqual(publishCalls, []) + } +}) + +test('submitStores dry run and submit fail when Firefox manifest is invalid before publishing', async () => { + for (const argv of [['--dry-run'], []]) { + for (const readJson of [ + async () => { + throw new Error('ENOENT') + }, + async () => null, + async () => ({ version: ' ' }), + ]) { + const publishCalls = [] + const metadataCalls = [] + + await assert.rejects( + submitStores({ + argv, + env: createStoreEnv(), + exists: async () => true, + readJson, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.deepEqual(publishCalls, []) + assert.deepEqual(metadataCalls, []) + } + } +}) + +test('submitStores falls back to process env when env is null', async () => { + const storeEnv = createStoreEnv() + const previousEnv = Object.fromEntries( + Object.keys(storeEnv).map((name) => [name, process.env[name]]), + ) + + for (const [name, value] of Object.entries(storeEnv)) { + process.env[name] = value + } + + try { + const publishCalls = [] + const metadataCalls = [] + + await submitStores({ + argv: [], + env: null, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, options }), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls[0].args, buildPublishExtensionArgs({ dryRun: false })) + assert.equal(publishCalls[0].options.env, process.env) + assert.equal(metadataCalls[0].extensionId, storeEnv.FIREFOX_EXTENSION_ID) + assert.equal(metadataCalls[0].jwtIssuer, storeEnv.FIREFOX_JWT_ISSUER) + assert.equal(metadataCalls[0].jwtSecret, storeEnv.FIREFOX_JWT_SECRET) + } finally { + for (const [name, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } + } +}) + +test('submitStores dry run still invokes publish-extension with dry-run args', async () => { + const publishCalls = [] + const metadataCalls = [] + + await submitStores({ + argv: ['--dry-run'], + env: createStoreEnv(), + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args) => publishCalls.push(args), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, [buildPublishExtensionArgs({ dryRun: true })]) + assert.deepEqual(metadataCalls, []) +}) + +test('submitStores dry run fails without store env before publishing', async () => { + const publishCalls = [] + let manifestRead = false + + await assert.rejects( + submitStores({ + argv: ['--dry-run'], + env: {}, + exists: async () => true, + readJson: async () => { + manifestRead = true + return { version: '2.6.1' } + }, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.equal(manifestRead, false) + assert.deepEqual(publishCalls, []) +}) + +test('submitStores submit fails without store env before publishing', async () => { + const publishCalls = [] + let manifestRead = false + + await assert.rejects( + submitStores({ + argv: [], + env: {}, + exists: async () => true, + readJson: async () => { + manifestRead = true + return { version: '2.6.1' } + }, + runPublishExtensionImpl: async (args) => publishCalls.push(args), + logger: () => {}, + errorLogger: () => {}, + }), + /Store submission preflight failed/, + ) + + assert.equal(manifestRead, false) + assert.deepEqual(publishCalls, []) +}) + +test('submitStores submit invokes publish-extension and updates Firefox metadata', async () => { + const env = createStoreEnv() + const publishCalls = [] + const metadataCalls = [] + + await submitStores({ + argv: [], + env, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, env: options.env }), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, [ + { + args: buildPublishExtensionArgs({ dryRun: false }), + env, + }, + ]) + assert.deepEqual(metadataCalls, [ + { + extensionId: 'chatgptbox', + version: '2.6.1', + jwtIssuer: 'firefox-issuer', + jwtSecret: 'firefox-secret', + }, + ]) +}) + test('stripFirefoxExtensionId removes AMO GUID braces', () => { assert.equal(stripFirefoxExtensionId('{chatgptbox@example.com}'), 'chatgptbox@example.com') assert.equal(stripFirefoxExtensionId('chatgptbox'), 'chatgptbox') From 0465953332c89842b1f647f482327b63b9f23722 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Sun, 23 Aug 2026 05:43:27 +0800 Subject: [PATCH 02/11] Harden release version sync retries Retry transient fetch and push races, abort conflicted rebases, and surface failures in the step summary without blocking publication. --- .github/workflows/tagged-release.yml | 33 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index a547b6cc9..59653a5bb 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -79,13 +79,38 @@ jobs: git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" git add src/manifest.json src/manifest.v2.json + report_sync_warning() { + echo "::warning::$1" + echo "Release version sync warning: $1" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" || true + } if git diff --cached --quiet; then echo "No release version changes to commit" + elif ! git commit -m "release v${VERSION}"; then + report_sync_warning "Failed to commit the release version sync; continuing with the release" else - git commit -m "release v${VERSION}" - git fetch origin master - git rebase FETCH_HEAD - git push origin HEAD:master + max_attempts=3 + for attempt in $(seq 1 "${max_attempts}"); do + if ! git fetch origin master; then + if [ "${attempt}" -eq "${max_attempts}" ]; then + report_sync_warning "Failed to fetch origin/master; skipping the release version sync" + else + echo "Release version sync fetch failed; retrying (${attempt}/${max_attempts})" + sleep $((attempt * 2)) + fi + elif ! git rebase FETCH_HEAD; then + git rebase --abort || true + report_sync_warning "Release version sync conflicted with origin/master; skipping the master push" + break + elif git push origin HEAD:master; then + echo "Release version sync succeeded" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" || true + break + elif [ "${attempt}" -eq "${max_attempts}" ]; then + report_sync_warning "Failed to push the release version sync; continuing with the release" + else + echo "Release version sync push rejected; retrying (${attempt}/${max_attempts})" + sleep $((attempt * 2)) + fi + done fi - name: Checkout release tag for artifacts From 8f2a8c6119d235fe1cd7dde5531896110b2b5811 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Sun, 23 Aug 2026 16:10:33 +0800 Subject: [PATCH 03/11] Harden tagged release retries and version sync Reuse existing releases and clobber partial uploads so retries can finish. Skip master sync when it already contains a newer manifest version. Reject malformed store credentials before they reach publisher code. --- .github/workflows/tagged-release.yml | 32 ++++++++++++++++++----- scripts/submit-stores.mjs | 7 +++-- tests/unit/release/submit-stores.test.mjs | 10 ++++++- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 59653a5bb..bc76014fb 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -83,6 +83,19 @@ jobs: echo "::warning::$1" echo "Release version sync warning: $1" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" || true } + release_version_is_newer() { + node -e ' + const [remote, requested] = process.argv.slice(1) + const parse = (version) => version.split(".").map(Number) + const compare = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index] + } + return 0 + } + process.exit(compare(parse(remote), parse(requested)) > 0 ? 0 : 1) + ' "$1" "$2" + } if git diff --cached --quiet; then echo "No release version changes to commit" elif ! git commit -m "release v${VERSION}"; then @@ -97,6 +110,11 @@ jobs: echo "Release version sync fetch failed; retrying (${attempt}/${max_attempts})" sleep $((attempt * 2)) fi + elif release_version_is_newer \ + "$(git show FETCH_HEAD:src/manifest.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" \ + "$VERSION"; then + report_sync_warning "Skipping older release version sync; origin/master is already newer than v${VERSION}" + break elif ! git rebase FETCH_HEAD; then git rebase --abort || true report_sync_warning "Release version sync conflicted with origin/master; skipping the master push" @@ -142,7 +160,9 @@ jobs: value: ${{ env.VERSION }} - run: | - gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + fi env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} @@ -152,11 +172,11 @@ jobs: - run: npm run release:firefox-sources - run: | - gh release upload "$RELEASE_TAG" build/chromium.zip - gh release upload "$RELEASE_TAG" build/firefox.zip - gh release upload "$RELEASE_TAG" build/safari.dmg - gh release upload "$RELEASE_TAG" build/chromium-without-katex-and-tiktoken.zip - gh release upload "$RELEASE_TAG" build/firefox-without-katex-and-tiktoken.zip + gh release upload "$RELEASE_TAG" --clobber build/chromium.zip + gh release upload "$RELEASE_TAG" --clobber build/firefox.zip + gh release upload "$RELEASE_TAG" --clobber build/safari.dmg + gh release upload "$RELEASE_TAG" --clobber build/chromium-without-katex-and-tiktoken.zip + gh release upload "$RELEASE_TAG" --clobber build/firefox-without-katex-and-tiktoken.zip env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index 9d6beb658..304ecace6 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -43,10 +43,9 @@ export function parseArgs(args) { } export function findMissingEnv(env = process.env) { - return REQUIRED_ENV.filter((name) => { - const value = env[name] - return typeof value !== 'string' || value.trim().length === 0 - }) + return REQUIRED_ENV.filter( + (name) => typeof env[name] !== 'string' || env[name].trim().length === 0, + ) } export async function findMissingArtifacts({ exists = fs.pathExists } = {}) { diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index abe083aca..51d7fe1f9 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -107,6 +107,14 @@ test('findMissingEnv treats whitespace-only secrets as missing', () => { assert.deepEqual(findMissingEnv(env), ['CHROME_CLIENT_SECRET', 'FIREFOX_JWT_SECRET']) }) +test('findMissingEnv rejects non-string required secrets', () => { + const env = createStoreEnv() + env.FIREFOX_EXTENSION_ID = 123 + env.FIREFOX_JWT_SECRET = false + + assert.deepEqual(findMissingEnv(env), ['FIREFOX_EXTENSION_ID', 'FIREFOX_JWT_SECRET']) +}) + test('findMissingArtifacts reports missing artifacts', async () => { const exists = async (file) => file.endsWith('firefox.zip') const missing = await findMissingArtifacts({ exists }) @@ -347,7 +355,7 @@ test('submitStores preflight fails when Firefox manifest version is missing or i } }) -test('submitStores dry run and submit fail when Firefox manifest is invalid before publishing', async () => { +test('submitStores rejects invalid Firefox manifests before publishing', async () => { for (const argv of [['--dry-run'], []]) { for (const readJson of [ async () => { From 2b8beb55b5cfa3e257a4f33fafbf3a1970d666d5 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 24 Aug 2026 22:21:39 +0800 Subject: [PATCH 04/11] Harden release version comparisons and reruns Treat missing version components as zero to prevent older tags from bypassing the master sync guard. Leave completed releases untouched on workflow retries. --- .github/workflows/tagged-release.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index bc76014fb..1325536e0 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -88,8 +88,11 @@ jobs: const [remote, requested] = process.argv.slice(1) const parse = (version) => version.split(".").map(Number) const compare = (left, right) => { - for (let index = 0; index < 3; index += 1) { - if (left[index] !== right[index]) return left[index] - right[index] + const length = Math.max(left.length, right.length) + for (let index = 0; index < length; index += 1) { + const leftPart = left[index] ?? 0 + const rightPart = right[index] ?? 0 + if (leftPart !== rightPart) return leftPart - rightPart } return 0 } @@ -159,8 +162,14 @@ jobs: field: version value: ${{ env.VERSION }} - - run: | - if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + - name: Prepare GitHub release + run: | + if release_is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft 2>/dev/null)"; then + if [ "$release_is_draft" != "true" ]; then + echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" + echo "Release $RELEASE_TAG is already published; skipping rerun" + fi + else gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" fi env: @@ -168,8 +177,10 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} - run: npm run build:safari + if: env.SKIP_RELEASE != 'true' - run: npm run release:firefox-sources + if: env.SKIP_RELEASE != 'true' - run: | gh release upload "$RELEASE_TAG" --clobber build/chromium.zip @@ -180,9 +191,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} + if: env.SKIP_RELEASE != 'true' - name: Submit stores run: npm run release:submit + if: env.SKIP_RELEASE != 'true' env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} @@ -202,6 +215,7 @@ jobs: - run: | gh release edit "$RELEASE_TAG" --draft=false + if: env.SKIP_RELEASE != 'true' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} From 4b8d07e04e0fca41728e84822cf789552c42ef68 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 24 Aug 2026 23:00:19 +0800 Subject: [PATCH 05/11] Reject whitespace-padded Firefox versions Keep manifest version values consistent with the release metadata path instead of silently accepting surrounding whitespace. --- scripts/submit-stores.mjs | 7 ++++++- tests/unit/release/submit-stores.test.mjs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index 304ecace6..2a5f5d2de 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -229,7 +229,12 @@ export async function submitStores({ throw new Error('Store submission preflight failed', { cause: error }) } - if (!manifest || typeof manifest.version !== 'string' || manifest.version.trim().length === 0) { + if ( + !manifest || + typeof manifest.version !== 'string' || + manifest.version.trim().length === 0 || + manifest.version !== manifest.version.trim() + ) { errorLogger('Missing Firefox manifest version: build/firefox/manifest.json') throw new Error('Store submission preflight failed') } diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index 51d7fe1f9..cc459be9a 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -333,6 +333,7 @@ test('submitStores preflight fails when Firefox manifest version is missing or i {}, { version: '' }, { version: ' ' }, + { version: ' 2.6.1 ' }, { version: 123 }, { version: null }, ]) { From f2845e5ea35e6103c015691b78673973667b27e3 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 24 Aug 2026 23:09:38 +0800 Subject: [PATCH 06/11] Harden tagged release validation and lookup Reject prerelease tags before syncing manifest versions. Build all browser archives before upload and distinguish missing releases from GitHub API failures during reruns. --- .github/workflows/tagged-release.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 1325536e0..f03029d4c 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -57,6 +57,13 @@ jobs: with: node-version: 22 + - name: Validate release tag + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+(\.[0-9]+){2,3}$ ]]; then + echo "::error::Release tags must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH.BUILD" + exit 1 + fi + - name: Resolve release version run: printf 'VERSION=%s\n' "${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" @@ -164,18 +171,30 @@ jobs: - name: Prepare GitHub release run: | - if release_is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft 2>/dev/null)"; then + release_response="$(gh api --include --jq .draft \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" 2>&1 || true)" + release_status="$(printf '%s\n' "$release_response" \ + | sed -n 's/^HTTP\/[^ ]* \([0-9][0-9][0-9]\) .*/\1/p' \ + | tail -n 1)" + if [ "$release_status" = "200" ]; then + release_is_draft="$(printf '%s\n' "$release_response" | tail -n 1)" if [ "$release_is_draft" != "true" ]; then echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" echo "Release $RELEASE_TAG is already published; skipping rerun" fi - else + elif [ "$release_status" = "404" ]; then gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + else + printf '%s\n' "$release_response" >&2 + exit 1 fi env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} + - run: npm run build + if: env.SKIP_RELEASE != 'true' + - run: npm run build:safari if: env.SKIP_RELEASE != 'true' From 43812842140955c106c955912f543bf7e897c25b Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 24 Aug 2026 23:25:51 +0800 Subject: [PATCH 07/11] Harden release tag and rerun handling Validate canonical manifest versions before syncing release tags. Build artifacts before upload and record successful store submissions so draft reruns can retry publication without resubmitting stores. --- .github/workflows/tagged-release.yml | 43 ++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index f03029d4c..627edc07e 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -59,8 +59,19 @@ jobs: - name: Validate release tag run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+(\.[0-9]+){2,3}$ ]]; then - echo "::error::Release tags must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH.BUILD" + version="${GITHUB_REF_NAME#v}" + if ! node -e ' + const version = process.argv[1] + const parts = version.split(".") + const valid = + parts.length >= 3 && + parts.length <= 4 && + parts.every( + (part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535, + ) + if (!valid) process.exit(1) + ' "$version"; then + echo "::error::Release tags must use canonical 3-4 part versions with components from 0 to 65535" exit 1 fi @@ -171,16 +182,21 @@ jobs: - name: Prepare GitHub release run: | - release_response="$(gh api --include --jq .draft \ + release_response="$(gh api --include --jq '[.draft, [.assets[].name]]' \ "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" 2>&1 || true)" release_status="$(printf '%s\n' "$release_response" \ | sed -n 's/^HTTP\/[^ ]* \([0-9][0-9][0-9]\) .*/\1/p' \ | tail -n 1)" if [ "$release_status" = "200" ]; then - release_is_draft="$(printf '%s\n' "$release_response" | tail -n 1)" + release_metadata="$(printf '%s\n' "$release_response" | tail -n 1)" + release_is_draft="$(printf '%s\n' "$release_metadata" | jq -r '.[0]')" if [ "$release_is_draft" != "true" ]; then echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" echo "Release $RELEASE_TAG is already published; skipping rerun" + elif printf '%s\n' "$release_metadata" \ + | jq -e '.[1] | index("store-submission-complete.marker") != null' >/dev/null; then + echo "SKIP_STORE_SUBMISSION=true" >> "$GITHUB_ENV" + echo "Store submission is already complete; resuming release publication" fi elif [ "$release_status" = "404" ]; then gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" @@ -193,13 +209,13 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} - run: npm run build - if: env.SKIP_RELEASE != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - run: npm run build:safari - if: env.SKIP_RELEASE != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - run: npm run release:firefox-sources - if: env.SKIP_RELEASE != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - run: | gh release upload "$RELEASE_TAG" --clobber build/chromium.zip @@ -210,11 +226,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} - if: env.SKIP_RELEASE != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - name: Submit stores run: npm run release:submit - if: env.SKIP_RELEASE != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} @@ -232,6 +248,15 @@ jobs: EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }} EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }} + - name: Mark store submission complete + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + run: | + printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.marker + gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.marker + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + - run: | gh release edit "$RELEASE_TAG" --draft=false if: env.SKIP_RELEASE != 'true' From d65a6c040fc4e4e476b13f00ec398484d02d602e Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 24 Aug 2026 23:47:27 +0800 Subject: [PATCH 08/11] Track partial store submissions across reruns Run each store submission independently and persist its completion marker. Reruns skip stores that already succeeded, keep Firefox metadata retryable, and reject invalid master versions before release synchronization. --- .github/workflows/tagged-release.yml | 104 +++++++++++++++++--- scripts/submit-stores.mjs | 80 ++++++++++------ tests/unit/release/submit-stores.test.mjs | 110 +++++++++++++++++++++- 3 files changed, 248 insertions(+), 46 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 627edc07e..5e629dbcb 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -104,7 +104,17 @@ jobs: release_version_is_newer() { node -e ' const [remote, requested] = process.argv.slice(1) - const parse = (version) => version.split(".").map(Number) + const parse = (version) => { + const parts = version.split(".") + const valid = + parts.length >= 3 && + parts.length <= 4 && + parts.every( + (part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535, + ) + if (!valid) process.exit(2) + return parts.map(Number) + } const compare = (left, right) => { const length = Math.max(left.length, right.length) for (let index = 0; index < length; index += 1) { @@ -136,6 +146,9 @@ jobs: "$VERSION"; then report_sync_warning "Skipping older release version sync; origin/master is already newer than v${VERSION}" break + elif [ "$?" -eq 2 ]; then + report_sync_warning "Skipping release version sync; origin/master has an invalid manifest version" + break elif ! git rebase FETCH_HEAD; then git rebase --abort || true report_sync_warning "Release version sync conflicted with origin/master; skipping the master push" @@ -193,10 +206,41 @@ jobs: if [ "$release_is_draft" != "true" ]; then echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" echo "Release $RELEASE_TAG is already published; skipping rerun" - elif printf '%s\n' "$release_metadata" \ - | jq -e '.[1] | index("store-submission-complete.marker") != null' >/dev/null; then - echo "SKIP_STORE_SUBMISSION=true" >> "$GITHUB_ENV" - echo "Store submission is already complete; resuming release publication" + else + release_has_asset() { + printf '%s\n' "$release_metadata" \ + | jq -e --arg asset "$1" '.[1] | index($asset) != null' >/dev/null + } + skip_chrome_store=false + skip_firefox_store=false + skip_edge_store=false + if release_has_asset "store-submission-complete.marker"; then + skip_chrome_store=true + skip_firefox_store=true + skip_edge_store=true + else + release_has_asset "store-submission-complete.chrome.marker" \ + && skip_chrome_store=true + release_has_asset "store-submission-complete.firefox.marker" \ + && skip_firefox_store=true + release_has_asset "store-submission-complete.edge.marker" \ + && skip_edge_store=true + fi + if [ "$skip_chrome_store" = "true" ]; then + echo "SKIP_CHROME_STORE=true" >> "$GITHUB_ENV" + fi + if [ "$skip_firefox_store" = "true" ]; then + echo "SKIP_FIREFOX_STORE=true" >> "$GITHUB_ENV" + fi + if [ "$skip_edge_store" = "true" ]; then + echo "SKIP_EDGE_STORE=true" >> "$GITHUB_ENV" + fi + if [ "$skip_chrome_store" = "true" ] \ + && [ "$skip_firefox_store" = "true" ] \ + && [ "$skip_edge_store" = "true" ]; then + echo "SKIP_STORE_SUBMISSION=true" >> "$GITHUB_ENV" + echo "All store submissions are complete; resuming release publication" + fi fi elif [ "$release_status" = "404" ]; then gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" @@ -228,9 +272,9 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - - name: Submit stores - run: npm run release:submit - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + - name: Submit Chrome store + run: npm run release:submit -- --store chrome + if: env.SKIP_RELEASE != 'true' && env.SKIP_CHROME_STORE != 'true' env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} @@ -239,20 +283,56 @@ jobs: CHROME_PUBLISH_TARGET: ${{ secrets.CHROME_PUBLISH_TARGET }} CHROME_DEPLOY_PERCENTAGE: ${{ secrets.CHROME_DEPLOY_PERCENTAGE }} CHROME_REVIEW_EXEMPTION: ${{ secrets.CHROME_REVIEW_EXEMPTION }} + + - name: Mark Chrome store submission complete + if: env.SKIP_RELEASE != 'true' && env.SKIP_CHROME_STORE != 'true' + run: | + printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.chrome.marker + gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.chrome.marker + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + + - name: Submit Firefox store + run: npm run release:submit -- --store firefox --skip-firefox-metadata + if: env.SKIP_RELEASE != 'true' && env.SKIP_FIREFOX_STORE != 'true' + env: FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }} FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }} FIREFOX_CHANNEL: ${{ secrets.FIREFOX_CHANNEL }} FIREFOX_COMPATIBILITY: ${{ secrets.FIREFOX_COMPATIBILITY }} + + - name: Mark Firefox store submission complete + if: env.SKIP_RELEASE != 'true' && env.SKIP_FIREFOX_STORE != 'true' + run: | + printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.firefox.marker + gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.firefox.marker + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + + - name: Update Firefox metadata + run: npm run release:update-firefox-metadata -- --version "$VERSION" + if: env.SKIP_RELEASE != 'true' + env: + FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} + FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }} + FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }} + + - name: Submit Edge store + run: npm run release:submit -- --store edge + if: env.SKIP_RELEASE != 'true' && env.SKIP_EDGE_STORE != 'true' + env: EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }} EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }} EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }} - - name: Mark store submission complete - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + - name: Mark Edge store submission complete + if: env.SKIP_RELEASE != 'true' && env.SKIP_EDGE_STORE != 'true' run: | - printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.marker - gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.marker + printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.edge.marker + gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.edge.marker env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index 2a5f5d2de..4659882e9 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -22,29 +22,43 @@ export const FIREFOX_COMPATIBILITY = { }, } -const REQUIRED_ENV = [ - 'CHROME_EXTENSION_ID', - 'CHROME_CLIENT_ID', - 'CHROME_CLIENT_SECRET', - 'CHROME_REFRESH_TOKEN', - 'FIREFOX_EXTENSION_ID', - 'FIREFOX_JWT_ISSUER', - 'FIREFOX_JWT_SECRET', - 'EDGE_PRODUCT_ID', - 'EDGE_CLIENT_ID', - 'EDGE_API_KEY', -] +const STORE_ENV = { + chrome: [ + 'CHROME_EXTENSION_ID', + 'CHROME_CLIENT_ID', + 'CHROME_CLIENT_SECRET', + 'CHROME_REFRESH_TOKEN', + ], + firefox: ['FIREFOX_EXTENSION_ID', 'FIREFOX_JWT_ISSUER', 'FIREFOX_JWT_SECRET'], + edge: ['EDGE_PRODUCT_ID', 'EDGE_CLIENT_ID', 'EDGE_API_KEY'], +} +const STORE_IDS = Object.keys(STORE_ENV) export function parseArgs(args) { + const storeFlag = args.find((arg) => arg.startsWith('--store=')) + const storeIndex = args.indexOf('--store') + const selectedStore = storeFlag + ? storeFlag.slice('--store='.length) + : storeIndex >= 0 + ? args[storeIndex + 1] + : null + + if (selectedStore !== null && !STORE_IDS.includes(selectedStore)) { + throw new Error(`Unknown store: ${selectedStore || '(missing)'}`) + } + return { dryRun: args.includes('--dry-run'), preflightOnly: args.includes('--preflight-only'), + stores: selectedStore ? [selectedStore] : STORE_IDS, } } -export function findMissingEnv(env = process.env) { - return REQUIRED_ENV.filter( - (name) => typeof env[name] !== 'string' || env[name].trim().length === 0, +export function findMissingEnv(env = process.env, stores = STORE_IDS) { + return stores.flatMap((store) => + STORE_ENV[store].filter( + (name) => typeof env[name] !== 'string' || env[name].trim().length === 0, + ), ) } @@ -60,18 +74,21 @@ export async function findMissingArtifacts({ exists = fs.pathExists } = {}) { return missing } -export function buildPublishExtensionArgs({ dryRun }) { - return [ - ...(dryRun ? ['--dry-run'] : []), - '--chrome-zip', - 'build/chromium.zip', - '--firefox-zip', - 'build/firefox.zip', - '--firefox-sources-zip', - 'build/firefox-sources.zip', - '--edge-zip', - 'build/chromium.zip', - ] +export function buildPublishExtensionArgs({ dryRun, stores = STORE_IDS }) { + const args = dryRun ? ['--dry-run'] : [] + + if (stores.includes('chrome')) { + args.push('--chrome-zip', 'build/chromium.zip') + } + if (stores.includes('firefox')) { + args.push('--firefox-zip', 'build/firefox.zip') + args.push('--firefox-sources-zip', 'build/firefox-sources.zip') + } + if (stores.includes('edge')) { + args.push('--edge-zip', 'build/chromium.zip') + } + + return args } export function buildFirefoxReleaseNotes(version) { @@ -206,10 +223,11 @@ export async function submitStores({ logger = console.log, errorLogger = console.error, } = {}) { - const { dryRun, preflightOnly } = parseArgs(argv) + const { dryRun, preflightOnly, stores } = parseArgs(argv) + const skipFirefoxMetadata = argv.includes('--skip-firefox-metadata') const env = envInput ?? process.env const missingArtifacts = await findMissingArtifacts({ exists }) - const missingEnv = preflightOnly ? [] : findMissingEnv(env) + const missingEnv = preflightOnly ? [] : findMissingEnv(env, stores) if (missingArtifacts.length > 0 || missingEnv.length > 0) { if (missingArtifacts.length > 0) { @@ -252,10 +270,10 @@ export async function submitStores({ return } - const args = buildPublishExtensionArgs({ dryRun }) + const args = buildPublishExtensionArgs({ dryRun, stores }) await runPublishExtensionImpl(args, { env }) - if (!dryRun) { + if (!dryRun && stores.includes('firefox') && !skipFirefoxMetadata) { await updateFirefoxVersionNotesImpl({ extensionId: env.FIREFOX_EXTENSION_ID, version: manifest.version, diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index cc459be9a..67c3a2d8c 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -39,13 +39,37 @@ const require = createRequire(import.meta.url) const publishExtensionCli = require.resolve('publish-browser-extension/cli') test('parseArgs detects dry run', () => { - assert.deepEqual(parseArgs(['--dry-run']), { dryRun: true, preflightOnly: false }) - assert.deepEqual(parseArgs(['--preflight-only']), { dryRun: false, preflightOnly: true }) + assert.deepEqual(parseArgs(['--dry-run']), { + dryRun: true, + preflightOnly: false, + stores: ['chrome', 'firefox', 'edge'], + }) + assert.deepEqual(parseArgs(['--preflight-only']), { + dryRun: false, + preflightOnly: true, + stores: ['chrome', 'firefox', 'edge'], + }) assert.deepEqual(parseArgs(['--dry-run', '--preflight-only']), { dryRun: true, preflightOnly: true, + stores: ['chrome', 'firefox', 'edge'], + }) + assert.deepEqual(parseArgs(['--store=firefox']), { + dryRun: false, + preflightOnly: false, + stores: ['firefox'], }) - assert.deepEqual(parseArgs([]), { dryRun: false, preflightOnly: false }) + assert.deepEqual(parseArgs(['--store', 'edge']), { + dryRun: false, + preflightOnly: false, + stores: ['edge'], + }) + assert.deepEqual(parseArgs([]), { + dryRun: false, + preflightOnly: false, + stores: ['chrome', 'firefox', 'edge'], + }) + assert.throws(() => parseArgs(['--store', 'unknown']), /Unknown store: unknown/) }) function createStoreEnv() { @@ -83,6 +107,14 @@ test('findMissingEnv accepts required secrets', () => { assert.deepEqual(findMissingEnv(createStoreEnv()), []) }) +test('findMissingEnv checks only the selected store', () => { + assert.deepEqual(findMissingEnv({}, ['firefox']), [ + 'FIREFOX_EXTENSION_ID', + 'FIREFOX_JWT_ISSUER', + 'FIREFOX_JWT_SECRET', + ]) +}) + test('findMissingEnv rejects blank required secrets', () => { const env = createStoreEnv() env.FIREFOX_JWT_SECRET = ' ' @@ -138,6 +170,15 @@ test('buildPublishExtensionArgs includes all stores and dry run', () => { ]) }) +test('buildPublishExtensionArgs can target one store', () => { + assert.deepEqual(buildPublishExtensionArgs({ dryRun: false, stores: ['firefox'] }), [ + '--firefox-zip', + 'build/firefox.zip', + '--firefox-sources-zip', + 'build/firefox-sources.zip', + ]) +}) + test('runPublishExtension merges env overrides before spawning publish-extension', async () => { const child = new EventEmitter() const spawnCalls = [] @@ -472,6 +513,69 @@ test('submitStores dry run fails without store env before publishing', async () assert.deepEqual(publishCalls, []) }) +test('submitStores can submit one store without other store credentials', async () => { + const publishCalls = [] + const metadataCalls = [] + + await submitStores({ + argv: ['--store', 'chrome'], + env: { + CHROME_EXTENSION_ID: 'chrome-id', + CHROME_CLIENT_ID: 'chrome-client', + CHROME_CLIENT_SECRET: 'chrome-secret', + CHROME_REFRESH_TOKEN: 'chrome-refresh', + }, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, env: options.env }), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, [ + { + args: buildPublishExtensionArgs({ dryRun: false, stores: ['chrome'] }), + env: { + CHROME_EXTENSION_ID: 'chrome-id', + CHROME_CLIENT_ID: 'chrome-client', + CHROME_CLIENT_SECRET: 'chrome-secret', + CHROME_REFRESH_TOKEN: 'chrome-refresh', + }, + }, + ]) + assert.deepEqual(metadataCalls, []) +}) + +test('submitStores can defer Firefox metadata after store submission', async () => { + const publishCalls = [] + const metadataCalls = [] + const env = { + FIREFOX_EXTENSION_ID: 'chatgptbox', + FIREFOX_JWT_ISSUER: 'firefox-issuer', + FIREFOX_JWT_SECRET: 'firefox-secret', + } + + await submitStores({ + argv: ['--store', 'firefox', '--skip-firefox-metadata'], + env, + exists: async () => true, + readJson: async () => ({ version: '2.6.1' }), + runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, env: options.env }), + updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), + logger: () => {}, + errorLogger: () => {}, + }) + + assert.deepEqual(publishCalls, [ + { + args: buildPublishExtensionArgs({ dryRun: false, stores: ['firefox'] }), + env, + }, + ]) + assert.deepEqual(metadataCalls, []) +}) + test('submitStores submit fails without store env before publishing', async () => { const publishCalls = [] let manifestRead = false From f7fc8439f48f850cb7182e60504e515a2094ea38 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Tue, 25 Aug 2026 00:15:10 +0800 Subject: [PATCH 09/11] Scope store checks and skip superseded releases Limit artifact and manifest validation to the stores being submitted.\n\nPrevent new releases for superseded tags while allowing existing drafts to\nresume incomplete store submissions. --- .github/workflows/tagged-release.yml | 20 ++++-- scripts/submit-stores.mjs | 74 ++++++++++++++++------- tests/unit/release/submit-stores.test.mjs | 30 ++++++++- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 5e629dbcb..4d4ffb557 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -144,7 +144,8 @@ jobs: elif release_version_is_newer \ "$(git show FETCH_HEAD:src/manifest.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" \ "$VERSION"; then - report_sync_warning "Skipping older release version sync; origin/master is already newer than v${VERSION}" + report_sync_warning "Skipping release version sync; origin/master is already newer than v${VERSION}" + echo "SUPERSEDED_RELEASE=true" >> "$GITHUB_ENV" break elif [ "$?" -eq 2 ]; then report_sync_warning "Skipping release version sync; origin/master has an invalid manifest version" @@ -166,20 +167,25 @@ jobs: fi - name: Checkout release tag for artifacts + if: env.SKIP_RELEASE != 'true' uses: actions/checkout@v7 with: ref: ${{ github.ref }} persist-credentials: false - uses: actions/setup-python@v7 + if: env.SKIP_RELEASE != 'true' with: python-version: '3.10' # for appdmg - uses: maxim-lobanov/setup-xcode@v1 + if: env.SKIP_RELEASE != 'true' with: xcode-version: 16.2 - run: npm ci + if: env.SKIP_RELEASE != 'true' - name: Update release artifact manifest.json version + if: env.SKIP_RELEASE != 'true' uses: jossef/action-set-json-field@v2.2 with: file: src/manifest.json @@ -187,6 +193,7 @@ jobs: value: ${{ env.VERSION }} - name: Update release artifact manifest.v2.json version + if: env.SKIP_RELEASE != 'true' uses: jossef/action-set-json-field@v2.2 with: file: src/manifest.v2.json @@ -194,6 +201,7 @@ jobs: value: ${{ env.VERSION }} - name: Prepare GitHub release + if: env.SKIP_RELEASE != 'true' run: | release_response="$(gh api --include --jq '[.draft, [.assets[].name]]' \ "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" 2>&1 || true)" @@ -243,7 +251,12 @@ jobs: fi fi elif [ "$release_status" = "404" ]; then - gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + if [ "${SUPERSEDED_RELEASE:-}" = "true" ]; then + echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" + echo "Release $RELEASE_TAG is superseded; skipping release creation" + else + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + fi else printf '%s\n' "$release_response" >&2 exit 1 @@ -252,9 +265,6 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} - - run: npm run build - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' - - run: npm run build:safari if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index 4659882e9..c1e661641 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -8,7 +8,6 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { signHs256Jwt } from '../src/utils/hs256-jwt.mjs' -const REQUIRED_ARTIFACTS = ['build/chromium.zip', 'build/firefox.zip', 'build/firefox-sources.zip'] const AMO_BASE_URL = 'https://addons.mozilla.org' const require = createRequire(import.meta.url) export const FIREFOX_COMPATIBILITY = { @@ -32,8 +31,27 @@ const STORE_ENV = { firefox: ['FIREFOX_EXTENSION_ID', 'FIREFOX_JWT_ISSUER', 'FIREFOX_JWT_SECRET'], edge: ['EDGE_PRODUCT_ID', 'EDGE_CLIENT_ID', 'EDGE_API_KEY'], } +const STORE_ARTIFACTS = { + chrome: ['build/chromium.zip'], + firefox: ['build/firefox.zip', 'build/firefox-sources.zip'], + edge: ['build/chromium.zip'], +} +const STORE_MANIFESTS = { + chrome: 'build/chromium/manifest.json', + firefox: 'build/firefox/manifest.json', + edge: 'build/chromium/manifest.json', +} const STORE_IDS = Object.keys(STORE_ENV) +export function isValidManifestVersion(version) { + const parts = typeof version === 'string' ? version.split('.') : [] + return ( + parts.length >= 3 && + parts.length <= 4 && + parts.every((part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535) + ) +} + export function parseArgs(args) { const storeFlag = args.find((arg) => arg.startsWith('--store=')) const storeIndex = args.indexOf('--store') @@ -62,10 +80,11 @@ export function findMissingEnv(env = process.env, stores = STORE_IDS) { ) } -export async function findMissingArtifacts({ exists = fs.pathExists } = {}) { +export async function findMissingArtifacts({ exists = fs.pathExists, stores = STORE_IDS } = {}) { const missing = [] + const artifacts = [...new Set(stores.flatMap((store) => STORE_ARTIFACTS[store]))] - for (const artifact of REQUIRED_ARTIFACTS) { + for (const artifact of artifacts) { if (!(await exists(artifact))) { missing.push(artifact) } @@ -226,7 +245,8 @@ export async function submitStores({ const { dryRun, preflightOnly, stores } = parseArgs(argv) const skipFirefoxMetadata = argv.includes('--skip-firefox-metadata') const env = envInput ?? process.env - const missingArtifacts = await findMissingArtifacts({ exists }) + const requiredArtifacts = [...new Set(stores.flatMap((store) => STORE_ARTIFACTS[store]))] + const missingArtifacts = await findMissingArtifacts({ exists, stores }) const missingEnv = preflightOnly ? [] : findMissingEnv(env, stores) if (missingArtifacts.length > 0 || missingEnv.length > 0) { @@ -239,31 +259,41 @@ export async function submitStores({ throw new Error('Store submission preflight failed') } - let manifest - try { - manifest = await readJson('build/firefox/manifest.json') - } catch (error) { - errorLogger('Missing or invalid Firefox manifest: build/firefox/manifest.json') - throw new Error('Store submission preflight failed', { cause: error }) + const manifestPaths = [...new Set(stores.map((store) => STORE_MANIFESTS[store]))] + const manifests = [] + for (const manifestPath of manifestPaths) { + try { + const manifest = await readJson(manifestPath) + if (!isValidManifestVersion(manifest?.version)) { + errorLogger(`Invalid manifest version: ${manifestPath}`) + throw new Error('Store submission preflight failed') + } + manifests.push({ path: manifestPath, version: manifest.version }) + } catch (error) { + if (error?.message === 'Store submission preflight failed') throw error + errorLogger(`Missing or invalid manifest: ${manifestPath}`) + throw new Error('Store submission preflight failed', { cause: error }) + } } - if ( - !manifest || - typeof manifest.version !== 'string' || - manifest.version.trim().length === 0 || - manifest.version !== manifest.version.trim() - ) { - errorLogger('Missing Firefox manifest version: build/firefox/manifest.json') + const manifestVersion = manifests[0]?.version + if (new Set(manifests.map(({ version }) => version)).size > 1) { + errorLogger('Manifest versions do not match across selected stores') throw new Error('Store submission preflight failed') } - const firefoxReleaseNotes = buildFirefoxReleaseNotes(manifest.version) + const firefoxReleaseNotes = stores.includes('firefox') + ? buildFirefoxReleaseNotes(manifestVersion) + : null const mode = preflightOnly ? 'preflight' : dryRun ? 'dry-run' : 'submit' + const versionLabel = manifestVersion ? ` ${manifestVersion}` : '' - logger(`${preflightOnly ? 'Checking' : 'Submitting'} ChatGPTBox ${manifest.version}`) + logger(`${preflightOnly ? 'Checking' : 'Submitting'} ChatGPTBox${versionLabel}`) logger(`Mode: ${mode}`) - logger(`Artifacts: ${REQUIRED_ARTIFACTS.join(', ')}`) - logger(`Firefox version notes: ${firefoxReleaseNotes}`) + logger(`Artifacts: ${requiredArtifacts.join(', ')}`) + if (firefoxReleaseNotes) { + logger(`Firefox version notes: ${firefoxReleaseNotes}`) + } if (preflightOnly) { logger('Store authentication, upload, and submission are skipped in preflight mode') @@ -276,7 +306,7 @@ export async function submitStores({ if (!dryRun && stores.includes('firefox') && !skipFirefoxMetadata) { await updateFirefoxVersionNotesImpl({ extensionId: env.FIREFOX_EXTENSION_ID, - version: manifest.version, + version: manifestVersion, jwtIssuer: env.FIREFOX_JWT_ISSUER, jwtSecret: env.FIREFOX_JWT_SECRET, }) diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index 67c3a2d8c..9843a3448 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -12,6 +12,7 @@ import { FIREFOX_COMPATIBILITY, findMissingArtifacts, findMissingEnv, + isValidManifestVersion, parseArgs, stripFirefoxExtensionId, submitStores, @@ -154,6 +155,24 @@ test('findMissingArtifacts reports missing artifacts', async () => { assert.deepEqual(missing, ['build/chromium.zip', 'build/firefox-sources.zip']) }) +test('findMissingArtifacts checks only selected store artifacts', async () => { + const missing = await findMissingArtifacts({ + stores: ['chrome'], + exists: async (file) => file === 'build/chromium.zip', + }) + + assert.deepEqual(missing, []) +}) + +test('isValidManifestVersion accepts canonical Chromium versions only', () => { + for (const version of ['2.6.1', '2.6.1.1']) { + assert.equal(isValidManifestVersion(version), true) + } + for (const version of ['next', '2.6', '01.2.3', '70000.1.1', ' 2.6.1 ']) { + assert.equal(isValidManifestVersion(version), false) + } +}) + test('buildPublishExtensionArgs includes all stores and dry run', () => { const args = buildPublishExtensionArgs({ dryRun: true }) @@ -368,13 +387,17 @@ test('submitStores preflight fails when Firefox manifest cannot be read', async assert.deepEqual(publishCalls, []) }) -test('submitStores preflight fails when Firefox manifest version is missing or invalid', async () => { +test('submitStores preflight fails when a manifest version is missing or invalid', async () => { for (const manifest of [ null, {}, { version: '' }, { version: ' ' }, { version: ' 2.6.1 ' }, + { version: 'next' }, + { version: '2.6' }, + { version: '01.2.3' }, + { version: '70000.1.1' }, { version: 123 }, { version: null }, ]) { @@ -526,7 +549,10 @@ test('submitStores can submit one store without other store credentials', async CHROME_REFRESH_TOKEN: 'chrome-refresh', }, exists: async () => true, - readJson: async () => ({ version: '2.6.1' }), + readJson: async (path) => { + assert.equal(path, 'build/chromium/manifest.json') + return { version: '2.6.1' } + }, runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, env: options.env }), updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), logger: () => {}, From ecef3237741ca42e9fe3271371d127a3cc14469c Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Tue, 25 Aug 2026 00:37:23 +0800 Subject: [PATCH 10/11] Harden store selection and release reruns Reject all-zero Chromium versions and clear unselected ZIP variables.\n\nSerialize same-tag releases and skip superseded reruns before artifact\npublication. --- .github/workflows/tagged-release.yml | 31 +++++++++++++---------- scripts/submit-stores.mjs | 18 ++++++++++--- tests/unit/release/submit-stores.test.mjs | 30 ++++++++++++++++++++-- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 4d4ffb557..080dd7863 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -43,6 +43,9 @@ jobs: release: if: github.event_name == 'push' runs-on: macos-14 + concurrency: + group: tagged-release-${{ github.ref_name }} + cancel-in-progress: false permissions: contents: write @@ -68,10 +71,11 @@ jobs: parts.length <= 4 && parts.every( (part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535, - ) + ) && + parts.some((part) => Number(part) > 0) if (!valid) process.exit(1) ' "$version"; then - echo "::error::Release tags must use canonical 3-4 part versions with components from 0 to 65535" + echo "::error::Release tags must use canonical 3-4 part versions with components from 0 to 65535 and at least one non-zero component" exit 1 fi @@ -107,11 +111,12 @@ jobs: const parse = (version) => { const parts = version.split(".") const valid = - parts.length >= 3 && - parts.length <= 4 && - parts.every( - (part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535, - ) + parts.length >= 3 && + parts.length <= 4 && + parts.every( + (part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535, + ) && + parts.some((part) => Number(part) > 0) if (!valid) process.exit(2) return parts.map(Number) } @@ -208,7 +213,10 @@ jobs: release_status="$(printf '%s\n' "$release_response" \ | sed -n 's/^HTTP\/[^ ]* \([0-9][0-9][0-9]\) .*/\1/p' \ | tail -n 1)" - if [ "$release_status" = "200" ]; then + if [ "${SUPERSEDED_RELEASE:-}" = "true" ]; then + echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" + echo "Release $RELEASE_TAG is superseded; skipping release rerun" + elif [ "$release_status" = "200" ]; then release_metadata="$(printf '%s\n' "$release_response" | tail -n 1)" release_is_draft="$(printf '%s\n' "$release_metadata" | jq -r '.[0]')" if [ "$release_is_draft" != "true" ]; then @@ -251,12 +259,7 @@ jobs: fi fi elif [ "$release_status" = "404" ]; then - if [ "${SUPERSEDED_RELEASE:-}" = "true" ]; then - echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" - echo "Release $RELEASE_TAG is superseded; skipping release creation" - else - gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" - fi + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" else printf '%s\n' "$release_response" >&2 exit 1 diff --git a/scripts/submit-stores.mjs b/scripts/submit-stores.mjs index c1e661641..d42594f54 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -41,6 +41,11 @@ const STORE_MANIFESTS = { firefox: 'build/firefox/manifest.json', edge: 'build/chromium/manifest.json', } +const STORE_ZIP_ENV = { + chrome: 'CHROME_ZIP', + firefox: 'FIREFOX_ZIP', + edge: 'EDGE_ZIP', +} const STORE_IDS = Object.keys(STORE_ENV) export function isValidManifestVersion(version) { @@ -48,7 +53,8 @@ export function isValidManifestVersion(version) { return ( parts.length >= 3 && parts.length <= 4 && - parts.every((part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535) + parts.every((part) => /^(0|[1-9][0-9]*)$/.test(part) && Number(part) <= 65535) && + parts.some((part) => Number(part) > 0) ) } @@ -210,15 +216,19 @@ function buildPublishExtensionEnv(env, baseEnv = process.env) { export async function runPublishExtension( args, - { env, baseEnv = process.env, spawnImpl = spawn } = {}, + { env, stores = STORE_IDS, baseEnv = process.env, spawnImpl = spawn } = {}, ) { const childArgs = [resolvePublishExtensionBin(), ...args] + const childEnv = { ...(env ?? {}) } + for (const store of STORE_IDS) { + if (!stores.includes(store)) childEnv[STORE_ZIP_ENV[store]] = '' + } await new Promise((resolve, reject) => { const child = spawnImpl(process.execPath, childArgs, { stdio: 'inherit', shell: false, - env: buildPublishExtensionEnv(env, baseEnv), + env: buildPublishExtensionEnv(childEnv, baseEnv), }) child.once('error', reject) @@ -301,7 +311,7 @@ export async function submitStores({ } const args = buildPublishExtensionArgs({ dryRun, stores }) - await runPublishExtensionImpl(args, { env }) + await runPublishExtensionImpl(args, { env, stores }) if (!dryRun && stores.includes('firefox') && !skipFirefoxMetadata) { await updateFirefoxVersionNotesImpl({ diff --git a/tests/unit/release/submit-stores.test.mjs b/tests/unit/release/submit-stores.test.mjs index 9843a3448..e3bc2af2f 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -168,7 +168,7 @@ test('isValidManifestVersion accepts canonical Chromium versions only', () => { for (const version of ['2.6.1', '2.6.1.1']) { assert.equal(isValidManifestVersion(version), true) } - for (const version of ['next', '2.6', '01.2.3', '70000.1.1', ' 2.6.1 ']) { + for (const version of ['next', '2.6', '01.2.3', '70000.1.1', ' 2.6.1 ', '0.0.0', '0.0.0.0']) { assert.equal(isValidManifestVersion(version), false) } }) @@ -253,6 +253,30 @@ test('runPublishExtension omits nullish env values before spawning publish-exten assert.equal('FIREFOX_JWT_SECRET' in spawnCalls[0].options.env, false) }) +test('runPublishExtension disables unselected store ZIP environment variables', async () => { + const child = new EventEmitter() + const spawnCalls = [] + + await runPublishExtension([], { + stores: ['chrome'], + baseEnv: { + PATH: 'parent-path', + CHROME_ZIP: 'chrome.zip', + FIREFOX_ZIP: 'firefox.zip', + EDGE_ZIP: 'edge.zip', + }, + spawnImpl: (command, args, options) => { + spawnCalls.push({ command, args, options }) + queueMicrotask(() => child.emit('exit', 0)) + return child + }, + }) + + assert.equal(spawnCalls[0].options.env.CHROME_ZIP, 'chrome.zip') + assert.equal(spawnCalls[0].options.env.FIREFOX_ZIP, '') + assert.equal(spawnCalls[0].options.env.EDGE_ZIP, '') +}) + test('runPublishExtension invokes publish-extension through node', async () => { const child = new EventEmitter() const spawnCalls = [] @@ -553,7 +577,8 @@ test('submitStores can submit one store without other store credentials', async assert.equal(path, 'build/chromium/manifest.json') return { version: '2.6.1' } }, - runPublishExtensionImpl: async (args, options) => publishCalls.push({ args, env: options.env }), + runPublishExtensionImpl: async (args, options) => + publishCalls.push({ args, env: options.env, stores: options.stores }), updateFirefoxVersionNotesImpl: async (options) => metadataCalls.push(options), logger: () => {}, errorLogger: () => {}, @@ -568,6 +593,7 @@ test('submitStores can submit one store without other store credentials', async CHROME_CLIENT_SECRET: 'chrome-secret', CHROME_REFRESH_TOKEN: 'chrome-refresh', }, + stores: ['chrome'], }, ]) assert.deepEqual(metadataCalls, []) From 4b9ef509a73f183e87b3581cb3d30740a69ad223 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Wed, 26 Aug 2026 03:28:20 +0800 Subject: [PATCH 11/11] Preserve submitted artifacts across release reruns Keep tag-triggered release reruns from replacing binaries already submitted to a store. Serialize master sync and reject equivalent version spellings before creating a release. --- .github/workflows/tagged-release.yml | 230 ++++++++++++++++++++------- 1 file changed, 174 insertions(+), 56 deletions(-) diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 080dd7863..81fa99f70 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -44,7 +44,8 @@ jobs: if: github.event_name == 'push' runs-on: macos-14 concurrency: - group: tagged-release-${{ github.ref_name }} + group: tagged-release + queue: max cancel-in-progress: false permissions: contents: write @@ -56,6 +57,15 @@ jobs: fetch-depth: 0 persist-credentials: true + - name: Validate release tag provenance + run: | + set -euo pipefail + git fetch origin master + if ! git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then + echo "::error::Release tag commit is not contained in origin/master" + exit 1 + fi + - uses: actions/setup-node@v7 with: node-version: 22 @@ -75,7 +85,8 @@ jobs: parts.some((part) => Number(part) > 0) if (!valid) process.exit(1) ' "$version"; then - echo "::error::Release tags must use canonical 3-4 part versions with components from 0 to 65535 and at least one non-zero component" + echo "::error::Release tags must use canonical 3-4 part versions with" \ + "components from 0 to 65535 and at least one non-zero component" exit 1 fi @@ -105,7 +116,7 @@ jobs: echo "::warning::$1" echo "Release version sync warning: $1" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" || true } - release_version_is_newer() { + release_version_requires_skip() { node -e ' const [remote, requested] = process.argv.slice(1) const parse = (version) => { @@ -129,32 +140,46 @@ jobs: } return 0 } - process.exit(compare(parse(remote), parse(requested)) > 0 ? 0 : 1) + const comparison = compare(parse(remote), parse(requested)) + process.exit( + comparison > 0 || (comparison === 0 && remote !== requested) ? 0 : 1, + ) ' "$1" "$2" } if git diff --cached --quiet; then echo "No release version changes to commit" elif ! git commit -m "release v${VERSION}"; then - report_sync_warning "Failed to commit the release version sync; continuing with the release" + echo "::error::Failed to commit the release version sync; cannot verify release version state" + exit 1 else max_attempts=3 for attempt in $(seq 1 "${max_attempts}"); do if ! git fetch origin master; then if [ "${attempt}" -eq "${max_attempts}" ]; then - report_sync_warning "Failed to fetch origin/master; skipping the release version sync" + echo "::error::Failed to fetch origin/master; cannot verify release version state" + exit 1 else echo "Release version sync fetch failed; retrying (${attempt}/${max_attempts})" sleep $((attempt * 2)) fi - elif release_version_is_newer \ - "$(git show FETCH_HEAD:src/manifest.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" \ - "$VERSION"; then - report_sync_warning "Skipping release version sync; origin/master is already newer than v${VERSION}" + elif ! remote_version="$( + git show FETCH_HEAD:src/manifest.json \ + | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version" + )"; then + if [ "${attempt}" -eq "${max_attempts}" ]; then + echo "::error::Failed to read the origin/master manifest version" + exit 1 + else + echo "Release version read failed; retrying (${attempt}/${max_attempts})" + sleep $((attempt * 2)) + fi + elif release_version_requires_skip "$remote_version" "$VERSION"; then + report_sync_warning "Skipping release version sync; origin/master already has an equivalent or newer version than v${VERSION}" echo "SUPERSEDED_RELEASE=true" >> "$GITHUB_ENV" break elif [ "$?" -eq 2 ]; then - report_sync_warning "Skipping release version sync; origin/master has an invalid manifest version" - break + echo "::error::Cannot verify release version state; origin/master has an invalid manifest version" + exit 1 elif ! git rebase FETCH_HEAD; then git rebase --abort || true report_sync_warning "Release version sync conflicted with origin/master; skipping the master push" @@ -178,46 +203,36 @@ jobs: ref: ${{ github.ref }} persist-credentials: false - - uses: actions/setup-python@v7 - if: env.SKIP_RELEASE != 'true' - with: - python-version: '3.10' # for appdmg - - uses: maxim-lobanov/setup-xcode@v1 - if: env.SKIP_RELEASE != 'true' - with: - xcode-version: 16.2 - - run: npm ci - if: env.SKIP_RELEASE != 'true' - - - name: Update release artifact manifest.json version - if: env.SKIP_RELEASE != 'true' - uses: jossef/action-set-json-field@v2.2 - with: - file: src/manifest.json - field: version - value: ${{ env.VERSION }} - - - name: Update release artifact manifest.v2.json version - if: env.SKIP_RELEASE != 'true' - uses: jossef/action-set-json-field@v2.2 - with: - file: src/manifest.v2.json - field: version - value: ${{ env.VERSION }} - - name: Prepare GitHub release if: env.SKIP_RELEASE != 'true' run: | - release_response="$(gh api --include --jq '[.draft, [.assets[].name]]' \ - "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" 2>&1 || true)" - release_status="$(printf '%s\n' "$release_response" \ - | sed -n 's/^HTTP\/[^ ]* \([0-9][0-9][0-9]\) .*/\1/p' \ - | tail -n 1)" + set -euo pipefail if [ "${SUPERSEDED_RELEASE:-}" = "true" ]; then echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" echo "Release $RELEASE_TAG is superseded; skipping release rerun" - elif [ "$release_status" = "200" ]; then - release_metadata="$(printf '%s\n' "$release_response" | tail -n 1)" + exit 0 + fi + release_metadata='' + for attempt in 1 2 3; do + if release_metadata="$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + | jq -c -s --arg tag "$RELEASE_TAG" ' + [ .[][] | select(.tag_name == $tag) ] + | if length > 1 then error("Multiple releases found for tag") + elif length == 0 then empty + else .[0] | [ .draft, [ .assets[].name ] ] + end + ')"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "::error::Failed to inspect GitHub release state after ${attempt} attempts" + exit 1 + fi + echo "GitHub release state lookup failed; retrying (${attempt}/3)" + sleep $((attempt * 2)) + done + if [ -n "$release_metadata" ]; then release_is_draft="$(printf '%s\n' "$release_metadata" | jq -r '.[0]')" if [ "$release_is_draft" != "true" ]; then echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" @@ -251,6 +266,26 @@ jobs: if [ "$skip_edge_store" = "true" ]; then echo "SKIP_EDGE_STORE=true" >> "$GITHUB_ENV" fi + if [ "$skip_chrome_store" = "true" ] \ + || [ "$skip_firefox_store" = "true" ] \ + || [ "$skip_edge_store" = "true" ]; then + if ! [ "$skip_chrome_store" = "true" ] \ + || ! [ "$skip_firefox_store" = "true" ] \ + || ! [ "$skip_edge_store" = "true" ]; then + for asset in \ + chromium.zip \ + firefox.zip \ + safari.dmg \ + chromium-without-katex-and-tiktoken.zip \ + firefox-without-katex-and-tiktoken.zip; do + if ! release_has_asset "$asset"; then + echo "::error::Cannot resume partial release: missing asset $asset" + exit 1 + fi + done + echo "REUSE_RELEASE_ARTIFACTS=true" >> "$GITHUB_ENV" + fi + fi if [ "$skip_chrome_store" = "true" ] \ && [ "$skip_firefox_store" = "true" ] \ && [ "$skip_edge_store" = "true" ]; then @@ -258,21 +293,71 @@ jobs: echo "All store submissions are complete; resuming release publication" fi fi - elif [ "$release_status" = "404" ]; then - gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" else - printf '%s\n' "$release_response" >&2 - exit 1 + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" fi env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} + - uses: actions/setup-python@v7 + if: env.SKIP_RELEASE != 'true' + with: + python-version: '3.10' # for appdmg + - uses: maxim-lobanov/setup-xcode@v1 + if: env.SKIP_RELEASE != 'true' + with: + xcode-version: 16.2 + - run: npm ci + if: env.SKIP_RELEASE != 'true' + + - name: Update release artifact manifest.json version + if: env.SKIP_RELEASE != 'true' + uses: jossef/action-set-json-field@v2.2 + with: + file: src/manifest.json + field: version + value: ${{ env.VERSION }} + + - name: Update release artifact manifest.v2.json version + if: env.SKIP_RELEASE != 'true' + uses: jossef/action-set-json-field@v2.2 + with: + file: src/manifest.v2.json + field: version + value: ${{ env.VERSION }} + + - name: Restore release artifacts for partial rerun + if: env.SKIP_RELEASE != 'true' && env.REUSE_RELEASE_ARTIFACTS == 'true' + run: | + set -euo pipefail + mkdir -p build + gh release download "$RELEASE_TAG" \ + --pattern 'chromium.zip' \ + --pattern 'firefox.zip' \ + --pattern 'safari.dmg' \ + --pattern 'chromium-without-katex-and-tiktoken.zip' \ + --pattern 'firefox-without-katex-and-tiktoken.zip' \ + --dir build + mkdir -p build/chromium build/firefox + unzip -p build/chromium.zip manifest.json > build/chromium/manifest.json + unzip -p build/firefox.zip manifest.json > build/firefox/manifest.json + for manifest in build/chromium/manifest.json build/firefox/manifest.json; do + restored_version="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")" + if [ "$restored_version" != "$VERSION" ]; then + echo "::error::Restored $manifest has version $restored_version, expected $VERSION" + exit 1 + fi + done + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + - run: npm run build:safari - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' && env.REUSE_RELEASE_ARTIFACTS != 'true' - run: npm run release:firefox-sources - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_FIREFOX_STORE != 'true' - run: | gh release upload "$RELEASE_TAG" --clobber build/chromium.zip @@ -283,7 +368,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} - if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' + if: env.SKIP_RELEASE != 'true' && env.SKIP_STORE_SUBMISSION != 'true' && env.REUSE_RELEASE_ARTIFACTS != 'true' - name: Submit Chrome store run: npm run release:submit -- --store chrome @@ -300,8 +385,19 @@ jobs: - name: Mark Chrome store submission complete if: env.SKIP_RELEASE != 'true' && env.SKIP_CHROME_STORE != 'true' run: | + set -euo pipefail printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.chrome.marker - gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.chrome.marker + for attempt in 1 2 3 4 5; do + if gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.chrome.marker; then + exit 0 + fi + if [ "$attempt" -eq 5 ]; then + echo "::error::Failed to upload the Chrome store completion marker after ${attempt} attempts" + exit 1 + fi + echo "Chrome store completion marker upload failed; retrying (${attempt}/5)" + sleep $((attempt * 2)) + done env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} @@ -319,8 +415,19 @@ jobs: - name: Mark Firefox store submission complete if: env.SKIP_RELEASE != 'true' && env.SKIP_FIREFOX_STORE != 'true' run: | + set -euo pipefail printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.firefox.marker - gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.firefox.marker + for attempt in 1 2 3 4 5; do + if gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.firefox.marker; then + exit 0 + fi + if [ "$attempt" -eq 5 ]; then + echo "::error::Failed to upload the Firefox store completion marker after ${attempt} attempts" + exit 1 + fi + echo "Firefox store completion marker upload failed; retrying (${attempt}/5)" + sleep $((attempt * 2)) + done env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} @@ -344,8 +451,19 @@ jobs: - name: Mark Edge store submission complete if: env.SKIP_RELEASE != 'true' && env.SKIP_EDGE_STORE != 'true' run: | + set -euo pipefail printf '%s\n' "$GITHUB_RUN_ID" > build/store-submission-complete.edge.marker - gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.edge.marker + for attempt in 1 2 3 4 5; do + if gh release upload "$RELEASE_TAG" --clobber build/store-submission-complete.edge.marker; then + exit 0 + fi + if [ "$attempt" -eq 5 ]; then + echo "::error::Failed to upload the Edge store completion marker after ${attempt} attempts" + exit 1 + fi + echo "Edge store completion marker upload failed; retrying (${attempt}/5)" + sleep $((attempt * 2)) + done env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }}