From 5b1f61be3596f1103d9825d1cc2ee28218d17251 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:54:28 +0000 Subject: [PATCH 1/5] feat: submit releases to both stores from CI Publishing was two manual uploads: download the zips from the build job, then hand them to the Chrome Web Store dashboard and the AMO developer hub. publish.yaml now does it. It runs when a release is promoted out of pre-release, downloads that release's own artifacts rather than rebuilding them, verifies their provenance and their version against the tag, and submits: Chrome via chrome-webstore-upload-cli, Firefox via web-ext sign against the listed channel. Both stores review by hand afterwards, so neither job waits for the result. AMO requires source alongside a bundled build, so the Firefox job attaches an archive of the tagged commit and SOURCE_BUILD_INSTRUCTIONS.md tells a reviewer how to reproduce dist/ from it. Attestation was skipped on tag builds, because its condition only held for pull requests from this repository. Release artifacts therefore carried no provenance at all, contrary to what CONTRIBUTING.md claimed, and the new verification step depends on it. The condition now covers tags. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: multica-agent --- .github/workflows/build.yaml | 5 +- .github/workflows/publish.yaml | 256 +++++++++++++++++++++++++++++++++ .gitignore | 1 + CONTRIBUTING.md | 66 ++++++--- SOURCE_BUILD_INSTRUCTIONS.md | 72 ++++++++++ bin/package_source.sh | 21 +++ 6 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/publish.yaml create mode 100644 SOURCE_BUILD_INSTRUCTIONS.md create mode 100755 bin/package_source.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 15a2848..10d59eb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -56,8 +56,11 @@ jobs: cd artifacts/dist/ zip -r ${{ github.workspace }}/screenly-${{ matrix.platform }}-extension.zip . + # Forks cannot mint attestations, so pull requests from them are skipped. + # Tag builds must be covered: the release artifacts are what we publish to + # the stores, and publish.yaml verifies their provenance before it does. - name: Attest - if: github.event.pull_request.head.repo.full_name == github.repository + if: startsWith(github.ref, 'refs/tags/') || github.event.pull_request.head.repo.full_name == github.repository uses: actions/attest-build-provenance@v4 with: subject-path: '${{ github.workspace }}/screenly-${{ matrix.platform }}-extension.zip' diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..5798557 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,256 @@ +--- +name: Publish Browser Extensions + +# Publishing is deliberately a separate workflow from build.yaml. build.yaml +# runs on the tag and leaves a *prerelease* behind; promoting that prerelease +# to a full release is the human decision that fires this workflow. Store +# submissions cannot be taken back, so nothing here runs off a bare tag push. +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, e.g. v2026.9.0' + required: true + type: string + platform: + description: 'Store to publish to' + required: true + default: 'both' + type: choice + options: + - both + - chrome + - firefox + dry_run: + description: 'Validate and (for Chrome) upload a draft, but do not submit for review' + required: false + default: false + type: boolean + +permissions: + contents: read + attestations: read + +env: + # Public identifiers, not secrets: both are visible on the store listings. + CHROME_EXTENSION_ID: kcoehkngnbhlmdcgcadliaadlmbjmcln + FIREFOX_ADDON_SLUG: save-to-screenly + +jobs: + chrome: + name: Chrome Web Store + runs-on: ubuntu-latest + # Add required reviewers to this environment in the repository settings to + # gate submissions on a second pair of eyes. It also holds the store + # credentials, which keeps them out of reach of workflows on pull requests. + environment: store-release + if: inputs.platform != 'firefox' + steps: + - name: Resolve the release tag + id: release + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + if [[ ! $TAG =~ ^v[0-9] ]]; then + echo "Expected a tag like v2026.9.0, got '$TAG'." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Check that the store credentials are present + env: + PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} + CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + run: | + missing=() + for name in PUBLISHER_ID CLIENT_ID CLIENT_SECRET REFRESH_TOKEN; do + [[ -n ${!name} ]] || missing+=("CHROME_$name") + done + if (( ${#missing[@]} )); then + echo "Missing secrets: ${missing[*]}." + echo "See the 'Publishing to the stores' section of CONTRIBUTING.md." + exit 1 + fi + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "screenly-chrome-extension-$TAG.zip" \ + --output extension.zip + + - name: Verify the artifact's build provenance + env: + GH_TOKEN: ${{ github.token }} + run: gh attestation verify extension.zip --repo "$GITHUB_REPOSITORY" + + - name: Check the packaged version matches the tag + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + packaged=$(unzip -p extension.zip manifest.json | jq -er .version) + if [[ $packaged != "$VERSION" ]]; then + echo "The package says $packaged but the tag says $VERSION." + exit 1 + fi + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Upload the package + env: + EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} + PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} + CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + run: npx --yes chrome-webstore-upload-cli@4 upload --source extension.zip + + - name: Submit for review + if: ${{ !inputs.dry_run }} + env: + EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} + PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} + CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + run: npx --yes chrome-webstore-upload-cli@4 publish + + - name: Summarise + env: + TAG: ${{ steps.release.outputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + { + echo "### Chrome Web Store" + echo + if [[ $DRY_RUN == true ]]; then + echo "\`$TAG\` uploaded as a draft. Not submitted for review." + else + echo "\`$TAG\` uploaded and submitted for review." + fi + echo + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + firefox: + name: Firefox Add-ons + runs-on: ubuntu-latest + environment: store-release + if: inputs.platform != 'chrome' + steps: + - name: Resolve the release tag + id: release + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + if [[ ! $TAG =~ ^v[0-9] ]]; then + echo "Expected a tag like v2026.9.0, got '$TAG'." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Check that the store credentials are present + env: + WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }} + WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }} + run: | + missing=() + [[ -n $WEB_EXT_API_KEY ]] || missing+=(AMO_JWT_ISSUER) + [[ -n $WEB_EXT_API_SECRET ]] || missing+=(AMO_JWT_SECRET) + if (( ${#missing[@]} )); then + echo "Missing secrets: ${missing[*]}." + echo "See the 'Publishing to the stores' section of CONTRIBUTING.md." + exit 1 + fi + + # AMO reviews the add-on by rebuilding it, so the submission carries the + # sources the released commit was built from — not the working tree. + - name: Checkout the released commit + uses: actions/checkout@v7 + with: + ref: ${{ steps.release.outputs.tag }} + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "screenly-firefox-extension-$TAG.zip" \ + --output extension.zip + + - name: Verify the artifact's build provenance + env: + GH_TOKEN: ${{ github.token }} + run: gh attestation verify extension.zip --repo "$GITHUB_REPOSITORY" + + - name: Unpack the artifact + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + unzip -q extension.zip -d dist + packaged=$(jq -er .version dist/manifest.json) + if [[ $packaged != "$VERSION" ]]; then + echo "The package says $packaged but the tag says $VERSION." + exit 1 + fi + + - name: Package the sources for review + env: + VERSION: ${{ steps.release.outputs.version }} + run: ./bin/package_source.sh + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Lint the package + run: npx --yes web-ext@10 lint --source-dir dist + + # --approval-timeout 0 returns as soon as AMO has accepted the upload. + # A listed submission then sits in a human review queue, so waiting for + # the signed XPI here would mean holding a runner open for days. + - name: Submit for review + if: ${{ !inputs.dry_run }} + env: + WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }} + WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }} + VERSION: ${{ steps.release.outputs.version }} + run: | + npx --yes web-ext@10 sign \ + --source-dir dist \ + --channel listed \ + --upload-source-code "screenly-extension-source-$VERSION.zip" \ + --approval-timeout 0 \ + --artifacts-dir artifacts + + - name: Summarise + env: + TAG: ${{ steps.release.outputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + { + echo "### Firefox Add-ons" + echo + if [[ $DRY_RUN == true ]]; then + echo "\`$TAG\` linted. Not submitted for review." + else + echo "\`$TAG\` submitted for review." + fi + echo + echo "" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1f1010b..8b4345d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ node_modules screenly-chrome-extension-*.zip screenly-firefox-extension-*.zip +screenly-extension-source-*.zip src/manifest.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d46fcc..9cb03fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,20 +71,52 @@ $ git tag $ git tag -a vYYYY.M.MICRO -m "tl;dr changelog." $ git push origin vYYYY.M.MICRO ``` -* Navigate to the [GitHub releases](https://github.com/Screenly/Browser-Extension/releases) and click 'Draft a new release'. -* Select the tag you just created above and provide a release title and description. - * You can use `git diff ..` to diff between the current and previous release to help you with the changelog. -* Go to the [CI Job](https://github.com/Screenly/Browser-Extension/actions/workflows/build.yaml) and pull down the release `.zip` files for the release you created. - * You can verify the `.zip` files you downloaded with the GitHub CLI by running `gh attestation verify path/to/release.zip --owner Screenly`. - -### Publishing to Stores - -#### Chrome - -* Navigate to [Chrome Web Store Developer Dashboard](https://chrome.google.com/u/1/webstore/devconsole/). -* Select the right publisher account and upload `screenly-chrome-extension.zip` you downloaded before. - -#### Firefox - -* Navigate to Firefox's [Add-on Developer Hub](https://addons.mozilla.org/en-US/developers/). -* Upload `screenly-firefox-extension.zip` you downloaded before. +* Pushing the tag runs [`build.yaml`](/.github/workflows/build.yaml), which builds + both extensions and opens a GitHub release for the tag as a **pre-release**, + with generated notes and the two `.zip` files attached. +* Edit that release: give it a title, tidy the notes, and check the `.zip` files. + * You can use `git diff ..` to diff between the current + and previous release to help you with the changelog. + * You can verify a downloaded `.zip` with the GitHub CLI by running + `gh attestation verify path/to/release.zip --owner Screenly`. + +## :package: Publishing to the Stores + +Untick 'Set as a pre-release' and publish the release. That is the point of no +return: it fires [`publish.yaml`](/.github/workflows/publish.yaml), which +submits what is attached to the release rather than rebuilding it, and checks +each artifact's build provenance and version against the tag before it does. +Nothing publishes off a bare tag push. + +For Chrome the workflow uploads the package and submits it for review. For +Firefox it uploads the package together with a source archive of the tagged +commit, which AMO requires from us because the build is bundled and minified; +see [`SOURCE_BUILD_INSTRUCTIONS.md`](/SOURCE_BUILD_INSTRUCTIONS.md). Both stores +then review by hand, so the new version goes live hours to days later. Neither +job waits for that. + +### Re-running a Submission + +If one store fails and the other succeeds, don't re-publish the release — run +`publish.yaml` from the Actions tab instead. It takes the tag, which store to +submit to, and a `dry_run` option that stops short of submitting: for Chrome it +leaves the package as an unsubmitted draft, for Firefox it only lints. + +### Credentials + +`publish.yaml` reads these from the `store-release` environment. Give that +environment required reviewers if you want submissions to need a second +approval. + +| Secret | Where it comes from | +| --- | --- | +| `CHROME_PUBLISHER_ID` | The publisher account ID, visible in the Developer Dashboard URL. Not the extension ID. | +| `CHROME_CLIENT_ID`, `CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN` | A Google Cloud OAuth client with the Chrome Web Store API enabled. `npx chrome-webstore-upload-keys` walks through it. | +| `AMO_JWT_ISSUER`, `AMO_JWT_SECRET` | [AMO API credentials](https://addons.mozilla.org/en-US/developers/addon/api/key/). The secret is shown once. | + +Two things to watch. A Google refresh token stops working if it goes unused for +six months, which is easy to hit at our release cadence — a failed Chrome job +with an auth error usually means a new token, not a broken workflow. And the +Chrome Web Store API v1.1 is switched off after 15 October 2026; we are on v2 +via `chrome-webstore-upload-cli` v4, so pin any replacement tooling to something +that speaks v2. diff --git a/SOURCE_BUILD_INSTRUCTIONS.md b/SOURCE_BUILD_INSTRUCTIONS.md new file mode 100644 index 0000000..58064c3 --- /dev/null +++ b/SOURCE_BUILD_INSTRUCTIONS.md @@ -0,0 +1,72 @@ +# Building this extension from source + +These are the instructions for reproducing the packaged extension from this +archive. They are written for [AMO source code +review](https://extensionworkshop.com/documentation/publish/source-code-submission/), +which we owe Mozilla because the shipped `popup.bundle.js` is bundled and +minified by webpack and so is not human readable. Everything needed to rebuild +it is in this archive, including the `bun.lock` lockfile. + +The same steps work for anyone who wants to check that a published build +matches this source. + +## Environment + +- Linux or macOS. Our CI builds on `ubuntu-latest`. +- [Bun](https://bun.com/) `1.4.2`, pinned in `Dockerfile`. Bun is both the + package manager and the script runner; there is no npm/yarn lockfile. +- `jq`, to stamp the version into the manifest. +- Optionally Docker, which removes the need to install Bun yourself. + +No network access is needed beyond the dependency install. + +## Build + +`PLATFORM` is `firefox` or `chrome`. `VERSION` is the version of the submission +you are reviewing, and must match, because it is written into `manifest.json` +and is what the store checks. + +### With Docker + +```bash +PLATFORM=firefox \ +VERSION= \ + ./bin/package_extension.sh +``` + +The result is `screenly--extension-.zip`, plus the unpacked +build in `dist/`. + +### Without Docker + +```bash +bun install --frozen-lockfile + +jq --arg version "" '.version = $version' \ + src/manifest-firefox.json > src/manifest.json + +bunx webpack --config webpack.prod.js +``` + +`dist/` now holds the extension. `src/manifest.json` is generated and +deliberately not committed — `src/manifest-chrome.json` and +`src/manifest-firefox.json` are the templates it is built from, and the only +difference between the two platforms is the `browser_specific_settings` block +Firefox needs. + +## Verifying the result + +```bash +bun run test # unit tests +bun run lint:check # ESLint +bunx web-ext lint --source-dir dist +``` + +Released builds also carry [GitHub build +provenance](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations), +so a `.zip` from our releases page can be tied back to the workflow run and +commit that produced it: + +```bash +gh attestation verify screenly-firefox-extension-.zip --owner Screenly +``` diff --git a/bin/package_source.sh b/bin/package_source.sh new file mode 100755 index 0000000..cbe3a09 --- /dev/null +++ b/bin/package_source.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Builds the source archive that accompanies a Firefox submission. AMO requires +# one whenever the shipped code has been bundled or minified, which ours is. +# See SOURCE_BUILD_INSTRUCTIONS.md for what a reviewer does with it. + +set -euo pipefail +IFS=$'\n\t' + +VERSION=${VERSION:-0.0.0} +REF=${REF:-HEAD} + +ARCHIVE="screenly-extension-source-$VERSION.zip" + +git archive \ + --format=zip \ + --prefix="screenly-extension-$VERSION/" \ + --output "$ARCHIVE" \ + "$REF" + +echo "$ARCHIVE" From 3ef11bd7d2d781d3fa7f0c9a53d2966a3f77da44 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:00:27 +0000 Subject: [PATCH 2/5] fix: pin the publishing tools and fetch them without credentials npx resolved chrome-webstore-upload-cli@4 and web-ext@10 on every run, so a bad release inside either major would have run with the store credentials in its environment. Both are now exact versions, installed in a separate step that holds no secrets and into $RUNNER_TEMP rather than the checkout, so the credentialed steps only execute code that is already on disk. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: multica-agent --- .github/workflows/publish.yaml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5798557..2b7697c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -107,6 +107,13 @@ jobs: with: node-version: 24 + # Pinned exactly, and fetched in its own step, so that nothing is pulled + # from the registry while the store credentials are in the environment. + - name: Install the Chrome Web Store CLI + run: | + npm install --no-save --prefix "$RUNNER_TEMP/tools" \ + chrome-webstore-upload-cli@4.0.1 + - name: Upload the package env: EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} @@ -114,7 +121,9 @@ jobs: CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} - run: npx --yes chrome-webstore-upload-cli@4 upload --source extension.zip + run: | + "$RUNNER_TEMP/tools/node_modules/.bin/chrome-webstore-upload" \ + upload --source extension.zip - name: Submit for review if: ${{ !inputs.dry_run }} @@ -124,7 +133,8 @@ jobs: CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} - run: npx --yes chrome-webstore-upload-cli@4 publish + run: | + "$RUNNER_TEMP/tools/node_modules/.bin/chrome-webstore-upload" publish - name: Summarise env: @@ -218,8 +228,15 @@ jobs: with: node-version: 24 + # Pinned exactly, and installed outside the checkout so it neither + # reaches the registry with credentials present nor disturbs the sources + # that go to AMO. + - name: Install web-ext + run: npm install --no-save --prefix "$RUNNER_TEMP/tools" web-ext@10.7.0 + - name: Lint the package - run: npx --yes web-ext@10 lint --source-dir dist + run: | + "$RUNNER_TEMP/tools/node_modules/.bin/web-ext" lint --source-dir dist # --approval-timeout 0 returns as soon as AMO has accepted the upload. # A listed submission then sits in a human review queue, so waiting for @@ -231,7 +248,7 @@ jobs: WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }} VERSION: ${{ steps.release.outputs.version }} run: | - npx --yes web-ext@10 sign \ + "$RUNNER_TEMP/tools/node_modules/.bin/web-ext" sign \ --source-dir dist \ --channel listed \ --upload-source-code "screenly-extension-source-$VERSION.zip" \ From 53e97e8f83efc3eeef295b428123bfebbaa1d316 Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:09:18 +0000 Subject: [PATCH 3/5] feat: authenticate to the Chrome Web Store without a stored token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refresh token is minted by a human consenting in a browser, so CI can only use one by keeping a copy. That copy is a standing grant derived from one person's consent, and Google expires it after six months unused — longer than some gaps between our releases, so the likeliest first symptom is a failed release. API v2 accepts service accounts, so the workflow now exchanges GitHub's OIDC token for a Google access token valid for an hour. What the repository stores is a provider resource name and a service account email, neither of which grants anything by itself; the authority is an IAM binding on Google's side, revocable without touching this repository. chrome-webstore-upload-cli cannot take an externally minted token — it always derives one from a client ID and refresh token — so bin/publish_chrome.sh calls the three v2 endpoints directly, matching the upload headers, the uploadState values and the fetchStatus polling of the reference client. Firefox is unchanged: AMO has no federated equivalent, and its issuer and secret do not expire. Co-Authored-By: Claude Opus 5 Co-authored-by: multica-agent --- .github/workflows/publish.yaml | 70 ++++++++++++------------- CONTRIBUTING.md | 57 ++++++++++++++++++--- bin/publish_chrome.sh | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 44 deletions(-) create mode 100755 bin/publish_chrome.sh diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2b7697c..6cf2860 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -43,10 +43,14 @@ jobs: name: Chrome Web Store runs-on: ubuntu-latest # Add required reviewers to this environment in the repository settings to - # gate submissions on a second pair of eyes. It also holds the store - # credentials, which keeps them out of reach of workflows on pull requests. + # gate submissions on a second pair of eyes. It also scopes the store + # identifiers to this workflow, out of reach of pull request builds. environment: store-release if: inputs.platform != 'firefox' + permissions: + contents: read + attestations: read + id-token: write # to exchange for a Google access token steps: - name: Resolve the release tag id: release @@ -62,14 +66,14 @@ jobs: - name: Check that the store credentials are present env: - PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} - CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} + GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} run: | missing=() - for name in PUBLISHER_ID CLIENT_ID CLIENT_SECRET REFRESH_TOKEN; do - [[ -n ${!name} ]] || missing+=("CHROME_$name") + for name in CHROME_PUBLISHER_ID GCP_SERVICE_ACCOUNT \ + GCP_WORKLOAD_IDENTITY_PROVIDER; do + [[ -n ${!name} ]] || missing+=("$name") done if (( ${#missing[@]} )); then echo "Missing secrets: ${missing[*]}." @@ -77,6 +81,14 @@ jobs: exit 1 fi + # Before the download: checkout cleans the working directory. The default + # ref is the commit this workflow is running from, so the script and the + # workflow always come from the same revision. + - name: Check out the publishing script + uses: actions/checkout@v7 + with: + sparse-checkout: bin + - name: Download the release artifact env: GH_TOKEN: ${{ github.token }} @@ -102,39 +114,25 @@ jobs: exit 1 fi - - name: Install Node.js - uses: actions/setup-node@v7 + # GitHub's OIDC token is exchanged for an access token that is good for + # an hour and scoped to the Chrome Web Store. Nothing that can reach the + # store is stored in this repository or its secrets. + - name: Authenticate to Google Cloud + id: auth + uses: google-github-actions/auth@v3 with: - node-version: 24 - - # Pinned exactly, and fetched in its own step, so that nothing is pulled - # from the registry while the store credentials are in the environment. - - name: Install the Chrome Web Store CLI - run: | - npm install --no-save --prefix "$RUNNER_TEMP/tools" \ - chrome-webstore-upload-cli@4.0.1 + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/chromewebstore - - name: Upload the package + - name: Upload and submit env: - EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} + ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} - CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} - run: | - "$RUNNER_TEMP/tools/node_modules/.bin/chrome-webstore-upload" \ - upload --source extension.zip - - - name: Submit for review - if: ${{ !inputs.dry_run }} - env: EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} - PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} - CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} - run: | - "$RUNNER_TEMP/tools/node_modules/.bin/chrome-webstore-upload" publish + SUBMIT: ${{ !inputs.dry_run }} + run: ./bin/publish_chrome.sh - name: Summarise env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cb03fb..52c2ea3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,15 +108,56 @@ leaves the package as an unsubmitted draft, for Firefox it only lints. environment required reviewers if you want submissions to need a second approval. -| Secret | Where it comes from | +| Secret | What it is | | --- | --- | | `CHROME_PUBLISHER_ID` | The publisher account ID, visible in the Developer Dashboard URL. Not the extension ID. | -| `CHROME_CLIENT_ID`, `CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN` | A Google Cloud OAuth client with the Chrome Web Store API enabled. `npx chrome-webstore-upload-keys` walks through it. | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | Resource name of the workload identity provider, `projects/.../locations/global/workloadIdentityPools/.../providers/...`. | +| `GCP_SERVICE_ACCOUNT` | Email of the service account the provider is allowed to impersonate. | | `AMO_JWT_ISSUER`, `AMO_JWT_SECRET` | [AMO API credentials](https://addons.mozilla.org/en-US/developers/addon/api/key/). The secret is shown once. | -Two things to watch. A Google refresh token stops working if it goes unused for -six months, which is easy to hit at our release cadence — a failed Chrome job -with an auth error usually means a new token, not a broken workflow. And the -Chrome Web Store API v1.1 is switched off after 15 October 2026; we are on v2 -via `chrome-webstore-upload-cli` v4, so pin any replacement tooling to something -that speaks v2. +Only the last row is a credential. The Chrome side stores nothing that grants +access on its own: at run time GitHub mints an OIDC token for the workflow, +Google trades it for an access token good for an hour, and the authority for +that trade lives in an IAM binding on Google's side rather than in this +repository. There is no refresh token to obtain, store, rotate, or lose — which +also removes the trap where a Google refresh token quietly stops working after +six months unused, a span shorter than the gap between some of our releases. + +Firefox has no equivalent. AMO authenticates with an issuer and secret that +have to be stored; the workflow uses them to sign short-lived JWTs, and they +don't expire on their own. + +#### Setting up the Chrome side + +One-time, and it needs both a Google Cloud project and Chrome Web Store +publisher access: + +1. In the Cloud project, enable the **Chrome Web Store API** and the **IAM + Service Account Credentials API**. +2. Create a service account. It needs no project roles. +3. In the Chrome Web Store Developer Dashboard, under **Account**, add that + service account's email. A publisher can have only one, so this is worth + agreeing on before creating it. +4. Create a workload identity pool and a GitHub OIDC provider in it, with the + attribute condition restricted to this repository. +5. Grant the provider's principal `roles/iam.workloadIdentityUser` on the + service account. + +Google's guide to [using a service account with the Chrome Web Store +API](https://developer.chrome.com/docs/webstore/service-accounts) covers 1–3, +and [`google-github-actions/auth`](https://github.com/google-github-actions/auth) +covers 4–5. + +If federation is more than you want to set up, the same action takes a service +account JSON key instead: replace `workload_identity_provider` with +`credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}` in `publish.yaml`. +That is a long-lived secret again, but unlike a refresh token it belongs to the +organisation rather than to whoever happened to click through the consent +screen, and it can be rotated without one. + +#### A note on the API version + +Chrome Web Store API v1.1 is switched off after 15 October 2026, and most of +the publishing actions on the GitHub Marketplace still speak it. `publish.yaml` +calls v2 directly, which is why it needs a publisher ID alongside the extension +ID. Anything that replaces `bin/publish_chrome.sh` needs to speak v2 too. diff --git a/bin/publish_chrome.sh b/bin/publish_chrome.sh new file mode 100755 index 0000000..7680332 --- /dev/null +++ b/bin/publish_chrome.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# Uploads a packaged extension to the Chrome Web Store and submits it for +# review, against API v2. Authentication is an OAuth access token that the +# caller supplies in ACCESS_TOKEN: the workflow has GitHub mint a short-lived +# one for a service account, so there is no long-lived store credential to +# keep. API reference: https://developer.chrome.com/docs/webstore/api +# +# ACCESS_TOKEN=... PUBLISHER_ID=... EXTENSION_ID=... ./bin/publish_chrome.sh +# +# Set SUBMIT=false to upload without submitting for review, which leaves the +# package in the dashboard as an unsubmitted draft. + +set -euo pipefail +IFS=$'\n\t' + +: "${ACCESS_TOKEN:?ACCESS_TOKEN is required}" +: "${PUBLISHER_ID:?PUBLISHER_ID is required}" +: "${EXTENSION_ID:?EXTENSION_ID is required}" + +PACKAGE=${PACKAGE:-extension.zip} +SUBMIT=${SUBMIT:-true} +UPLOAD_TIMEOUT=${UPLOAD_TIMEOUT:-300} +POLL_INTERVAL=${POLL_INTERVAL:-5} + +API="https://chromewebstore.googleapis.com" +ITEM="publishers/$PUBLISHER_ID/items/$EXTENSION_ID" + +call() { + local method=$1 url=$2 + shift 2 + curl \ + --silent --show-error --fail-with-body \ + --request "$method" \ + --header "Authorization: Bearer $ACCESS_TOKEN" \ + "$@" \ + "$url" +} + +fail() { + echo "$1" >&2 + if [[ -n ${2:-} ]]; then + jq . <<< "$2" >&2 2>/dev/null || echo "$2" >&2 + fi + exit 1 +} + +if [[ ! -f $PACKAGE ]]; then + fail "No package at $PACKAGE." +fi + +echo "Uploading $PACKAGE to $EXTENSION_ID..." +if ! response=$(call POST "$API/upload/v2/$ITEM:upload" \ + --header 'X-Goog-Upload-Protocol: raw' \ + --header "X-Goog-Upload-File-Name: $(basename "$PACKAGE")" \ + --header 'Content-Type: application/zip' \ + --data-binary "@$PACKAGE"); then + fail "The Chrome Web Store rejected the upload." "$response" +fi + +state=$(jq -r '.uploadState // "UPLOAD_STATE_UNSPECIFIED"' <<< "$response") + +# Larger packages are processed asynchronously, so the upload call can answer +# IN_PROGRESS and leave the real outcome to be read back from the item. +waited=0 +while [[ $state == IN_PROGRESS && $waited -lt $UPLOAD_TIMEOUT ]]; do + sleep "$POLL_INTERVAL" + waited=$((waited + POLL_INTERVAL)) + if ! status=$(call GET "$API/v2/$ITEM:fetchStatus"); then + fail "Could not read the item's status back." "$status" + fi + state=$(jq -r '.lastAsyncUploadState // "UPLOAD_STATE_UNSPECIFIED"' <<< "$status") + echo "Upload state after ${waited}s: $state" +done + +case $state in + SUCCEEDED) echo "Upload succeeded." ;; + IN_PROGRESS) fail "Upload still processing after ${UPLOAD_TIMEOUT}s." "$response" ;; + *) fail "Upload finished in state $state." "$response" ;; +esac + +if [[ $SUBMIT != true ]]; then + echo "SUBMIT is $SUBMIT, leaving the package as an unsubmitted draft." + exit 0 +fi + +echo "Submitting for review..." +if ! response=$(call POST "$API/v2/$ITEM:publish" \ + --header 'Content-Type: application/json' \ + --data '{"publishType":"DEFAULT_PUBLISH"}'); then + fail "The Chrome Web Store rejected the submission." "$response" +fi + +echo "Submitted. Store state: $(jq -r '.state // "unknown"' <<< "$response")" From 50626d9244fdeebdf6f4d200e1861d4cb38cd14d Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:15:54 +0000 Subject: [PATCH 4/5] fix: run the publishing scripts from the workflow's own revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Firefox job checked out the released tag, so a manual retry paired the current workflow with whatever bin/package_source.sh looked like at that tag — and for any tag older than this branch, with no script at all. It now checks out the default ref like the Chrome job and passes the tag to the script, which already takes REF, so the archive is still cut from the released commit while the script itself matches the workflow driving it. Also enable the Security Token Service API in the setup steps: the OIDC exchange runs on it, so omitting it fails at authentication rather than at setup. Co-Authored-By: Claude Opus 5 Co-authored-by: multica-agent --- .github/workflows/publish.yaml | 13 +++++++++---- CONTRIBUTING.md | 6 ++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6cf2860..0df99cc 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -183,12 +183,14 @@ jobs: exit 1 fi - # AMO reviews the add-on by rebuilding it, so the submission carries the - # sources the released commit was built from — not the working tree. - - name: Checkout the released commit + # The default ref, so the scripts come from the same revision as the + # workflow driving them. The full history is needed because the archive + # AMO reviews is cut from the released tag, not from what is checked out. + - name: Check out the publishing scripts uses: actions/checkout@v7 with: - ref: ${{ steps.release.outputs.tag }} + fetch-depth: 0 + fetch-tags: true - name: Download the release artifact env: @@ -216,9 +218,12 @@ jobs: exit 1 fi + # AMO reviews the add-on by rebuilding it, so the archive has to hold the + # sources the released artifact was built from. - name: Package the sources for review env: VERSION: ${{ steps.release.outputs.version }} + REF: ${{ steps.release.outputs.tag }} run: ./bin/package_source.sh - name: Install Node.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52c2ea3..2499613 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,8 +132,10 @@ don't expire on their own. One-time, and it needs both a Google Cloud project and Chrome Web Store publisher access: -1. In the Cloud project, enable the **Chrome Web Store API** and the **IAM - Service Account Credentials API**. +1. In the Cloud project, enable the **Chrome Web Store API**, the **IAM Service + Account Credentials API** and the **Security Token Service API**. The last + two are what the token exchange runs on, and leaving them off fails at + authentication rather than at setup. 2. Create a service account. It needs no project roles. 3. In the Chrome Web Store Developer Dashboard, under **Account**, add that service account's email. A publisher can have only one, so this is worth From 20c17009ac4e9daac5cb53f357ef504c318cb8fb Mon Sep 17 00:00:00 2001 From: arm64-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:35:13 +0000 Subject: [PATCH 5/5] feat: read the Google identity from provisioned variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workload identity provider resource name and the service account email are not secrets — neither authorises anything without the IAM binding behind them — and keeping them secret hid which identity a failed run had tried. They are now environment variables, provisioned alongside the environment itself, so the value in GitHub follows the value in the Google project instead of being copied across by hand. CONTRIBUTING.md records which values are provisioned and which are set by hand, and keeps the one step the Chrome Web Store has no API for. Co-Authored-By: Claude Opus 5 Co-authored-by: multica-agent --- .github/workflows/publish.yaml | 19 +++++++---- CONTRIBUTING.md | 58 +++++++++++++--------------------- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0df99cc..1c7a63c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -42,9 +42,10 @@ jobs: chrome: name: Chrome Web Store runs-on: ubuntu-latest - # Add required reviewers to this environment in the repository settings to - # gate submissions on a second pair of eyes. It also scopes the store - # identifiers to this workflow, out of reach of pull request builds. + # The environment, its reviewers and the refs allowed to reach it are + # provisioned by our infrastructure automation rather than set in the + # repository settings UI. It also scopes the store identifiers to this + # workflow, out of reach of pull request builds. environment: store-release if: inputs.platform != 'firefox' permissions: @@ -64,11 +65,15 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + # The two GCP values are variables, not secrets: a provider resource name + # and a service account email, neither of which authorises anything on + # its own. They are provisioned, so a missing one means the environment + # has drifted from the configuration that sets it. - name: Check that the store credentials are present env: CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} - GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} - GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} run: | missing=() for name in CHROME_PUBLISHER_ID GCP_SERVICE_ACCOUNT \ @@ -121,8 +126,8 @@ jobs: id: auth uses: google-github-actions/auth@v3 with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} token_format: access_token access_token_scopes: https://www.googleapis.com/auth/chromewebstore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2499613..7f03b52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,16 +104,15 @@ leaves the package as an unsubmitted draft, for Firefox it only lints. ### Credentials -`publish.yaml` reads these from the `store-release` environment. Give that -environment required reviewers if you want submissions to need a second -approval. +`publish.yaml` runs in the `store-release` environment, which is provisioned +along with its reviewers and the refs allowed to reach it. What it reads: -| Secret | What it is | -| --- | --- | -| `CHROME_PUBLISHER_ID` | The publisher account ID, visible in the Developer Dashboard URL. Not the extension ID. | -| `GCP_WORKLOAD_IDENTITY_PROVIDER` | Resource name of the workload identity provider, `projects/.../locations/global/workloadIdentityPools/.../providers/...`. | -| `GCP_SERVICE_ACCOUNT` | Email of the service account the provider is allowed to impersonate. | -| `AMO_JWT_ISSUER`, `AMO_JWT_SECRET` | [AMO API credentials](https://addons.mozilla.org/en-US/developers/addon/api/key/). The secret is shown once. | +| Name | Kind | What it is | +| --- | --- | --- | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | variable, provisioned | Resource name of the workload identity provider the OIDC token is exchanged through. | +| `GCP_SERVICE_ACCOUNT` | variable, provisioned | Email of the service account that provider may impersonate. | +| `CHROME_PUBLISHER_ID` | secret, by hand | The publisher account ID, visible in the Developer Dashboard URL. Not the extension ID. | +| `AMO_JWT_ISSUER`, `AMO_JWT_SECRET` | secret, by hand | [AMO API credentials](https://addons.mozilla.org/en-US/developers/addon/api/key/). The secret is shown once. | Only the last row is a credential. The Chrome side stores nothing that grants access on its own: at run time GitHub mints an OIDC token for the workflow, @@ -129,33 +128,20 @@ don't expire on their own. #### Setting up the Chrome side -One-time, and it needs both a Google Cloud project and Chrome Web Store -publisher access: - -1. In the Cloud project, enable the **Chrome Web Store API**, the **IAM Service - Account Credentials API** and the **Security Token Service API**. The last - two are what the token exchange runs on, and leaving them off fails at - authentication rather than at setup. -2. Create a service account. It needs no project roles. -3. In the Chrome Web Store Developer Dashboard, under **Account**, add that - service account's email. A publisher can have only one, so this is worth - agreeing on before creating it. -4. Create a workload identity pool and a GitHub OIDC provider in it, with the - attribute condition restricted to this repository. -5. Grant the provider's principal `roles/iam.workloadIdentityUser` on the - service account. - -Google's guide to [using a service account with the Chrome Web Store -API](https://developer.chrome.com/docs/webstore/service-accounts) covers 1–3, -and [`google-github-actions/auth`](https://github.com/google-github-actions/auth) -covers 4–5. - -If federation is more than you want to set up, the same action takes a service -account JSON key instead: replace `workload_identity_provider` with -`credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}` in `publish.yaml`. -That is a long-lived secret again, but unlike a refresh token it belongs to the -organisation rather than to whoever happened to click through the consent -screen, and it can be rotated without one. +The Google side — the service account, its workload identity binding to this +repository, and the Chrome Web Store API — and the GitHub environment with the +two variables above are managed by Screenly's internal infrastructure +automation. Change them there rather than in the Google or GitHub consoles; +Screenly engineers will find the details alongside the rest of our CI +configuration. + +One step has no API and stays manual: someone with publisher access has to paste +the service account's email into **Account** in the [Chrome Web Store Developer +Dashboard](https://chrome.google.com/webstore/devconsole/). A publisher accepts +exactly one service account, so agree on it before changing it — swapping it +breaks releases for whatever was using the old one. Google's [guide to service +accounts](https://developer.chrome.com/docs/webstore/service-accounts) describes +what that box does. #### A note on the API version