From d21a0ec216bf58e3232bac774e1aceb5cae01a7d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 22 Oct 2026 12:35:30 -0700 Subject: [PATCH 1/2] v1.91.177.0 ci(cso): give the promote candidate step GH_TOKEN; fail any workflow step that runs gh without a token (#3336) * ci(cso): give the promote candidate step GH_TOKEN, and fail any workflow step that runs gh without a token Promote run 39691485727 failed after its cso-runtime-release approval: "Generate a fail-closed catalog candidate" calls gh api .../compare with no GH_TOKEN, and gh on a hosted runner exits 4. test/workflow-gh-token.test.ts scans every workflow step that invokes gh for a token in step, job or workflow env. * changelog: promote workflow GH_TOKEN fix * v1.91.177.0 stamp --- .github/workflows/cso-runtime-promote.yml | 1 + CHANGELOG.md | 8 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/workflow-gh-token.test.ts | 55 +++++++++++++++++++++++ 6 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 test/workflow-gh-token.test.ts 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 885ac513e..b358118b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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..b1278b8ee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.176.0 +1.91.177.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3d83693bf..9cbc1b74f 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.177.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/package.json b/package.json index 95b7fab57..a794123da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.176", + "version": "1.91.177", "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/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([]); + } + }); +}); From df017c50b8c4fb2b17bed3dae484c9d3660292ae Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 22 Oct 2026 12:57:52 -0700 Subject: [PATCH 2/2] v1.91.178.0 fix(qa): materialize names the next write instead of qualifying the verdict by report root (flaky pass 2026-10-22) (#3341) * fix(qa): materialize names the next write; fixture states the write boundary (GSTA-63) qa-functional-webhook-fix (gate 39646086155) and qa-functional-cli-fix (gate 39166935531) each wrote a stray placeholder beside the fixture right after a fail verdict that read "final for this report root". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.178.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/qa-evidence.ts | 2 +- package.json | 2 +- test/helpers/qa-functional-eval.ts | 2 +- test/qa-evidence.test.ts | 3 ++- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b358118b0..2d2041bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [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. diff --git a/VERSION b/VERSION index b1278b8ee..6f962c98e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.177.0 +1.91.178.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9cbc1b74f..de69107ff 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.177.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 a794123da..13c65d39c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.177", + "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', () => {