diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index ec8d4cb29..5f7d35133 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -998,6 +998,13 @@ jobs: permissions: contents: read packages: read + # A container job's run steps default to `sh -e {0}` (dash in the CI + # image), not bash as on a bare runner: run 40456150409 died at + # `${PREFIX_SHA:0:12}` with "Bad substitution" on every prefix before a + # case ran. test/workflow-shell-lint.test.ts keeps every sh-run step POSIX. + defaults: + run: + shell: bash container: image: ${{ needs.build-image.outputs.image-tag }} credentials: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4e5230e..bdd29a8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.271.0] - 2026-10-27 + +**The merge-train bisect can run in CI.** Every `train-bisect` job of `evals.yml` had died at its first step: a container job's `run` steps default to `sh -e {0}` (dash in the CI image), and "Verify the prefix checkout" used the bash-only `${PREFIX_SHA:0:12}`, so run 40456150409 (the first bisect ever dispatched, gate.bisect-b3d085bb87ee-d9b0d60082cc) failed all three prefixes with `Bad substitution` (exit 2) before a case ran; the second bisect run was cancelled. The job shipped in 1.91.162.0 (GSTA-49 T1b) and had never passed. Found while GSTA-96's auq-format-gate red put a train on the bisect path. + +### Fixed +- **`.github/workflows/evals.yml` `train-bisect`**: the job declares `defaults.run.shell: bash`, so every one of its run steps runs under `bash -e -o pipefail` like the steps on bare runners do. No other run step whose effective shell is sh uses a bash construct (the new lint below checked all 23 of them across `evals.yml`, `evals-periodic.yml`, `evals-marathon.yml` and `eval-sweep.yml`). + +### Tests +- `test/workflow-shell-lint.test.ts` (new, free, offline): parses every workflow under `.github/workflows/` and every composite action under `.github/actions/`, computes each run step's effective shell (step `shell` → job `defaults.run.shell` → workflow `defaults.run.shell` → GitHub's default: sh in a container job, pwsh on Windows, bash otherwise), and fails on a bash-only construct in a step whose effective shell is sh (`${x:0:n}`, `${x//…}`, `${!x}`, `[[`, arrays, `declare -a`/`mapfile`, `set -o pipefail`, `PIPESTATUS`, `<<<`, `<(…)`, `$'…'`, `source`, `function`). The detector has positive controls for each construct and negative controls for the POSIX forms (`${x:-d}`, `${x#p}`, `$(…)`, `$((…))`, comments). A fourth test pins the `train-bisect` job to bash by name; it was red at d9b0d60 and is green with the fix. + +### Gate integrity +- No threshold, trial count, case kind, budget or reservation changed; the bisect's cases, prefixes and spend path are untouched. The job's run steps gain `pipefail` from bash's explicit-shell default; the one that pipes (the Codex login) already ran under `shell: bash` with `set -euo pipefail`, and the others run single commands. + ## [1.91.270.0] - 2026-10-27 ### Added diff --git a/VERSION b/VERSION index 8ed250420..0e325b0fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.270.0 +1.91.271.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c6fe441c9..b6d102b9e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.270.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.271.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 87925b480..f34f1ab39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.270", + "version": "1.91.271", "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-shell-lint.test.ts b/test/workflow-shell-lint.test.ts new file mode 100644 index 000000000..67a454e06 --- /dev/null +++ b/test/workflow-shell-lint.test.ts @@ -0,0 +1,181 @@ +/** + * Workflow run-step shells (free). + * + * GitHub's default shell for a `run` step is `bash -e {0}` on a Linux runner + * but `sh -e {0}` inside a container job (actions/runner picks sh when the + * step runs in a job container), and the `sh` in gstack's CI image is dash. A + * step that leans on a bash expansion under that default dies before its first + * command: run 40456150409 (evals.yml, train-bisect (1)(2)(3)) failed every + * prefix at "Verify the prefix checkout" with `sh: 6: Bad substitution` on + * `${PREFIX_SHA:0:12}`, so no bisect had ever run a case in CI. The job now + * declares `defaults.run.shell: bash`. + * + * This test computes each run step's effective shell (step `shell` → job + * `defaults.run.shell` → workflow `defaults.run.shell` → GitHub's default for + * the job: sh for a container job, pwsh on Windows, bash otherwise; composite + * actions always name a shell) and fails when a step whose effective shell is + * sh uses a bash-only construct. Static, offline, parses every workflow and + * composite action under .github/. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const ROOT = path.resolve(__dirname, '..'); +const GITHUB_DIR = path.join(ROOT, '.github'); + +/** Bash-only constructs POSIX sh (dash) rejects or misreads, by name. */ +export const BASHISMS: ReadonlyArray<{ name: string; pattern: RegExp }> = [ + // ${x:0:12}, ${x: -3}, ${x:$a:$b}, ${@:2}, ${1:0:2}; ${x:-d} ${x:=d} ${x:?m} ${x:+a} are POSIX. + { name: 'substring expansion ${x:offset:length}', pattern: /\$\{(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*]):(?![-+?=])/ }, + // ${x/a/b}, ${x//a/b}, ${x^^}, ${x,,}, ${x@Q}, ${!x}; ${x#p} ${x%s} are POSIX. + { name: 'pattern or case expansion ${x/…} ${x^^} ${x,,} ${x@…}', pattern: /\$\{[A-Za-z_][A-Za-z0-9_]*[/^,@]/ }, + { name: 'indirect expansion ${!x}', pattern: /\$\{![A-Za-z_]/ }, + { name: '[[ … ]] test', pattern: /(?:^|[\s;(&|!])\[\[(?:\s|$)/m }, + { name: 'array assignment name=(…)', pattern: /(?:^|[\s;(])[A-Za-z_][A-Za-z0-9_]*(?:\+)?=\(/m }, + { name: 'array expansion ${x[…]}', pattern: /\$\{[A-Za-z_][A-Za-z0-9_]*\[/ }, + { name: 'declare/local/readonly -a or -A, mapfile, readarray', pattern: /(?:^|[\s;])(?:(?:declare|local|readonly|typeset)\s+-[a-zA-Z]*[aA]|mapfile|readarray)\b/m }, + { name: 'set -o pipefail', pattern: /(?:^|[\s;&|])set\s+(?:-[a-zA-Z]*\s+)*-(?:[a-zA-Z]*o\s+pipefail|o\s+pipefail)\b/m }, + { name: 'PIPESTATUS', pattern: /\bPIPESTATUS\b/ }, + { name: 'here-string <<<', pattern: /<<(…)', pattern: /(?:^|[\s=])[<>]\(/m }, + { name: "ANSI-C quoting $'…'", pattern: /\$'/ }, + { name: 'source (use .)', pattern: /(?:^|[\s;&|])source\s/m }, + { name: 'function keyword', pattern: /(?:^|[\s;])function\s+[A-Za-z_]/m }, +]; + +/** Shell lines, comment lines dropped so a prose mention of a construct does not count. */ +function scriptLines(script: string): string { + return script.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); +} + +/** Names of the bash-only constructs a script uses. */ +export function bashisms(script: string): string[] { + const body = scriptLines(script); + return BASHISMS.filter(({ pattern }) => pattern.test(body)).map(({ name }) => name); +} + +/** sh, bash, pwsh, python, cmd… from a `shell:` value such as `bash -e {0}` or `/usr/bin/sh`. */ +export function shellKind(shell: unknown): string | null { + if (typeof shell !== 'string' || shell.trim() === '') return null; + const command = shell.trim().split(/\s+/)[0]!; + const base = command.slice(command.lastIndexOf('/') + 1).toLowerCase(); + return base === 'dash' ? 'sh' : base; +} + +/** GitHub's default shell for a job with no `shell` anywhere in its chain. */ +export function defaultShellForJob(job: any): string { + if (job?.container !== undefined && job?.container !== null) return 'sh'; + const runsOn = JSON.stringify(job?.['runs-on'] ?? '').toLowerCase(); + return runsOn.includes('windows') ? 'pwsh' : 'bash'; +} + +export function effectiveShell(step: any, job: any, workflow: any): string { + return shellKind(step?.shell) ?? shellKind(job?.defaults?.run?.shell) ?? shellKind(workflow?.defaults?.run?.shell) ?? defaultShellForJob(job); +} + +type RunStep = { file: string; job: string; step: string; shell: string; run: string }; + +/** Every run step in the repository's workflows and composite actions with its effective shell. */ +export function runSteps(): RunStep[] { + const steps: RunStep[] = []; + const workflows = path.join(GITHUB_DIR, 'workflows'); + for (const name of fs.readdirSync(workflows).sort()) { + if (!/\.ya?ml$/.test(name)) continue; + const file = path.join('.github', 'workflows', name); + const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, file), 'utf8')) as any; + for (const [jobName, job] of Object.entries(workflow?.jobs ?? {})) { + for (const [index, step] of (Array.isArray(job?.steps) ? job.steps : []).entries()) { + if (typeof step?.run !== 'string') continue; + steps.push({ file, job: jobName, step: step.name ?? step.id ?? `step ${index + 1}`, shell: effectiveShell(step, job, workflow), run: step.run }); + } + } + } + const actions = path.join(GITHUB_DIR, 'actions'); + for (const name of fs.existsSync(actions) ? fs.readdirSync(actions).sort() : []) { + for (const base of ['action.yml', 'action.yaml']) { + const file = path.join('.github', 'actions', name, base); + if (!fs.existsSync(path.join(ROOT, file))) continue; + const action = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, file), 'utf8')) as any; + for (const [index, step] of (Array.isArray(action?.runs?.steps) ? action.runs.steps : []).entries()) { + if (typeof step?.run !== 'string') continue; + steps.push({ file, job: 'runs', step: step.name ?? step.id ?? `step ${index + 1}`, shell: shellKind(step.shell) ?? 'sh', run: step.run }); + } + } + } + return steps; +} + +describe('workflow run-step shells', () => { + test('the detector names bash-only constructs and accepts POSIX sh', () => { + const cases: Array<[string, string]> = [ + ['echo "short=${PREFIX_SHA:0:12}" >> "$GITHUB_OUTPUT"', 'substring expansion'], + ['x=${name: -3}', 'substring expansion'], + ['set -- "${@:2}"', 'substring expansion'], + ['echo "${1:0:2}"', 'substring expansion'], + ['echo "${path//\\//_}"', 'pattern or case expansion'], + ['echo "${name^^}"', 'pattern or case expansion'], + ['echo "${!ref}"', 'indirect expansion'], + ['if [[ -n "$x" ]]; then echo y; fi', '[[ … ]] test'], + ['args=(--one --two)', 'array assignment'], + ['echo "${args[@]}"', 'array expansion'], + ['declare -a list', 'declare/local/readonly -a'], + ['set -euo pipefail', 'set -o pipefail'], + ['set -o pipefail', 'set -o pipefail'], + ['cmd | tee log; test "${PIPESTATUS[0]}" = 0', 'PIPESTATUS'], + ['read -r a <<< "$line"', 'here-string'], + ['diff <(sort a) <(sort b)', 'process substitution'], + ["printf $'a\\tb'", 'ANSI-C quoting'], + ['source ./env.sh', 'source (use .)'], + ['function greet { echo hi; }', 'function keyword'], + ]; + for (const [script, expected] of cases) { + expect(bashisms(script).join('; '), script).toContain(expected); + } + for (const posix of [ + 'echo "${HOME:-/tmp}" "${X:=d}" "${Y:?missing}" "${Z:+alt}" "${1:-x}" "${@}" "$*"', + 'echo "${file#*/}" "${file##*/}" "${file%.ts}" "${file%%.*}" "${#name}"', + 'out=$(git rev-parse HEAD); if [ "$out" != "$PREFIX_SHA" ]; then exit 1; fi', + '. ./env.sh && printf "%s\\n" "$out" >> "$GITHUB_OUTPUT"', + 'case "$x" in a) echo a ;; *) echo b ;; esac', + 'set -eu\nmkdir -p "$dir"', + 'echo "$(( 1 + 2 ))"', + '# [[ in a comment, ${x:0:1} too\necho ok', + ]) { + expect(bashisms(posix), posix).toEqual([]); + } + }); + + test('the effective shell follows step, job defaults, workflow defaults, then the runner default', () => { + const container = { 'runs-on': 'ubicloud-standard-8', container: { image: 'ghcr.io/x/ci' } }; + const linux = { 'runs-on': 'ubuntu-24.04' }; + const windows = { 'runs-on': 'windows-latest' }; + expect(effectiveShell({}, container, {})).toBe('sh'); + expect(effectiveShell({}, linux, {})).toBe('bash'); + expect(effectiveShell({}, windows, {})).toBe('pwsh'); + expect(effectiveShell({}, { 'runs-on': '${{ matrix.os }}' }, {})).toBe('bash'); + expect(effectiveShell({ shell: 'bash' }, container, {})).toBe('bash'); + expect(effectiveShell({}, { ...container, defaults: { run: { shell: 'bash' } } }, {})).toBe('bash'); + expect(effectiveShell({}, container, { defaults: { run: { shell: '/usr/bin/bash -e {0}' } } })).toBe('bash'); + expect(effectiveShell({ shell: 'sh' }, { ...linux, defaults: { run: { shell: 'bash' } } }, {})).toBe('sh'); + expect(effectiveShell({ shell: 'dash -e {0}' }, linux, {})).toBe('sh'); + expect(effectiveShell({ shell: 'python' }, container, {})).toBe('python'); + }); + + test('no run step whose effective shell is sh uses a bash-only construct', () => { + const steps = runSteps(); + expect(steps.length).toBeGreaterThan(100); + const sh = steps.filter((step) => step.shell === 'sh'); + // Container jobs without a shell default are where this bites; a parse that + // found none would pass vacuously. + expect(sh.length, 'expected at least one sh-defaulted container step to lint').toBeGreaterThan(0); + const problems = sh.flatMap((step) => bashisms(step.run).map((name) => `${step.file} ${step.job} / "${step.step}": ${name}`)); + expect(problems, 'give the job defaults.run.shell: bash (or the step shell: bash)').toEqual([]); + }); + + test('evals.yml train-bisect runs its steps under bash (run 40456150409 died at ${PREFIX_SHA:0:12} under sh)', () => { + const steps = runSteps().filter((step) => step.file === path.join('.github', 'workflows', 'evals.yml') && step.job === 'train-bisect'); + expect(steps.map((step) => step.step)).toContain('Verify the prefix checkout'); + for (const step of steps) expect(step.shell, step.step).toBe('bash'); + }); +});