Repository navigation
ci: verify pull requests locally (make ci-local), Lockpick format - #134
Conversation
Every check now runs in `make ci-local` on the agent's machine, in a clean worktree of the exact pushed head, selected from the diff against the PR's base. It reuses steps whose inputs are unchanged since an earlier attested head, takes a machine-wide slot, waits for memory, and can offload the steps to a Davit box. It posts the `local-ci` commit status and fills the PR body's "Local verification" section. A failed head is refused for good. GitHub runs only two small jobs, started by that body edit: a gitleaks secret scan and a check that `local-ci` and the section hold for the head. Main and dev run no CI; Deploy gate passes only attested commits for Railway's "Wait for CI". `make pr-merge` merges on local verification, and on `local-ci` alone where Actions cannot run. The rules live in scripts/cilocal/verify, a new test boundary (100% covered). The migration order check moves into it, so it no longer needs bash 4. The old single CI job, its check action and scripts are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Davit's sync adds and updates files but never deletes them, so a box made from a project image built at an older commit kept the four files this branch removes, and cilocal exec refused the tree (rightly: nothing ran). The payload now carries the head's paths (gzipped), and exec removes the rest before it checks the tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Davit syncs the index but no commit, so git status in the box listed every file as added and make check-generated failed on a clean head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Railway's GitHub auto-deploy is off (platform.yaml repo: ""), so only the workflows ship code: - Deploy staging: every push to main that make ci-local attested, to the Railway staging environment (web, admin, backup, then /healthz). - Staging catch-up: hourly, dispatches Deploy staging when staging is behind. - Draft release: drafts vX.Y.Z at the commit staging runs. - Deploy production: a published release, after a gate proving staging ran it, still runs it or later, is healthy, and the publisher may release. Until RAILWAY_DEPLOY is true the deploys dry-run. main is the only deployed branch; Deploy gate is gone. docs/RELEASES.md has the flow and setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runs the deploy tests - The staging and production GitHub environments each hold a secret named RAILWAY_TOKEN, a Railway project token scoped to that environment. - Readiness needs /healthz to answer 200 with data.status ok. - make deploy-check (and so make ci-local and make ci) runs go test in the deploy/ module, and deploy/platform is a test boundary for platform.yaml's rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deploy/platform is a test boundary now, so check-test-probes planted its deploy probe where a test is allowed and the probe was not reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The boundary check ran only inside make lint, which a change confined to deploy/ (its own module) never selects, so a test file there merged unchecked and failed the next full make lint on main (sparkrack #6). Plan now adds scripts/check-test-boundaries.sh on its own whenever make lint is not selected; its reuse input is every file. deploy/ has no tests again (AGENTS.md "Test boundaries"): make deploy-check and make deploy-vars cover the deploy config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an account whose Actions are blocked by billing, GitHub still creates the CI run and fails each job within seconds, with no runner and the annotation "The job was not started because recent account payments have failed...". make pr-merge counted those as failed checks and refused, so local-ci alone could never merge there. It now reads each failed CI job's annotations and treats a never-started job as no run (--require-actions still refuses). The ci-local hint is shown only for verification problems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make ci-local RAN="<what you ran>" now runs nothing. It refuses a dirty,
unpushed or already-failed head, posts local-ci on the exact SHA
("<result> · body <digest> · by <author> on <host>") and fills the PR
body's Local verification section with the head and the statement.
FAILED= records a failure, final for that head; COMMIT= attests a main
commit. It takes seconds: no worktree, plan, slot, Davit box or memory
wait.
- verify: StatementOf, RenderSection, ClaimsHead, BodyProblems and
LocalVerificationProblems (the section names the head and says what
ran, its digest matches local-ci, the head never failed), shared by
cilocal check, merge and attest.
- ci.yml: a "Local verification claim" job; the checking job is named
"Local verification" only for a claimed head, "Awaiting make ci-local"
otherwise, so an unattested PR shows Expected, not red.
- make pr-merge: Decide() per PR (draft, stacked, conflict, attestation,
failed, hollow, pending, mergeability), latest attempt per job; CI jobs
GitHub never started still count as not run, and with no CI job at all
it merges on local-ci alone (--require-actions refuses). Prints the
squash SHA and --check-deploy <sha>, a one-read staging verdict
(Deploy staging runs plus <STAGING_WEB_URL>/healthz; a dry run says
nothing deployed); --check-staging and --then-check-staging.
- Post-merge report: comments on the merged PR when Deploy staging
fails on main; a verify test fails when a push-to-main workflow is
missing from its list.
- Removed: the step plan, runner and exec, reuse ledger, Davit routing,
machine slot, memory wait, failure triage, report save/load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
left a comment
There was a problem hiding this comment.
CHANGES — reviewed bf0b5d3 (63 files, +6,469/-1,044). Three inline findings:
- Doug (complex), P1: run the hosted verifier and scanner/config from trusted code; PR-controlled Go can bypass both required checks, including from a fork.
- Danny (simple), P1: make a report rejected for tree drift permanently unpublishable.
- Doug (complex), P2: provide a safe first-attestation/full-tree path for a new template repository with no attested ancestors, including a root commit.
Evidence: all 13 selected ci-local steps passed locally, zero reuse, in 3m33s. Invoked make ci-local itself: correctly refused detached HEAD; then executed every selected item through make ci-local ARGS="--local --only secrets,build,lint,generated,tests,migrations,smoke,deploy,mobile --keep-going", which posts nothing. Separate make lint, make test and make build passed; 12 test packages passed, all six coverage floors passed, and cilocal/verify has 100% statement coverage. Four additional focused reproduction tests passed; separate PR-controlled init() probes made both hosted subcommands return 0 without verification or scanning.
Hand-edited sections, wrong-head reports/new heads without a status, and a missing executable were refused in focused checks. Secret scan and Local verification remain hosted, triggered only by PR edits; staging/release gates and bench remain workflows. The nine-item body/ledger/digest format follows the Lockpick model. A writer can still forge local-ci by design; the fork/code-execution bypass is a separate trust-boundary problem.
Fresh-project evidence: exercised the current new-pattern-project helper against this head with --skip-github --verify. Renamed lint passed; tests failed at the existing unchanged packages/auth/tokens_test.go mixed-case BetterStack://Home fixture after the helper rewrites only its lowercase counterpart. Renamed build passed separately. That bootstrap failure also exists in the base inputs and is not counted as a new inline finding. Independently, the clean new-repository/no-status fixture reproduced the new first-attestation refusal described inline.
All heavy checks used ~/Documents/Projects/spark-work/heavy.sh; no VMs. The review clone and reproducible evidence are under ~/Documents/Projects/spark-work/zig-dsv41-scratch/randy-gfs134{,-evidence}. No PR branch pushes, merges, or local-ci status/body updates were made. The PR head remained unchanged at final verification.
docs/CI.md: "What runs" (the claim job and the Awaiting name), a new "What to run" table for this stack, "Local verification" (RAN, FAILED, COMMIT), "Merging on local verification" (Decide, --check-deploy, --check-staging, the Post-merge report) and "When Actions cannot run" with the never-started-jobs rule kept. The Davit, slot, memory, reuse and plan sections are gone. AGENTS.md "Verifying", "Pull requests" and "Environments", CONTEXT.md (claimed, attested, failed head; no ledger), README, development.md and the deploy docs say the same. The github-prs skill, adapted to make ci-local and make pr-merge, is at .claude/skills and .agents/skills, linked from AGENTS.md and CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cked Randy's review of #134 (bf0b5d3), applied to the attest-only redesign: - P1, PR-controlled gate: ci.yml ran on pull_request, so the pull request supplied the workflow and the scripts/cilocal it ran; a fork adding `func init() { os.Exit(0) }` passed Secret scan and Local verification, and the scan used the fork's .gitleaks.toml. ci.yml now runs on pull_request_target: GitHub takes the workflow and the checkout from main, and the jobs read the head as data only. `cilocal secret-scan --head <sha>` copies the head's files out of git's objects (verify.ScanTargets; a path leaving the scan directory fails closed) and scans them with main's scanner and config. A fork's head fails Local verification, make pr-merge and the staging gate (Verification.Fork, verify.Claimed), and openPull skips a fork PR that shares the branch's name. verify.TrustProblems holds ci.yml to all of it. - P1, publishing a refused run: publish now reads the checkout again just before it posts and refuses unless the report is for exactly that clean, pushed head and tree (verify.PublishRefusal). The save/--publish path Randy reproduced was removed in de66901; this is the one choke point now. - P2, first attestation: a main attestation finds the last attested first-parent ancestor (at most 50; a root or shallow-first commit has none). With none, as in a new project, the statement must name make ci (verify.FirstAttestationProblem). Documented in docs/CI.md, README, AGENTS.md and the github-prs skill. Regression tests in scripts/cilocal/verify (still 100% statement coverage, which also covers two workflows.go helpers 9068dd7 left at 99.4%): TestCIRunsOnlyTrustedCode and TestTrustProblems (they fail on the ci.yml of bf0b5d3 and 9068dd7), TestScanTargets, TestClaimed, the fork cases of TestLocalVerificationProblems, TestPublishRefusal, TestLastAttested, TestRanFullCheck and TestFirstAttestationProblem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed 2f1cca7, on top of the attest-only rework (9068dd7), with fixes for all three review findings and regression tests in scripts/cilocal/verify, still at 100% coverage. |
There was a problem hiding this comment.
CHANGES — reviewed 2f1cca7. One remaining P1 is inline: Danny (simple), pin gitleaks' ignore policy to trusted input.
The target workflow never checks out or executes head code. All jobs explicitly use read-only permissions; no repository secrets/write token reach the scanner. But copied head .gitleaksignore is interpreted as scanner policy: the synthetic-secret control exits 1, then the same head plus an ignore fingerprint exits 0.
Forks fail closed. A/B probes show old fork acceptance/root refusal and safe new behavior. The old dirty-report publication safety assertion fails; the new --publish call refuses without posting. The workflow guard rejects actual bf0b5d3 YAML and accepts this head. The rewritten tests are not portable onto the old APIs; comparisons used executable reproductions. Note the design change: ci-local now records a manual signoff and runs no checks; the saved-report runner is removed.
Validation through heavy.sh: lint, actionlint, build, 36 rule tests/163 including subtests, 100% statement coverage. All three commands replayed against real #134 passed locally, including the 62-file scan.
Bootstrap: local-ci-only dry-run is not hosted proof. Live validation before merging here is possible in a disposable repository with the candidate on its default branch and hostile branch/fork PRs. Local replay cannot prove GitHub event/ref wiring. No pushes, merges, workflow dispatches or status/body updates.
Randy's re-review of #134 (2f1cca7): the Secret scan job used main's .gitleaks.toml, but gitleaks also loads .gitleaksignore from the root of the directory it scans, where the head's files were copied. A head adding a .gitleaksignore with a finding's fingerprint passed the scan. --gitleaks-ignore-path alone does not stop it: gitleaks 8.30.1 still loads the scanned root's file. Inline gitleaks:allow comments were a second way for head content to act as policy. - Policy (verify.ScannerPolicy: .gitleaks.toml, .gitleaksignore) comes only from the trusted commit (the checkout's HEAD, main in the job; --base locally), written to a directory beside the scanned one, with gitleaks' defaults and an empty ignore list where it has none. - The change's own policy-named files are copied in as content under another name (verify.ScanCopyPath); gitleaks runs with --ignore-gitleaks-allow and without GITLEAKS_* env (verify.GitleaksArgs). - A change that adds, edits or removes a policy file fails the scan for review even when it finds nothing (verify.PolicyChanges, PolicyProblem). TestHeadPolicyIsNeverScannerPolicy is Randy's repro: both heads fail. scripts/cilocal/verify stays at 100% statement coverage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
left a comment
There was a problem hiding this comment.
CHANGES — reviewed ce1ba40. The suppression-input fix works, but I do not accept the proposed blanket path allowlist; one P1 is inline (Danny, simple).
Seven detection probes failed closed: positive control, .gitleaksignore, inline gitleaks:allow, GITLEAKS_CONFIG, GITLEAKS_CONFIG_TOML, alternate config filenames, and a head-supplied extension config. In contrast, the identical synthetic PAT passed in all five excluded locations: *_test.go, testdata/, root/nested .env.example, and scripts/bench-devloop.sh.
No additional active config-discovery route was found. The trusted policy only uses useDefault=true. Future [extend].path policy must remain outside scanned content: upstream resolves it from the working directory, not the config's directory (pinned documentation).
Lint, actionlint, build and 39 top-level tests/166 including subtests passed; rules coverage is 100%. All computation used heavy.sh.
The self-flag is expected: make secret-scan found no leaks, then exited nonzero solely because this PR adds policy. Its local-ci statement honestly records that. Current pr-merge --dry-run would merge on local-ci alone because no hosted job ran; this is a bootstrap/manual-review exception, not approval of the policy. A real hosted Secret scan failure still blocks ordinary pr-merge and is not overridden by local-ci. Tighten the policy before accepting that exception. No push or merge.
jayleaton
left a comment
There was a problem hiding this comment.
MERGE — focused final review of 34e7ffe. No remaining findings.
All 5 location probes and all 7 suppression probes now fail. The AND behavior is verified with the real scanner: the exact fake fixture passes in the bench script, fails elsewhere, and a one-character variation fails both inside and outside the script. The two explicit bench values are visibly synthetic; the exception is scoped to generic-api-key.
TestScannerPolicyAllowlistsValuesOnly fails with ce1ba40's config and passes with this config. Lint, build and all 41 top-level tests/168 including subtests passed, with 100% rules coverage. secret-scan --all scanned 469 files and found no leaks. All computation used heavy.sh.
Only five files changed since ce1ba40: policy, docs, regression test/helper, and scanner error-message wording. No other runtime behavior changed. No push or merge.
Problem
Every PR ran its checks on GitHub-hosted runners (
ci-ok), so verification cost billed minutes on every push and could not run at all on a repo whose Actions budget is blocked. Railway deployed straight from GitHub on every push, with nothing proving the commit was verified, and production moved by branch promotion (dev→main).Lockpick first replaced that with a local runner. It still blocked everything: in Lockpick #1210 a 4-file PR took about 15 min and 16 PRs queued for about 4 hours. JJ, 31 Oct: "ci:local just tells the PR that you have sanitized that CI should have passed." The template ships that final model for a Go + make + Railway stack.
Fix
make ci-local RAN="<what you ran>"(scripts/cilocal, Go, standard library only) runs nothing.local-cion the exact SHA (result and section digest), and fills the section with the head, who and where, and the statement.FAILED=records a failure, which is final for that head;COMMIT=attests amaincommit.scripts/cilocal/verify, a test boundary at 99.4% coverage): the section names the head, says what ran, and matches the status digest, and the head never failed. TheLocal verificationjob,make pr-mergeand the staging gate (cilocal attest, Deploy staging's plan) all use it.Local verification claimjob names the checking jobAwaiting make ci-localuntil the head is claimed, so an unattested PR's required check stays Expected. A claimed head with a bad section is red.make pr-merge PR="…"merges every green PR at once and never waits.--match-head-commit, then printsARGS="--check-deploy <squash sha>", a one-read check of Deploy staging plus/healthz.--check-stagingchecks main's head.local-cialone merges there.main. A verify test fails when a push-to-main workflow is missing from its list.Deploy staging,Staging catch-up,Draft releaseandDeploy production(with a gate), with.github/scripts/deploy-railway.sh. Each environment holds its ownRAILWAY_TOKEN.RAILWAY_DEPLOYistruethe deploys dry-run.deploy/platform.yamldefaults torepo: ""(Railway's auto-deploy off), andmainis the only deployed branch.make lint;deploy-railway.shomits--environmentwhen a token is present..claude/skills/and.agents/skills/, identical), linked from AGENTS.md and CLAUDE.md.Evidence
9068dd7, in a Davit box of the exact head (8 stale image files pruned first):go test -cover ./scripts/cilocal/verify: 99.4%.go vet, fullmake lint(0 issues),make lint-layering, the test probes, the coverage floors,make actionlintandmake deploy-check: all pass.make ci-local RAN="…"took 8 s. It postedlocal-ciand filled the section, and that edit's run passedSecret scan,Local verification claimandLocal verification.make pr-merge PR=134 ARGS=--dry-runsaidskip - is not verified locally: the Local verification section is not the one local-ci attested (edited by hand?).Local verificationwith the same error. Re-attesting restored the section.Merge Danger
Door: two-way for code. Reverting restores the old local runner. One-way only if someone sets
RAILWAY_DEPLOY=trueand publishes a release.Blast Radius: platform
Every later PR of the template, and of the repos cloned from it, is verified by the agent and attested with
make ci-local RAN=…, not run by it. The attestation is only as honest as the statement; Lockpick has the same risk.Settings needed, not made here:
mainrequiresSecret scanandLocal verification(GitHub Actions).refs/tags/v*.staging: main only, secretRAILWAY_TOKEN.production: main andv*, secretRAILWAY_TOKEN.PRODUCTION_RELEASERS,STAGING_WEB_URL,PRODUCTION_WEB_URL, the optional admin URLs,RAILWAY_DEPLOY, and the optionalRAILWAY_PROJECT_ID,CI_RUNNERandRELEASE_RUNNER.🤖 Generated with Claude Code
Local verification
Head
34e7ffeff2d416536134b4e2355538d45b842d6f· tree97b32790bc96· passedAttested by
unknownondev-boxRan: make ci; make build; make actionlint; make check-fast; make secret-scan (no leaks, refused only the .gitleaks.toml change); Randy's 5-location probe: the same synthetic token now fails in all 5