From d21a0ec216bf58e3232bac774e1aceb5cae01a7d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 22 Oct 2026 12:35:30 -0700 Subject: [PATCH] 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([]); + } + }); +});