Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 80 additions & 39 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand All @@ -185,21 +226,21 @@ 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: 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
Expand All @@ -214,7 +255,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"
33 changes: 18 additions & 15 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **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/<publisher-id>`

#### Firefox Add-ons

Expand Down Expand Up @@ -106,20 +108,20 @@ 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.

## 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 **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"

Expand All @@ -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

Expand All @@ -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
Expand Down
Loading