From cc498ad4130497b09dd4bc1e3901447fb71a7b6b Mon Sep 17 00:00:00 2001 From: Seismix Date: Thu, 23 Jul 2026 14:25:28 +0200 Subject: [PATCH 1/2] chore(ci): bump actions to node24, add token script and back-merge workflow Three unrelated pieces of CI maintenance. Actions bumped off the deprecated node20 runtime: actions/checkout v4 -> v7, actions/setup-node v4 -> v7, pnpm/action-setup v4 -> v6. All three latest majors run on node24. The only behavioural change that could affect us is setup-node v5 auto-caching when package.json declares a `packageManager` field; ours does not, so the explicit `cache: "pnpm"` still governs. scripts/chrome-refresh-token.ts mints a Chrome Web Store refresh token. `wxt submit init` is meant to do this but still requests the out-of-band redirect Google blocked in 2022, and omits access_type=offline, without which Google returns no refresh token at all. The script uses the loopback redirect that replaced OOB. It can be deleted once wxt fixes this upstream. back-merge.yaml automates the `-s ours` reconciliation after each release. It needs a BACKMERGE_TOKEN secret and is inert until that exists. Also dropped the `paths:` filters from the unit and e2e workflows. A workflow skipped by a path filter never reports a status, so a required check would sit on "Expected" and block the PR forever. They have to run unconditionally before they can safely be made required. --- .github/workflows/back-merge.yaml | 57 +++++++++++++ .github/workflows/css-selectors.yaml | 6 +- .github/workflows/e2e.yaml | 16 ++-- .github/workflows/prettier-check.yaml | 6 +- .github/workflows/publish.yaml | 16 ++-- .github/workflows/semver.yaml | 2 +- .github/workflows/unit.yaml | 16 ++-- package.json | 1 + scripts/chrome-refresh-token.ts | 117 ++++++++++++++++++++++++++ 9 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/back-merge.yaml create mode 100644 scripts/chrome-refresh-token.ts diff --git a/.github/workflows/back-merge.yaml b/.github/workflows/back-merge.yaml new file mode 100644 index 0000000..ff47447 --- /dev/null +++ b/.github/workflows/back-merge.yaml @@ -0,0 +1,57 @@ +name: Back-merge main into dev + +# `main` enforces linear history and the repo only allows squash merges, so +# every dev -> main release lands as a single new commit with no ancestry link +# back to dev. Without repair, the next release re-applies every commit main +# already has and conflicts on every touched file. +# +# This records main as an ancestor of dev using `-s ours`, which keeps dev's +# tree byte-for-byte and changes no files - it only fixes the history graph. +# +# Requires a BACKMERGE_TOKEN secret: a fine-grained PAT with Contents: read and +# write on this repo. GITHUB_TOKEN cannot be used, because dev requires pull +# requests and a PR opened by GITHUB_TOKEN does not trigger dev's required +# checks, so it could never merge. Branch protection on dev has +# enforce_admins disabled, so a PAT owned by an admin may push directly. + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + back-merge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 0 + token: ${{ secrets.BACKMERGE_TOKEN }} + + - name: Reconcile main into dev + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin main + + if git merge-base --is-ancestor origin/main HEAD; then + echo "main is already an ancestor of dev - nothing to reconcile." + exit 0 + fi + + git merge -s ours origin/main -m "chore: reconcile squash-merged main into dev + + Automated by .github/workflows/back-merge.yaml. Records main as + an ancestor of dev so the next release merges cleanly. Changes + no files." + + # Guard against a bad merge: dev's tree must be untouched. + if [ -n "$(git diff --stat origin/dev HEAD)" ]; then + echo "Refusing to push - the merge changed files, which -s ours must never do." + git diff --stat origin/dev HEAD + exit 1 + fi + + git push origin dev + echo "Reconciled. main is now an ancestor of dev." diff --git a/.github/workflows/css-selectors.yaml b/.github/workflows/css-selectors.yaml index 300d3d4..155316b 100644 --- a/.github/workflows/css-selectors.yaml +++ b/.github/workflows/css-selectors.yaml @@ -30,15 +30,15 @@ jobs: NEEDRESTART_SUSPEND: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 240ca9a..cabbc5f 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1,15 +1,13 @@ name: E2E Tests +# No `paths:` filter on purpose. This is a required status check, and a +# workflow that is skipped by a path filter never reports a status at all, so +# the check sits on "Expected" and the PR can never merge. Running it on every +# PR costs ~45s and keeps the gate honest. on: pull_request: branches: [main, dev] types: [opened, reopened, synchronize, ready_for_review] - paths: - - 'src/**' - - 'playwright.config.ts' - - 'package.json' - - 'pnpm-lock.yaml' - - 'wxt.config.ts' jobs: e2e: @@ -24,15 +22,15 @@ jobs: NEEDRESTART_SUSPEND: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/prettier-check.yaml b/.github/workflows/prettier-check.yaml index 64633e9..571410c 100644 --- a/.github/workflows/prettier-check.yaml +++ b/.github/workflows/prettier-check.yaml @@ -11,15 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index bddaf71..c746b93 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -20,7 +20,7 @@ jobs: has_chrome: ${{ steps.chrome.outputs.has_chrome }} version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -61,15 +61,15 @@ jobs: if: needs.check.outputs.should_publish == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" @@ -96,15 +96,15 @@ jobs: if: needs.check.outputs.should_publish == 'true' && needs.check.outputs.has_chrome == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" @@ -143,7 +143,7 @@ jobs: env: VERSION: ${{ needs.check.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Tag and create GitHub release env: diff --git a/.github/workflows/semver.yaml b/.github/workflows/semver.yaml index 45421ac..dd9029f 100644 --- a/.github/workflows/semver.yaml +++ b/.github/workflows/semver.yaml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout PR branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Fetch main branch run: git fetch origin main diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index c55ee0e..1f08b82 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -1,15 +1,13 @@ name: Unit Tests +# No `paths:` filter on purpose. This is a required status check, and a +# workflow that is skipped by a path filter never reports a status at all, so +# the check sits on "Expected" and the PR can never merge. Running it on every +# PR costs ~30s and keeps the gate honest. on: pull_request: branches: [main, dev] types: [opened, reopened, synchronize, ready_for_review] - paths: - - 'src/**' - - 'vitest.config.ts' - - 'package.json' - - 'pnpm-lock.yaml' - - 'wxt.config.ts' jobs: unit: @@ -18,15 +16,15 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 - name: Setup node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/package.json b/package.json index 7a2e37a..998fff1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "check": "svelte-check --tsconfig ./tsconfig.json", + "chrome:token": "node scripts/chrome-refresh-token.ts", "postinstall": "wxt prepare", "lint": "pnpm build && web-ext lint --source-dir .output/firefox-mv2", "test": "playwright test --project=e2e --reporter=list", diff --git a/scripts/chrome-refresh-token.ts b/scripts/chrome-refresh-token.ts new file mode 100644 index 0000000..e8d17cc --- /dev/null +++ b/scripts/chrome-refresh-token.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +/** + * Mints a Chrome Web Store refresh token for the publish workflow. + * + * `wxt submit init` is the intended way to do this, but it still requests the + * out-of-band redirect (`urn:ietf:wg:oauth:2.0:oob`) that Google blocked in + * 2022, so it fails with "Access blocked: invalid_request". It also omits + * `access_type=offline`, without which Google returns no refresh token at all. + * + * This script does the same job with the loopback redirect that replaced OOB. + * It can be deleted once `wxt submit init` is fixed upstream. + * + * Requires an OAuth client of type "Desktop" (loopback is rejected for other + * client types) with the Chrome Web Store API enabled. + * + * Usage: + * pnpm chrome:token + * + * Credentials are read from the environment so they never reach shell history: + * read -rsp "Client secret: " S && echo && CHROME_CLIENT_ID= CHROME_CLIENT_SECRET="$S" pnpm chrome:token + * + * Store the result with: + * printf %s "" | gh secret set CHROME_REFRESH_TOKEN + */ + +import http from "node:http" + +const clientId = process.env.CHROME_CLIENT_ID +const clientSecret = process.env.CHROME_CLIENT_SECRET +const port = Number(process.env.PORT || 8080) +const redirectUri = `http://localhost:${port}` +const scope = "https://www.googleapis.com/auth/chromewebstore" + +if (!clientId || !clientSecret) { + console.error( + "Set CHROME_CLIENT_ID and CHROME_CLIENT_SECRET in the environment.", + ) + console.error( + 'Example: read -rsp "Client secret: " S && echo && CHROME_CLIENT_ID= CHROME_CLIENT_SECRET="$S" pnpm chrome:token', + ) + process.exit(1) +} + +// access_type=offline is what makes Google return a refresh token rather than +// only an access token. prompt=consent forces a new one even when this client +// has been authorized before, so re-running always yields a usable token. +const authUrl = + "https://accounts.google.com/o/oauth2/v2/auth?" + + new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope, + access_type: "offline", + prompt: "consent", + }) + +console.log("\nOpen this URL and approve access:\n") +console.log(authUrl) +console.log(`\nWaiting for the redirect on ${redirectUri} ...\n`) + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", redirectUri) + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + + if (error) { + res.end(`Authorization failed: ${error}. You can close this tab.`) + console.error(`\nAuthorization failed: ${error}`) + server.close() + process.exit(1) + } + + // Browsers also request /favicon.ico, which carries no code. + if (!code) { + res.end("Waiting for the authorization code...") + return + } + + res.end("Authorized. You can close this tab and return to the terminal.") + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }) + const body = await response.json() + + if (!body.refresh_token) { + console.error("\nNo refresh token returned. Response:\n", body) + if (body.error === "invalid_client") { + console.error( + "\ninvalid_client means the secret does not belong to this client ID.", + ) + } + server.close() + process.exit(1) + } + + console.log("\nRefresh token:\n") + console.log(body.refresh_token) + console.log("\nStore it with:\n") + console.log( + `printf %s "${body.refresh_token}" | gh secret set CHROME_REFRESH_TOKEN`, + ) + server.close() + process.exit(0) +}) + +server.listen(port) From f413cec80204b8e7bc716f06300af1db6c94b0de Mon Sep 17 00:00:00 2001 From: Seismix Date: Thu, 23 Jul 2026 14:39:03 +0200 Subject: [PATCH 2/2] chore(ci): drop back-merge workflow Superseded by allowing merge commits for dev -> main. A merge commit keeps dev's tip as a parent of main, so the branches never diverge and there is nothing to reconcile. The workflow was automating a repair for damage the squash-only policy was causing; removing the cause removes the need. --- .github/workflows/back-merge.yaml | 57 ------------------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/back-merge.yaml diff --git a/.github/workflows/back-merge.yaml b/.github/workflows/back-merge.yaml deleted file mode 100644 index ff47447..0000000 --- a/.github/workflows/back-merge.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: Back-merge main into dev - -# `main` enforces linear history and the repo only allows squash merges, so -# every dev -> main release lands as a single new commit with no ancestry link -# back to dev. Without repair, the next release re-applies every commit main -# already has and conflicts on every touched file. -# -# This records main as an ancestor of dev using `-s ours`, which keeps dev's -# tree byte-for-byte and changes no files - it only fixes the history graph. -# -# Requires a BACKMERGE_TOKEN secret: a fine-grained PAT with Contents: read and -# write on this repo. GITHUB_TOKEN cannot be used, because dev requires pull -# requests and a PR opened by GITHUB_TOKEN does not trigger dev's required -# checks, so it could never merge. Branch protection on dev has -# enforce_admins disabled, so a PAT owned by an admin may push directly. - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - back-merge: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: dev - fetch-depth: 0 - token: ${{ secrets.BACKMERGE_TOKEN }} - - - name: Reconcile main into dev - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch origin main - - if git merge-base --is-ancestor origin/main HEAD; then - echo "main is already an ancestor of dev - nothing to reconcile." - exit 0 - fi - - git merge -s ours origin/main -m "chore: reconcile squash-merged main into dev - - Automated by .github/workflows/back-merge.yaml. Records main as - an ancestor of dev so the next release merges cleanly. Changes - no files." - - # Guard against a bad merge: dev's tree must be untouched. - if [ -n "$(git diff --stat origin/dev HEAD)" ]; then - echo "Refusing to push - the merge changed files, which -s ours must never do." - git diff --stat origin/dev HEAD - exit 1 - fi - - git push origin dev - echo "Reconciled. main is now an ancestor of dev."