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")"