From ff2d2a97e5648cda4cc4aca344a58ba5d7543af2 Mon Sep 17 00:00:00 2001 From: Souta Date: Mon, 14 Sep 2026 23:11:11 +0900 Subject: [PATCH] Let CI do every release after the first one souta registered the developer account and asked whether CI could take it from there. Mostly yes, and the line is not where you would guess. The Chrome Web Store API cannot create a listing. Description, screenshots, category and the data disclosure are not reachable from it, and the extension ID does not exist until the Store listing and Privacy tabs have been filled in by a person. So the first submission is by hand and always will be. Every update after it is a package upload and a publish, and both are API calls. `store.yml` does those on a published release. The human gate is not removed, it moves: merging the release PR is the decision, and what reaches the store is the package that was built, tested and attached to that release rather than one dragged into a browser. A service account rather than a refresh token. A refresh token issued while the OAuth consent screen is still in "Testing" expires after a week, so the pipeline would work today and fail next month having changed nothing. v2 of the API takes service accounts, which do not expire; the JSON key is one secret and the dashboard takes the account's email under Account. It refuses before authenticating when a secret is missing, and names which. An authentication error twenty lines into a log is the same fact told worse. Two things the docs say and experience will otherwise teach: the API does not set visibility, so an item whose visibility was changed by hand must be published by hand once before the API will do it again; and a version cannot be uploaded twice, which is what the `versions agree` check has been protecting all along. The endpoints and the service-account flow are from the Chrome for Developers documentation rather than memory, and the action SHA resolves to the v3.0.0 tag rather than the moving `v3`. Signed-off-by: Souta --- .github/workflows/store.yml | 117 ++++++++++++++++++++++++++++++++++++ extension/STORE.md | 43 ++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/store.yml diff --git a/.github/workflows/store.yml b/.github/workflows/store.yml new file mode 100644 index 0000000..7471e19 --- /dev/null +++ b/.github/workflows/store.yml @@ -0,0 +1,117 @@ +name: store + +# Upload a release to the Chrome Web Store, and publish it. +# +# What this can and cannot do, because the line is not where you would guess: +# +# - It cannot create the listing. The Store listing and Privacy tabs have to be filled in the +# dashboard by a person, and the extension ID does not exist until they have been. +# Description, screenshots, category, the data disclosure — none of them are reachable from +# this API. The first submission is by hand and always will be. +# - After that it can do every update: upload the package and publish it. +# +# The human gate is not removed, it moves. Merging the release PR is the decision; a release is +# what triggers this. Nothing reaches anybody that was not merged first. +# +# A service account rather than a refresh token. Refresh tokens issued by an OAuth consent +# screen still in "Testing" expire in a week, which makes a release pipeline that worked in +# March fail in April for a reason nobody changed. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to upload, e.g. v0.5.0" + required: true + type: string + publish: + description: "Publish after uploading, rather than leaving it as a draft" + type: boolean + default: false + +permissions: + contents: read + +jobs: + store: + name: upload to the Chrome Web Store + runs-on: ubuntu-latest + steps: + - name: Refuse early if this has never been set up + # Before the token exchange, so a missing secret is named rather than arriving as an + # authentication error twenty lines into a log. + env: + KEY: ${{ secrets.CWS_SERVICE_ACCOUNT }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + missing= + [ -n "$KEY" ] || missing="$missing CWS_SERVICE_ACCOUNT" + [ -n "$PUBLISHER" ] || missing="$missing CWS_PUBLISHER_ID" + [ -n "$ITEM" ] || missing="$missing CWS_EXTENSION_ID" + if [ -n "$missing" ]; then + echo "::error::missing repository secrets:$missing — see extension/STORE.md" + exit 1 + fi + + - id: auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + credentials_json: ${{ secrets.CWS_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/chromewebstore + + - name: Fetch the release package + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + REPO: ${{ github.repository }} + run: | + gh release download "$TAG" --repo "$REPO" --pattern '*.zip' --dir . + # Exactly one, or the shell picks. Two zips on a release would otherwise mean + # uploading whichever sorted first. + count=$(ls -1 ./*.zip | wc -l) + test "$count" -eq 1 || { echo "::error::expected one zip, found $count"; exit 1; } + mv ./*.zip package.zip + unzip -p package.zip manifest.json | grep '"version"' + + - name: Upload + env: + TOKEN: ${{ steps.auth.outputs.access_token }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + # `--fail-with-body` rather than `--fail`: the store says why in the body, and a bare + # "exit 22" is the least useful half of that. + curl --silent --show-error --fail-with-body \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/zip" \ + --data-binary @package.zip \ + "https://chromewebstore.googleapis.com/upload/v2/publishers/$PUBLISHER/items/$ITEM:upload" \ + | tee upload.json + # The HTTP status can be 200 while the item state is FAILURE, so the body decides. + grep -q '"uploadState"[[:space:]]*:[[:space:]]*"SUCCESS"' upload.json + + - name: Publish + # A release publishes; a manual run does so only when asked. The button defaults to the + # cautious answer because somebody pressing it is usually testing the pipeline. + if: ${{ github.event_name == 'release' || inputs.publish }} + env: + TOKEN: ${{ steps.auth.outputs.access_token }} + PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }} + ITEM: ${{ secrets.CWS_EXTENSION_ID }} + run: | + # Visibility is whatever the dashboard already says; this API does not set it. If it + # was changed by hand, the store refuses until it has been published by hand once at + # the new visibility — so a failure here is worth reading rather than retrying. + curl --silent --show-error --fail-with-body \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Length: 0" \ + "https://chromewebstore.googleapis.com/v2/publishers/$PUBLISHER/items/$ITEM:publish" \ + | tee publish.json + # Review is not instant. This says it was accepted, not that it is live. + echo "submitted; the store reviews it before anyone sees it" diff --git a/extension/STORE.md b/extension/STORE.md index 770725f..41b07a0 100644 --- a/extension/STORE.md +++ b/extension/STORE.md @@ -151,10 +151,51 @@ the first run of this produced exactly that. `shots.yml` runs on a fresh runner throwaway sshd and the invented `ssh_config` in `e2e/shots-config/`, and `shots.mjs` refuses any host but a local one so it cannot happen by habit. -## Still to do by hand +## The first submission, by hand + +Once. The API cannot create a listing — description, screenshots, category and the data +disclosure are not reachable from it, and the extension ID does not exist until the Store +listing and Privacy tabs have been filled in. 1. Register the developer account. Five dollars, once. 2. Upload the zip, paste the text above, attach the screenshots. 3. Choose visibility. Unlisted is worth considering first: this extension does nothing without a daemon installed separately, and a public listing collects installs from people who have not done that and will reasonably report it as broken. + +## Every release after that, by CI + +`.github/workflows/store.yml` uploads the release's zip and publishes it. The decision is still +a person's — it is merging the release PR — but nothing is retyped, and what reaches the store +is the package that was built, tested and attached to the release rather than one somebody +dragged into a browser. + +Three repository secrets, set once: + +| secret | where it comes from | +|---|---| +| `CWS_EXTENSION_ID` | the item's ID, from its dashboard URL, once it exists | +| `CWS_PUBLISHER_ID` | Developer Dashboard → Account | +| `CWS_SERVICE_ACCOUNT` | the JSON key of a Google Cloud service account | + +A service account rather than a refresh token: a refresh token issued while the OAuth consent +screen is still in "Testing" expires after a week, so the pipeline would work today and fail +next month having changed nothing. + +1. In the Google Cloud console, create a project and enable the **Chrome Web Store API**. +2. Create a service account. It needs no roles. +3. Create a JSON key for it, and put the whole file in `CWS_SERVICE_ACCOUNT`. +4. In the Developer Dashboard, under **Account**, add the service account's email address. + Only one service account can be attached to a publisher, so this is the one. + +Then `gh workflow run store.yml -f tag=v0.5.0` tries it without publishing, and after that +every published release goes on its own. + +Two things that will bite: + +- **Visibility is not set by this API.** The item publishes at whatever the dashboard says, and + if visibility is changed by hand the store refuses API publishing until it has been published + by hand once at the new setting. +- **A version cannot be uploaded twice.** `versions agree` in CI keeps `manifest.json` in step + with `Cargo.toml`; if that ever drifts, the store rejects the upload rather than quietly + taking it.