From bf4b4c3695425e2db01a4c31192c62c45dec7c1b Mon Sep 17 00:00:00 2001 From: drewstone Date: Fri, 11 Sep 2026 19:40:24 -0700 Subject: [PATCH 01/10] fix(tangle): mint a separate single-use broker bearer per execution --- src/tangle/index.ts | 59 +++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/src/tangle/index.ts b/src/tangle/index.ts index 3092391f..baa5dbfe 100644 --- a/src/tangle/index.ts +++ b/src/tangle/index.ts @@ -16,10 +16,10 @@ * (their Tangle session authorizes the app for a connection + scopes). * 2. On the callback, the consumer's client `exchangeAuthCode`s the `agc_` * code into the first broker token + a durable grant. - * 3. {@link createBrokerTokenProvider} — the runtime path: a cached provider - * that re-mints a fresh single-use broker token per `/v1/hub/exec` from the - * durable grant using only the app credentials (no user session). Caches - * until just before expiry so a burst of hub calls shares one mint. + * 3. {@link createBrokerTokenProvider} — the runtime path: each request mints + * a fresh single-use broker token from the durable grant using only app + * credentials (no user session). Neither tokens nor in-flight mint + * promises may be shared between execution attempts. */ /** A single-use hub bearer minted from a durable grant — mirrors @@ -82,56 +82,41 @@ export interface BrokerTokenProviderOptions { grantId: string /** Requested token TTL (seconds). */ ttlSeconds?: number - /** Re-mint this many ms BEFORE expiry so an in-flight call never uses a - * just-expired token. Default 30s. */ + /** @deprecated Retained for source compatibility. Single-use tokens are + * never cached, so refresh skew is ignored. */ refreshSkewMs?: number - /** Injectable clock (ms). Default `Date.now`. */ + /** @deprecated Retained for source compatibility; no local expiry cache. */ now?: () => number } -/** Provide and refresh broker bearer tokens, allowing forced token invalidation */ +/** Mint a separate single-use bearer for each execution attempt. */ export interface BrokerTokenProvider { - /** A valid `sk-tan-broker-` bearer, minting/refreshing as needed. */ + /** Mint a fresh bearer for exactly one Hub execution; never cache or share it. */ getToken(): Promise - /** Force the next `getToken` to re-mint (e.g. after a 401 from the hub). */ + /** Compatibility no-op: no bearer is cached. Does not revoke Hub grants or + * already-issued tokens; revocation belongs to the authoritative Hub. */ invalidate(): void } /** - * Cache + auto-refresh a broker token for one grant. A burst of hub calls - * shares a single mint; the token is re-minted once it's within `refreshSkewMs` - * of expiry, or on demand via {@link BrokerTokenProvider.invalidate}. - * Concurrent `getToken` calls during a mint share the same in-flight promise - * (no thundering herd). + * Mint a fresh broker token for every call, including concurrent calls. + * A broker bearer is consumed by one Hub execution even when its TTL has not + * expired. Cache the durable grant, never the bearer or an in-flight mint. + * Mint failures propagate; this helper never retries an external action. */ export function createBrokerTokenProvider(opts: BrokerTokenProviderOptions): BrokerTokenProvider { - const now = opts.now ?? (() => Date.now()) - const skew = opts.refreshSkewMs ?? 30_000 - let cached: { token: string; expiresAt: number } | null = null - let inflight: Promise | null = null - - async function mint(): Promise { - const t = await opts.client.mintBrokerToken({ - clientId: opts.clientId, - clientSecret: opts.clientSecret, - grantId: opts.grantId, - ttlSeconds: opts.ttlSeconds, - }) - cached = { token: t.accessToken, expiresAt: now() + t.expiresIn * 1000 } - return t.accessToken - } - return { async getToken() { - if (cached && now() < cached.expiresAt - skew) return cached.token - if (inflight) return inflight - inflight = mint().finally(() => { - inflight = null + const token = await opts.client.mintBrokerToken({ + clientId: opts.clientId, + clientSecret: opts.clientSecret, + grantId: opts.grantId, + ttlSeconds: opts.ttlSeconds, }) - return inflight + return token.accessToken }, invalidate() { - cached = null + // No cached bearer to clear. Keep this method for existing callers. }, } } From dffa3d01779f01d0f15d81b42c28f43eb6ff26eb Mon Sep 17 00:00:00 2001 From: drewstone Date: Fri, 11 Sep 2026 19:41:10 -0700 Subject: [PATCH 02/10] test(tangle): reject broker token reuse across sequential and concurrent calls --- tests/tangle.test.ts | 129 ++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 45 deletions(-) diff --git a/tests/tangle.test.ts b/tests/tangle.test.ts index 674e21b4..409a596c 100644 --- a/tests/tangle.test.ts +++ b/tests/tangle.test.ts @@ -25,69 +25,108 @@ describe('buildConsentUrl', () => { }) }) -/** A fake minter recording calls + a controllable token, so the provider's - * caching/refresh is tested without the network. */ -function fakeMinter(token: Partial = {}): { minter: BrokerTokenMinter; mints: number } { +/** Each mint creates a distinct bearer, as the Hub issuer does. */ +function fakeMinter(): { minter: BrokerTokenMinter; mints: number } { let mints = 0 - const minter: BrokerTokenMinter = { - async mintBrokerToken() { - mints++ - return { accessToken: `sk-tan-broker-${mints}`, expiresIn: 3600, scope: 'gmail.read', ...token } - }, - } return { - minter, - get mints() { - return mints + minter: { + async mintBrokerToken() { + mints++ + return { accessToken: `sk-tan-broker-${mints}`, expiresIn: 3600, scope: 'gmail.read' } + }, }, + get mints() { return mints }, } } +function provider(client: BrokerTokenMinter) { + return createBrokerTokenProvider({ client, clientId: 'c', clientSecret: 's', grantId: 'g' }) +} + describe('createBrokerTokenProvider', () => { - it('mints once and caches across calls within the TTL', async () => { - let t = 1_000_000 + it('mints a new bearer for successive calls even within the TTL', async () => { const f = fakeMinter() - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', now: () => t }) + const p = provider(f.minter) expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(f.mints).toBe(1) - }) - - it('re-mints once inside the refresh-skew window before expiry', async () => { - let t = 1_000_000 - const f = fakeMinter({ expiresIn: 100 }) // expires at +100s - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', refreshSkewMs: 30_000, now: () => t }) - expect(await p.getToken()).toBe('sk-tan-broker-1') - t += 60_000 // 60s in: still >30s skew before the 100s expiry → cached - expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(f.mints).toBe(1) - t += 20_000 // 80s in: within 30s of expiry → re-mint expect(await p.getToken()).toBe('sk-tan-broker-2') expect(f.mints).toBe(2) }) - it('shares one in-flight mint across concurrent getToken calls (no thundering herd)', async () => { - let resolveMint!: (v: BrokerToken) => void - let mints = 0 - const minter: BrokerTokenMinter = { - mintBrokerToken() { - mints++ - return new Promise((res) => { resolveMint = res }) - }, - } - const p = createBrokerTokenProvider({ client: minter, clientId: 'c', clientSecret: 's', grantId: 'g' }) + it('does not share an in-flight mint between concurrent callers', async () => { + const pending: Array<(token: BrokerToken) => void> = [] + const p = provider({ + mintBrokerToken: () => new Promise((resolve) => { pending.push(resolve) }), + }) const a = p.getToken() const b = p.getToken() - resolveMint({ accessToken: 'sk-tan-broker-x', expiresIn: 3600, scope: '' }) - expect(await a).toBe('sk-tan-broker-x') - expect(await b).toBe('sk-tan-broker-x') - expect(mints).toBe(1) + expect(pending).toHaveLength(2) + // Independent operations may resolve in either order. + pending[1]!({ accessToken: 'token-b', expiresIn: 3600, scope: '' }) + pending[0]!({ accessToken: 'token-a', expiresIn: 3600, scope: '' }) + expect(await a).toBe('token-a') + expect(await b).toBe('token-b') }) - it('invalidate() forces a fresh mint on the next call', async () => { - let t = 1_000_000 + it('supports a burst without reusing a consumed bearer', async () => { const f = fakeMinter() - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', now: () => t }) + const p = provider(f.minter) + const tokens = await Promise.all(Array.from({ length: 8 }, () => p.getToken())) + const consumed = new Set() + for (const token of tokens) { + // A second execution with the same token is a replay, not a cache hit. + expect(consumed.has(token)).toBe(false) + consumed.add(token) + } + expect(f.mints).toBe(8) + }) + + it('legacy clock and skew options do not enable caching', async () => { + const f = fakeMinter() + const p = createBrokerTokenProvider({ + client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', + now: () => 0, refreshSkewMs: 0, + }) + expect(await p.getToken()).not.toBe(await p.getToken()) + expect(f.mints).toBe(2) + }) + + it('propagates a failed mint without retrying it or poisoning later calls', async () => { + let attempts = 0 + const p = provider({ + async mintBrokerToken() { + if (++attempts === 1) throw new Error('issuer unavailable') + return { accessToken: 'fresh', expiresIn: 3600, scope: '' } + }, + }) + await expect(p.getToken()).rejects.toThrow('issuer unavailable') + expect(attempts).toBe(1) + expect(await p.getToken()).toBe('fresh') + }) + + it('forwards the exact grant and requested TTL for each mint', async () => { + const calls: Array[0]> = [] + const p = createBrokerTokenProvider({ + client: { + async mintBrokerToken(input) { + calls.push(input) + return { accessToken: `token-${calls.length}`, expiresIn: 60, scope: '' } + }, + }, + clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60, + }) + await p.getToken() + await p.getToken() + expect(calls).toEqual([ + { clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60 }, + { clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60 }, + ]) + }) + + it('retains invalidate as a compatibility no-op without minting', async () => { + const f = fakeMinter() + const p = provider(f.minter) + p.invalidate() + expect(f.mints).toBe(0) expect(await p.getToken()).toBe('sk-tan-broker-1') p.invalidate() expect(await p.getToken()).toBe('sk-tan-broker-2') From 6035d623950e0bd67bcbc01fbe35813d5a507155 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 15:14:34 -0700 Subject: [PATCH 03/10] ci: run package checks on pull requests without release credentials --- .github/workflows/verify-pr.yml | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/verify-pr.yml diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml new file mode 100644 index 00000000..6e405b7a --- /dev/null +++ b/.github/workflows/verify-pr.yml @@ -0,0 +1,51 @@ +name: Verify PR + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: verify-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + NODE_OPTIONS: --max-old-space-size=12288 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Record source identity + run: | + mkdir -p verification + git rev-parse HEAD > verification/commit.txt + git archive --format=tar.gz -o verification/source.tar.gz HEAD + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts=false + - name: Generate API documentation + run: | + pnpm docs:gen + git diff -- docs > verification/generated-docs.patch + - run: pnpm run typecheck + - run: pnpm run test:gates + - run: pnpm run build + - run: pnpm run test + - run: pnpm run test:generated + - run: pnpm run knip + - name: Retain verification inputs + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-verification-${{ github.event.pull_request.number }} + path: verification/ + retention-days: 7 From c90e33a8d97df4787c189999bbed2cf2a3cc423a Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 15:33:43 -0700 Subject: [PATCH 04/10] test(tangle): exercise concurrent single-use bearer consumption --- tests/tangle-token-concurrency.test.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/tangle-token-concurrency.test.ts diff --git a/tests/tangle-token-concurrency.test.ts b/tests/tangle-token-concurrency.test.ts new file mode 100644 index 00000000..658bf5a2 --- /dev/null +++ b/tests/tangle-token-concurrency.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createBrokerTokenProvider, type BrokerToken, type BrokerTokenMinter } from '../src/tangle/index' + +// Broker tokens authorize one execution, not all requests within their TTL. +// In-flight promise sharing would give competing executions the same bearer. +describe('createBrokerTokenProvider concurrency', () => { + it('gives concurrent executions separate tokens, including out-of-order mints', async () => { + const pending: Array<(token: BrokerToken) => void> = [] + const minter: BrokerTokenMinter = { + mintBrokerToken: () => new Promise((resolve) => { pending.push(resolve) }), + } + const provider = createBrokerTokenProvider({ + client: minter, clientId: 'app', clientSecret: 'test-only', grantId: 'grant', + }) + const first = provider.getToken() + const second = provider.getToken() + expect(pending).toHaveLength(2) + pending[1]!({ accessToken: 'second', expiresIn: 120, scope: 'gmail.read' }) + pending[0]!({ accessToken: 'first', expiresIn: 120, scope: 'gmail.read' }) + const issued = await Promise.all([first, second]) + const consumed = new Set() + for (const token of issued) { + expect(consumed.has(token)).toBe(false) + consumed.add(token) + } + expect(issued).toEqual(['first', 'second']) + }) +}) From 4d720612610385c2f737b990d33a688b86a34fdc Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 15:34:42 -0700 Subject: [PATCH 05/10] ci: commit generated API references on same-repository PRs and test exact head --- .github/workflows/verify-pr.yml | 41 +++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index 6e405b7a..25ea14cd 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -15,17 +15,15 @@ jobs: verify: runs-on: ubuntu-latest timeout-minutes: 35 + permissions: + contents: write env: NODE_OPTIONS: --max-old-space-size=12288 steps: - uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Record source identity - run: | - mkdir -p verification - git rev-parse HEAD > verification/commit.txt - git archive --format=tar.gz -o verification/source.tar.gz HEAD - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -34,8 +32,41 @@ jobs: - run: pnpm install --frozen-lockfile --ignore-scripts=false - name: Generate API documentation run: | + mkdir -p verification pnpm docs:gen git diff -- docs > verification/generated-docs.patch + - name: Commit only generated references + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + script: | + const fs = require('node:fs'); + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const paths = ['docs/api/tangle.md', 'docs/codemap.json', 'docs/llms-full.txt']; + const head = (await github.rest.git.getRef({owner, repo, ref: `heads/${pr.head.ref}`})).data.object.sha; + if (head !== pr.head.sha) throw new Error('PR changed during generation; retry the new head'); + const commit = (await github.rest.git.getCommit({owner, repo, commit_sha: head})).data; + const tree = []; + for (const path of paths) { + const content = fs.readFileSync(path, 'utf8'); + const blob = (await github.rest.git.createBlob({owner, repo, content, encoding: 'utf-8'})).data; + const old = (await github.rest.repos.getContent({owner, repo, path, ref: head})).data; + if (old.sha !== blob.sha) tree.push({path, mode: '100644', type: 'blob', sha: blob.sha}); + } + let testedHead = head; + if (tree.length) { + const updated = (await github.rest.git.createTree({owner, repo, base_tree: commit.tree.sha, tree})).data; + const next = (await github.rest.git.createCommit({owner, repo, tree: updated.sha, parents: [head], message: 'docs: regenerate broker token API references'})).data; + await github.rest.git.updateRef({owner, repo, ref: `heads/${pr.head.ref}`, sha: next.sha, force: false}); + testedHead = next.sha; + } + fs.writeFileSync('verification/commit.txt', testedHead + '\n'); + - name: Record actual tested inputs + run: | + git status --short > verification/worktree.txt + find src tests -type f -print0 | sort -z | xargs -0 sha256sum > verification/source-files.sha256 + tar --exclude=node_modules --exclude=.git --exclude=verification --exclude=dist -czf verification/source.tar.gz . - run: pnpm run typecheck - run: pnpm run test:gates - run: pnpm run build From 0ac4d7085c3ce73f67c7702a456b4fdde5135058 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:35:44 +0000 Subject: [PATCH 06/10] docs: regenerate broker token API references --- docs/api/tangle.md | 4 ++-- docs/codemap.json | 4 ++-- docs/llms-full.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api/tangle.md b/docs/api/tangle.md index 376d7272..58d5ff55 100644 --- a/docs/api/tangle.md +++ b/docs/api/tangle.md @@ -24,7 +24,7 @@ interface BrokerTokenMinter ### `BrokerTokenProvider` -`interface` — Provide and refresh broker bearer tokens, allowing forced token invalidation +`interface` — Mint a separate single-use bearer for each execution attempt. ```ts interface BrokerTokenProvider @@ -56,7 +56,7 @@ interface ConsentUrlInput ### `createBrokerTokenProvider` -`function` — Cache + auto-refresh a broker token for one grant. +`function` — Mint a fresh broker token for every call, including concurrent calls. ```ts (opts: BrokerTokenProviderOptions) => BrokerTokenProvider diff --git a/docs/codemap.json b/docs/codemap.json index 874c05a9..8715bb8d 100644 --- a/docs/codemap.json +++ b/docs/codemap.json @@ -15330,7 +15330,7 @@ "name": "BrokerTokenProvider", "kind": "interface", "signature": "interface BrokerTokenProvider", - "doc": "Provide and refresh broker bearer tokens, allowing forced token invalidation" + "doc": "Mint a separate single-use bearer for each execution attempt." }, { "name": "BrokerTokenProviderOptions", @@ -15354,7 +15354,7 @@ "name": "createBrokerTokenProvider", "kind": "function", "signature": "(opts: BrokerTokenProviderOptions) => BrokerTokenProvider", - "doc": "Cache + auto-refresh a broker token for one grant." + "doc": "Mint a fresh broker token for every call, including concurrent calls." } ] }, diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 095a4f2c..37b7773f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -19861,7 +19861,7 @@ interface BrokerTokenMinter ### `BrokerTokenProvider` -`interface` — Provide and refresh broker bearer tokens, allowing forced token invalidation +`interface` — Mint a separate single-use bearer for each execution attempt. ```ts interface BrokerTokenProvider @@ -19893,7 +19893,7 @@ interface ConsentUrlInput ### `createBrokerTokenProvider` -`function` — Cache + auto-refresh a broker token for one grant. +`function` — Mint a fresh broker token for every call, including concurrent calls. ```ts (opts: BrokerTokenProviderOptions) => BrokerTokenProvider From cb4ae4fe84a2aa16fd8399f97d5d224af9c7a1e3 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 16:00:20 -0700 Subject: [PATCH 07/10] ci: verify exact committed docs read-only and retain structured test reports --- .github/workflows/verify-pr.yml | 74 +++++++++------------------------ 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index 25ea14cd..d8106b38 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -1,81 +1,47 @@ name: Verify PR - on: pull_request: types: [opened, synchronize, reopened, ready_for_review] - permissions: contents: read - concurrency: group: verify-pr-${{ github.event.pull_request.number }} cancel-in-progress: true - jobs: verify: runs-on: ubuntu-latest timeout-minutes: 35 - permissions: - contents: write env: NODE_OPTIONS: --max-old-space-size=12288 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts=false - - name: Generate API documentation + - name: Record exact source run: | mkdir -p verification - pnpm docs:gen - git diff -- docs > verification/generated-docs.patch - - name: Commit only generated references - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v7 + git rev-parse HEAD > verification/commit.txt + git archive --format=tar.gz HEAD > verification/source.tar.gz + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: - script: | - const fs = require('node:fs'); - const { owner, repo } = context.repo; - const pr = context.payload.pull_request; - const paths = ['docs/api/tangle.md', 'docs/codemap.json', 'docs/llms-full.txt']; - const head = (await github.rest.git.getRef({owner, repo, ref: `heads/${pr.head.ref}`})).data.object.sha; - if (head !== pr.head.sha) throw new Error('PR changed during generation; retry the new head'); - const commit = (await github.rest.git.getCommit({owner, repo, commit_sha: head})).data; - const tree = []; - for (const path of paths) { - const content = fs.readFileSync(path, 'utf8'); - const blob = (await github.rest.git.createBlob({owner, repo, content, encoding: 'utf-8'})).data; - const old = (await github.rest.repos.getContent({owner, repo, path, ref: head})).data; - if (old.sha !== blob.sha) tree.push({path, mode: '100644', type: 'blob', sha: blob.sha}); - } - let testedHead = head; - if (tree.length) { - const updated = (await github.rest.git.createTree({owner, repo, base_tree: commit.tree.sha, tree})).data; - const next = (await github.rest.git.createCommit({owner, repo, tree: updated.sha, parents: [head], message: 'docs: regenerate broker token API references'})).data; - await github.rest.git.updateRef({owner, repo, ref: `heads/${pr.head.ref}`, sha: next.sha, force: false}); - testedHead = next.sha; - } - fs.writeFileSync('verification/commit.txt', testedHead + '\n'); - - name: Record actual tested inputs + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts=false > verification/install.log 2>&1 || { tail -60 verification/install.log; exit 1; } + - name: Verify generated documentation run: | - git status --short > verification/worktree.txt - find src tests -type f -print0 | sort -z | xargs -0 sha256sum > verification/source-files.sha256 - tar --exclude=node_modules --exclude=.git --exclude=verification --exclude=dist -czf verification/source.tar.gz . - - run: pnpm run typecheck - - run: pnpm run test:gates - - run: pnpm run build - - run: pnpm run test - - run: pnpm run test:generated - - run: pnpm run knip - - name: Retain verification inputs + pnpm docs:gen > verification/docs.log 2>&1 + git diff --exit-code -- docs + - run: pnpm run typecheck > verification/typecheck.log 2>&1 || { cat verification/typecheck.log; exit 1; } + - run: pnpm run test:gates > verification/gates.log 2>&1 || { tail -80 verification/gates.log; exit 1; } + - run: pnpm run build > verification/build.log 2>&1 || { tail -80 verification/build.log; exit 1; } + - name: Test all source + run: pnpm exec vitest run --reporter=json --outputFile=verification/tests.json > verification/tests.log 2>&1 || { tail -100 verification/tests.log; exit 1; } + - run: pnpm run test:generated > verification/generated.log 2>&1 || { tail -100 verification/generated.log; exit 1; } + - run: pnpm run knip > verification/knip.log 2>&1 || { cat verification/knip.log; exit 1; } + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() - uses: actions/upload-artifact@v4 with: name: pr-verification-${{ github.event.pull_request.number }} path: verification/ From 979cb74ab4ca22c79a2924852cbd4b07f0b5003d Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 16:08:59 -0700 Subject: [PATCH 08/10] ci: keep verification archives outside the dependency-audited source tree --- .github/workflows/verify-pr.yml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index d8106b38..ee7dcc73 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -13,36 +13,37 @@ jobs: timeout-minutes: 35 env: NODE_OPTIONS: --max-old-space-size=12288 + VERIFICATION_DIR: ${{ runner.temp }}/agent-app-verification steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Record exact source + - name: Record exact source outside the audited tree run: | - mkdir -p verification - git rev-parse HEAD > verification/commit.txt - git archive --format=tar.gz HEAD > verification/source.tar.gz + mkdir -p "$VERIFICATION_DIR" + git rev-parse HEAD > "$VERIFICATION_DIR/commit.txt" + git archive --format=tar.gz HEAD > "$VERIFICATION_DIR/source.tar.gz" - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts=false > verification/install.log 2>&1 || { tail -60 verification/install.log; exit 1; } + - run: pnpm install --frozen-lockfile --ignore-scripts=false > "$VERIFICATION_DIR/install.log" 2>&1 || { tail -60 "$VERIFICATION_DIR/install.log"; exit 1; } - name: Verify generated documentation run: | - pnpm docs:gen > verification/docs.log 2>&1 + pnpm docs:gen > "$VERIFICATION_DIR/docs.log" 2>&1 git diff --exit-code -- docs - - run: pnpm run typecheck > verification/typecheck.log 2>&1 || { cat verification/typecheck.log; exit 1; } - - run: pnpm run test:gates > verification/gates.log 2>&1 || { tail -80 verification/gates.log; exit 1; } - - run: pnpm run build > verification/build.log 2>&1 || { tail -80 verification/build.log; exit 1; } + - run: pnpm run typecheck > "$VERIFICATION_DIR/typecheck.log" 2>&1 || { cat "$VERIFICATION_DIR/typecheck.log"; exit 1; } + - run: pnpm run test:gates > "$VERIFICATION_DIR/gates.log" 2>&1 || { tail -80 "$VERIFICATION_DIR/gates.log"; exit 1; } + - run: pnpm run build > "$VERIFICATION_DIR/build.log" 2>&1 || { tail -80 "$VERIFICATION_DIR/build.log"; exit 1; } - name: Test all source - run: pnpm exec vitest run --reporter=json --outputFile=verification/tests.json > verification/tests.log 2>&1 || { tail -100 verification/tests.log; exit 1; } - - run: pnpm run test:generated > verification/generated.log 2>&1 || { tail -100 verification/generated.log; exit 1; } - - run: pnpm run knip > verification/knip.log 2>&1 || { cat verification/knip.log; exit 1; } + run: pnpm exec vitest run --reporter=json --outputFile="$VERIFICATION_DIR/tests.json" > "$VERIFICATION_DIR/tests.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/tests.log"; exit 1; } + - run: pnpm run test:generated > "$VERIFICATION_DIR/generated.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/generated.log"; exit 1; } + - run: pnpm run knip > "$VERIFICATION_DIR/knip.log" 2>&1 || { cat "$VERIFICATION_DIR/knip.log"; exit 1; } - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: pr-verification-${{ github.event.pull_request.number }} - path: verification/ + path: ${{ env.VERIFICATION_DIR }}/ retention-days: 7 From cdd600a510fd99d7425b3ed604f2ef4aa59b984f Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 16:55:25 -0700 Subject: [PATCH 09/10] ci: use a valid job environment context for verification output --- .github/workflows/verify-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index ee7dcc73..f78cd1b3 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 35 env: NODE_OPTIONS: --max-old-space-size=12288 - VERIFICATION_DIR: ${{ runner.temp }}/agent-app-verification + VERIFICATION_DIR: /tmp/agent-app-verification-${{ github.run_id }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: From 1f8cea4e0cb95e20bec95e82737f35d9639e137c Mon Sep 17 00:00:00 2001 From: drewstone Date: Sat, 12 Sep 2026 17:28:59 -0700 Subject: [PATCH 10/10] ci: honor repository Node/action pins and check the release contract on PRs --- .github/workflows/verify-pr.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index f78cd1b3..59e3241d 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -15,7 +15,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=12288 VERIFICATION_DIR: /tmp/agent-app-verification-${{ github.run_id }} steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -24,11 +24,13 @@ jobs: mkdir -p "$VERIFICATION_DIR" git rev-parse HEAD > "$VERIFICATION_DIR/commit.txt" git archive --format=tar.gz HEAD > "$VERIFICATION_DIR/source.tar.gz" - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: 22 + node-version-file: .nvmrc cache: pnpm + - name: Check release contract + run: node .github/scripts/test-publish-workflow.mjs - run: pnpm install --frozen-lockfile --ignore-scripts=false > "$VERIFICATION_DIR/install.log" 2>&1 || { tail -60 "$VERIFICATION_DIR/install.log"; exit 1; } - name: Verify generated documentation run: | @@ -41,7 +43,7 @@ jobs: run: pnpm exec vitest run --reporter=json --outputFile="$VERIFICATION_DIR/tests.json" > "$VERIFICATION_DIR/tests.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/tests.log"; exit 1; } - run: pnpm run test:generated > "$VERIFICATION_DIR/generated.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/generated.log"; exit 1; } - run: pnpm run knip > "$VERIFICATION_DIR/knip.log" 2>&1 || { cat "$VERIFICATION_DIR/knip.log"; exit 1; } - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: always() with: name: pr-verification-${{ github.event.pull_request.number }}