diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 742d13f7..a18a00fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -680,7 +680,7 @@ jobs: # Windows bills at twice the Linux rate, and this job needs the artifact # `rust` produces, so it follows the same gate. if: >- - always() && needs.rust.result == 'success' + !cancelled() && needs.rust.result == 'success' && needs.changes.outputs.docs_only != 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || contains(github.event.pull_request.labels.*.name, 'ci:full')) diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index 5fa8fadd..f4e6598e 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -163,8 +163,8 @@ assignment, RPC decoder and primary/secondary exception pipeline. This fixture correction does not establish the protected installation failure's cause or relax the round-trip assertions. -The lock now selects reviewed source `2dd79ec1505d9c12051552109244067b35813357`, -tree `5187dc5f385bb07f2a5c2f637ffe58898741017b`, including all 25 governed files +The lock now selects reviewed source `ffa954c2678bd933d7057d526b5fc4ed67a16f32`, +tree `3d667c7d816fe3fac088b6865b46756a84581beb`, including all 25 governed files and ten production deltas. The checkout regression exercises all five labels from that immutable revision. SDK rebinding, both scope rotations and fresh protected validation remain required; this binding alone is not acceptance. @@ -2874,3 +2874,21 @@ independent profile-probe job lifetimes described at the top of this document. Candidate, consumer, Cave, Coven, and supervisor executable authorities are unchanged. Native Windows checks and an SDK binding to the eventual delivery remain required before protected conformance can establish acceptance. + + +### Cancelling superseded pull-request Windows CI + +The ordinary `Windows supervisor behavior` job uses `!cancelled()` together +with its existing Rust-success, docs-only, main-push and `ci:full` gates. This +lets a superseded pull-request run stop instead of retaining a Windows runner +through `always()`. Main concurrency still completes each merge, and independent +conformance scan, keychain cleanup and artifact-retention steps keep their +existing conditions. The job name, 20-minute limit, and supervisor containment +are unchanged. + +A cancelled run is not cleanup or conformance acceptance evidence. Its replacement +must pass all required checks. Issue #306 tracks this change and the remaining +hosted supersession/replacement validation. The motivating old-head run +`35117564510` was cancelled while its Windows job continued to completion; the +replacement run `35118809575` subsequently passed all eleven jobs. That history +does not validate the new cancellation guard. diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index d2fdf1cd..070d568e 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -18,11 +18,11 @@ }, "harness": { "repository": "OpenCoven/chat", - "revision": "2dd79ec1505d9c12051552109244067b35813357" + "revision": "ffa954c2678bd933d7057d526b5fc4ed67a16f32" }, "harnessAuthority": { - "revision": "2dd79ec1505d9c12051552109244067b35813357", - "tree": "5187dc5f385bb07f2a5c2f637ffe58898741017b", + "revision": "ffa954c2678bd933d7057d526b5fc4ed67a16f32", + "tree": "3d667c7d816fe3fac088b6865b46756a84581beb", "files": [ { "path": "scripts/phase1-conformance.mjs", @@ -141,8 +141,8 @@ }, { "path": ".github/workflows/ci.yml", - "blob": "742d13f7a75faa0c7f7064328d0c1f1d4877c6c4", - "sha256": "f21eb5af2925a6d721ba45f73f51718bb9f43c36f9230e03655210251df38911" + "blob": "a18a00fc1e8dda0c427cd1a4864d896044d7b3df", + "sha256": "b28f4700104c70690b0068edfdd435f4b4810043cdd7ba23e868b5e782cf500a" }, { "path": ".github/workflows/client-v1-conformance.yml", diff --git a/src/phase1-conformance-lock.test.ts b/src/phase1-conformance-lock.test.ts index 66b5d3c8..195eef80 100644 --- a/src/phase1-conformance-lock.test.ts +++ b/src/phase1-conformance-lock.test.ts @@ -80,8 +80,8 @@ const committedHarnessAuthority = JSON.parse( readFileSync(resolve(projectRoot, 'phase1-conformance.lock.json'), 'utf8'), ).harnessAuthority; const expectedBehaviorAuthority = { - revision: '2dd79ec1505d9c12051552109244067b35813357', - tree: '5187dc5f385bb07f2a5c2f637ffe58898741017b', + revision: 'ffa954c2678bd933d7057d526b5fc4ed67a16f32', + tree: '3d667c7d816fe3fac088b6865b46756a84581beb', files: [ { path: 'scripts/phase1-conformance.mjs', @@ -125,8 +125,8 @@ const expectedBehaviorAuthority = { }, { path: '.github/workflows/ci.yml', - blob: '742d13f7a75faa0c7f7064328d0c1f1d4877c6c4', - sha256: 'f21eb5af2925a6d721ba45f73f51718bb9f43c36f9230e03655210251df38911', + blob: 'a18a00fc1e8dda0c427cd1a4864d896044d7b3df', + sha256: 'b28f4700104c70690b0068edfdd435f4b4810043cdd7ba23e868b5e782cf500a', }, { path: '.github/workflows/client-v1-conformance.yml', diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index 0e71d73d..1646a9c5 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; type CapabilityFile = { $schema?: string; @@ -1239,6 +1240,81 @@ describe('Phase 1 specification guards', () => { expect(workflow).toContain('cancel-in-progress: ${' + "{ github.ref_name != 'main' }}"); }); + it('cancels superseded Windows supervision without dropping its eligibility gates', () => { + const workflow = readText('.github/workflows/ci.yml'); + const job = workflow.split('\n windows-supervisor-behavior:\n')[1]; + expect(job).toBeDefined(); + const condition = job?.match(/\n {4}if: >-\n([\s\S]*?)\n {4}timeout-minutes:/)?.[1]; + expect(condition?.trim()).toBe( + "!cancelled() && needs.rust.result == 'success'\n" + + " && needs.changes.outputs.docs_only != 'true'\n" + + " && ((github.event_name == 'push' && github.ref == 'refs/heads/main')\n" + + " || contains(github.event.pull_request.labels.*.name, 'ci:full'))", + ); + // Cancellation must not remove the independent conformance cleanup steps. + expect(workflow).toContain( + "if: always() && hashFiles('test-results/phase1-conformance/report.json') != ''", + ); + expect(workflow).toContain( + "if: always() && steps.secret-scan.outcome == 'success' && steps.phase1-keychain-cleanup.outcome == 'success'", + ); + expect(workflow.match(/^ {8}if: always\(\)$/gm)).toHaveLength(2); + }); + + it.each([ + ['labelled PR', 'success', false, false, 'pull_request', 'refs/pull/308/merge', true, true], + ['failed Rust', 'failure', false, false, 'pull_request', 'refs/pull/308/merge', true, false], + ['skipped Rust', 'skipped', false, false, 'pull_request', 'refs/pull/308/merge', true, false], + [ + 'cancelled Rust', + 'cancelled', + false, + false, + 'pull_request', + 'refs/pull/308/merge', + true, + false, + ], + ['cancelled PR', 'success', true, false, 'pull_request', 'refs/pull/308/merge', true, false], + ['docs-only PR', 'success', false, true, 'pull_request', 'refs/pull/308/merge', true, false], + ['unlabelled PR', 'success', false, false, 'pull_request', 'refs/pull/308/merge', false, false], + ['main push', 'success', false, false, 'push', 'refs/heads/main', false, true], + ['main with failed Rust', 'failure', false, false, 'push', 'refs/heads/main', false, false], + ['cancelled main', 'success', true, false, 'push', 'refs/heads/main', false, false], + ['feature push', 'success', false, false, 'push', 'refs/heads/feature', false, false], + ] as const)( + 'evaluates Windows supervision eligibility for %s', + (_name, rust, cancelled, docsOnly, eventName, ref, full, expected) => { + const workflow = readText('.github/workflows/ci.yml'); + const job = workflow.split('\n windows-supervisor-behavior:\n')[1]; + const condition = job?.match(/\n {4}if: >-\n([\s\S]*?)\n {4}timeout-minutes:/)?.[1]; + expect(condition).toBeDefined(); + if (!condition) throw new Error('Missing Windows supervision condition'); + // Evaluate this boolean/string subset, not GitHub cancellation scheduling. + // The exact-expression guard above prevents unsupported syntax drifting in. + const expression = condition.replace( + 'github.event.pull_request.labels.*.name', + 'github.event.pull_request.labels.map(label => label.name)', + ); + const result: unknown = runInNewContext( + expression, + { + always: () => true, + cancelled: () => cancelled, + contains: (values: string[], value: string) => values.includes(value), + needs: { rust: { result: rust }, changes: { outputs: { docs_only: String(docsOnly) } } }, + github: { + event_name: eventName, + ref, + event: { pull_request: { labels: full ? [{ name: 'ci:full' }] : [] } }, + }, + }, + { timeout: 1000 }, + ); + expect(result).toBe(expected); + }, + ); + it('reads counterpart repositories with a token that does not depend on their visibility', () => { // The default GITHUB_TOKEN is scoped to this repository, so it can only // read a counterpart that happens to be public. Relying on that has taken