Skip to content
Open
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
5 changes: 4 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
281 changes: 281 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
vpetersson-bot marked this conversation as resolved.

- 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 "<https://chrome.google.com/webstore/devconsole/>"
} >> "$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 "<https://addons.mozilla.org/en-US/developers/addon/$FIREFOX_ADDON_SLUG/versions>"
} >> "$GITHUB_STEP_SUMMARY"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ node_modules

screenly-chrome-extension-*.zip
screenly-firefox-extension-*.zip
screenly-extension-source-*.zip
src/manifest.json
95 changes: 78 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <previous tag>..<new tag>` 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 <previous tag>..<new tag>` 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.
Loading
Loading