diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2d12a6e..6b4edd3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,14 +19,25 @@ jobs: with: { node-version: '22.19', cache: pnpm } - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm cf:build - - uses: cloudflare/wrangler-action@v3 + - name: Upload Worker version + id: upload + uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # Code releases preserve existing routes; route changes use a separately authorized credential. - command: | - versions upload --env="" --tag ${{ github.sha }} - versions deploy --env="" --version-tag ${{ github.sha }}@100% --yes + command: versions upload --env="" --tag ${{ github.sha }} + - name: Resolve this upload's version ID + id: uploaded-version + run: node scripts/uploaded-worker-version.mjs + env: + WORKER_UPLOAD_OUTPUT: ${{ steps.upload.outputs.command-output }} + - name: Deploy the uploaded version + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: versions deploy --env="" ${{ steps.uploaded-version.outputs.version-id }}@100% --yes - name: Smoke check production run: pnpm smoke:prod diff --git a/__tests__/deploy-workflow.test.ts b/__tests__/deploy-workflow.test.ts index 4ead4a3..51dd165 100644 --- a/__tests__/deploy-workflow.test.ts +++ b/__tests__/deploy-workflow.test.ts @@ -11,9 +11,14 @@ describe('deploy workflow', () => { expect(deployWorkflow).toContain('workflow_dispatch:'); expect(deployWorkflow).toContain("if: github.event_name == 'workflow_dispatch'"); expect(deployWorkflow).toContain('pnpm cf:build'); + expect(deployWorkflow).toMatch(/versions upload --env="" --tag \$\{\{ github\.sha \}\}/); expect(deployWorkflow).toMatch( - /versions upload --env="" --tag \$\{\{ github\.sha \}\}\n\s+versions deploy --env="" --version-tag \$\{\{ github\.sha \}\}@100% --yes/ + /WORKER_UPLOAD_OUTPUT: \$\{\{ steps\.upload\.outputs\.command-output \}\}/ ); + expect(deployWorkflow).toMatch( + /versions deploy --env="" \$\{\{ steps\.uploaded-version\.outputs\.version-id \}\}@100% --yes/ + ); + expect(deployWorkflow).not.toContain('--version-tag'); expect(deployWorkflow).not.toMatch( /^[ \t]+(?:command: )?(?:wrangler )?(?:deploy|triggers deploy)(?:[ \t]|$)/m ); diff --git a/__tests__/uploaded-worker-version.test.ts b/__tests__/uploaded-worker-version.test.ts new file mode 100644 index 0000000..92d2f3c --- /dev/null +++ b/__tests__/uploaded-worker-version.test.ts @@ -0,0 +1,29 @@ +// A repeat deployment produced two versions with the same SHA tag. Resolve the +// UUID from this upload's actual Wrangler output, never from a shared tag. +import { describe, expect, it } from 'vitest'; +import { uploadedWorkerVersionId } from '../scripts/uploaded-worker-version.mjs'; + +const version = '11111111-2222-4333-8444-555555555555'; + +describe('uploaded Worker identity', () => { + it('selects the uploaded version despite other IDs and a reused SHA tag', () => { + const output = `Bucket ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\nUploaded resume-tailor (6.17 sec)\nWorker Version ID: ${version}\nTag: ${'a'.repeat(40)}\n`; + expect(uploadedWorkerVersionId(output)).toBe(version); + }); + + it('accepts Wrangler ANSI formatting without changing the UUID', () => { + expect(uploadedWorkerVersionId(`\u001b[32mWorker Version ID: ${version}\u001b[0m`)).toBe( + version + ); + }); + + it.each([ + '', + `Version ID: ${version}`, + 'Worker Version ID: not-a-version', + `Worker Version ID: ${version} trailing-command`, + `Worker Version ID: ${version}\nWorker Version ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee`, + ])('refuses missing or ambiguous upload identity (%s)', (output) => { + expect(() => uploadedWorkerVersionId(output)).toThrow(); + }); +}); diff --git a/scripts/uploaded-worker-version.mjs b/scripts/uploaded-worker-version.mjs new file mode 100644 index 0000000..133e007 --- /dev/null +++ b/scripts/uploaded-worker-version.mjs @@ -0,0 +1,21 @@ +import { appendFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function uploadedWorkerVersionId(output) { + const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + const plain = String(output).replace(ansi, ''); + const matches = [ + ...plain.matchAll( + /^\s*Worker Version ID:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\s*$/gim + ), + ]; + if (matches.length !== 1) throw new Error('Expected exactly one uploaded Worker Version ID.'); + return matches[0][1].toLowerCase(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const versionId = uploadedWorkerVersionId(process.env.WORKER_UPLOAD_OUTPUT ?? ''); + if (!process.env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is required.'); + appendFileSync(process.env.GITHUB_OUTPUT, `version-id=${versionId}\n`); +} diff --git a/src/__tests__/resume-oauth.test.ts b/src/__tests__/resume-oauth.test.ts index 9c22cd3..027d1d5 100644 --- a/src/__tests__/resume-oauth.test.ts +++ b/src/__tests__/resume-oauth.test.ts @@ -185,7 +185,13 @@ it('qualifies registration, consent, PKCE, replay resistance, owner identity, re expect(valid.status).toBe(200); expect(await valid.json()).toEqual({ user: 'oauth-owner' }); expect((await invoke(tokens.access_token, '/api/resume')).status).toBe(401); - expect((await invoke(`${tokens.access_token.slice(0, -2)}XX`)).status).toBe(401); + // Base64url's unused trailing bits can make an XX suffix decode to the same + // signature. Change the first signature character so the bytes always differ. + const parts = tokens.access_token.split('.'); + const signature = parts[2]; + parts[2] = `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`; + expect(Buffer.from(parts[2], 'base64url')).not.toEqual(Buffer.from(signature, 'base64url')); + expect((await invoke(parts.join('.'))).status).toBe(401); expect((await token({ ...grant, code: freshCode })).status).toBeGreaterThanOrEqual(400); const refreshed = await token({ grant_type: 'refresh_token',