* feat(workflow): support expansion of env context references in expressions
`${{ env.* }}` references in workflow expressions silently resolved to empty
string because `resolveExprAtom` had no `env.` handler and no env context was
threaded through the evaluator. The fix adds initial support for expanding these
references in `run:` scripts, `with:` inputs, `name:`, and `env:` value
expressions — matching GitHub Actions behavior. `if:` conditions remain
unsupported (noted in `compatibility.md`).
* chore(smoke): add smoke-env-context.yml as proof for env.* row
Adds a smoke workflow that exercises ${{ env.* }} expansion in `run:`
bodies, in another step's `env:` value, in `with:` inputs (the original
Rails CI bug surface), and verifies the unknown-key → empty-string
contract. Also wires it as the `proof` reference on the env.* row in
compatibility.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Peter Pistorius <peter.pistorius@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): surface degraded local runs for resource mismatches
Add resource fidelity classification that compares local host resources
against workflow-declared runner/resource hints. When the local machine
is smaller than what the workflow requests, jobs are marked as degraded
and a warning is shown before execution starts.
Features:
- Parse runs-on labels and NODE_OPTIONS from workflows
- Classify jobs as faithful or degraded based on CPU/memory hints
- Show [degraded] tag and actionable warnings in CLI output
- Add smoke test for degraded classification
- Update documentation with degraded mode guidance
Complements PR #226 which adds memory-aware concurrency limiting.
Together they replace the approach in closed PR #78.
* refactor(cli): use runner specs instead of NODE_OPTIONS for resource classification
Replace NODE_OPTIONS parsing with GitHub runner label heuristics.
This is more accurate and works for all workloads (not just Node).
Changes:
- Add RUNNER_SPECS mapping for known GitHub runner sizes
- Add parseRunnerSpecs() to look up full {cpu, memory} from labels
- Remove parseNodeMaxOldSpaceMb() and NODE_OPTIONS dependency
- Update tests to use runner specs
- Simplify smoke test fixture
Runner specs used:
- ubuntu-latest: 2 CPU, 7GB RAM
- ubuntu-latest-4-cores: 4 CPU, 16GB RAM
- ubuntu-latest-8-cores: 8 CPU, 32GB RAM
- ubuntu-latest-16-cores: 16 CPU, 64GB RAM
* feat(cli): warn when using unknown runner labels
When a workflow uses an unknown runner label (e.g., 'self-hosted' or custom
labels not in RUNNER_SPECS), the job is now marked as degraded with a
warning that explains we're assuming local host capacity.
This makes the behavior transparent - users know we're making an assumption
about the runner size rather than silently defaulting to faithful mode.
* Address PR review comments: remove smoke tests, resource warnings, and writeStdoutLine refactor
* Remove remote Docker daemon suggestion from degraded resource warnings
* chore(compat): add resource-mismatch smoke and document degraded mode
- Add smoke-resource-mismatch.yml fixture that uses ubuntu-latest-999-cores
to deterministically trigger the degraded classification on any host.
- Extend the runs-on row in compatibility.json with a sentence describing
the degraded warning and reference the smoke as proof.
- Regenerate compatibility.md.
- Add changeset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(classifier): only flag unknown labels for the ubuntu family
The "unknown runner label" reason fired for any label missing from
RUNNER_SPECS, including macos-* / windows-* / self-hosted — labels that
runs-on-compat already handles and that the resource classifier has no
useful opinion on. Restrict the warning to labels that look like a
hosted ubuntu variant (`ubuntu` or `ubuntu-*`), which is the family
RUNNER_SPECS actually covers; this still catches drift when GitHub
adds a new ubuntu label without us noticing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(smoke): de-flake bun smoke by pre-fetching the binary
The smoke depended on oven-sh/setup-bun@v2 pulling bun-linux-aarch64.zip
from github.com/oven-sh/bun/releases inside the nested runner. The
upstream action has no per-request timeout or retry, so a stalled CDN
socket presented as a hung step with one log line ("Downloading a new
version of Bun: …") and nothing else — pure flake, no signal about
agent-ci.
Pre-fetch the binary on the outer host with curl --retry, ship it into
the inner workspace as a fixture, and replace the setup-bun call in the
inner workflow with two narrower steps that exercise the actual #212
failure modes: an actions/cache@v4 probe against a randomized `bun-…`
key (must miss — guards against the virtualCachePatterns substring
fake-hit) and a manual `mkdir -p $HOME/.bun/bin` install (exercises the
bind-mount permissions path).
Local run: 16s, deterministic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Peter Pistorius <peter.pistorius@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each overloaded row becomes one row per documented feature (or a tighter
note when the sub-features behave identically). No behaviour changes.
* github.* split into sha / repository family / everything-else
* runner.* new row for unsupported siblings
* contains etc. three rows, one per function
* success family downgraded partial (cancelled() always false locally)
* on (other) note enumerates ~20 events
Closes the "overloaded row" bucket on #296.
Refs #296.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds rows for features GitHub Actions documents that our compat table
said nothing about. Each status was picked per code inspection of the
existing parser / runner paths — no behaviour changes.
Closes the "missing rows" bucket on #296.
Refs #296.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The YAML parser extracted `jobs.<id>.container.options` into job.container
but the docker createContainer call never consumed it, so `options: --env
FOO=bar` silently produced a container without FOO.
parseContainerOptions now pulls --env/-e and --label/-l out of the options
string and merges them into the container's Env array and Labels map. All
other Docker flags in options: are still ignored by design — --privileged,
--user, --network, --cap-add, --workdir tend to clash with agent-ci's own
container orchestration, and silently dropping them is safer than a partial
honoring that breaks the runner's invariants. Documented in the
compatibility.json note for jobs.<id>.container.
Also updated two nearby notes while running through #296:
* actions/cache — documents the lack of ref-based isolation (branches
share cache keys locally, unlike GitHub's per-ref scoping)
* GITHUB_TOKEN — documents that OIDC id-token issuance is not wired up,
so permissions: id-token: write → federated cloud auth does not work
Refs #296.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small expression-engine fixes surfaced by extending smoke-expressions
with assertions for #296's "questionable claim" rows:
1. toJSON now emits 2-space-indented JSON (was compact), so hashFiles
keys that hash a toJSON(x) match GitHub. Parses rawValue first so
toJSON(fromJSON(x)) round-trips cleanly.
2. compareValues() no longer bails out of the numeric path when either
side is empty. `''`, `null`, and numeric strings all coerce to
numbers per the spec; non-numeric strings become NaN (string compare
path, always false).
Five new assertions (contains-on-array, toJSON pretty-print, coercion,
hashFiles multi-pattern, hashFiles negation) all pass. hashFiles
multi-pattern and negation were never broken — cleared off #296 with
proof rather than a fix.
Refs #296.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workflow): hashFiles descends into dotted directories
findFiles was skipping any directory starting with `.`, so a pattern
like hashFiles('.github/workflows/*.yml') never matched a file and
returned the 40-zero placeholder. Skip only node_modules now.
The smoke assertion is tightened back to 64 chars (SHA-256) since the
real digest now appears.
Closes#294.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workflow): resolve steps.*.outputs.* to empty at parse time
The parser was returning a sentinel so that `${{ steps.X.outputs.Y }}`
expressions would be preserved verbatim for the runner to evaluate at
runtime. The runner does not re-evaluate expressions inside run-script
bodies, so the literal `${{ }}` reached bash as "bad substitution".
Per the long-standing compatibility contract, these expressions now
resolve to an empty string at parse time. Cross-job values still work
through needs.*.outputs.*.
Closes#295.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runner executes every run: step with bash regardless of inputs.shell,
so setting shell: sh / python / pwsh had no effect. Wrap the expanded
script at parse time — `<interpreter> <flags> <<'EOF' ... EOF` — so the
requested shell actually runs the user's code. Workflow, job, and step
scopes compose via the existing resolveStepRunDefault helper.
Adds smoke-shell-defaults.yml (removed in an earlier bug-filing cut)
covering all three scopes with a BASH_VERSION assertion.
Closes#293.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(compat): make compatibility.json the source of truth
Flip the compatibility matrix so packages/cli/compatibility.json is
authoritative. scripts/gen-compatibility-md.mjs renders the .md from it,
pnpm check runs a drift check, and the website imports the JSON directly
and renders inline markdown in keys/notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add compat smoke coverage for secrets, working-dir defaults, runner image
Fill three audit gaps identified against packages/cli/compatibility.json:
- smoke-secrets-env-file: secrets resolved from .env.agent-ci and from
the shell environment fallback
- smoke-defaults-workdir: workflow- and job-level
defaults.run.working-directory plus a step-level override
- smoke-runner-image: .github/agent-ci.Dockerfile is picked up and the
resulting image is used at job start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workflow): propagate defaults.run.working-directory to steps
Parse defaults.run.working-directory at workflow and job level and merge
it into each run-step's inputs with step > job > workflow precedence.
Step-level working-directory already worked; the defaults blocks were
silently ignored.
Closes#290.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(compat): add proof field to compatibility.json rows
Every `supported`/`partial` row now lists the smoke workflows that
exercise it end-to-end. `pnpm compat:gen`/`compat:check` fails if a
listed path does not resolve, catching renames.
Rows with an empty `proof: []` are the audit gaps tracked in #292.
Refs #292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dtu): attach step env per-step so it does not leak to siblings
Step-level env was being aggregated into the job-wide EnvironmentVariables
map (last-wins), so a later step's `env: FOO=x` would override an earlier
step's effective FOO at runtime. Emit step.Env as the mapped step's own
`environment` instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(compat): fill audit gaps #292 — env, expressions, local action
Three smokes covering eight of the ten gaps listed in #292:
- smoke-env-scoping: workflow + job + step `env:` precedence
- smoke-expressions: hashFiles, format, join, runner.arch, type
literals, startsWith/endsWith
- smoke-local-action: `steps[*].uses: ./.github/actions/...`
(verified via marker file rather than steps.<id>.outputs.*)
Proof fields updated for every row these smokes cover. hashFiles
downgraded to ⚠️ partial — agent-ci emits SHA-1, GitHub Actions emits
SHA-256 (tracked by #294).
Two gaps remain open as filed issues:
- #293 defaults.run.shell / steps[*].shell not applied
- #295 ${{ steps.<id>.outputs.<name> }} leaks literal text in run
scripts
Refs #292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>