diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index e36931d65..81fa99f70 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -3,62 +3,97 @@ 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 + concurrency: + group: tagged-release + queue: max + cancel-in-progress: false + 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 + + - 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: - xcode-version: 16.2 - - run: npm ci + node-version: 22 - - name: Resolve release version + - name: Validate release tag 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 + 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, + ) && + 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" + exit 1 fi + - name: Resolve release version + 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 +101,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 +108,271 @@ 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 + report_sync_warning() { + echo "::warning::$1" + echo "Release version sync warning: $1" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" || true + } + release_version_requires_skip() { + node -e ' + const [remote, requested] = process.argv.slice(1) + 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.some((part) => Number(part) > 0) + 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) { + const leftPart = left[index] ?? 0 + const rightPart = right[index] ?? 0 + if (leftPart !== rightPart) return leftPart - rightPart + } + return 0 + } + 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 + 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 + 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 ! 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 + 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" + 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 + if: env.SKIP_RELEASE != 'true' + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + persist-credentials: false - - if: github.event_name == 'push' + - name: Prepare GitHub release + if: env.SKIP_RELEASE != 'true' run: | - gh release create ${{github.ref_name}} -d -F CURRENT_CHANGE.md -t ${{github.ref_name}} + set -euo pipefail + if [ "${SUPERSEDED_RELEASE:-}" = "true" ]; then + echo "SKIP_RELEASE=true" >> "$GITHUB_ENV" + echo "Release $RELEASE_TAG is superseded; skipping release rerun" + 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" + echo "Release $RELEASE_TAG is already published; skipping rerun" + 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 + 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 + echo "SKIP_STORE_SUBMISSION=true" >> "$GITHUB_ENV" + echo "All store submissions are complete; resuming release publication" + fi + fi + else + gh release create "$RELEASE_TAG" -d -F CURRENT_CHANGE.md -t "$RELEASE_TAG" + fi + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} - - if: github.event_name == 'push' - run: npm run build:safari + - 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' - - if: github.event_name != 'push' - run: npm run build + - 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 }} - - run: npm run release:firefox-sources + - 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 }} - - 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 - - - name: Submit stores - if: github.event_name == 'push' || inputs.submit_stores == 'true' + - name: Restore release artifacts for partial rerun + if: env.SKIP_RELEASE != 'true' && env.REUSE_RELEASE_ARTIFACTS == '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." + 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 - args+=(--dry-run) - fi - npm run release:submit -- "${args[@]}" + 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' && env.REUSE_RELEASE_ARTIFACTS != 'true' + + - run: npm run release:firefox-sources + if: env.SKIP_RELEASE != 'true' && env.SKIP_FIREFOX_STORE != 'true' + + - run: | + 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 }} + 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 + 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 }} @@ -122,15 +381,96 @@ 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: | + set -euo pipefail + printf '%s\n' "$GITHUB_RUN_ID" > 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 }} + + - 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: | + set -euo pipefail + printf '%s\n' "$GITHUB_RUN_ID" > 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 }} + + - 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 }} - - if: github.event_name == 'push' + - name: Mark Edge store submission complete + if: env.SKIP_RELEASE != 'true' && env.SKIP_EDGE_STORE != 'true' run: | - gh release edit ${{github.ref_name}} --draft=false + set -euo pipefail + printf '%s\n' "$GITHUB_RUN_ID" > 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 }} + + - run: | + gh release edit "$RELEASE_TAG" --draft=false + if: env.SKIP_RELEASE != 'true' + 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..d42594f54 100644 --- a/scripts/submit-stores.mjs +++ b/scripts/submit-stores.mjs @@ -2,13 +2,14 @@ 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', @@ -20,36 +21,76 @@ 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_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_ZIP_ENV = { + chrome: 'CHROME_ZIP', + firefox: 'FIREFOX_ZIP', + edge: 'EDGE_ZIP', +} +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) && + parts.some((part) => Number(part) > 0) + ) +} 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) => { - const value = env[name] - return typeof value !== 'string' || value.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, + ), + ) } -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) } @@ -58,18 +99,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) { @@ -158,18 +202,33 @@ 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') } -async function runPublishExtension(args) { - const command = resolvePublishExtensionBin() +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)]), + ) +} + +export async function runPublishExtension( + args, + { 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 = spawn(command, args, { + const child = spawnImpl(process.execPath, childArgs, { stdio: 'inherit', shell: false, - env: process.env, + env: buildPublishExtensionEnv(childEnv, baseEnv), }) child.once('error', reject) @@ -183,36 +242,81 @@ 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, stores } = parseArgs(argv) + const skipFirefoxMetadata = argv.includes('--skip-firefox-metadata') + const env = envInput ?? process.env + 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) { 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 }) - const firefoxReleaseNotes = buildFirefoxReleaseNotes(manifest.version) + 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 }) + } + } + + 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') + } - 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}`) + 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${versionLabel}`) + logger(`Mode: ${mode}`) + logger(`Artifacts: ${requiredArtifacts.join(', ')}`) + if (firefoxReleaseNotes) { + logger(`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, stores }) + await runPublishExtensionImpl(args, { env, stores }) - if (!dryRun) { - await updateFirefoxVersionNotes({ + 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 a6e580957..e3bc2af2f 100644 --- a/tests/unit/release/submit-stores.test.mjs +++ b/tests/unit/release/submit-stores.test.mjs @@ -1,15 +1,21 @@ 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, + isValidManifestVersion, parseArgs, stripFirefoxExtensionId, + submitStores, updateFirefoxVersionNotes, } from '../../../scripts/submit-stores.mjs' @@ -30,11 +36,58 @@ 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, + 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(['--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() { + 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 +105,22 @@ 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 checks only the selected store', () => { + assert.deepEqual(findMissingEnv({}, ['firefox']), [ + 'FIREFOX_EXTENSION_ID', + 'FIREFOX_JWT_ISSUER', + 'FIREFOX_JWT_SECRET', + ]) +}) - assert.deepEqual(findMissingEnv(env), []) +test('findMissingEnv rejects blank required secrets', () => { + const env = createStoreEnv() + env.FIREFOX_JWT_SECRET = ' ' + + assert.deepEqual(findMissingEnv(env), ['FIREFOX_JWT_SECRET']) }) test('findMissingEnv treats whitespace-only secrets as missing', () => { @@ -85,6 +140,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 }) @@ -92,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 ', '0.0.0', '0.0.0.0']) { + assert.equal(isValidManifestVersion(version), false) + } +}) + test('buildPublishExtensionArgs includes all stores and dry run', () => { const args = buildPublishExtensionArgs({ dryRun: true }) @@ -108,6 +189,141 @@ 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 = [] + + 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 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 = [] + + 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 +331,359 @@ 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 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 }, + ]) { + 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 rejects invalid Firefox manifests 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 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 (path) => { + assert.equal(path, 'build/chromium/manifest.json') + return { version: '2.6.1' } + }, + runPublishExtensionImpl: async (args, options) => + publishCalls.push({ args, env: options.env, stores: options.stores }), + 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', + }, + stores: ['chrome'], + }, + ]) + 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 + + 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')