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
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-22 19:35:30 +00:00
1 parent 514fbfdc97
commit d21a0ec216
6 files changed
+67 -3

No files matched your search

@@ -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
+8
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
1.91.176.0
1.91.177.0
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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",
+55
View File
@@ -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]/;
/** `<file>:<job>:<step>` 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<any>(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<string, string>) => ({ 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([]);
}
});
});