From 33208ed29316e642a260b33c74b498ddc66d87c1 Mon Sep 17 00:00:00 2001 From: Lev Gelfenbuim Date: Sun, 25 Oct 2026 12:08:14 +0200 Subject: [PATCH 1/3] ci(publish): move Chrome Web Store to API v2 Chrome Web Store API v1.1, and the OAuth client/refresh-token auth the workflow used, stopped working on 2026-10-15 (publish-browser-extension v6 warns about this). Switch to API v2: - Mint a short-lived access token from a service-account JSON key (secret CHROME_SERVICE_ACCOUNT_KEY) by signing a JWT with node's crypto, and share it between the version check and the upload. - Check the store version with v2 fetchStatus (published and submitted revisions), failing the step on API errors rather than guessing. - Publish with --chrome-api-version v2 and CHROME_PUBLISHER_ID. Also let Firefox and Edge publish even if Chrome fails (the job still fails), report per-store failures in the summary, point release notes at CHANGELOG.md, and update PUBLISHING.md for the new setup. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 116 +++++++++++++++++++++++----------- PUBLISHING.md | 29 +++++---- 2 files changed, 95 insertions(+), 50 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8a68050..fcc64b2b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,65 +48,103 @@ jobs: echo "chrome=$CHROME_VERSION" >> $GITHUB_OUTPUT echo "firefox=$FIREFOX_VERSION" >> $GITHUB_OUTPUT - # Chrome Web Store + # Chrome Web Store (API v2). API v1.1 and its OAuth refresh tokens + # stopped working on 2026-10-15; v2 authenticates as a service account. + - name: Get Chrome Web Store access token + id: chrome-auth + env: + CHROME_SERVICE_ACCOUNT_KEY: ${{ secrets.CHROME_SERVICE_ACCOUNT_KEY }} + run: | + # Exchange a JWT signed with the service account's JSON key for a + # short-lived OAuth token, shared by the version check and publish. + node --input-type=module <<'EOF' + import { sign } from 'node:crypto'; + import { appendFileSync } from 'node:fs'; + + const key = JSON.parse(process.env.CHROME_SERVICE_ACCOUNT_KEY); + const now = Math.floor(Date.now() / 1000); + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const unsigned = [ + b64({ alg: 'RS256', typ: 'JWT' }), + b64({ + iss: key.client_email, + scope: 'https://www.googleapis.com/auth/chromewebstore', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }), + ].join('.'); + const signature = sign('RSA-SHA256', Buffer.from(unsigned), key.private_key); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `${unsigned}.${signature.toString('base64url')}`, + }), + }); + if (!res.ok) { + console.error(`Token exchange failed: ${res.status} ${await res.text()}`); + process.exit(1); + } + const { access_token } = await res.json(); + console.log(`::add-mask::${access_token}`); + appendFileSync(process.env.GITHUB_OUTPUT, `access_token=${access_token}\n`); + EOF + - name: Check Chrome Web Store version id: chrome-check env: - CHROME_CLIENT_ID: ${{ vars.CHROME_CLIENT_ID }} - CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + ACCESS_TOKEN: ${{ steps.chrome-auth.outputs.access_token }} + CHROME_PUBLISHER_ID: ${{ vars.CHROME_PUBLISHER_ID }} + LOCAL_VERSION: ${{ steps.versions.outputs.chrome }} run: | - # Get access token - TOKEN_RESPONSE=$(curl -s -X POST "https://oauth2.googleapis.com/token" \ - -d "client_id=$CHROME_CLIENT_ID" \ - -d "client_secret=$CHROME_CLIENT_SECRET" \ - -d "refresh_token=$CHROME_REFRESH_TOKEN" \ - -d "grant_type=refresh_token") - ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token') + # curl -f: fail the step on an API error instead of guessing. + STATUS=$(curl -sSf -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://chromewebstore.googleapis.com/v2/publishers/$CHROME_PUBLISHER_ID/items/pfplfifdaaaalkefgnknfgoiabegcbmf:fetchStatus") - # Get current version from Chrome Web Store API - ITEM_INFO=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "x-goog-api-version: 2" \ - "https://www.googleapis.com/chromewebstore/v1.1/items/pfplfifdaaaalkefgnknfgoiabegcbmf?projection=DRAFT") + # Versions that are published or currently submitted for review + STORE_VERSIONS=$(echo "$STATUS" | jq -r '[.publishedItemRevisionStatus, .submittedItemRevisionStatus][]?.distributionChannels[]?.crxVersion' | sort -u) - STORE_VERSION=$(echo $ITEM_INFO | jq -r '.crxVersion // empty') + echo "Chrome Web Store versions (published/submitted): ${STORE_VERSIONS:-none}" + echo "Local Chrome version: $LOCAL_VERSION" - echo "Current Chrome version: $STORE_VERSION" - echo "Local Chrome version: ${{ steps.versions.outputs.chrome }}" - - if [ -z "$STORE_VERSION" ] || [ "$STORE_VERSION" != "${{ steps.versions.outputs.chrome }}" ]; then - echo "needs_publish=true" >> $GITHUB_OUTPUT - else + if echo "$STORE_VERSIONS" | grep -qxF "$LOCAL_VERSION"; then echo "needs_publish=false" >> $GITHUB_OUTPUT + else + echo "needs_publish=true" >> $GITHUB_OUTPUT fi - name: Publish to Chrome Web Store + id: chrome-publish if: steps.chrome-check.outputs.needs_publish == 'true' env: # Pass secrets via env, not inline ${{ ... }} expansion. GitHub # substitutes the literal value into bash before execution, so a # secret containing $ would be re-expanded by bash and corrupted. - CHROME_CLIENT_ID: ${{ vars.CHROME_CLIENT_ID }} - CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + CHROME_PUBLISHER_ID: ${{ vars.CHROME_PUBLISHER_ID }} + CHROME_SERVICE_ACCOUNT_ACCESS_TOKEN: ${{ steps.chrome-auth.outputs.access_token }} run: | cd dist/chrome zip -r ../../chrome-extension.zip . cd ../.. npx publish-browser-extension \ + --chrome-api-version v2 \ --chrome-zip chrome-extension.zip \ --chrome-extension-id pfplfifdaaaalkefgnknfgoiabegcbmf \ - --chrome-client-id "$CHROME_CLIENT_ID" \ - --chrome-client-secret "$CHROME_CLIENT_SECRET" \ - --chrome-refresh-token "$CHROME_REFRESH_TOKEN" + --chrome-publisher-id "$CHROME_PUBLISHER_ID" - name: Skip Chrome publish - if: steps.chrome-check.outputs.needs_publish != 'true' + if: steps.chrome-check.outputs.needs_publish == 'false' run: echo "✓ Chrome Web Store is already up to date" + # Firefox and Edge use !cancelled() so a Chrome failure doesn't block + # them; the job still fails overall. + # Firefox Add-ons - name: Check Firefox Add-ons version id: firefox-check + if: ${{ !cancelled() && steps.versions.outcome == 'success' }} run: | # Check if version exists in any state (published, pending, etc.) VERSION_EXISTS=$(curl -s "https://addons.mozilla.org/api/v5/addons/addon/markdown-printer/versions/" | jq -r --arg ver "${{ steps.versions.outputs.firefox }}" '.results[] | select(.version == $ver) | .version') @@ -120,7 +158,8 @@ jobs: fi - name: Publish to Firefox Add-ons - if: steps.firefox-check.outputs.needs_publish == 'true' + id: firefox-publish + if: ${{ !cancelled() && steps.firefox-check.outputs.needs_publish == 'true' }} env: FIREFOX_ISSUER: ${{ vars.FIREFOX_ISSUER }} FIREFOX_SECRET: ${{ secrets.FIREFOX_SECRET }} @@ -137,12 +176,13 @@ jobs: --firefox-jwt-secret "$FIREFOX_SECRET" - name: Skip Firefox publish - if: steps.firefox-check.outputs.needs_publish != 'true' + if: ${{ !cancelled() && steps.firefox-check.outputs.needs_publish == 'false' }} run: echo "✓ Firefox Add-ons is already up to date" # Microsoft Edge Add-ons - name: Check Edge Add-ons version id: edge-check + if: ${{ !cancelled() && steps.versions.outcome == 'success' }} env: EDGE_PRODUCT_ID: ${{ vars.EDGE_PRODUCT_ID }} EDGE_CLIENT_ID: ${{ vars.EDGE_CLIENT_ID }} @@ -172,7 +212,8 @@ jobs: fi - name: Publish to Edge Add-ons - if: steps.edge-check.outputs.needs_publish == 'true' + id: edge-publish + if: ${{ !cancelled() && steps.edge-check.outputs.needs_publish == 'true' }} env: EDGE_PRODUCT_ID: ${{ vars.EDGE_PRODUCT_ID }} EDGE_CLIENT_ID: ${{ vars.EDGE_CLIENT_ID }} @@ -189,17 +230,18 @@ jobs: --edge-skip-submit-review - name: Skip Edge publish - if: steps.edge-check.outputs.needs_publish != 'true' + if: ${{ !cancelled() && steps.edge-check.outputs.needs_publish == 'false' }} run: echo "✓ Edge Add-ons is already up to date" # Summary - name: Publish summary + if: ${{ !cancelled() }} run: | echo "## Publication Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "- Chrome: ${{ steps.chrome-check.outputs.needs_publish == 'true' && '✓ Published' || '⊘ Skipped (up to date)' }}" >> $GITHUB_STEP_SUMMARY - echo "- Firefox: ${{ steps.firefox-check.outputs.needs_publish == 'true' && '✓ Published' || '⊘ Skipped (up to date)' }}" >> $GITHUB_STEP_SUMMARY - echo "- Edge: ${{ steps.edge-check.outputs.needs_publish == 'true' && '✓ Published' || '⊘ Skipped (up to date)' }}" >> $GITHUB_STEP_SUMMARY + echo "- Chrome: ${{ steps.chrome-publish.outcome == 'success' && '✓ Published' || steps.chrome-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY + echo "- Firefox: ${{ steps.firefox-publish.outcome == 'success' && '✓ Published' || steps.firefox-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY + echo "- Edge: ${{ steps.edge-publish.outcome == 'success' && '✓ Published' || steps.edge-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY # Create GitHub Release with built packages - name: Get version @@ -214,7 +256,7 @@ jobs: run: | gh release create "v${{ steps.get_version.outputs.version }}" \ --title "v${{ steps.get_version.outputs.version }}" \ - --notes "See [CHANGELOG](https://github.com/${{ github.repository }}/blob/main/description.md#version-history) for details." \ + --notes "See [CHANGELOG](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details." \ chrome-extension.zip#"Chrome Extension" \ firefox-extension.zip#"Firefox Extension" \ edge-extension.zip#"Edge Extension" diff --git a/PUBLISHING.md b/PUBLISHING.md index a4690616..0d5d300b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -27,13 +27,15 @@ Add these secrets to your repository at `Settings > Secrets and variables > Acti #### Chrome Web Store -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Enable the Chrome Web Store API -3. Create OAuth 2.0 credentials -4. Follow [this guide](https://developer.chrome.com/docs/webstore/using-api) to get: - - `CHROME_CLIENT_ID` - - `CHROME_CLIENT_SECRET` - - `CHROME_REFRESH_TOKEN` +The workflow uses Chrome Web Store API v2, which authenticates as a service account (API v1.1 and its OAuth refresh tokens stopped working on 2026-10-15). See [Google's guide](https://developer.chrome.com/docs/webstore/service-accounts). + +1. In [Google Cloud Console](https://console.cloud.google.com/), select a project and enable the **Chrome Web Store API** +2. Under **IAM & Admin > Service Accounts**, create a service account (no roles needed) +3. On the service account's **Keys** tab, add a JSON key and download it +4. In the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole), open **Account** and add the service account's email (one service account per publisher) +5. Add to GitHub: + - Secret `CHROME_SERVICE_ACCOUNT_KEY`: the entire contents of the JSON key file + - Variable `CHROME_PUBLISHER_ID`: the ID in the dashboard URL, `https://chrome.google.com/webstore/devconsole/` #### Firefox Add-ons @@ -113,9 +115,9 @@ Publication Summary ## Troubleshooting -### Chrome: "Invalid refresh token" +### Chrome: "Token exchange failed" or 403 from the version check -Wait an hour after setting up the Chrome Web Store API before generating the refresh token. +Check that `CHROME_SERVICE_ACCOUNT_KEY` holds the full JSON key file, the Chrome Web Store API is enabled in that key's project, the service account's email is added under **Account** in the Developer Dashboard, and `CHROME_PUBLISHER_ID` matches the dashboard URL. ### Chrome/Edge: "Item already in review" @@ -131,7 +133,7 @@ Verify your Product ID is correct by checking your extension's overview page in ### Version check fails -If the version check step fails, the workflow will still attempt to publish (using `continue-on-error: true`). Check the logs for details. +If a store's version check or publish step fails, that store is marked failed in the summary and the job fails, but the other stores still publish. Check the logs for details, then re-run the workflow from the Actions tab; stores that already have the version are skipped. ## Manual Publishing @@ -140,11 +142,12 @@ If you prefer to publish manually, you can use the CLI directly: ```bash # Publish to specific stores npx publish-browser-extension \ + --chrome-api-version v2 \ --chrome-zip dist/chrome.zip \ --chrome-extension-id pfplfifdaaaalkefgnknfgoiabegcbmf \ - --chrome-client-id "$CHROME_CLIENT_ID" \ - --chrome-client-secret "$CHROME_CLIENT_SECRET" \ - --chrome-refresh-token "$CHROME_REFRESH_TOKEN" + --chrome-publisher-id "$CHROME_PUBLISHER_ID" \ + --chrome-service-account-client-email "$CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL" \ + --chrome-service-account-private-key "$CHROME_SERVICE_ACCOUNT_PRIVATE_KEY" ``` ## Notes From 410f9132cf26b7cec91fc80e1d199de40ce65f1c Mon Sep 17 00:00:00 2001 From: Lev Gelfenbuim Date: Sun, 25 Oct 2026 12:34:05 +0200 Subject: [PATCH 2/3] ci(publish): don't report Edge as published after upload-only Edge uses --edge-skip-submit-review, so the run only uploads a draft that still has to be submitted in Partner Center. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fcc64b2b..b40cfffc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -241,7 +241,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- Chrome: ${{ steps.chrome-publish.outcome == 'success' && '✓ Published' || steps.chrome-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY echo "- Firefox: ${{ steps.firefox-publish.outcome == 'success' && '✓ Published' || steps.firefox-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- Edge: ${{ steps.edge-publish.outcome == 'success' && '✓ Published' || steps.edge-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY + echo "- Edge: ${{ steps.edge-publish.outcome == 'success' && '⏳ Uploaded — submit for review in Partner Center' || steps.edge-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY # Create GitHub Release with built packages - name: Get version From 74fd3c5d055004b4ebe32cedfd4e2b85d208af35 Mon Sep 17 00:00:00 2001 From: Lev Gelfenbuim Date: Sun, 25 Oct 2026 12:36:10 +0200 Subject: [PATCH 3/3] ci(publish): submit Edge uploads for review automatically Drop --edge-skip-submit-review so Edge, like Chrome and Firefox, is submitted for review after upload instead of left as a draft. It was added to avoid 'already in review' rejections; with stores now isolated, that case fails only the Edge step and can be re-run. Also point PUBLISHING.md at Publisher > Settings, where the dashboard actually puts the service account field. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 5 ++--- PUBLISHING.md | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b40cfffc..72efab29 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -226,8 +226,7 @@ jobs: --edge-zip edge-extension.zip \ --edge-product-id "$EDGE_PRODUCT_ID" \ --edge-client-id "$EDGE_CLIENT_ID" \ - --edge-api-key "$EDGE_API_KEY" \ - --edge-skip-submit-review + --edge-api-key "$EDGE_API_KEY" - name: Skip Edge publish if: ${{ !cancelled() && steps.edge-check.outputs.needs_publish == 'false' }} @@ -241,7 +240,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- Chrome: ${{ steps.chrome-publish.outcome == 'success' && '✓ Published' || steps.chrome-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY echo "- Firefox: ${{ steps.firefox-publish.outcome == 'success' && '✓ Published' || steps.firefox-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- Edge: ${{ steps.edge-publish.outcome == 'success' && '⏳ Uploaded — submit for review in Partner Center' || steps.edge-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY + echo "- Edge: ${{ steps.edge-publish.outcome == 'success' && '✓ Published' || steps.edge-check.outputs.needs_publish == 'false' && '⊘ Skipped (up to date)' || '✗ Failed' }}" >> $GITHUB_STEP_SUMMARY # Create GitHub Release with built packages - name: Get version diff --git a/PUBLISHING.md b/PUBLISHING.md index 0d5d300b..517d7297 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -32,7 +32,7 @@ The workflow uses Chrome Web Store API v2, which authenticates as a service acco 1. In [Google Cloud Console](https://console.cloud.google.com/), select a project and enable the **Chrome Web Store API** 2. Under **IAM & Admin > Service Accounts**, create a service account (no roles needed) 3. On the service account's **Keys** tab, add a JSON key and download it -4. In the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole), open **Account** and add the service account's email (one service account per publisher) +4. In the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole), open **Publisher > Settings** and add the service account's email (one service account per publisher) 5. Add to GitHub: - Secret `CHROME_SERVICE_ACCOUNT_KEY`: the entire contents of the JSON key file - Variable `CHROME_PUBLISHER_ID`: the ID in the dashboard URL, `https://chrome.google.com/webstore/devconsole/` @@ -108,7 +108,7 @@ Publication Summary ### Auto-Submit Behavior - **Chrome**: uploads are submitted for review automatically. -- **Edge**: uploads use `--edge-skip-submit-review`. Uploads are automatic, but you must click "Submit for review" in the [Edge Partner Center](https://partner.microsoft.com/en-us/dashboard/microsoftedge/overview). +- **Edge**: uploads are submitted for review automatically. If a previous version is still under review, Edge rejects the submission and the Edge step fails (Chrome and Firefox still publish); re-run the workflow once the review clears, or submit the uploaded draft in the [Edge Partner Center](https://partner.microsoft.com/en-us/dashboard/microsoftedge/overview). - **Firefox**: automatically submits for review (cannot be skipped). **Note for Chrome:** if a previous version is still under review when the workflow runs, the publish step will fail with "Item already in review". Wait for the pending review to clear before tagging a new version. @@ -117,11 +117,11 @@ Publication Summary ### Chrome: "Token exchange failed" or 403 from the version check -Check that `CHROME_SERVICE_ACCOUNT_KEY` holds the full JSON key file, the Chrome Web Store API is enabled in that key's project, the service account's email is added under **Account** in the Developer Dashboard, and `CHROME_PUBLISHER_ID` matches the dashboard URL. +Check that `CHROME_SERVICE_ACCOUNT_KEY` holds the full JSON key file, the Chrome Web Store API is enabled in that key's project, the service account's email is added under **Publisher > Settings** in the Developer Dashboard, and `CHROME_PUBLISHER_ID` matches the dashboard URL. ### Chrome/Edge: "Item already in review" -A version is currently under review. Wait for review completion before tagging a new version. (Chrome auto-submits, so this can happen on rapid successive releases.) +A version is currently under review. Wait for review completion before tagging a new version. (Chrome and Edge auto-submit, so this can happen on rapid successive releases.) ### Firefox: "Submission failed"