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..1c7a63c --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,281 @@ +--- +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 + # 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: + contents: read + attestations: read + id-token: write # to exchange for a Google access token + 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" + + # 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: ${{ vars.GCP_SERVICE_ACCOUNT }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + run: | + missing=() + 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[*]}." + echo "See the 'Publishing to the stores' section of CONTRIBUTING.md." + 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 }} + 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 + + # 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: + 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 + + - name: Upload and submit + env: + ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} + EXTENSION_ID: ${{ env.CHROME_EXTENSION_ID }} + SUBMIT: ${{ !inputs.dry_run }} + run: ./bin/publish_chrome.sh + + - 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 + + # 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: + fetch-depth: 0 + fetch-tags: true + + - 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 + + # 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 + uses: actions/setup-node@v7 + 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: | + "$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 + # 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: | + "$RUNNER_TEMP/tools/node_modules/.bin/web-ext" 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..7f03b52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,20 +71,81 @@ $ 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` runs in the `store-release` environment, which is provisioned +along with its reviewers and the refs allowed to reach it. What it reads: + +| 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, +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 + +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 + +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/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" 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")"