* chore: require Node 24 and drop tsx
Node 24 ships native TypeScript stripping as a stable feature. We no
longer need tsx to execute .ts files in development scripts.
Changes:
- `engines.node` bumped from `>=22` to `>=24` in each package.
- `tsx` removed from every workspace's devDependencies.
- Each `tsx foo.ts` invocation in package scripts becomes `node foo.ts`.
Includes `scripts/agent-ci-dev.sh` (the dev wrapper) and the demo
package.
- tsconfigs gain `allowImportingTsExtensions`, `rewriteRelativeImportExtensions`,
and `verbatimModuleSyntax` so source uses `.ts` extensions while tsc
still emits `.js` paths in dist.
- All relative imports rewritten from `from "./foo.js"` to `from "./foo.ts"`
across 72 source files. Dynamic imports updated too.
- Type-only imports (`Job`, `Polka`, MessageResponse, …) converted to
`import type` to satisfy `verbatimModuleSyntax`.
- The json-loader hook registration in workflow-parser.ts picks `.ts`
when the source file exists (dev) and `.js` otherwise (compiled dist).
- Root `package.json` adds `"type": "module"` (no existing .js files
affected) and `yaml` as a devDep (already used by scripts/diff-parse.ts;
previously hoisted through cli's deps).
- `tests.yml` CI bumps from Node 22 to Node 24. Smoke fixtures that set
`node-version: 22` are left alone — they exercise `actions/setup-node`
with various versions deliberately.
Verified:
- pnpm check: clean
- Node 24 dev run: `node packages/cli/src/cli.ts --help` works
- Full smoke suite (`pnpm agent-ci-dev run --all`): 45/45 jobs pass
* fix(shutdown.test): use `node --input-type=module` instead of `npx tsx`
The shutdown integration test spawns a fresh process to sidestep
vitest's mock system. It was using `npx tsx`, but `tsx` is no longer
a dependency. Node 24 reads TypeScript files natively, so we ask Node
directly via `--input-type=module`.
Six identifiers were exported but used only inside their own file:
- `log-prune.ts`: DEFAULT_RETAIN_DAYS, DEFAULT_RETAIN_RUNS,
DEFAULT_THROTTLE_MS (constants used only by `pruneLogs`).
- `generators.ts`: toContextData, toTemplateTokenMapping (helpers
called only by `createJobResponse` and friends in the same file).
Plus one wholly-unused function — `toContainerTemplateToken` in
`generators.ts` — that nothing called internally or externally.
It was leftover utility code from an earlier design; deleted.
No runtime behaviour change. fallow's unused-exports report drops
by six items as a result.
* 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>
* feat(cli): run macos-* jobs in real macOS VMs via tart
When the host is darwin/arm64 with tart + sshpass installed, jobs
targeting runs-on: macos-* boot an ephemeral cirruslabs macOS VM,
rsync the macOS actions-runner binary in, and connect it to the
in-process DTU via the host bridge.
- New runner/macos-vm/ module (tart wrapper, image mapping, host
capability probe, runner binary fetch/cache, semaphore, job driver).
- cli.ts routes macOS jobs to the VM executor when the host supports
it; otherwise falls through to the existing skip-with-warning path.
- DTU generators.ts accepts runnerWorkDir/runnerOs/runnerArch so the
seed payload reflects where the runner actually materializes _work
(sibling of run.sh, not a caller-chosen path).
- runner-credentials.ts extracted from local-job.ts so the macOS path
can reuse the same deterministic .runner/.credentials generation.
Concurrency capped at 2 VMs by default (AGENT_CI_MACOS_VM_CONCURRENCY).
Image override via AGENT_CI_MACOS_VM_IMAGE.
Closes#258.
* feat(cli): say *why* macOS jobs skip on incapable hosts
Before: the skip warning for macos-* jobs on a host that can't run the
VM just pointed at #258 and parroted the pre-Phase-3 line about only
running in a Linux container — which is no longer accurate now that
macOS jobs *can* run locally when the host supports it.
Now: formatUnsupportedOSWarning accepts the HostCapability result and
prints the specific reason + install hint when available — e.g.
'tart not installed' with a brew command, or 'requires Apple Silicon
(got x64)'.
* refactor(cli): deduplicate runs-on classification + warning body
- cli.ts: extract classifyJob(ej) + canRunMacosHere(kind) so the
skip path and the executor-routing path share one source of truth
for 'should this job run in the macOS VM?' — the two previously
duplicated the parseJobRunsOn + classifyRunsOn + capability check.
- runs-on-compat.ts: flatten formatUnsupportedOSWarning to a single
body array per kind, filter undefined, then uniformly indent —
drops the optional-hint if-branch and the per-push indent literals.
Pure refactor; all 14 runs-on-compat tests still pass, warning output
is byte-identical across all 4 permutations (macOS with/without
capability, with/without hint, windows).
* docs: document macOS VM runner support
- README: add tart + sshpass to 'Optional' prerequisites and a new
'macOS jobs' section (image table, env vars, caveats).
- compatibility.md: unstale the three entries that claimed Linux-only
(jobs.<id>.runs-on, runner.os, runner.arch). runs-on now reads as a
partial (⚠️) that explains linux/macos/windows separately.
Leaves apps/website/CompatibilityMatrix.tsx alone — it has the same
stale rows but is in an unrelated mid-merge state on this branch.
* chore: oxfmt packages/cli/README.md and compatibility.md
* fix: setup-node "Bad credentials" + stale-toolcache hint (#171, #249)
Route setup-node's versions-manifest fetch through the DTU so it no
longer 401s against real api.github.com, and surface an actionable
hint when a stale /opt/hostedtoolcache bind mount blocks tar extract.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: unit + smoke coverage for setup-node manifest mock
Adds unit tests for rewriteSetupNodeTarball and for the cached tree/blob
route paths, plus a nested-agent-ci smoke workflow that fails if
setup-node logs "Bad credentials" (the symptom of the manifest mock
breaking and falling back to nodejs.org).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Actions like dorny/paths-filter read github.event.repository.default_branch
to determine the base branch. The DTU was already passing this value in
Resources.Repositories but omitting it from the event context.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Checkout v6 added post-fetch validation that the checked-out ref matches
github.sha. The git shim was returning a fake placeholder SHA (aaaa...)
in dirty workspace mode while github.sha used the real HEAD SHA, causing
the validation to fail. Use realHeadSha consistently everywhere and
remove the now-unnecessary computeFakeSha helper.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: support composite action step outputs and push event context
Enable workflows that use composite actions with cross-step output
references (e.g. Khan/actions@get-changed-files + filter-files) by:
- Preserving steps.* expressions for runner-time evaluation instead
of expanding them to empty string at parse time
- Using correct BasicExpression token type (type 3, not 6) matching
the runner's wire format
- Adding ContextName to steps so the runner can resolve
steps.<id>.outputs.<name> expressions
- Adding push event before/after SHAs and GITHUB_API_URL so
changed-files actions can call the compare commits API
- Implementing compare commits and list-PRs-for-commit mock endpoints
in the DTU server using git diff on the original repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks in agent-ci-dev.sh so it works when invoked via symlink
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: support local composite actions (`uses: ./.github/actions/...`)
Set RepositoryType to "self" for local action references so the runner
resolves them from the workspace instead of trying to download a tarball.
Add defensive guard in actiondownloadinfo to skip local/empty actions.
Closes#162
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix formatting in .docs/ files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: propagate job-level env into DTU Variables and add AGENT_CI_LOCAL to container env
Closes#104. Job-level `env` from the CLI payload was not propagated into
the DTU's Variables store or ContextData, so `AGENT_CI_LOCAL` was unavailable
to the runner's expression engine. Also adds `AGENT_CI_LOCAL=true` to the
Docker container env for shell access.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: re-trigger agent-ci checks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When running --all, jobs seeded without runnerName landed in the shared
generic pool where runners from other concurrent workflows could steal
them, causing single-job workflows to hang indefinitely.
- Pin job.runnerName = containerName before the /_dtu/seed call
- Move container and ephemeralDtu cleanup into finally block
- Add process.setMaxListeners(0) for parallel job EventEmitter warnings
- Add reproduction test for generic pool job stealing
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: action tarball disk cache, step output capture, and demo recording
- Cache action tarballs on first download and serve from disk on re-runs,
eliminating ~30s GitHub CDN delays
- Capture step output via tee to signals dir for reliable pause-on-failure
tail display
- Fix CLI to treat empty results as failure and skip summary print
- Add marketing demo recording setup with VHS tape and test project
- Add `agent-ci` script alias in root package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: add tests for action tarball cache, group filtering, and empty-results fix (#119)
Cover the untested changes from the tarball cache PR: action tarball proxy
cache hit/miss, download info URL rewriting with sub-path stripping, step
output group filtering, tee output capture, and CLI empty-results exit logic.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent concurrent tarball cache-miss race condition
Use an in-flight download map so concurrent requests for the same
tarball coalesce into a single GitHub fetch instead of racing on
the same tmp file, which could cause corruption or rename errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use relative path in demo.tape for portability
Replace hard-coded absolute path with a relative path so the demo
recording works on any contributor's machine.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: correct sanitized-refs test to assert 404 for slash-containing refs
The test was passing vacuously (status >= 200 matches 404). Now it
properly asserts that Polka does not bind :ref across slashes, so
slash-containing refs return 404 and do not serve cached content.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Three fixes discovered while running agent-ci against a ~40K file
pnpm/turbo monorepo:
1. Sub-path actions (e.g. `actions/cache/save@v5`) produce invalid
tarball URLs. The DTU was using `action.nameWithOwner` directly
which includes the sub-path, producing
`api.github.com/repos/actions/cache/save/tarball/v5` (404).
Fix: strip to `owner/repo` before constructing the URL.
2. `copyWorkspace` uses `execSync("git ls-files ...")` with the
default 1MB maxBuffer. Repos with 30K+ tracked files overflow
this, causing ENOBUFS and an empty workspace in the container.
Fix: increase maxBuffer to 100MB.
3. Post-success cleanup `fs.rmSync(containerWorkDir)` can throw
ENOTEMPTY when the container hasn't fully released file handles.
Fix: wrap in try/catch for best-effort cleanup.
On Linux, host.docker.internal doesn't work by default (only available
since Docker 20.10+ and requires explicit configuration). This change:
- Detects macOS vs Linux and returns appropriate host IP
- Uses 172.17.0.1 (docker bridge gateway) on Linux host
- Uses host.docker.internal on macOS
- Adds containerUrl to ephemeral DTU for container-to-host communication
- Adds ExtraHosts mapping for containers to resolve host.docker.internal
Fixes connectivity issues when running agent-ci on Linux hosts.
execa was in devDependencies but imported in production code,
causing ERR_MODULE_NOT_FOUND when running via npx. Replaced with
Node's built-in child_process.exec and removed execa entirely.
When a runner-specific job was seeded, it was stored in both state.jobs
(generic pool) and state.runnerJobs (runner-targeted). A runner polling
before its own job was seeded could fall back to state.jobs and steal
another runner's job, leaving the original runner spinning indefinitely.
Fix: runner-targeted jobs now go only into state.runnerJobs. The generic
pool is reserved for jobs without runner affinity.
Adds three tests covering the race condition, concurrent stress with
8 runners, and the job-theft scenario.
Closes#47
The runner's FinalizeJob deletes _temp/_runner_file_commands/ inside the
container before exit, making files unreachable from the host.
Solution: inject a synthetic 'Capture outputs' step (runs with if:always())
that reads all set_output_* files and echoes them as ::agent-ci-output::key=value.
The DTU parses these prefixed lines and persists them to outputs.json in the
runner's log directory. On job completion, local-job.ts reads outputs.json and
attaches the outputs to the JobResult.
Pipeline verified end-to-end — all 8 smoke tests pass:
- Cross-job outputs (setup-outputs → consume-outputs) ✅
- Job-level if:always() ✅
- Matrix shards with fail-fast:false ✅
- Artifacts, services, env vars ✅
All 279 unit tests pass.
- Fix extractStepOutputs path: _temp/_runner_file_commands/ (not root)
- DTU outputs endpoint now captures and persists outputs.json
- local-job.ts reads outputs.json from log dir instead of work dir
- Add smoke test jobs for cross-job outputs and if:always()
All 279 tests pass.