diff --git a/.github/workflows/cso-runtime-promote.yml b/.github/workflows/cso-runtime-promote.yml index 51bc78723..2acac8864 100644 --- a/.github/workflows/cso-runtime-promote.yml +++ b/.github/workflows/cso-runtime-promote.yml @@ -64,6 +64,7 @@ jobs: --name cso-qualified-runtime-statements --dir qualification-evidence - name: Generate a fail-closed catalog candidate env: + GH_TOKEN: ${{ github.token }} CSO_QUALIFICATION_RUN: ${{ inputs.qualification_run_id }} run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index cb56a74c8..811a37719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ - **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. - **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. + ## [1.91.176.0] - 2026-10-21 **A qualified /cso runtime set can be released again after the S1 shebang change.** The release contract fingerprints every source byte compiled into a runtime image (`preparationSha256`). S1 (#3312) changed only line 1 of the two compiled helpers, `lib/cso/verifier.ts` and `lib/cso/preparation-container.ts`, from `#!/usr/bin/env bun` to the isolated shebang. That line is embedded in the compiled binary as an unused string and never runs in the image, but it moved the fingerprint. Qualify run 39535948607 therefore refused the run-5 statements, staged at 133cb36, with `STALE_RUNTIME_QUALIFICATION: preparationSha256`. Garry chose this narrow rule change over a re-stage and a fresh paid qualification (2026-10-21, option 2). diff --git a/VERSION b/VERSION index 56e49edc2..6f962c98e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.176.0 +1.91.178.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3d83693bf..de69107ff 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.176.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.178.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/qa-evidence.ts b/lib/qa-evidence.ts index e32212410..4fc9eead6 100644 --- a/lib/qa-evidence.ts +++ b/lib/qa-evidence.ts @@ -385,7 +385,7 @@ function materialize(root: string, source: string) { const sha256 = publish(root, 'evidence.json', { ...annotations, evidence, learning, verdict }); return { action: 'materialize', status: 'complete', sha256, annotationsSha256: hash(bytes), exitCode: 0, verdict, reportLinks: notes.map(note => `[checkpoint ${note.name.slice(12, 15)}](${note.name})`), - next: `Include every reportLinks entry in the Markdown report, and report the overall status as ${verdict.status}${verdict.open.length ? ` (open: ${verdict.open.join('; ')})` : ''}; this verdict is final for this report root.` }; + next: `Include every reportLinks entry in the Markdown report, and report the overall status as ${verdict.status}${verdict.open.length ? ` (open: ${verdict.open.join('; ')})` : ''}; this verdict is final. Next, write the Markdown report beside evidence.json.` }; } const QA_EVIDENCE_USAGE = 'capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)'; diff --git a/package.json b/package.json index 95b7fab57..13c65d39c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.176", + "version": "1.91.178", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/helpers/qa-functional-eval.ts b/test/helpers/qa-functional-eval.ts index b21aab9e2..b20483980 100644 --- a/test/helpers/qa-functional-eval.ts +++ b/test/helpers/qa-functional-eval.ts @@ -51,7 +51,7 @@ Use one causal sentence per checkpoint hypothesis (English, more than 20 charact Fixture execution boundary: - No prior plans, global learnings, remote or cross-session artifact store. Branch/base are main, with a clean successful seed commit. Do not run global setup, telemetry, plan discovery, base-detection scripts or learning writes. -- Reports belong only in existing qa-reports. Retain fixture state; the owner cleans it after preserving evidence. Authorized source/test edits use Write/Edit. Read/Glob/Grep support arbitrary read-only discovery, including directory/path inventory. +- Reports belong only in existing qa-reports. Retain fixture state; the owner cleans it after preserving evidence. Authorized source/test edits use Write/Edit. Every Write/Edit targets an owned path inside this working directory; a write anywhere else, including its parent directory, fails acceptance. Read/Glob/Grep support arbitrary read-only discovery, including directory/path inventory. - Bash accepts separate literal commands only: no shell composition, scripts or added path operands. Read-only forms are pwd, ls, ls -la, git status --short, git status --porcelain, git branch --show-current, git diff, git diff --stat, git rev-parse HEAD, bun --version, and exactly date -u +%Y-%m-%dT%H:%M:%SZ. Native tests use bun test with optional named test/*.test.ts selectors. - These are complete command forms, not general shell examples. For inventory inside a named directory, use Read/Glob/Grep; the listed ls forms inspect only the working directory. Do not add operands or flags beyond the declared forms, even for read-only discovery. - The installed production helper is bin/gstack-qa-evidence (absolute owned path also accepted); \`bun bin/gstack-qa-evidence --help\` prints its usage, and the helper source is not part of the task. Probe outputs are declared public/synthetic, so capture with: bun bin/gstack-qa-evidence capture qa-reports NNN --public --timeout-ms 10000 -- NATIVE_PROBE. The child must be one of the observation forms below. Each execution/replay gets a fresh three-digit ID. The helper does not authorize another command, interpreter, path, pipeline or redirect. diff --git a/test/qa-evidence.test.ts b/test/qa-evidence.test.ts index 04097731d..5cf292db7 100644 --- a/test/qa-evidence.test.ts +++ b/test/qa-evidence.test.ts @@ -495,7 +495,8 @@ test('every capture after an input change names the commands still to rerun, unt const materialized = f.run('materialize', f.root, 'annotations.json'); expect(materialized.status, materialized.stderr).toBe(0); expect(receipt(materialized.stdout).verdict).toEqual({ status: 'pass', open: [] }); - expect(receipt(materialized.stdout).next).toContain('this verdict is final for this report root'); + expect(receipt(materialized.stdout).next).toContain('this verdict is final. Next, write the Markdown report beside evidence.json.'); + expect(receipt(materialized.stdout).next).not.toContain('report root'); }); test('a descriptive classification is rejected before publication, so the corrected label can still materialize', () => { diff --git a/test/workflow-gh-token.test.ts b/test/workflow-gh-token.test.ts new file mode 100644 index 000000000..08b768291 --- /dev/null +++ b/test/workflow-gh-token.test.ts @@ -0,0 +1,55 @@ +/** + * Every workflow step that runs the GitHub CLI gets a token (free). + * + * On a GitHub-hosted runner `gh` reads no ambient credential: a step without + * GH_TOKEN (or GITHUB_TOKEN) in its step, job or workflow env fails at its + * first gh call with "To use GitHub CLI in a GitHub Actions workflow, set the + * GH_TOKEN environment variable" (exit 4). That is how promote run 39691485727 + * died after a 40-minute protected-environment approval: its candidate step + * called `gh api …/compare` with no token. actionlint does not check this. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const WORKFLOWS = path.join(ROOT, '.github', 'workflows'); +/** A gh invocation at a command position, never ghcr.io, --gh or a word ending in gh. */ +const GH_CALL = /(?:^|[\s;&|(`]|\$\()gh\s+[a-z]/; + +/** `::` for every run step that calls gh with no token in scope. */ +export function tokenlessGhSteps(file: string, document: any): string[] { + const found: string[] = []; + const hasToken = (env: any) => Boolean(env?.GH_TOKEN || env?.GITHUB_TOKEN); + for (const [jobName, job] of Object.entries(document?.jobs ?? {})) { + for (const [index, step] of (Array.isArray(job?.steps) ? job.steps : []).entries()) { + if (typeof step?.run !== 'string') continue; + const calls = step.run.split('\n').some((line: string) => !line.trim().startsWith('#') && GH_CALL.test(line)); + if (calls && !hasToken(step.env) && !hasToken(job.env) && !hasToken(document.env)) found.push(`${file}:${jobName}:${step.name ?? index}`); + } + } + return found; +} + +describe('workflow gh token', () => { + test('no workflow step runs gh without GH_TOKEN or GITHUB_TOKEN in scope', () => { + const files = fs.readdirSync(WORKFLOWS).filter(name => /\.ya?ml$/.test(name)); + expect(files).toContain('cso-runtime-promote.yml'); + const missing = files.flatMap(file => tokenlessGhSteps(file, Bun.YAML.parse(fs.readFileSync(path.join(WORKFLOWS, file), 'utf8')))); + expect(missing).toEqual([]); + }); + + test('the scanner flags the promote run 39691485727 shape and accepts every scope that supplies a token', () => { + const step = (env?: Record) => ({ name: 'candidate', env, run: 'set -e\nx="$(jq -r .a f)"\ngh api "repos/$R/compare/$a...$b" > out.json' }); + expect(tokenlessGhSteps('w.yml', { jobs: { propose: { steps: [step({ CSO_QUALIFICATION_RUN: '1' })] } } })).toEqual(['w.yml:propose:candidate']); + expect(tokenlessGhSteps('w.yml', { jobs: { propose: { steps: [step({ GH_TOKEN: '${{ github.token }}' })] } } })).toEqual([]); + expect(tokenlessGhSteps('w.yml', { jobs: { propose: { env: { GITHUB_TOKEN: 't' }, steps: [step()] } } })).toEqual([]); + expect(tokenlessGhSteps('w.yml', { env: { GH_TOKEN: 't' }, jobs: { propose: { steps: [step()] } } })).toEqual([]); + for (const run of ['v="$(gh run list --json id)"', 'a && gh pr create --fill', 'gh attestation verify "oci://$i"']) { + expect(tokenlessGhSteps('w.yml', { jobs: { j: { steps: [{ run }] } } })).toEqual(['w.yml:j:0']); + } + for (const run of ['docker pull ghcr.io/o/r@sha256:x', 'tool --gh api', '# gh api is called by the next step', 'echo high']) { + expect(tokenlessGhSteps('w.yml', { jobs: { j: { steps: [{ run }] } } })).toEqual([]); + } + }); +});