mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
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
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user