Date: 2026-06-14 Status: Accepted Related: ADR-008 (CI/CD Pipeline), ADR-016 (CI/CD Pipeline Finalization)
Update (2026-06-15):
pr-checks.ymlno longer declares aservices:block — the service-containerintegration-testjob was split into.github/workflows/integration-tests.ymlbecauseact0.2.89 panics on service containers in dry-run (act -n). Service-bearing workflows are now kept out of the dry-run gate by a regression guard. Theservices:row in the table below and the "Timescale/Postgres inpr-checks" mention are superseded — see ADR-027.
Update (2026-06-15): The Gradle task mirror described below (
verifyWorkflows,workflowLint,workflowDryRun, etc.) was removed.actionlintnow lives only in thepre-commithook andactonly in thepre-pushhook, with theMakefileas the unified entry point (make verify-workflows).build.gradlecontains no GitHub Actions references. The "build.gradle mirrors those as Gradle tasks" bullet in the Decision below is superseded.
Update (2026-06-15): Hook installation is now automatic on
./gradlew buildvia a first-partybuildSrcplugin (io.tickonomics.git-hooks); see ADR-028.make install-hooksremains as the bootstrap path for installing before the first build. The "Developer setup gains one step:make install-hooksafter clone" consequence below is softened: that step is now optional once any./gradlew buildhas run.
Update (2026-06-15): The Tier-1
pre-commithook now also runsnpm run lint(eslint) for each Next.js app (frontend/,landing/) that has staged files, mirroring the Lint step ofpr-checks.yml'sfrontend-build-testjob so a frontend lint error is caught locally before it fails CI. It is gated on staged paths (no frontend changes → no lint) and onnode_modulespresence (a checkout without installed deps skips with a hint instead of blocking), matching the actionlint "missing tool → warn and skip" philosophy. The actionlint path is unchanged; new Given-When-Then scenarios inscripts/test-pre-commit-hook.shcover the skip / block / allow cases.
Workflow changes have repeatedly broken CI after push — most recently benchmark.yml
required a fixup commit (2eee0f4 fix(benchmark): fix benchmark regression workflow)
and several workflows are modified in-flight on this branch. The only feedback loop
today is "push and watch GitHub Actions," which costs a push, a round-trip, and
Actions minutes for errors that are detectable locally.
The goal is to catch workflow errors before commit/push without leaving the developer machine. Two candidate tools were investigated:
nektos/act— runs GitHub Actions locally in Docker containers that match GitHub's runner images. 70k★, mature, Go.bahdotsh/wrkflw— validates and runs workflows locally with Docker/Podman/emulation runtimes and a TUI. Newer, Rust.
Surveying .github/workflows/*.yml, the gate-critical workflows
(pr-checks.yml, benchmark.yml) and others rely on features that differ sharply
between the two tools:
| Feature used here | act |
wrkflw |
|---|---|---|
pull_request / push / schedule triggers (8 of 10 workflows) |
✅ | ❌ only workflow_dispatch |
services: (Timescale/Postgres in pr-checks, chaos-*, demo-report) |
✅ | |
actions/cache (used in every workflow) |
✅ | ❌ emulation; partial container |
upload-artifact |
✅ | ❌ |
concurrency: (pr-checks, benchmark) |
ignored locally (harmless) | ❌ |
Matrix builds, needs: DAG, reusable workflows |
✅ | ✅ / partial |
Dry-run / graph resolution (-n / -l) |
✅ | ❌ |
Because wrkflw only fully supports workflow_dispatch, it cannot execute 8 of
the 10 workflows as written. Its remaining value is wrkflw validate — a static
syntax/structure check that is strictly weaker than actionlint, which is already
installed in this environment.
Adopt act as the local execution verifier, layered with actionlint as
the fast static gate. Do not introduce wrkflw.
Verification is split into two tiers, because running the full Java/Python/frontend DAG locally on every commit is too slow to gate every commit:
- Tier 1 — static (every commit, <1s, no Docker).
actionlintover the staged.github/workflows/*.ymlfiles, wired as a gitpre-commithook. Catches YAML syntax, schema, and${{ }}expression errors before the commit lands. - Tier 2 — execution (opt-in / pre-push).
act -ndry-runs the PR-gate workflows (pr-checks.yml,benchmark.yml) for thepull_requestevent, resolving the full job/step graph and pulling actions without executing steps. Single-job runs (act -j) are used to actually execute a target job.
Concrete artifacts:
.actrcmapsubuntu-latest/ubuntu-22.04/ubuntu-24.04to thecatthehacker/ubuntu:act-*runner images and pins--container-architecture. Per-machine overrides (image choice, secrets) go in.actrc.local(gitignored).Makefileexposes:verify-workflows(full pipeline),workflow-lint,workflow-list,workflow-dryrun,workflow-run JOB=<id> [WF=...],install-hooks,act-pull,test-hooks,test-gate. This is the single entry point; the former Gradle task mirror was removed so there is one source of truth andbuild.gradlecarries no GitHub Actions references.scripts/git-hooks/{pre-commit,pre-push}are version-controlled hook sources;scripts/install-git-hooks.shcopies them into.git/hooks/. Thepre-pushdry-run is opt-in (WORKFLOW_DRYRUN_ON_PUSH=1orgit config --bool workflow.dryrunOnPush true), since the first run pulls a ~1–2 GB runner image.scripts/test-pre-commit-hook.shexercises the hook with Given-When-Then scenarios including a false-positive guard (non-workflow YAML is never linted).
| Workflows | Local mode | Rationale |
|---|---|---|
pr-checks.yml, benchmark.yml |
full / single-job (-j) |
primary breakage surface; fully runnable locally |
chaos-tests.yml, chaos-benchmark.yml, demo-report.yml |
full run | act supports their services: containers |
codeql.yml, forecast-deploy.yml, deploy-*.yml |
dry-run only (-n) |
need cloud creds / GitHub API / CodeQL DB; verify graph resolution only |
- Workflow errors are caught locally: static errors at
git commit, structural errors atgit push(opt-in), with no Actions minutes spent. actignoresconcurrencylocally (a no-op) and storesactions/cache/upload-artifactdata locally rather than on GitHub — harmless for verification.- Jobs that call the GitHub API with
secrets.GITHUB_TOKEN(e.g.benchmark.yml's "Comment on PR" step) will fail locally without a real token; run other jobs with-jor provide a token in.secrets. This is documented in the runbook. - The first
actrun downloads the runner image (~1–2 GB);make act-pullmakes that explicit and separable. - Developer setup gains one step:
make install-hooksafter clone.
wrkflw(run mode). Rejected: it cannot executepull_request-triggered workflows at all, and lacks artifacts/cache/service-containers-in-emulation — every gate-critical workflow here uses at least one of those.wrkflw validate(static only). Rejected in favor ofactionlint, which is already installed, more thorough, and the de-facto standard for GitHub Actions static analysis.actas the sole gate. Rejected as a commit gate: the full local DAG is too slow and the image pull too heavy to run on every commit. The two-tier split keeps the commit gate instant while reserving execution for push/on-demand.- GitHub Local Actions VS Code extension (wraps
act). Compatible and optional; developers who prefer an editor-driven flow can use it on top of this setup.