From e75a0035d753d8d58a9c04a8c254bf8914135e24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:40:29 +0900 Subject: [PATCH 1/9] test(ci): require stacked pull request gates --- src/workflowExactHead.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 8da26fe4..ad4a1f14 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -33,6 +33,12 @@ describe('exact-head CI workflow contract', () => { expect(workflow.match(/persist-credentials: false/g)).toHaveLength(3); }); + it('runs exact-head CI for pull requests regardless of stacked base branch', () => { + expect(workflow).toContain(' push:\n branches: [main]'); + expect(workflow).toContain(' pull_request:\n'); + expect(workflow).not.toContain(' pull_request:\n branches: [main]'); + }); + it('keeps the workflow read-only and hash-pins every third-party action', () => { expect(workflow).toContain('permissions:\n contents: read'); expect(workflow).not.toContain('contents: write'); From 3ea55387d8d7d88a80933b398abda78e42a4496b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:44:44 +0900 Subject: [PATCH 2/9] fix(ci): gate stacked pull requests --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 210bbdb8..8e753a4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read From 1c597b037330f2fbb1943c10484e7640b4d0a513 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 00:34:42 +0900 Subject: [PATCH 3/9] test(ci): require bounded job timeouts --- src/workflowExactHead.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index ad4a1f14..670e1a38 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -39,6 +39,10 @@ describe('exact-head CI workflow contract', () => { expect(workflow).not.toContain(' pull_request:\n branches: [main]'); }); + it('bounds every CI job with an explicit 30-minute timeout', () => { + expect(workflow.match(/timeout-minutes: 30/g)).toHaveLength(3); + }); + it('keeps the workflow read-only and hash-pins every third-party action', () => { expect(workflow).toContain('permissions:\n contents: read'); expect(workflow).not.toContain('contents: write'); From 479847a177d0bfbdc93f091d1befac3b1960404b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 00:37:40 +0900 Subject: [PATCH 4/9] fix(ci): bound canonical job runtimes --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e753a4e..33e2b6ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ env: jobs: build-and-test: runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -73,6 +74,7 @@ jobs: office: name: Office / Python ${{ matrix.python-version }} runs-on: ubuntu-24.04 + timeout-minutes: 30 strategy: fail-fast: false matrix: From 85572a50d79a11c27c9171cc9180d7a53128bea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 01:05:01 +0900 Subject: [PATCH 5/9] test(ci): require runtime exact-head verification --- src/workflowExactHead.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 670e1a38..be656f04 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -43,6 +43,19 @@ describe('exact-head CI workflow contract', () => { expect(workflow.match(/timeout-minutes: 30/g)).toHaveLength(3); }); + it('verifies the runtime checkout SHA before any job consumes repository code', () => { + const verificationStep = [ + '- name: Verify exact checkout', + ' env:', + ' INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', + ' run: |', + ' actual_head="$(git rev-parse HEAD)"', + ' test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA"', + ].join('\n'); + + expect(workflow.split(verificationStep)).toHaveLength(4); + }); + it('keeps the workflow read-only and hash-pins every third-party action', () => { expect(workflow).toContain('permissions:\n contents: read'); expect(workflow).not.toContain('contents: write'); From 58ef72706299eec1b5edfff22680785acc7b0cf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 01:08:26 +0900 Subject: [PATCH 6/9] fix(ci): verify runtime checkout head --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e2b6ba..fb574e54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" # pnpm version comes from the package.json "packageManager" field # (pnpm 10+), which is required to read `overrides` from # pnpm-workspace.yaml consistently with the committed lockfile. @@ -55,6 +61,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -89,6 +101,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} From a74e150c06c22e3caee48446d03c3b0726bfc0f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:04:11 -0700 Subject: [PATCH 7/9] test(ci): reproduce browser evidence timeout exhaustion --- src/workflowExactHead.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index be656f04..41575ac6 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -39,8 +39,16 @@ describe('exact-head CI workflow contract', () => { expect(workflow).not.toContain(' pull_request:\n branches: [main]'); }); - it('bounds every CI job with an explicit 30-minute timeout', () => { - expect(workflow.match(/timeout-minutes: 30/g)).toHaveLength(3); + it('keeps ordinary jobs bounded while allowing slow browser dependency mirrors to finish', () => { + expect(workflow.match(/timeout-minutes: 30/g)).toHaveLength(2); + expect(workflow.match(/timeout-minutes: 60/g)).toHaveLength(1); + + const browserStart = workflow.indexOf(' browser-release-evidence:'); + const officeStart = workflow.indexOf(' office:', browserStart); + expect(browserStart).toBeGreaterThan(-1); + expect(officeStart).toBeGreaterThan(browserStart); + const browserJob = workflow.slice(browserStart, officeStart); + expect(browserJob).toContain('timeout-minutes: 60'); }); it('verifies the runtime checkout SHA before any job consumes repository code', () => { From b7e9bb8156a3e46204523ab37292566811f560f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:07:42 -0700 Subject: [PATCH 8/9] fix(ci): allow browser evidence through slow package mirrors --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb574e54..954d5e16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: browser-release-evidence: name: Cross-engine Clipboard / Playwright 1.62.0 runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 60 env: PLAYWRIGHT_BROWSERS_PATH: /tmp/inkspan-playwright-browsers steps: From 1efbd632719b0e54c5a33e3a95224a4bd2f869d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:13:27 -0700 Subject: [PATCH 9/9] test(ci): bind exact-head assertions to each workflow job --- src/workflowExactHead.test.ts | 125 +++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 34 deletions(-) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 41575ac6..d1f05310 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -19,49 +19,102 @@ const VULNERABLE_PNPM_ACTION_SETUP_PIN = 'pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8'; const SETUP_NODE_PIN = 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0'; +const SETUP_PYTHON_PIN = + 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0'; +const EXPECTED_HEAD_REF = + 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; +const VERIFY_EXACT_CHECKOUT_STEP = [ + '- name: Verify exact checkout', + ' env:', + ' INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', + ' run: |', + ' actual_head="$(git rev-parse HEAD)"', + ' test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA"', +].join('\n'); + +/** Return one top-level workflow job without borrowing assertions from siblings. */ +function workflowJob(source: string, name: string, nextName?: string): string { + const startMarker = ` ${name}:\n`; + const start = source.indexOf(startMarker); + expect(start).toBeGreaterThan(-1); + if (nextName === undefined) { + return source.slice(start); + } + const end = source.indexOf(`\n ${nextName}:\n`, start + startMarker.length); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +/** Require checkout -> exact-SHA verification -> first consumer in one job. */ +function expectExactCheckoutBeforeConsumer(job: string, consumer: string): void { + const checkout = job.indexOf(`- uses: ${CHECKOUT_PIN}`); + const verification = job.indexOf(VERIFY_EXACT_CHECKOUT_STEP); + const consumerIndex = job.indexOf(consumer); + + expect(checkout).toBeGreaterThan(-1); + expect(verification).toBeGreaterThan(checkout); + expect(consumerIndex).toBeGreaterThan(verification); + expect(job.match(new RegExp(CHECKOUT_PIN, 'g'))).toHaveLength(1); + expect(job.match(new RegExp(EXPECTED_HEAD_REF.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1); + expect(job.match(/persist-credentials: false/g)).toHaveLength(1); + expect(job.split(VERIFY_EXACT_CHECKOUT_STEP)).toHaveLength(2); + + const checkoutStep = job.slice(checkout, verification); + expect(checkoutStep).not.toContain('\n - '); +} + +const buildJob = workflowJob(workflow, 'build-and-test', 'browser-release-evidence'); +const browserJob = workflowJob(workflow, 'browser-release-evidence', 'office'); +const officeJob = workflowJob(workflow, 'office'); describe('exact-head CI workflow contract', () => { - it('uses a fixed runner and checks out the immutable current PR head', () => { + it('uses a fixed runner and checks out the immutable current PR head in every job', () => { expect(workflow).not.toContain('ubuntu-latest'); - expect(workflow.match(/runs-on: ubuntu-24\.04/g)).toHaveLength(3); - expect(workflow.match(new RegExp(CHECKOUT_PIN, 'g'))).toHaveLength(3); - expect( - workflow.match( - /ref: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/g, - ), - ).toHaveLength(3); - expect(workflow.match(/persist-credentials: false/g)).toHaveLength(3); + for (const job of [buildJob, browserJob, officeJob]) { + expect(job.match(/runs-on: ubuntu-24\.04/g)).toHaveLength(1); + expect(job).toContain(`- uses: ${CHECKOUT_PIN}`); + expect(job).toContain(EXPECTED_HEAD_REF); + expect(job).toContain('persist-credentials: false'); + } }); it('runs exact-head CI for pull requests regardless of stacked base branch', () => { - expect(workflow).toContain(' push:\n branches: [main]'); - expect(workflow).toContain(' pull_request:\n'); - expect(workflow).not.toContain(' pull_request:\n branches: [main]'); + const triggerStart = workflow.indexOf('on:\n'); + const triggerEnd = workflow.indexOf('\npermissions:', triggerStart); + expect(triggerStart).toBeGreaterThan(-1); + expect(triggerEnd).toBeGreaterThan(triggerStart); + const triggerBlock = workflow.slice(triggerStart, triggerEnd); + + expect(triggerBlock).toContain(' push:\n branches: [main]'); + const pullRequestLines = triggerBlock + .split('\n') + .filter((line) => line.startsWith(' pull_request:')); + expect(pullRequestLines).toEqual([' pull_request:']); + expect(triggerBlock).not.toMatch(/ pull_request:\n(?: .*\n)*? branches:/u); }); - it('keeps ordinary jobs bounded while allowing slow browser dependency mirrors to finish', () => { - expect(workflow.match(/timeout-minutes: 30/g)).toHaveLength(2); - expect(workflow.match(/timeout-minutes: 60/g)).toHaveLength(1); - - const browserStart = workflow.indexOf(' browser-release-evidence:'); - const officeStart = workflow.indexOf(' office:', browserStart); - expect(browserStart).toBeGreaterThan(-1); - expect(officeStart).toBeGreaterThan(browserStart); - const browserJob = workflow.slice(browserStart, officeStart); + it('binds timeout policy to the intended job instead of global counts', () => { + expect(buildJob.match(/timeout-minutes:/g)).toHaveLength(1); + expect(buildJob).toContain('timeout-minutes: 30'); + expect(browserJob.match(/timeout-minutes:/g)).toHaveLength(1); expect(browserJob).toContain('timeout-minutes: 60'); + expect(officeJob.match(/timeout-minutes:/g)).toHaveLength(1); + expect(officeJob).toContain('timeout-minutes: 30'); }); - it('verifies the runtime checkout SHA before any job consumes repository code', () => { - const verificationStep = [ - '- name: Verify exact checkout', - ' env:', - ' INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', - ' run: |', - ' actual_head="$(git rev-parse HEAD)"', - ' test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA"', - ].join('\n'); - - expect(workflow.split(verificationStep)).toHaveLength(4); + it('verifies each runtime checkout immediately before repository-consuming setup', () => { + expectExactCheckoutBeforeConsumer( + buildJob, + `- uses: ${PNPM_ACTION_SETUP_PIN}`, + ); + expectExactCheckoutBeforeConsumer( + browserJob, + `- uses: ${PNPM_ACTION_SETUP_PIN}`, + ); + expectExactCheckoutBeforeConsumer( + officeJob, + `- uses: ${SETUP_PYTHON_PIN}`, + ); }); it('keeps the workflow read-only and hash-pins every third-party action', () => { @@ -82,12 +135,16 @@ describe('exact-head CI workflow contract', () => { }); it('does not bootstrap pnpm through the vulnerable action release', () => { - expect(workflow.match(new RegExp(PNPM_ACTION_SETUP_PIN, 'g'))).toHaveLength(2); + expect(buildJob).toContain(PNPM_ACTION_SETUP_PIN); + expect(browserJob).toContain(PNPM_ACTION_SETUP_PIN); + expect(officeJob).not.toContain(PNPM_ACTION_SETUP_PIN); expect(workflow).not.toContain(VULNERABLE_PNPM_ACTION_SETUP_PIN); }); it('uses the current native-Node-24 setup-node release in every JavaScript job', () => { - expect(workflow.match(new RegExp(SETUP_NODE_PIN, 'g'))).toHaveLength(2); + expect(buildJob).toContain(SETUP_NODE_PIN); + expect(browserJob).toContain(SETUP_NODE_PIN); + expect(officeJob).not.toContain(SETUP_NODE_PIN); expect(workflow).not.toContain( 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0', );