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)