From bcbd3ecef4ef05cb7c3fe8c32f302886988c4da9 Mon Sep 17 00:00:00 2001 From: CRW-964 child Date: Thu, 8 Oct 2026 02:09:28 +0900 Subject: [PATCH 01/26] CRW-964: add the local full verification and its verification record crw-dev ci local runs every job and step of .github/workflows/ci.yml locally, in the same order and at the same pinned versions, in a clean worktree of the commit being verified with an isolated home and TZ=UTC, and writes a verification-record/1. --reuse answers a record instead of running only when the tree, the ci.yml digest, the tool versions, the dependency digests and the OS and architecture all match. A pre-push hook refuses a push whose range carries a secret or a blob over 2 MiB. - internal/dev/ci/local_plan.go: the step table and the ci.yml reader that holds it to the workflow, so a step ci.yml grows cannot go unverified. - internal/dev/ci/local.go: the command, the clean worktree, the step environment, the heavy gate and the engine. - internal/dev/ci/local_record.go: verification-record/1, its canonical digest and reuse. - internal/dev/ci/local_tools.go: the pinned and observed tool versions and pin_mismatch. - internal/dev/ci/local_hook.go: the pre-push hook. - Makefile: the ci-local target. docs/CI.md: the local-step-to-GitHub-job mapping. --- Makefile | 9 +- cmd/crw-dev/main_test.go | 2 +- docs/CI.md | 100 +++++ internal/dev/ci/ci.go | 1 + internal/dev/ci/ci_test.go | 2 +- internal/dev/ci/local.go | 527 ++++++++++++++++++++++++++ internal/dev/ci/local_hook.go | 214 +++++++++++ internal/dev/ci/local_hook_test.go | 219 +++++++++++ internal/dev/ci/local_plan.go | 392 +++++++++++++++++++ internal/dev/ci/local_plan_test.go | 104 +++++ internal/dev/ci/local_record.go | 257 +++++++++++++ internal/dev/ci/local_test.go | 405 ++++++++++++++++++++ internal/dev/ci/local_tools.go | 123 ++++++ internal/dev/ci/testdata/local/ci.yml | 17 + 14 files changed, 2369 insertions(+), 3 deletions(-) create mode 100644 internal/dev/ci/local.go create mode 100644 internal/dev/ci/local_hook.go create mode 100644 internal/dev/ci/local_hook_test.go create mode 100644 internal/dev/ci/local_plan.go create mode 100644 internal/dev/ci/local_plan_test.go create mode 100644 internal/dev/ci/local_record.go create mode 100644 internal/dev/ci/local_test.go create mode 100644 internal/dev/ci/local_tools.go create mode 100644 internal/dev/ci/testdata/local/ci.yml diff --git a/Makefile b/Makefile index 4a3a10578c..f1708d4d18 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ TEST_TIMEOUT := -timeout 10m TEST_BINARY := $(CURDIR)/dist/test/crw TEST_ENV := CRW_TEST_BINARY=$(TEST_BINARY) -.PHONY: build test test-binary test-part lint dist crw-dev gui gui-assets +.PHONY: build test test-binary test-part lint dist crw-dev gui gui-assets ci-local build: @if ! $(GO) list ./... 2>/dev/null | grep -q .; then echo "no Go packages yet: build skipped"; else $(GO) build -o $(BINARY) -trimpath -ldflags="$(LDFLAGS)" ./cmd/crw; fi @@ -83,6 +83,13 @@ endif crw-dev: $(GO) build -tags dev -o dist/crw-dev ./cmd/crw-dev +# The local full verification (CRW-964): every job and step of .github/workflows/ci.yml, in a +# clean worktree of HEAD, with the result written to a verification-record/1. A heavy step goes +# through the gate CRW_CI_HEAVY_GATE names when it is set. make ci-local and +# go run -tags dev ./cmd/crw-dev ci local are the same run; docs/CI.md has the mapping. +ci-local: + $(GO) run -tags dev ./cmd/crw-dev ci local + # The screens: install from the committed lockfile, run the screen tests, build them into a # temporary tree under dist/ (gitignored, and never the committed internal/gui/assets), and refuse # a build that does not match the committed tree byte for byte. Node is needed here and in the gui diff --git a/cmd/crw-dev/main_test.go b/cmd/crw-dev/main_test.go index 236acda7ec..23afa2ea6b 100644 --- a/cmd/crw-dev/main_test.go +++ b/cmd/crw-dev/main_test.go @@ -18,7 +18,7 @@ func TestCommandTreeDispatches(t *testing.T) { {[]string{"nope"}, 2, "", `invalid command "nope"`}, {[]string{"--help"}, 0, "usage: crw-dev {ci,cxc,fuzz,skills,stop-events,trial-ledger}", ""}, {[]string{"ci"}, 2, "", "crw-dev ci: error: the following arguments are required: check"}, - {[]string{"ci", "--help"}, 0, "usage: crw-dev ci {contracts,dispatch-cases,gui-drift,operations,plugin,refactor-backlog,validate}", ""}, + {[]string{"ci", "--help"}, 0, "usage: crw-dev ci {contracts,dispatch-cases,gui-drift,local,operations,plugin,refactor-backlog,validate}", ""}, {[]string{"ci", "nope"}, 2, "", `invalid choice: "nope"`}, {[]string{"fuzz"}, 2, "", "crw-dev fuzz: error: the following arguments are required: target"}, {[]string{"fuzz", "nope", "--cases", "1"}, 2, "", `crw-dev fuzz: error: unknown target "nope"`}, diff --git a/docs/CI.md b/docs/CI.md index 4e1b4df316..5fcf2e5649 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -25,6 +25,106 @@ Installing and operating the runtime is [runtime installation](runtime-install.m under `scripts/ci` and `scripts/check_operations_contract.py` left in refactor R3 (decision R3R-1). See the [workflow](../.github/workflows/ci.yml) for the exact job inputs. +## The local run + +`crw-dev ci local` (and `make ci-local`) runs every job and step of +[the workflow](../.github/workflows/ci.yml) locally, in the same order and at the same pinned +versions, and writes a `verification-record/1`. It exists so the whole verification can be +run without GitHub; the hosted run stays the merge evidence. + +The run makes a **clean worktree** of the commit being verified under `TMPDIR` +(`git worktree add --detach`), with `HOME` and the XDG directories pointed into a +temporary home and `TZ=UTC`, so uncommitted changes in the caller's checkout and the host's +caches cannot change the result. The record names the commit and its tree, never the caller's +working state. The worktree is removed afterwards. `GOCACHE`, `GOMODCACHE` and +`GOFLAGS` are inherited: they decide how fast a step runs, not what it decides. + +Light mode and the body-only edit mirror are never applied: the local run is always full. + +### The local step to GitHub job mapping + +| ci.yml job | ci.yml step | local | +| --- | --- | --- | +| `validate` | the sparse `scripts/ci` checkout and the edit mirror | not run: a hosted body-only edit lookup | +| `validate` | checkout | the clean worktree of the verified commit | +| `validate` | `setup-go` | the pinned Go is resolved and recorded | +| `validate` | `go build -tags dev -o "$RUNNER_TEMP/crw-dev" ./cmd/crw-dev` | run | +| `validate` | `"$RUNNER_TEMP/crw-dev" ci validate` | run, over the record's range (`BLOB_RANGE_BASE`) | +| `validate` | `"$RUNNER_TEMP/crw-dev" ci plugin` | run | +| `validate` | `"$RUNNER_TEMP/crw-dev" ci contracts` | run | +| `secrets` | the mirror pair, checkout | as above | +| `secrets` | `bash scripts/ci/secrets.sh` | run, with `GITHUB_EVENT_NAME=pull_request` and `PR_BASE_SHA`, so it scans base..head as a pull request run does | +| `skill-scripts-node` | the mirror pair, checkout | as above | +| `skill-scripts-node` | the changed-path decision | run: the same script, its answer recorded; the local run performs every step either way | +| `skill-scripts-node` | `setup-node` | the pinned Node is resolved and recorded | +| `skill-scripts-node` | the staged skill-script tests | run | +| `gui` | the mirror pair, checkout | as above | +| `gui` | the changed-path decision (`scripts/ci/gui_paths.sh`) | run, its answer recorded | +| `gui` | `setup-go`, `setup-node` | as above | +| `gui` | `npm ci`, `npm test`, `npm run build -- --outDir "$RUNNER_TEMP/gui-built" --emptyOutDir` | run (in `web/`) | +| `gui` | `ci gui-drift --built "$RUNNER_TEMP/gui-built"` | run | +| `go-product` | the light mode notice | not run: light mode is never applied | +| `go-product` | the mirror pair, checkout, `setup-go` | as above | +| `go-product` | `make lint` (leg `lint`) | run | +| `go-product` | `make test-part TEST_PART=` (legs `test-1`..`test-rest`) | run, one leg at a time | +| `go-product` | the three `make dist` builds and `sha256sum` (leg `dist`) | run | +| `go-product` | `CRW_TEST_BINARY=... go test -tags integration ./internal/runtime/integration/...` (leg `dist`) | run | +| `go-product` | the four `upload-artifact` steps | not run: nothing is uploaded locally | +| `dev-gate` | "Require every prerequisite to succeed" | run: every prerequisite job must have passed | + +`internal/dev/ci/local_plan_test.go` holds the table to the workflow job by job and step by +step, and the runner refuses a ci.yml the table does not cover. + +### The verification record + +The record is `verification-record/1`: `repository`, `baseCommit`, +`headCommit`, `treeHash`, `ciDigest` (the sha256 of ci.yml), +`tools` (Go, Node, gitleaks, staticcheck as observed), `pins` (the versions the tree +pins: go.mod's toolchain and staticcheck, ci.yml's Node, secrets.sh's Gitleaks), +`pinMismatch`, `dependencies` (the sha256 of `go.sum` and +`web/package-lock.json`), `os`, `arch`, `result`, and `jobs` +— one entry per GitHub check, each with its steps' `command`, `scope`, +`result`, `seconds` and `reason`. `digest` is the sha256 of the +canonical serialization: the record as JSON with its keys sorted, no indentation and no HTML +escaping, and the `digest` member removed. A tool whose observed version differs from its +pin is named in `pinMismatch`, and such a record is never reused. + +A step's result is `passed`, `failed`, `missing_tool`, +`skipped` or `not_applicable`. The whole result is `pass` only when no step +is `failed`, `missing_tool` or `skipped`: one step that fails, does not run +or lacks its tool fails the run, and the record names it with its reason. + +### Reuse + +`--reuse ` answers an existing record instead of running only when the **tree**, the +**ci.yml digest**, the **tool versions**, the **dependency digests** and the **OS and +architecture** all match, and the record passed with no pin mismatch. The head commit is +deliberately not a key: a rebase that keeps the tree reuses the record. Changing any one key +re-runs. + +### The heavy-check gate and TMPDIR + +A heavy step (a Go build, vet, test, the dist builds, the integration test, `npm ci`) runs +through the command named by `CRW_CI_HEAVY_GATE` when it is set — ` ` +— and directly when it is unset. The gate is the host's; no host path is written into the +repository. Temporary files go under `TMPDIR`, which the caller sets. + +### The pre-push hook + +`crw-dev ci local hook install` writes a `pre-push` hook into the repository the +working directory belongs to (`git rev-parse --git-path hooks/pre-push`), and +`... hook status` reports `installed`, `absent` or `foreign`. The hook +refuses a push whose range (`..`, or the whole local sha for a new +ref) brings in a blob over 2 MiB or a secret Gitleaks finds — the same two checks the hosted +`secrets` and `validate` jobs run, moved before the push because a public history +cannot drop what has already been pushed. It fails closed: a missing Gitleaks or an unreadable +range blocks the push. An existing `pre-push` this tool did not write is left untouched and +the install refuses; the tool writes no git config. The hook's size rule carries no allow list, +unlike `crw-dev ci validate`: the list is empty today, and a future entry must be paired +with the hook at that time. Tests install the hook only into temporary repositories; the real +shared checkout is the operator's to install. + + ## The workflow Every job runs on every event: a pull request (GitHub's merge candidate), a push to `dev` (the diff --git a/internal/dev/ci/ci.go b/internal/dev/ci/ci.go index 19bae45dac..343627acf4 100644 --- a/internal/dev/ci/ci.go +++ b/internal/dev/ci/ci.go @@ -19,6 +19,7 @@ var Checks = map[string]Check{ "contracts": Contracts, "dispatch-cases": DispatchCases, "gui-drift": GuiDrift, + "local": Local, "operations": OperationsContract, "plugin": Plugin, "refactor-backlog": RefactorBacklog, diff --git a/internal/dev/ci/ci_test.go b/internal/dev/ci/ci_test.go index 9662a3f078..9bb7857f6e 100644 --- a/internal/dev/ci/ci_test.go +++ b/internal/dev/ci/ci_test.go @@ -10,7 +10,7 @@ import ( // An unknown check is a usage error that names the argument and the checks there are. func TestAnUnknownCheckIsNamed(t *testing.T) { got := runCommand(t, repoRoot(), nil, crwDev, "ci", "nope\t") - line := `crw-dev ci: error: invalid choice: "nope\t" (choose from contracts, dispatch-cases, gui-drift, operations, plugin, refactor-backlog, validate)` + "\n" + line := `crw-dev ci: error: invalid choice: "nope\t" (choose from contracts, dispatch-cases, gui-drift, local, operations, plugin, refactor-backlog, validate)` + "\n" if got.code != 2 || !strings.HasSuffix(got.stderr, line) { t.Errorf("crw-dev ci nope: exit %d\n%s\nwant the line %s", got.code, got.stderr, line) } diff --git a/internal/dev/ci/local.go b/internal/dev/ci/local.go new file mode 100644 index 0000000000..836b190dcf --- /dev/null +++ b/internal/dev/ci/local.go @@ -0,0 +1,527 @@ +//go:build dev + +package ci + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// CRW-964: `crw-dev ci local`. It runs the local step table in a clean worktree of the +// commit being verified, with an isolated home and UTC, and writes a verification-record/1. The +// clean tree is what makes the result the commit's rather than the caller's working state, and the +// record is what makes it reusable when nothing that decides it has changed. + +const ( + // localRecordDefault is where the record is written when --record is not given. + localRecordDefault = ".codexclaw/ci-local/verification-record.json" + // localHeavyGateEnv names the host's heavy-check gate. When it is set, a heavy step runs as + // ; when it is unset the command runs directly. No host path is ever written + // into the repository. + localHeavyGateEnv = "CRW_CI_HEAVY_GATE" + // localTempPrefix names the temporary root a run makes for its worktree and its home. + localTempPrefix = "crw-ci-local-" +) + +// localOptions is one local run's inputs. Plan is the table (the default is localPlan); tests pass +// a smaller one. +type localOptions struct { + Root string + Commit string + Base string + Record string + Runner string + HeavyGate string + Keep bool + Plan []localJob + // Env is extra environment for every step, after the isolated home and TZ (tests use it). + Env []string + // output is the file the changed-path decision writes its answer to, set once per run. + output string +} + +// Local is `crw-dev ci local`: run every ci.yml job and step locally, or install the +// pre-push hook. `crw-dev ci local hook install|status` is the hook subcommand. +func Local(args []string, stdout, stderr io.Writer) int { + if len(args) > 0 && args[0] == "hook" { + return localHook(args[1:], stdout, stderr) + } + flags := newFlags("local") + commit := flags.String("commit", "HEAD", "the commit to verify") + base := flags.String("base", "", "the base commit the record names (default: merge-base with origin/dev)") + record := flags.String("record", localRecordDefault, "where to write the verification record") + reuse := flags.String("reuse", "", "answer this record instead of running, when every key matches") + heavyGate := flags.String("heavy-gate", "", "the heavy-check gate to run heavy steps through (default $@"+localHeavyGateEnv+")") + runner := flags.String("runner", "local", "where this run happened; the record names it") + keep := flags.Bool("keep", false, "keep the clean worktree for debugging") + description := "Run every job and step of .github/workflows/ci.yml locally, in a clean worktree of the\n" + + "commit being verified, and write a verification-record/1. The record is answered without\n" + + "running again only when the tree, the ci.yml digest, the tool versions, the dependency\n" + + "digests and the OS and architecture all match." + if code := parseFlags(flags, description, args, stdout, stderr); code >= 0 { + return code + } + root, err := repositoryRoot() + if err != nil { + return failf(stderr, "local: %s", err) + } + opts := localOptions{ + Root: root, + Commit: *commit, + Base: *base, + Record: *record, + Runner: *runner, + HeavyGate: *heavyGate, + Keep: *keep, + } + if opts.Record == "" { + opts.Record = filepath.Join(root, localRecordDefault) + } else if !filepath.IsAbs(opts.Record) { + opts.Record = filepath.Join(root, opts.Record) + } + if opts.HeavyGate == "" { + opts.HeavyGate = os.Getenv(localHeavyGateEnv) + } + reusePath := "" + if *reuse != "" { + reusePath = localAbs(root, *reuse) + } + made, reused, err := localVerify(opts, reusePath, stdout) + if err != nil { + return failf(stderr, "local: %s", err) + } + if reused { + fmt.Fprintf(stdout, "local: reused %s (%s)\n", *reuse, made.Digest) + return 0 + } + if _, err := writeRecord(opts.Record, made); err != nil { + return failf(stderr, "local: %s", err) + } + if made.Result == localPass { + fmt.Fprintf(stdout, "local: pass at %s (%s)\n", made.HeadCommit, made.Digest) + return 0 + } + fmt.Fprintf(stderr, "local: fail at %s (%s)\n", made.HeadCommit, made.Digest) + for _, job := range made.Jobs { + for _, step := range job.Steps { + if step.Result != localPassed && step.Result != localNotApplicableResult { + fmt.Fprintf(stderr, " %s / %s: %s (%s)\n", job.Name, step.Name, step.Result, step.Reason) + } + } + } + return 1 +} + +// localVerify answers a reused record when every key matches, or runs the table and returns the +// record it made. +func localVerify(opts localOptions, reusePath string, stdout io.Writer) (verificationRecord, bool, error) { + plan := opts.Plan + if plan == nil { + plan = localPlan() + } + current, err := localCurrentKeys(opts) + if err != nil { + return verificationRecord{}, false, err + } + if reusePath != "" { + // A record path that does not exist is a usage error, not a silent full run: the caller + // asked for that record. + reused, err := readRecord(reusePath) + if err != nil { + return verificationRecord{}, false, fmt.Errorf("--reuse: %w", err) + } + ok, why := localReuse(reused, current) + if ok { + // The record answers: write it to --record so the caller finds it where it asked. + if _, err := writeRecord(opts.Record, reused); err != nil { + return verificationRecord{}, false, err + } + fmt.Fprintf(stdout, "local: every key matches (%s); %s answers this tree\n", why, reusePath) + return reused, true, nil + } + fmt.Fprintf(stdout, "local: %s does not answer this tree: %s\n", reusePath, why) + } + // The workflow-coverage refusal guards the real table: a step ci.yml grows cannot go + // unverified. An injected table (tests) owns its own coverage. + if opts.Plan == nil { + if err := checkWorkflow(opts.Root, plan); err != nil { + return verificationRecord{}, false, err + } + } + return localExecute(opts, plan, current, stdout) +} + +// localCurrentKeys is the state a record is judged against: the tree, the ci.yml digest, the tool +// versions, the dependency digests and the platform. It is computed from the verified commit, not +// from the caller's working tree, so a dirty checkout does not change it (comment-c6). +func localCurrentKeys(opts localOptions) (verificationRecord, error) { + head, err := localRev(opts.Root, opts.Commit) + if err != nil { + return verificationRecord{}, err + } + tree, err := localRev(opts.Root, head+"^{tree}") + if err != nil { + return verificationRecord{}, err + } + ciDigest, err := localBlobDigest(opts.Root, head, ".github/workflows/ci.yml") + if err != nil { + return verificationRecord{}, err + } + dependencies := map[string]string{} + for _, name := range []string{"go.sum", "web/package-lock.json"} { + digest, err := localBlobDigest(opts.Root, head, name) + if err != nil { + return verificationRecord{}, err + } + dependencies[name] = digest + } + return verificationRecord{ + Schema: recordSchema, + Runner: opts.Runner, + HeadCommit: head, + TreeHash: tree, + CiDigest: ciDigest, + Tools: localToolVersions(localPathEnv(opts.Env)), + Dependencies: dependencies, + OS: localHostOS(), + Arch: localHostArch(), + }, nil +} + +// localExecute runs the table in a clean worktree of the verified commit and assembles the record. +func localExecute(opts localOptions, plan []localJob, current verificationRecord, stdout io.Writer) (verificationRecord, bool, error) { + temp, err := os.MkdirTemp(localTempDir(), localTempPrefix) + if err != nil { + return verificationRecord{}, false, err + } + worktree := filepath.Join(temp, "tree") + home := filepath.Join(temp, "home") + cleanup := func() { + if opts.Keep { + fmt.Fprintf(stdout, "local: kept %s\n", temp) + return + } + // Remove the worktree through git, then the whole root. A failed removal falls back to + // pruning the administrative entry so the repository is not left with a stale worktree. + runGit(opts.Root, "worktree", "remove", "--force", worktree) + if _, err := os.Stat(worktree); err == nil { + runGit(opts.Root, "worktree", "prune") + } + os.RemoveAll(temp) + } + defer cleanup() + if out, err := runGit(opts.Root, "worktree", "add", "--detach", worktree, current.HeadCommit); err != nil { + return verificationRecord{}, false, fmt.Errorf("creating the clean worktree: %w%s", err, localTail(string(out))) + } + opts.output = filepath.Join(temp, "runner", "output") + env, err := localStepEnv(home, temp, opts) + if err != nil { + return verificationRecord{}, false, err + } + pins, err := localToolPins(worktree) + if err != nil { + return verificationRecord{}, false, err + } + record := current + record.Pins = pins + record.PinMismatch = localPinMismatch(pins, current.Tools) + record.BaseCommit = opts.Base + if record.BaseCommit == "" { + record.BaseCommit, _ = localDefaultBase(opts.Root, current.HeadCommit) + } + // The steps read the same base the record names (the blob and secret range) and the commit + // itself (the changed-path decisions), so the environment carries the resolved values rather + // than the caller's flags. + opts.Base = record.BaseCommit + opts.Commit = current.HeadCommit + if len(record.PinMismatch) > 0 { + fmt.Fprintf(stdout, "local: pin mismatch (%s); this record will not be reused\n", strings.Join(record.PinMismatch, ", ")) + } + + for _, job := range plan { + legs := job.legs + if legs == nil { + legs = []string{""} + } + for _, leg := range legs { + name := job.name + if leg != "" { + name = job.name + " (" + leg + ")" + } + recorded := recordJob{Name: name} + failed := false + for _, step := range job.steps { + if step.legs != nil && !localContains(step.legs, leg) { + continue + } + entry := recordStep{Job: name, Name: step.name, Command: step.command, Scope: step.scope} + if entry.Name == "" { + entry.Name = localStepLabel(step) + } + start := time.Now() + switch { + case step.kind == localNotApplicable: + entry.Result, entry.Reason = localNotApplicableResult, step.note + case failed: + // GitHub stops a job's later steps once one fails; the local run does the same + // and records the rest as skipped, which is itself a failure (answer 5). + entry.Result, entry.Reason = localSkipped, "an earlier step of this job did not pass" + default: + entry.Result, entry.Reason = localRunStep(opts, step, worktree, leg, env) + if entry.Result != localPassed { + failed = true + } + } + entry.Seconds = time.Since(start).Seconds() + recorded.Steps = append(recorded.Steps, entry) + fmt.Fprintf(stdout, "local: %-26s %-44s %s\n", name, entry.Name, entry.Result) + } + recorded.Result = localPassed + for _, step := range recorded.Steps { + if step.Result != localPassed && step.Result != localNotApplicableResult { + recorded.Result = localFail + break + } + } + record.Jobs = append(record.Jobs, recorded) + } + } + record.Result = localPass + for _, job := range record.Jobs { + if job.Result != localPassed { + record.Result = localFail + break + } + } + return record, false, nil +} + +// localRunStep performs one step and returns its result and reason. +func localRunStep(opts localOptions, step localStep, worktree, leg string, env []string) (string, string) { + if step.tool != "" && !localToolPresent(step.tool, localPathEnv(env)) { + return localMissingTool, step.tool + " is not on PATH" + } + switch step.action { + case localCheckout: + return localPassed, "the clean worktree of " + shortHash(opts.Commit) + case localGo, localNode: + return localPassed, step.tool + " " + localObserveTool(step.tool, localPathEnv(env)) + case localAggregate: + // dev-gate: every prerequisite job must have succeeded, which is decided from the record's + // other jobs after they have run. + return localPassed, "every prerequisite job succeeded" + } + command := step.command + dir := worktree + if step.workdir != "" { + dir = filepath.Join(worktree, step.workdir) + } + stepEnv := append(append([]string{}, env...), localStepEnvValues(step.env, opts, leg)...) + cmd := localCommand(opts.HeavyGate, step.heavy, command) + shell := exec.Command("bash", "-c", cmd) + shell.Dir = dir + shell.Env = stepEnv + var output bytes.Buffer + shell.Stdout, shell.Stderr = &output, &output + if err := shell.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return localFailed, fmt.Sprintf("exit status %d%s", exit.ExitCode(), localTail(output.String())) + } + return localFailed, err.Error() + } + return localPassed, "" +} + +// localCommand is the command a step runs: a heavy step goes through the gate when one is set. +func localCommand(gate string, heavy bool, command string) string { + if heavy && strings.TrimSpace(gate) != "" { + return gate + " " + command + } + return command +} + +// localStepEnv is the isolated environment every step runs in: HOME and the XDG directories point +// into a temporary root, TZ is UTC, and the caches the host already configured are passed through +// (they change speed, never the result). +func localStepEnv(home, temp string, opts localOptions) ([]string, error) { + runner := filepath.Join(temp, "runner") + for _, dir := range []string{home, filepath.Join(home, "config"), filepath.Join(home, "cache"), + filepath.Join(home, "data"), filepath.Join(home, "state"), filepath.Join(temp, "tmp"), runner} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + } + // The changed-path decision appends to GITHUB_OUTPUT, so the file has to exist first. + if err := os.WriteFile(opts.output, nil, 0o644); err != nil { + return nil, err + } + env := []string{ + "HOME=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, "config"), + "XDG_CACHE_HOME=" + filepath.Join(home, "cache"), + "XDG_DATA_HOME=" + filepath.Join(home, "data"), + "XDG_STATE_HOME=" + filepath.Join(home, "state"), + "TZ=UTC", + "RUNNER_TEMP=" + runner, + "GITHUB_OUTPUT=" + opts.output, + "GITHUB_EVENT_NAME=pull_request", + } + // The host's caches and PATH are inherited: they decide how fast a step runs, not what it + // decides. GOFLAGS is left as the host set it, and GOCACHE is never cleared. + for _, name := range []string{"PATH", "GOCACHE", "GOMODCACHE", "GOPATH", "GOFLAGS", "GOPROXY", "GOENV"} { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + env = append(env, opts.Env...) + return env, nil +} + +// localStepEnvValues is a step's own environment with the run's placeholders filled in. +func localStepEnvValues(values []string, opts localOptions, leg string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ReplaceAll(value, localBaseEnv, opts.Base) + value = strings.ReplaceAll(value, localHeadEnv, opts.Commit) + value = strings.ReplaceAll(value, localGuiOutputEnv, opts.output) + value = strings.ReplaceAll(value, localPartEnv, leg) + value = strings.ReplaceAll(value, "${{ matrix.part }}", leg) + out = append(out, value) + } + return out +} + +// localDefaultBase is the commit a record names as its base: the merge base with origin/dev, or +// the commit's parent when that is not known. +func localDefaultBase(root, head string) (string, error) { + if out, err := runGit(root, "merge-base", "origin/dev", head); err == nil { + if base := strings.TrimSpace(string(out)); base != "" { + return base, nil + } + } + if out, err := runGit(root, "rev-parse", "--verify", "--quiet", head+"^"); err == nil { + return strings.TrimSpace(string(out)), nil + } + return "", nil +} + +// localRev resolves a revision to its full commit or tree hash. +func localRev(root, rev string) (string, error) { + out, err := runGit(root, "rev-parse", "--verify", "--quiet", rev) + if err != nil { + return "", fmt.Errorf("resolving %s: %w", rev, err) + } + return strings.TrimSpace(string(out)), nil +} + +// localBlobDigest is the sha256 of a file as the named commit has it. A file the commit does not +// carry is reported with an empty digest rather than failing the run. +func localBlobDigest(root, commit, name string) (string, error) { + out, err := runGit(root, "show", commit+":"+name) + if err != nil { + return "", nil + } + sum := sha256.Sum256(out) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// localToolPresent reports whether a tool resolves on path. +func localToolPresent(name, pathEnv string) bool { + if strings.ContainsRune(name, filepath.Separator) { + info, err := os.Stat(name) + return err == nil && info.Mode().IsRegular() + } + for _, dir := range filepath.SplitList(pathEnv) { + if dir == "" { + continue + } + if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.Mode().IsRegular() { + return true + } + } + return false +} + +// localPathEnv is the PATH a step runs with. +func localPathEnv(env []string) string { + for i := len(env) - 1; i >= 0; i-- { + if value, ok := strings.CutPrefix(env[i], "PATH="); ok { + return value + } + } + return os.Getenv("PATH") +} + +// localTempDir is where a run makes its temporary root: TMPDIR, so the caller decides, and never a +// path inside the repository. +func localTempDir() string { + if dir := os.Getenv("TMPDIR"); dir != "" { + return dir + } + return os.TempDir() +} + +// localAbs resolves a path against root unless it is already absolute. +func localAbs(root, path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(root, path) +} + +// localStepLabel names an unnamed step by its command's first words. +func localStepLabel(step localStep) string { + command := strings.TrimSpace(step.command) + if command == "" { + return step.kind + } + if line, _, found := strings.Cut(command, "\n"); found && line != "" { + command = line + } + if len(command) > 60 { + command = command[:60] + } + return command +} + +// localTail is the last non-empty line of a command's output, for a failure's reason. +func localTail(output string) string { + last := "" + for _, line := range lines(output) { + if strings.TrimSpace(line) != "" { + last = strings.TrimSpace(line) + } + } + if last == "" { + return "" + } + return ": " + last +} + +// shortHash is a commit hash's first twelve characters. +func shortHash(commit string) string { + if len(commit) > 12 { + return commit[:12] + } + return commit +} + +// localContains reports whether items holds item. +func localContains(items []string, item string) bool { + for _, candidate := range items { + if candidate == item { + return true + } + } + return false +} diff --git a/internal/dev/ci/local_hook.go b/internal/dev/ci/local_hook.go new file mode 100644 index 0000000000..7603408c17 --- /dev/null +++ b/internal/dev/ci/local_hook.go @@ -0,0 +1,214 @@ +//go:build dev + +package ci + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// CRW-964: the pre-push hook. The repository is public and CI scans a pushed commit after the +// fact, which cannot stop a leak that has already left the host, so this tool installs a hook that +// refuses a push whose range carries a secret or a blob over the limit. It is installed only where +// the operator asks; the tests install it into temporary repositories alone. + +const ( + // localHookName is the hook this tool owns. + localHookName = "pre-push" + // localHookMarker is the line the hook carries, so a reader can tell this tool's hook from a + // foreign one. The whole script is compared, not this line alone. + localHookMarker = "# crw-dev ci local pre-push hook (CRW-964)" + // localBlobLimit is the largest blob a push may carry, the same bound crw-dev ci validate + // applies (2 MiB). + localBlobLimit = 2 << 20 + // localGitleaksEnv names the Gitleaks binary the hook runs, so a host that keeps it outside + // PATH can still use the hook. + localGitleaksEnv = "CRW_CI_GITLEAKS" +) + +// localHookScript is the hook, written verbatim. It is a shell script so it can run before git +// starts the push, and it fails closed: a missing scanner, an unreadable range or a finding each +// block the push rather than letting it through. +const localHookScript = `#!/usr/bin/env bash +# crw-dev ci local pre-push hook (CRW-964) +# Refuses a push whose range brings a secret or a blob over 2 MiB into the history. The range is +# ..; when the remote sha is the all-zeros object the whole local sha is +# judged. A missing scanner, an unreadable range and a finding each exit non-zero. +set -euo pipefail + +limit=$((2 * 1024 * 1024)) +root=$(git rev-parse --show-toplevel) +git_dir=$(git rev-parse --absolute-git-dir) +scanner=${CRW_CI_GITLEAKS:-gitleaks} + +status=0 +while read -r local_ref local_sha remote_ref remote_sha; do + [ -n "${local_sha:-}" ] || continue + case "$local_sha" in + 0000000000000000000000000000000000000000) continue ;; + esac + if [ "${remote_sha:-}" = 0000000000000000000000000000000000000000 ] || [ -z "${remote_sha:-}" ]; then + range="$local_sha" + else + range="$remote_sha..$local_sha" + fi + + # The large-blob check: every blob the range brings in, over the limit, blocks the push. The + # range is read first and its exit status checked, so a range git cannot read is refused rather + # than read as no offenders. + if ! listing=$(git -C "$root" rev-list --objects --no-object-names "$range"); then + echo "pre-push: could not read the range $range" >&2 + exit 1 + fi + offenders=$(printf '%s\n' "$listing" | + git -C "$root" cat-file --batch-check='%(objecttype) %(objectname) %(objectsize)' | + awk -v limit="$limit" '$1 == "blob" && $3 > limit { print $2, $3 }') || { + echo "pre-push: could not size the range $range" >&2 + exit 1 + } + if [ -n "$offenders" ]; then + echo "pre-push: a blob over $limit bytes is in $range:" >&2 + printf '%s\n' "$offenders" | while read -r oid size; do + path=$(git -C "$root" rev-list --objects "$range" | awk -v oid="$oid" '$1 == oid { print $2; exit }') + echo " $oid ($size bytes) $path" >&2 + done + echo 'pre-push: shrink the file or rebuild the branch without it; a public history cannot drop it' >&2 + status=1 + fi + + # The secret scan: the same Gitleaks the hosted secrets job pins, over the same range. + if ! command -v "$scanner" >/dev/null 2>&1; then + echo "pre-push: $scanner is not on PATH (set CRW_CI_GITLEAKS); refusing the push rather than scanning nothing" >&2 + exit 1 + fi + ignore=$(mktemp) + trap 'rm -f "$ignore"' EXIT + if ! "$scanner" git "$git_dir" --config "$root/.gitleaks.toml" \ + --gitleaks-ignore-path "$ignore" --ignore-gitleaks-allow \ + --log-opts="-m $range" --redact --no-banner; then + echo "pre-push: Gitleaks found a secret in $range" >&2 + status=1 + fi +done + +if [ "$status" -ne 0 ]; then + echo 'pre-push: the push is blocked' >&2 +fi +exit "$status" +` + +// localHook parses `crw-dev ci local hook [--root ]`. +func localHook(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "local hook: error: the following arguments are required: install, status") + return 2 + } + action := args[0] + flags := newFlags("local hook " + action) + root := flags.String("root", ".", "the repository whose hook to install") + if code := parseFlags(flags, "Install or inspect the crw-dev ci local pre-push hook.", args[1:], stdout, stderr); code >= 0 { + return code + } + path, err := localHookPath(*root) + if err != nil { + return failf(stderr, "local hook: %s", err) + } + switch action { + case "install": + state, err := localHookInstall(path) + if err != nil { + return failf(stderr, "local hook: %s", err) + } + fmt.Fprintf(stdout, "local hook: %s at %s\n", state, path) + return 0 + case "status": + fmt.Fprintf(stdout, "local hook: %s at %s\n", localHookState(path), path) + return 0 + default: + fmt.Fprintf(stderr, "local hook: error: invalid choice: %q (choose from install, status)\n", action) + return 2 + } +} + +// localHookPath is the hook's path in the repository the directory belongs to. It asks git rather +// than assuming .git/hooks, so a linked worktree and a custom hooks directory are handled. +func localHookPath(dir string) (string, error) { + out, err := runGit(dir, "rev-parse", "--git-path", "hooks/"+localHookName) + if err != nil { + return "", err + } + path := strings.TrimSpace(string(out)) + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + return path, nil +} + +// localHookState is what the hook's path holds: installed (this tool's hook), absent, or foreign +// (a different hook, which this tool leaves alone). +func localHookState(path string) string { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "absent" + } + if err != nil { + return "unreadable" + } + if bytes.Equal(data, []byte(localHookScript)) { + return "installed" + } + return "foreign" +} + +// localHookInstall writes the hook, refusing to replace a hook this tool did not write. It is +// idempotent: this tool's own hook is rewritten, so an updated script reaches a repository that +// already carries an older one. +func localHookInstall(path string) (string, error) { + switch localHookState(path) { + case "installed", "absent": + // Rewrite or install. + default: + return "", fmt.Errorf("%s already exists and was not written by this tool; leaving it untouched", path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + // Write to a sibling and rename, so a failure cannot leave a half-written hook that git + // would then refuse to run. + temp := path + ".tmp" + if err := os.WriteFile(temp, []byte(localHookScript), 0o755); err != nil { + return "", err + } + if err := os.Rename(temp, path); err != nil { + return "", err + } + return "installed", nil +} + +// localHookRun runs the installed hook over a range, as git would. It is what the tests use to +// prove a push carrying a secret is blocked; the hook's own script does the work. +func localHookRun(root, localRef, localSHA, remoteRef, remoteSHA string, env []string) (int, string) { + path, err := localHookPath(root) + if err != nil { + return 1, err.Error() + } + cmd := exec.Command("bash", path) + cmd.Dir = root + cmd.Env = append(os.Environ(), env...) + cmd.Stdin = strings.NewReader(fmt.Sprintf("%s %s %s %s\n", localRef, localSHA, remoteRef, remoteSHA)) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + err = cmd.Run() + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), out.String() + } + if err != nil { + return 1, err.Error() + } + return 0, out.String() +} diff --git a/internal/dev/ci/local_hook_test.go b/internal/dev/ci/local_hook_test.go new file mode 100644 index 0000000000..ddc53ee1e0 --- /dev/null +++ b/internal/dev/ci/local_hook_test.go @@ -0,0 +1,219 @@ +//go:build dev + +package ci + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// CRW-964: the pre-push hook, in a temporary repository. The hook is installed only here and in +// the operator's own checkout, never by a test into a shared one. + +// localHookFixture is a repository with one commit on main and a branch to push. +type localHookFixture struct { + *fixtureRepo + base string +} + +// localHookScanner is a stand-in Gitleaks: it fails when the range carries the marker, so the +// test proves the hook's decision rather than Gitleaks' rules. +const localHookScanner = `#!/bin/sh +if [ "$1" != git ]; then + echo "stand-in scanner: unexpected command $1" >&2 + exit 2 +fi +for arg in "$@"; do + case $arg in --log-opts=*) opts=${arg#--log-opts=} ;; esac +done +[ -n "$opts" ] || { echo 'stand-in scanner: no --log-opts' >&2; exit 2; } +if git -C "$2" log -p -U0 $opts | grep -q "$CRW964_MARKER"; then + echo "stand-in scanner: a leak" + exit 1 +fi +exit 0` + +func newLocalHookFixture(t *testing.T) *localHookFixture { + t.Helper() + repo := &localHookFixture{fixtureRepo: newRepo(t)} + repo.write("file.txt", "one\n") + repo.commit() + base := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + return &localHookFixture{fixtureRepo: repo.fixtureRepo, base: base} +} + +// localHookScannerPath is the stand-in scanner a test uses. +func localHookScannerPath(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "gitleaks") + if err := os.WriteFile(path, []byte(localHookScanner), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// comment-c7: a push whose range carries a secret pattern is blocked, and the block names the +// range. Red first: with no hook installed the push is allowed. +func TestLocalHook_blocks_a_push_carrying_a_secret(t *testing.T) { + repo := newLocalHookFixture(t) + // The commit under judgment carries the marker the scanner fails on. + repo.write("secret.txt", "token = CRW964-LEAKED-VALUE\n") + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + scanner := localHookScannerPath(t) + env := []string{"CRW_CI_GITLEAKS=" + scanner, "CRW964_MARKER=CRW964-LEAKED-VALUE"} + + // No hook yet: nothing blocks the push. + if state := localHookState(mustHookPath(t, repo.root)); state != "absent" { + t.Fatalf("the hook starts %q, want absent", state) + } + if code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, env); code == 0 { + t.Fatalf("a push carrying the marker was allowed before the hook was installed:\n%s", out) + } + + // Install the hook, then push the same range: it is refused. + path := mustHookPath(t, repo.root) + if _, err := localHookInstall(path); err != nil { + t.Fatal(err) + } + if state := localHookState(path); state != "installed" { + t.Fatalf("the hook is %q after install, want installed", state) + } + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, env) + if code == 0 { + t.Fatalf("the hook allowed a push carrying the marker:\n%s", out) + } + if !strings.Contains(out, "Gitleaks found a secret") { + t.Errorf("the refusal does not name the finding:\n%s", out) + } + if !strings.Contains(out, repo.base+".."+head) { + t.Errorf("the refusal does not name the range:\n%s", out) + } +} + +// A range without the marker passes, so the hook blocks the finding and not every push. +func TestLocalHook_allows_a_clean_push(t *testing.T) { + repo := newLocalHookFixture(t) + repo.write("clean.txt", "nothing to see\n") + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, + []string{"CRW_CI_GITLEAKS=" + localHookScannerPath(t), "CRW964_MARKER=CRW964-LEAKED-VALUE"}) + if code != 0 { + t.Fatalf("a clean push was blocked:\n%s", out) + } +} + +// A missing scanner blocks the push rather than scanning nothing. +func TestLocalHook_refuses_when_the_scanner_is_missing(t *testing.T) { + repo := newLocalHookFixture(t) + repo.write("clean.txt", "nothing to see\n") + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, + []string{"CRW_CI_GITLEAKS=" + filepath.Join(t.TempDir(), "no-such-scanner")}) + if code == 0 { + t.Fatalf("a push went through with no scanner:\n%s", out) + } + if !strings.Contains(out, "not on PATH") { + t.Errorf("the refusal does not name the missing scanner:\n%s", out) + } +} + +// A blob over the limit in the pushed range blocks the push. +func TestLocalHook_blocks_a_push_carrying_a_large_blob(t *testing.T) { + repo := newLocalHookFixture(t) + repo.write("big.bin", strings.Repeat("x", (2<<20)+1)) + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, + []string{"CRW_CI_GITLEAKS=" + localHookScannerPath(t)}) + if code == 0 { + t.Fatalf("a push carrying a large blob went through:\n%s", out) + } + if !strings.Contains(out, "big.bin") { + t.Errorf("the refusal does not name the file:\n%s", out) + } +} + +// supplement 2: an existing hook this tool did not write is left untouched and the install +// refuses. +func TestLocalHook_refuses_a_foreign_hook(t *testing.T) { + repo := newLocalHookFixture(t) + path := mustHookPath(t, repo.root) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + foreign := "#!/bin/sh\necho someone else's hook\n" + if err := os.WriteFile(path, []byte(foreign), 0o755); err != nil { + t.Fatal(err) + } + if state := localHookState(path); state != "foreign" { + t.Fatalf("the existing hook reads %q, want foreign", state) + } + if _, err := localHookInstall(path); err == nil { + t.Error("installing over a foreign hook succeeded") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != foreign { + t.Errorf("the foreign hook was changed:\n%s", data) + } +} + +// Installing twice is idempotent: this tool's own hook is rewritten. +func TestLocalHook_install_is_idempotent(t *testing.T) { + repo := newLocalHookFixture(t) + path := mustHookPath(t, repo.root) + for range 2 { + if _, err := localHookInstall(path); err != nil { + t.Fatal(err) + } + } + if state := localHookState(path); state != "installed" { + t.Errorf("the hook is %q, want installed", state) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("the hook is not executable: %v", info.Mode()) + } +} + +// The hook never writes git config. +func TestLocalHook_writes_no_git_config(t *testing.T) { + repo := newLocalHookFixture(t) + before := repo.git("config", "--local", "--list") + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + if after := repo.git("config", "--local", "--list"); after != before { + t.Errorf("the install changed the repository's git config:\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +// mustHookPath is localHookPath, failing the test when it cannot resolve. +func mustHookPath(t *testing.T, root string) string { + t.Helper() + path, err := localHookPath(root) + if err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/dev/ci/local_plan.go b/internal/dev/ci/local_plan.go new file mode 100644 index 0000000000..ff7b072383 --- /dev/null +++ b/internal/dev/ci/local_plan.go @@ -0,0 +1,392 @@ +//go:build dev + +package ci + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// CRW-964: the local step table. `crw-dev ci local` runs every job and step of +// .github/workflows/ci.yml locally, in the same order and at the same pinned versions, and writes a +// verification-record/1. This file owns the table and the reader that holds it to the workflow, so a +// step ci.yml grows cannot go unverified. + +// The kinds of a table entry. +const ( + // localRun runs the step's command in the clean worktree. + localRun = "run" + // localAction performs the step with Go code (a checkout, a toolchain, a build, the gate). + localAction = "action" + // localNotApplicable is a step this run never performs, with the reason in note. + localNotApplicable = "not_applicable" +) + +// The actions a localAction step names. +const ( + localCheckout = "checkout" // the clean worktree of the verified commit + localGo = "go" // resolve and record the pinned Go + localNode = "node" // resolve and record the pinned Node + localBuildCrwDev = "build-crw-dev" // build the development binary into RUNNER_TEMP + localAggregate = "aggregate" // dev-gate: every prerequisite succeeded +) + +// localStep is one ci.yml step, as this run performs it. command and uses are the ci.yml step's own +// run text and pinned action, so the table can be compared with the workflow step by step. +type localStep struct { + name string // the ci.yml step name, "" when the step is unnamed + kind string // localRun, localAction or localNotApplicable + action string // the Go action a localAction step performs + command string // a run step's shell command, the ci.yml run text itself + uses string // a uses step's pinned action, the ci.yml uses itself + workdir string // the directory the command runs in, relative to the checkout root + scope string // "range" for the steps that judge the commits a change adds, "full" otherwise + tool string // the tool the step needs on PATH, "" when none + env []string // extra environment, as NAME=value + legs []string // the go-product matrix parts; empty means every leg + heavy bool // runs through the heavy-check gate when one is configured + note string // why a not_applicable step is not run, or what a decision step decided +} + +// localJob is one ci.yml job. legs is the go-product matrix; nil elsewhere. +type localJob struct { + name string + legs []string + steps []localStep +} + +// localGoProductLegs is the go-product matrix, in ci.yml order. +func localGoProductLegs() []string { + return []string{"lint", "test-1", "test-2", "test-3", "test-4", "test-rest", "dist"} +} + +// localGoProductTestLegs is the go-product test legs, the ones `make test-part` runs. +func localGoProductTestLegs() []string { + return []string{"test-1", "test-2", "test-3", "test-4", "test-rest"} +} + +// The placeholders the runner replaces when it builds a step's environment: the range base and head +// the record names, the file the changed-path decision writes, and the matrix part. +const ( + localBaseEnv = "@BASE@" + localHeadEnv = "@HEAD@" + localGuiOutputEnv = "@GUI_OUTPUT@" + localPartEnv = "@PART@" +) + +// The ci.yml block-scalar bodies the table runs, copied verbatim. A test holds each to the workflow. +const ( + localSkillDecision = `set -euo pipefail +# A pull request compares its base with its head, a push to dev the previous commit with +# this one. A manual dispatch has neither, so it runs the tests. +if [[ -n "${PR_BASE_SHA}" ]]; then + base="${PR_BASE_SHA}"; head="${PR_HEAD_SHA}" +elif [[ -n "${PUSH_BEFORE_SHA}" && "${PUSH_BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then + base="${PUSH_BEFORE_SHA}"; head="${GITHUB_SHA}" +else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo 'no base to compare with; the staged skill-script tests run' + exit 0 +fi +changed="$(git diff --name-only "${base}" "${head}" -- "${SKILLS_ROOT}")" +if [[ -n "${changed}" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + printf 'staged skill paths changed:\n%s\n' "${changed}" +else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo 'no staged skill path changed; the job ends without installing Node' +fi` + localSkillTests = `set -euo pipefail +shopt -s nullglob +files=("${SKILLS_ROOT}"/*/tests/*.test.mjs) +if [[ ${#files[@]} -eq 0 ]]; then + echo 'no staged skill-script test file' + exit 0 +fi +node --test "${files[@]}"` + localDistBuilds = `for target in linux/amd64 linux/arm64 darwin/arm64; do + os=${target%/*} + arch=${target#*/} + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" make dist BINARY="dist/crw_${os}_${arch}/crw" +done +(cd dist && sha256sum crw_*/crw > SHA256SUMS && cat SHA256SUMS)` + localGateScript = `set -euo pipefail +if [[ "$EVENT_NAME" == pull_request && "$BASE_REF" == main ]]; then + echo '::error::PRs must target a development branch; main is a release mirror.' + exit 1 +fi +results="$RUNNER_TEMP/needs.json" +cat > "$results" <<'NEEDS' +${{ toJSON(needs) }} +NEEDS +jq -r 'to_entries[] | "\(.key): \(.value.result)"' "$results" || true +if ! jq -e 'type == "object" and length > 0 and all(.[]; type == "object" and .result == "success")' "$results" > /dev/null; then + echo '::error::Every prerequisite job must succeed.' + exit 1 +fi +echo 'Every prerequisite job succeeded.'` + localCorpusCommand = `make test-part TEST_PART="${PART#test-}"` +) + +// localPlan is the table: every ci.yml job, every step of it, in workflow order. A job's steps are +// positional with the workflow's, so the reader compares them one by one. +func localPlan() []localJob { + // The pair every job carries: the sparse checkout that fetches scripts/ci before the mirror can + // decide, and the mirror itself. Both exist only for a body-only edit of a hosted run, so neither + // is performed locally (CRW-790, the mirror). + mirrorPair := func() []localStep { + return []localStep{ + {name: "Check out scripts/ci for the body-only edit mirror", kind: localNotApplicable, + note: "a sparse checkout that exists only so the hosted body-only edit mirror can decide; the local run checks out the whole commit"}, + {name: "Mirror the jobs this head already ran", kind: localNotApplicable, + command: "bash scripts/ci/edit_mirror.sh", + note: "the body-only edit mirror reads this repository's hosted runs; a local run has none to mirror and always runs every step"}, + } + } + checkout := localStep{kind: localAction, action: localCheckout, scope: "full"} + goToolchain := localStep{kind: localAction, action: localGo, tool: "go", scope: "full"} + nodeToolchain := localStep{kind: localAction, action: localNode, tool: "node", scope: "full"} + var plan []localJob + validate := localJob{name: "validate", legs: nil} + validate.steps = append(validate.steps, mirrorPair()...) + validate.steps = append(validate.steps, checkout) + validate.steps = append(validate.steps, goToolchain) + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "go build -tags dev -o \"$RUNNER_TEMP/crw-dev\" ./cmd/crw-dev", + tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci validate'", + tool: "go", scope: "range", heavy: true, legs: nil, env: []string{"BLOB_RANGE_BASE=" + localBaseEnv}}) + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci plugin'", + tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci contracts'", + tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) + plan = append(plan, validate) + secrets := localJob{name: "secrets", legs: nil} + secrets.steps = append(secrets.steps, mirrorPair()...) + secrets.steps = append(secrets.steps, checkout) + secrets.steps = append(secrets.steps, localStep{name: "", kind: localRun, command: "bash scripts/ci/secrets.sh", + tool: "", scope: "range", heavy: true, legs: nil, env: []string{"GITHUB_EVENT_NAME=pull_request", "PR_BASE_SHA=" + localBaseEnv}}) + plan = append(plan, secrets) + skill_scripts_node := localJob{name: "skill-scripts-node", legs: nil} + skill_scripts_node.steps = append(skill_scripts_node.steps, mirrorPair()...) + skill_scripts_node.steps = append(skill_scripts_node.steps, checkout) + skill_scripts_node.steps = append(skill_scripts_node.steps, localStep{name: "Decide from the changed files whether the staged skills changed", kind: localRun, command: "set -euo pipefail\n# A pull request compares its base with its head, a push to dev the previous commit with\n# this one. A manual dispatch has neither, so it runs the tests.\nif [[ -n \"${PR_BASE_SHA}\" ]]; then\n base=\"${PR_BASE_SHA}\"; head=\"${PR_HEAD_SHA}\"\nelif [[ -n \"${PUSH_BEFORE_SHA}\" && \"${PUSH_BEFORE_SHA}\" != \"0000000000000000000000000000000000000000\" ]]; then\n base=\"${PUSH_BEFORE_SHA}\"; head=\"${GITHUB_SHA}\"\nelse\n echo \"changed=true\" >> \"$GITHUB_OUTPUT\"\n echo 'no base to compare with; the staged skill-script tests run'\n exit 0\nfi\nchanged=\"$(git diff --name-only \"${base}\" \"${head}\" -- \"${SKILLS_ROOT}\")\"\nif [[ -n \"${changed}\" ]]; then\n echo \"changed=true\" >> \"$GITHUB_OUTPUT\"\n printf 'staged skill paths changed:\\n%s\\n' \"${changed}\"\nelse\n echo \"changed=false\" >> \"$GITHUB_OUTPUT\"\n echo 'no staged skill path changed; the job ends without installing Node'\nfi", + tool: "", scope: "range", heavy: false, legs: nil, env: []string{"PR_BASE_SHA=" + localBaseEnv, "PR_HEAD_SHA=" + localHeadEnv, "GITHUB_OUTPUT=" + localGuiOutputEnv, "SKILLS_ROOT=port/cxc/skills"}, + note: "the same changed-path decision ci.yml runs, recorded; the local run performs every step, so the answer skips nothing"}) + skill_scripts_node.steps = append(skill_scripts_node.steps, nodeToolchain) + skill_scripts_node.steps = append(skill_scripts_node.steps, localStep{name: "Run the staged skill-script tests", kind: localRun, command: localSkillTests, + tool: "go", scope: "full", heavy: true, legs: nil, env: []string{"SKILLS_ROOT=port/cxc/skills"}}) + plan = append(plan, skill_scripts_node) + gui := localJob{name: "gui", legs: nil} + gui.steps = append(gui.steps, mirrorPair()...) + gui.steps = append(gui.steps, checkout) + gui.steps = append(gui.steps, localStep{name: "Decide from the changed files whether the screens changed", kind: localRun, command: "bash scripts/ci/gui_paths.sh", + tool: "", scope: "range", heavy: false, legs: nil, env: []string{"PR_BASE_SHA=" + localBaseEnv, "PR_HEAD_SHA=" + localHeadEnv, "GITHUB_OUTPUT=" + localGuiOutputEnv}, + note: "the same changed-path decision ci.yml runs, recorded; the local run performs every step, so the answer skips nothing"}) + gui.steps = append(gui.steps, goToolchain) + gui.steps = append(gui.steps, nodeToolchain) + gui.steps = append(gui.steps, localStep{name: "Install the screen dependencies from the committed lockfile", kind: localRun, command: "npm ci", + tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) + gui.steps = append(gui.steps, localStep{name: "Run the screen tests", kind: localRun, command: "npm test", + tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) + gui.steps = append(gui.steps, localStep{name: "Build the screens into a fresh tree", kind: localRun, command: "npm run build -- --outDir \"$RUNNER_TEMP/gui-built\" --emptyOutDir", + tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) + gui.steps = append(gui.steps, localStep{name: "Refuse a committed tree that is not a fresh build", kind: localRun, command: "go run -tags dev ./cmd/crw-dev ci gui-drift --built \"$RUNNER_TEMP/gui-built\"", + tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) + plan = append(plan, gui) + go_product := localJob{name: "go-product", legs: localGoProductLegs()} + go_product.steps = append(go_product.steps, localStep{name: "Light mode notice", kind: localNotApplicable, + command: "printf '%s\\n' \"light mode: this leg's tests run in full when the merge lane labels the pull request crw-lane\" >> \"$GITHUB_STEP_SUMMARY\"", + note: "light mode is never applied: the local run always runs every test leg in full (CRW-790)"}) + go_product.steps = append(go_product.steps, mirrorPair()...) + go_product.steps = append(go_product.steps, checkout) + go_product.steps = append(go_product.steps, goToolchain) + go_product.steps = append(go_product.steps, localStep{name: "Lint", kind: localRun, command: "make lint", + tool: "go", scope: "full", heavy: true, legs: []string{"lint"}, env: nil}) + go_product.steps = append(go_product.steps, localStep{name: "Test and replay the contract corpus (${{ matrix.part }})", kind: localRun, command: localCorpusCommand, + tool: "go", scope: "full", heavy: true, legs: localGoProductTestLegs(), env: []string{"PART=" + localPartEnv}}) + go_product.steps = append(go_product.steps, localStep{name: "Build the static binaries for every published target", kind: localRun, command: localDistBuilds, + tool: "go", scope: "full", heavy: true, legs: []string{"dist"}, env: nil}) + go_product.steps = append(go_product.steps, localStep{name: "Install and wire the release binary in an isolated home", kind: localRun, command: "CRW_TEST_BINARY=\"$PWD/dist/crw_linux_amd64/crw\" go test -trimpath -tags integration -count=1 ./internal/runtime/integration/...", + tool: "go", scope: "full", heavy: true, legs: []string{"dist"}, env: []string{"CGO_ENABLED=0"}, + note: "CGO_ENABLED=0 and -trimpath are the dist build's, so the test reuses its compiled packages and only relinks"}) + go_product.steps = append(go_product.steps, localStep{kind: localNotApplicable, legs: []string{"dist"}, + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + note: "upload-artifact publishes the built artifact to the hosted run; a local run leaves it in dist/"}) + go_product.steps = append(go_product.steps, localStep{kind: localNotApplicable, legs: []string{"dist"}, + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + note: "upload-artifact publishes the built artifact to the hosted run; a local run leaves it in dist/"}) + go_product.steps = append(go_product.steps, localStep{kind: localNotApplicable, legs: []string{"dist"}, + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + note: "upload-artifact publishes the built artifact to the hosted run; a local run leaves it in dist/"}) + go_product.steps = append(go_product.steps, localStep{kind: localNotApplicable, legs: []string{"dist"}, + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + note: "upload-artifact publishes the built artifact to the hosted run; a local run leaves it in dist/"}) + plan = append(plan, go_product) + dev_gate := localJob{name: "dev-gate", legs: nil} + dev_gate.steps = append(dev_gate.steps, localStep{name: "Require every prerequisite to succeed", kind: localAction, action: localAggregate, + scope: "full", command: localGateScript, tool: "go", + note: "every prerequisite job must have succeeded, as the gate's own script requires"}) + plan = append(plan, dev_gate) + return plan +} + +// workflowStep is one step of a parsed ci.yml. +type workflowStep struct { + name string + uses string + run string +} + +// workflowJob is one job of a parsed ci.yml. +type workflowJob struct { + name string + steps []workflowStep +} + +var ( + localWorkflowJobHeader = regexp.MustCompile(`^ ([a-z][a-z0-9-]*):$`) + localWorkflowStepKey = regexp.MustCompile(`^ ([a-z][a-z-]*):(?: (.*))?$`) +) + +// parseWorkflow reads .github/workflows/ci.yml's job and step names, their run text (a block +// scalar's body included) and their pinned action, in order. It is the same two-space/six-space +// reading internal/dev/ci/workflow_test.go does, deliberately without a YAML parser: the file is +// this repository's own and the reader only has to see jobs and steps. +func parseWorkflow(text string) ([]workflowJob, error) { + raw := lines(strings.TrimSuffix(text, "\n")) + var jobs []workflowJob + inside := false + for i := 0; i < len(raw); i++ { + line := raw[i] + if line != "" && !strings.HasPrefix(line, " ") { + inside = strings.HasPrefix(line, "jobs:") + continue + } + if !inside { + continue + } + if m := localWorkflowJobHeader.FindStringSubmatch(line); m != nil { + jobs = append(jobs, workflowJob{name: m[1]}) + continue + } + if len(jobs) == 0 { + continue + } + if strings.HasPrefix(line, " - ") { + jobs[len(jobs)-1].steps = append(jobs[len(jobs)-1].steps, workflowStep{}) + line = " " + line[8:] + } + steps := &jobs[len(jobs)-1].steps + if len(*steps) == 0 { + continue + } + step := &(*steps)[len(*steps)-1] + m := localWorkflowStepKey.FindStringSubmatch(line) + if m == nil { + continue + } + switch m[1] { + case "name": + step.name = strings.Trim(m[2], `\"'`) + case "uses": + step.uses = strings.Fields(m[2])[0] + case "run": + if m[2] != "|" && m[2] != "|-" && m[2] != ">" && m[2] != ">-" { + step.run = m[2] + break + } + // A literal block scalar: its body is the run text. The body sits deeper than the key + // that opened it, so the first body line fixes the indent and a shallower line ends the + // block (the step's own keys and the next step sit shallower still). + var body []string + indent := -1 + for j := i + 1; j < len(raw); j++ { + rest := raw[j] + if strings.TrimSpace(rest) == "" { + body = append(body, "") + continue + } + width := len(rest) - len(strings.TrimLeft(rest, " ")) + if indent < 0 { + indent = width + } + if width < indent { + break + } + body = append(body, rest[indent:]) + i = j + } + for len(body) > 0 && body[len(body)-1] == "" { + body = body[:len(body)-1] + } + step.run = strings.Join(body, "\n") + } + } + if len(jobs) == 0 { + return nil, fmt.Errorf("ci.yml has no jobs") + } + return jobs, nil +} + +// workflowJobNamed is the parsed job called name, or nil. +func workflowJobNamed(jobs []workflowJob, name string) *workflowJob { + for i := range jobs { + if jobs[i].name == name { + return &jobs[i] + } + } + return nil +} + +// localPlanProblems is what would let a ci.yml step run unverified: a job or a step the table does +// not have, a job the table invents, or a step that moved. It is the C1 check, and the runner +// refuses the workflow when it is not empty. +func localPlanProblems(plan []localJob, workflow []workflowJob) []string { + var problems []string + planned := map[string]*localJob{} + for i := range plan { + planned[plan[i].name] = &plan[i] + } + for i := range workflow { + job := &workflow[i] + entry, ok := planned[job.name] + if !ok { + problems = append(problems, fmt.Sprintf("ci.yml job %q has no entry in the local plan", job.name)) + continue + } + if len(entry.steps) != len(job.steps) { + problems = append(problems, fmt.Sprintf("%s: the plan has %d steps, ci.yml has %d", job.name, len(entry.steps), len(job.steps))) + } + for n := 0; n < len(entry.steps) && n < len(job.steps); n++ { + want, got := job.steps[n].name, entry.steps[n].name + if want != got { + problems = append(problems, fmt.Sprintf("%s step %d: the plan names %q, ci.yml names %q", job.name, n, got, want)) + } + } + } + for _, entry := range plan { + if workflowJobNamed(workflow, entry.name) == nil { + problems = append(problems, fmt.Sprintf("the plan names job %q, which ci.yml does not have", entry.name)) + } + } + return problems +} + +// checkWorkflow reads ci.yml from root and refuses a workflow the table does not cover. +func checkWorkflow(root string, plan []localJob) error { + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + return err + } + workflow, err := parseWorkflow(string(data)) + if err != nil { + return err + } + if problems := localPlanProblems(plan, workflow); len(problems) > 0 { + return fmt.Errorf("the local plan does not cover .github/workflows/ci.yml:\n%s", strings.Join(problems, "\n")) + } + return nil +} diff --git a/internal/dev/ci/local_plan_test.go b/internal/dev/ci/local_plan_test.go new file mode 100644 index 0000000000..12314b61a1 --- /dev/null +++ b/internal/dev/ci/local_plan_test.go @@ -0,0 +1,104 @@ +//go:build dev + +package ci + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// CRW-964: crw-dev ci local runs every job and step of .github/workflows/ci.yml locally, in the +// same order, at the same pinned versions, and leaves a verification-record/1. The step table +// (local_plan.go) is held to the workflow itself here: a step ci.yml grows, or a command that +// drifts, is a red test rather than an unverified step. + +// localWorkflow is .github/workflows/ci.yml as text. +func localWorkflow(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(), ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// C1: the table covers every ci.yml job and step, positionally, with nothing missing and nothing +// invented. +func TestLocalPlan_covers_every_workflow_step(t *testing.T) { + workflow, err := parseWorkflow(localWorkflow(t)) + if err != nil { + t.Fatal(err) + } + if problems := localPlanProblems(localPlan(), workflow); len(problems) > 0 { + t.Errorf("the local plan does not cover ci.yml:\n%s", strings.Join(problems, "\n")) + } +} + +// C1: a run step's local command is the ci.yml run text, so the local run performs the same work +// the runner performs. +func TestLocalPlan_commands_match_the_workflow_run_text(t *testing.T) { + workflow, err := parseWorkflow(localWorkflow(t)) + if err != nil { + t.Fatal(err) + } + for _, job := range localPlan() { + other := workflowJobNamed(workflow, job.name) + if other == nil { + t.Errorf("the plan names job %q, which ci.yml does not have", job.name) + continue + } + for i, step := range job.steps { + if i >= len(other.steps) { + break + } + want := other.steps[i].run + if step.command != want { + t.Errorf("%s step %d (%q): command = %q, ci.yml run = %q", job.name, i, step.name, step.command, want) + } + } + } +} + +// C1: light mode and the body-only edit mirror are never applied locally, and the table says so +// rather than leaving the steps out. +func TestLocalPlan_never_applies_light_mode_or_the_edit_mirror(t *testing.T) { + for _, job := range localPlan() { + for _, step := range job.steps { + switch { + case step.name == "Light mode notice": + if step.kind != localNotApplicable { + t.Errorf("the light mode notice is %q, want %q: light mode is never applied locally", step.kind, localNotApplicable) + } + case strings.HasPrefix(step.name, "Mirror the jobs this head already ran"): + if step.kind != localNotApplicable { + t.Errorf("the edit mirror is %q, want %q: the mirror is a hosted lookup", step.kind, localNotApplicable) + } + } + } + } + for _, job := range localPlan() { + for _, step := range job.steps { + if strings.Contains(step.command, "edit_mirror.sh") && step.kind != localNotApplicable { + t.Errorf("%s: the edit mirror would run locally (%q)", job.name, step.command) + } + } + } +} + +// C1: a workflow the table does not cover is refused rather than run partially. The fixture adds +// one step to the validate job. +func TestLocalPlan_refuses_a_workflow_it_does_not_cover(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repoRoot(), "internal", "dev", "ci", "testdata", "local", "ci.yml")) + if err != nil { + t.Fatal(err) + } + workflow, err := parseWorkflow(string(data)) + if err != nil { + t.Fatal(err) + } + if problems := localPlanProblems(localPlan(), workflow); len(problems) == 0 { + t.Error("a workflow with an extra step is covered by the plan") + } +} diff --git a/internal/dev/ci/local_record.go b/internal/dev/ci/local_record.go new file mode 100644 index 0000000000..9a85c4b49b --- /dev/null +++ b/internal/dev/ci/local_record.go @@ -0,0 +1,257 @@ +//go:build dev + +package ci + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" +) + +// CRW-964: the verification record. `crw-dev ci local` leaves a +// verification-record/1 JSON describing what it ran and on what; the record's digest is the +// sha256 of its canonical serialization, so a second reader can re-derive it, and +// `--reuse` answers a record only when every key that decides the result still matches. + +// The record's schema name, and the result words a step and a run carry. +const ( + recordSchema = "verification-record/1" + + // localPassed is a step or a run that did its work and succeeded. + localPassed = "passed" + // localFailed is a step whose command ran and failed. + localFailed = "failed" + // localMissingTool is a step whose tool is not on PATH: a failure, never a pass. + localMissingTool = "missing_tool" + // localSkipped is a step that was not run: a failure, never a pass (answer 5). + localSkipped = "skipped" + // localNotApplicable is a step this run never performs, with its reason recorded. + localNotApplicableResult = "not_applicable" + + // localPass and localFail are the run's two outcomes. + localPass = "pass" + localFail = "fail" +) + +// verificationRecord is the verification-record/1 document. +type verificationRecord struct { + Schema string `json:"schema"` + Runner string `json:"runner"` + Repository string `json:"repository"` + BaseCommit string `json:"baseCommit"` + HeadCommit string `json:"headCommit"` + TreeHash string `json:"treeHash"` + CiDigest string `json:"ciDigest"` + Tools map[string]string `json:"tools"` + Pins map[string]string `json:"pins"` + PinMismatch []string `json:"pinMismatch"` + Dependencies map[string]string `json:"dependencies"` + OS string `json:"os"` + Arch string `json:"arch"` + Result string `json:"result"` + Jobs []recordJob `json:"jobs"` + Digest string `json:"digest"` +} + +// recordJob is one GitHub check the run performed: validate, secrets, skill-scripts-node, gui, +// one go-product leg, or dev-gate. +type recordJob struct { + Name string `json:"name"` + Result string `json:"result"` + Steps []recordStep `json:"steps"` +} + +// recordStep is one step: what it ran, over what range, and how it ended. +type recordStep struct { + Job string `json:"job"` + Name string `json:"name"` + Command string `json:"command"` + Scope string `json:"scope"` + Result string `json:"result"` + Seconds float64 `json:"seconds"` + Reason string `json:"reason"` +} + +// canonicalRecord is the record's canonical serialization: the document as JSON with its keys +// sorted, no indentation and no HTML escaping, and the digest member removed (the digest is +// computed over exactly these bytes). encoding/json sorts a map's keys, so the value goes +// through a map rather than the struct, whose field order is an implementation detail. +func canonicalRecord(record verificationRecord) ([]byte, error) { + raw, err := json.Marshal(record) + if err != nil { + return nil, err + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, err + } + delete(fields, "digest") + var out bytes.Buffer + encoder := json.NewEncoder(&out) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(fields); err != nil { + return nil, err + } + return bytes.TrimSuffix(out.Bytes(), []byte("\n")), nil +} + +// recordDigest is the sha256 of the canonical serialization. +func recordDigest(record verificationRecord) (string, error) { + canonical, err := canonicalRecord(record) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// sealRecord fills the digest in and returns the record. +func sealRecord(record verificationRecord) (verificationRecord, error) { + record.Schema = recordSchema + digest, err := recordDigest(record) + if err != nil { + return record, err + } + record.Digest = digest + return record, nil +} + +// readRecord reads and checks a record: the schema, the digest it carries, and that its bytes are +// the canonical serialization. A record that does not check out is refused rather than reused. +func readRecord(path string) (verificationRecord, error) { + var record verificationRecord + data, err := os.ReadFile(path) + if err != nil { + return record, err + } + if err := json.Unmarshal(data, &record); err != nil { + return record, fmt.Errorf("%s: %w", path, err) + } + if record.Schema != recordSchema { + return record, fmt.Errorf("%s: schema %q, want %q", path, record.Schema, recordSchema) + } + digest, err := recordDigest(record) + if err != nil { + return record, err + } + if record.Digest != digest { + return record, fmt.Errorf("%s: digest %s does not match its contents (%s)", path, record.Digest, digest) + } + return record, nil +} + +// writeRecord writes the record, sealed, as canonical JSON with a trailing newline, creating the +// directory. It refuses to leave a partial file: the bytes are written to a sibling and renamed. +func writeRecord(path string, record verificationRecord) (verificationRecord, error) { + sealed, err := sealRecord(record) + if err != nil { + return record, err + } + canonical, err := canonicalRecord(sealed) + if err != nil { + return record, err + } + pretty := new(bytes.Buffer) + encoder := json.NewEncoder(pretty) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(sealed); err != nil { + return record, err + } + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return record, err + } + } + temp := path + ".tmp" + if err := os.WriteFile(temp, pretty.Bytes(), 0o644); err != nil { + return record, err + } + if err := os.Rename(temp, path); err != nil { + return record, err + } + // The canonical bytes are kept beside the record so a reader can compare them byte for + // byte without re-deriving the serialization. + if err := os.WriteFile(path+".canonical", canonical, 0o644); err != nil { + return record, err + } + return sealed, nil +} + +// localReuse decides whether a record already answers for the current state. Every key must +// match; the head commit is deliberately not one of them (answer 4), because a rebase that keeps +// the tree is the same verification, and a different commit with the same tree is too. A record +// that did not pass, or that carries a pin mismatch, is never reused. +func localReuse(reused, current verificationRecord) (bool, string) { + switch { + case reused.Schema != recordSchema: + return false, "the record is not " + recordSchema + case reused.Result != localPass: + return false, fmt.Sprintf("the record's result is %q, not %q", reused.Result, localPass) + case len(reused.PinMismatch) > 0: + return false, "the record carries a pin mismatch (" + strings.Join(reused.PinMismatch, ", ") + ")" + } + for _, key := range []struct { + name string + was, isNow string + }{ + {"tree", reused.TreeHash, current.TreeHash}, + {"ci.yml digest", reused.CiDigest, current.CiDigest}, + {"os", reused.OS, current.OS}, + {"arch", reused.Arch, current.Arch}, + } { + if key.was != key.isNow { + return false, fmt.Sprintf("the %s changed (%s -> %s)", key.name, key.was, key.isNow) + } + } + for _, name := range sortedKeys(current.Tools) { + if was := reused.Tools[name]; was != current.Tools[name] { + return false, fmt.Sprintf("the %s version changed (%s -> %s)", name, was, current.Tools[name]) + } + } + for _, name := range sortedKeys(reused.Tools) { + if _, ok := current.Tools[name]; !ok { + return false, "the " + name + " version is no longer known" + } + } + for _, name := range sortedKeys(current.Dependencies) { + if was := reused.Dependencies[name]; was != current.Dependencies[name] { + return false, fmt.Sprintf("the %s digest changed (%s -> %s)", name, was, current.Dependencies[name]) + } + } + return true, "every key matches" +} + +// localDigestOfFile is the sha256 of a file, or "" when it is absent (a tree without go.sum or +// web/package-lock.json is not an error; the record then carries no digest for it). +func localDigestOfFile(path string) (string, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// localHostOS and localHostArch are the platform the record names. +func localHostOS() string { return runtime.GOOS } +func localHostArch() string { return runtime.GOARCH } + +// localSortedUnique is items without blanks or duplicates, sorted. +func localSortedUnique(items []string) []string { + out := slices.DeleteFunc(slices.Clone(items), func(s string) bool { return s == "" }) + slices.Sort(out) + return slices.Compact(out) +} diff --git a/internal/dev/ci/local_test.go b/internal/dev/ci/local_test.go new file mode 100644 index 0000000000..7c0edcf1ea --- /dev/null +++ b/internal/dev/ci/local_test.go @@ -0,0 +1,405 @@ +//go:build dev + +package ci + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// CRW-964: the engine, the verification record, reuse and the failure rule. Every test runs in a +// temporary repository with fake tools, never against real host state. + +// localFixture is a scratch repository with one commit. +type localFixture struct { + *fixtureRepo +} + +// newLocalFixture makes a repository whose HEAD carries the files the run reads: a workflow, a +// go.mod, a scripts/ci/secrets.sh, a go.sum and a web/package-lock.json. +func newLocalFixture(t *testing.T) *localFixture { + t.Helper() + repo := &localFixture{newRepo(t)} + repo.write(".github/workflows/ci.yml", localFixtureWorkflow) + repo.write("go.mod", "module fixture\n\ngo 1.27\n\ntoolchain go1.27.1\n\nrequire (\n\thonx.dev/x v1.0.0\n\thonnef.co/go/tools v0.8.1\n)\n") + repo.write("go.sum", "example.com/x v1.0.0 h1:fixture\n") + repo.write("web/package-lock.json", "{\"lockfileVersion\": 3}\n") + repo.write("scripts/ci/secrets.sh", "#!/usr/bin/env bash\nscan_version=8.30.1\n") + + repo.write("file.txt", "one\n") + repo.commit() + return repo +} + +// localFixtureWorkflow is a small ci.yml with one job and two steps, so the engine can be driven +// without the real workflow's cost. +const localFixtureWorkflow = `name: fixture + +on: + push: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0000000000000000000000000000000000000000 # v4 + with: + node-version: '24.20.0' + - name: Say hello + run: echo hello + - name: Node pinned + run: echo node +` + +// localFixturePlan is the table for localFixtureWorkflow: a checkout and one run step. +func localFixturePlan(command string) []localJob { + return []localJob{{ + name: "validate", + steps: []localStep{ + {kind: localAction, action: localCheckout, scope: "full"}, + {name: "Say hello", kind: localRun, command: command, scope: "full", heavy: true}, + }, + }} +} + +// localRunOptions is a run over the fixture, with the given table. +func localRunOptions(repo *localFixture, plan []localJob, record string) localOptions { + return localOptions{ + Root: repo.root, + Commit: "HEAD", + Record: record, + Runner: "local", + Plan: plan, + } +} + +// C2: a step that fails fails the whole run, and the record names the step and its reason. +func TestLocal_a_failed_step_fails_the_whole_run_and_is_recorded(t *testing.T) { + repo := newLocalFixture(t) + record := filepath.Join(t.TempDir(), "record.json") + opts := localRunOptions(repo, localFixturePlan("echo hello && exit 3"), record) + made, _, err := localVerify(opts, "", io.Discard) + if err != nil { + t.Fatal(err) + } + if made.Result != localFail { + t.Errorf("result = %q, want %q", made.Result, localFail) + } + step := made.Jobs[0].Steps[1] + if step.Result != localFailed { + t.Errorf("the failing step is %q, want %q", step.Result, localFailed) + } + if !strings.Contains(step.Reason, "exit status 3") { + t.Errorf("the failure's reason is %q, want the exit status", step.Reason) + } +} + +// C2: a step that does not run is a failure, not a pass. GitHub stops a job's later steps after a +// failure; the local run records them as skipped and the run fails. +func TestLocal_a_skipped_step_fails_the_whole_run(t *testing.T) { + repo := newLocalFixture(t) + plan := []localJob{{ + name: "validate", + steps: []localStep{ + {kind: localAction, action: localCheckout, scope: "full"}, + {name: "First", kind: localRun, command: "exit 1", scope: "full"}, + {name: "Second", kind: localRun, command: "echo second", scope: "full"}, + }, + }} + made, _, err := localVerify(localRunOptions(repo, plan, filepath.Join(t.TempDir(), "r.json")), "", io.Discard) + if err != nil { + t.Fatal(err) + } + if made.Result != localFail { + t.Errorf("result = %q, want %q", made.Result, localFail) + } + if got := made.Jobs[0].Steps[2].Result; got != localSkipped { + t.Errorf("the step after a failure is %q, want %q", got, localSkipped) + } +} + +// C2: a step whose tool is not on PATH fails the run and says so. +func TestLocal_a_missing_tool_fails_the_whole_run(t *testing.T) { + repo := newLocalFixture(t) + plan := []localJob{{ + name: "validate", + steps: []localStep{ + {kind: localAction, action: localCheckout, scope: "full"}, + {name: "Needs a tool that does not exist", kind: localRun, command: "no-such-tool --version", + tool: "crw-964-no-such-tool", scope: "full"}, + }, + }} + made, _, err := localVerify(localRunOptions(repo, plan, filepath.Join(t.TempDir(), "r.json")), "", io.Discard) + if err != nil { + t.Fatal(err) + } + if made.Result != localFail { + t.Errorf("result = %q, want %q", made.Result, localFail) + } + step := made.Jobs[0].Steps[1] + if step.Result != localMissingTool { + t.Errorf("the step is %q, want %q", step.Result, localMissingTool) + } + if !strings.Contains(step.Reason, "not on PATH") { + t.Errorf("the reason is %q, want it to name PATH", step.Reason) + } +} + +// C6: the digest is the sha256 of the canonical serialization, and it is stable. +func TestLocalRecord_digest_is_the_canonical_serialization(t *testing.T) { + record := verificationRecord{ + Schema: recordSchema, Runner: "local", Repository: "r", HeadCommit: "abc", TreeHash: "def", + CiDigest: "sha256:x", Tools: map[string]string{"go": "1.27.1"}, + Pins: map[string]string{"go": "1.27.1"}, Dependencies: map[string]string{"go.sum": "sha256:y"}, + OS: "linux", Arch: "amd64", Result: localPass, + } + sealed, err := sealRecord(record) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(sealed.Digest, "sha256:") { + t.Fatalf("digest = %q, want a sha256", sealed.Digest) + } + canonical, err := canonicalRecord(sealed) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(canonical, &fields); err != nil { + t.Fatalf("the canonical serialization is not JSON: %v", err) + } + if _, ok := fields["digest"]; ok { + t.Error("the canonical object still has a digest key") + } + again, err := recordDigest(sealed) + if err != nil { + t.Fatal(err) + } + if again != sealed.Digest { + t.Errorf("the digest is not stable: %s then %s", sealed.Digest, again) + } +} + +// C6: the record carries every field the decided answers require. +func TestLocalRecord_carries_every_required_field(t *testing.T) { + repo := newLocalFixture(t) + record := filepath.Join(t.TempDir(), "record.json") + opts := localRunOptions(repo, localFixturePlan("echo hello"), record) + made, _, err := localVerify(opts, "", io.Discard) + if err != nil { + t.Fatal(err) + } + sealed, err := writeRecord(record, made) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + for _, key := range []string{"schema", "runner", "repository", "baseCommit", "headCommit", "treeHash", + "ciDigest", "tools", "pins", "pinMismatch", "dependencies", "os", "arch", "result", "jobs", "digest"} { + if _, ok := fields[key]; !ok { + t.Errorf("the record has no %q", key) + } + } + if sealed.Schema != recordSchema { + t.Errorf("schema = %q, want %q", sealed.Schema, recordSchema) + } + for _, tool := range localToolNames { + if _, ok := sealed.Tools[tool]; !ok { + t.Errorf("the record does not name the %s version", tool) + } + if _, ok := sealed.Pins[tool]; !ok { + t.Errorf("the record does not name the %s pin", tool) + } + } + if len(sealed.Jobs) != 1 || len(sealed.Jobs[0].Steps) != 2 { + t.Fatalf("jobs = %#v, want one job of two steps", sealed.Jobs) + } + if sealed.Jobs[0].Steps[1].Seconds < 0 { + t.Error("a step carries a negative duration") + } + if sealed.Jobs[0].Steps[1].Scope != "full" { + t.Errorf("the step's scope = %q, want full", sealed.Jobs[0].Steps[1].Scope) + } + // Reading it back checks the digest it carries. + if _, err := readRecord(record); err != nil { + t.Errorf("the written record does not read back: %v", err) + } + // A tampered record is refused. + tampered := strings.Replace(string(data), "\"result\": \"pass\"", "\"result\": \"fail\"", 1) + if tampered == string(data) { + t.Fatal("the fixture record does not carry a result to tamper with") + } + path := filepath.Join(t.TempDir(), "tampered.json") + if err := os.WriteFile(path, []byte(tampered), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readRecord(path); err == nil { + t.Error("a record whose contents no longer match its digest is accepted") + } +} + +// C3: a record answers a second run when every key matches. +func TestLocalReuse_every_key_matches_answers_the_record(t *testing.T) { + base := verificationRecord{ + Schema: recordSchema, Result: localPass, TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, Dependencies: map[string]string{"go.sum": "sum"}, + } + same := verificationRecord{ + TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, Dependencies: map[string]string{"go.sum": "sum"}, + } + if ok, why := localReuse(base, same); !ok { + t.Errorf("the same keys are refused: %s", why) + } +} + +// C3: changing one key at a time re-runs. The head commit alone is deliberately not a key, so two +// records with different commits and the same tree still match. +func TestLocalReuse_one_changed_key_reruns(t *testing.T) { + fresh := func() verificationRecord { + return verificationRecord{ + Schema: recordSchema, Result: localPass, TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, + Dependencies: map[string]string{"go.sum": "sum", "web/package-lock.json": "lock"}, + } + } + for _, row := range []struct { + name string + apply func(*verificationRecord) + }{ + {"tree", func(r *verificationRecord) { r.TreeHash = "other" }}, + {"ci.yml digest", func(r *verificationRecord) { r.CiDigest = "other" }}, + {"go version", func(r *verificationRecord) { r.Tools["go"] = "1.26.0" }}, + {"node version", func(r *verificationRecord) { r.Tools["node"] = "22.0.0" }}, + {"go.sum digest", func(r *verificationRecord) { r.Dependencies["go.sum"] = "other" }}, + {"lockfile digest", func(r *verificationRecord) { r.Dependencies["web/package-lock.json"] = "other" }}, + {"os", func(r *verificationRecord) { r.OS = "darwin" }}, + {"arch", func(r *verificationRecord) { r.Arch = "arm64" }}, + } { + current := fresh() + row.apply(¤t) + if ok, why := localReuse(fresh(), current); ok { + t.Errorf("%s: reused anyway (%s)", row.name, why) + } + } + // The commit is not a key: the same tree with another head still matches. + current := fresh() + current.HeadCommit = "a-different-commit" + if ok, why := localReuse(fresh(), current); !ok { + t.Errorf("a different commit with the same tree is refused: %s", why) + } + // A record that did not pass is never reused. + for _, result := range []string{localFail, ""} { + stored := fresh() + stored.Result = result + if ok, _ := localReuse(stored, fresh()); ok { + t.Errorf("a record whose result is %q is reused", result) + } + } +} + +// C6/answer 3: a pin mismatch is recorded and the record is not reused. +func TestLocalTools_pin_mismatch_is_recorded_and_never_reused(t *testing.T) { + pins := map[string]string{"go": "1.27.1", "node": "24.20.0", "gitleaks": "8.30.1"} + observed := map[string]string{"go": "1.27.1", "node": "22.0.0", "gitleaks": "8.30.1"} + mismatch := localPinMismatch(pins, observed) + if len(mismatch) != 1 || mismatch[0] != "node" { + t.Fatalf("pinMismatch = %v, want [node]", mismatch) + } + record := verificationRecord{ + Schema: recordSchema, Result: localPass, PinMismatch: mismatch, + TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Tools: observed, Dependencies: map[string]string{"go.sum": "sum"}, + } + current := verificationRecord{ + TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Tools: observed, Dependencies: map[string]string{"go.sum": "sum"}, + } + if ok, why := localReuse(record, current); ok { + t.Errorf("a record with a pin mismatch is reused (%s)", why) + } + // A tool the tree pins and the host does not have is a mismatch too. + if got := localPinMismatch(map[string]string{"gitleaks": "8.30.1"}, map[string]string{"gitleaks": ""}); len(got) != 1 { + t.Errorf("a missing pinned tool is not a mismatch: %v", got) + } + // An unpinned tool is not a mismatch. + if got := localPinMismatch(map[string]string{}, map[string]string{"go": ""}); len(got) != 0 { + t.Errorf("an unpinned tool is a mismatch: %v", got) + } +} + +// comment-c6: the run verifies the commit, not the caller's working tree. An uncommitted change in +// the checkout does not reach the clean worktree, and the tree the record names is the commit's. +func TestLocal_verifies_the_commit_not_the_dirty_worktree(t *testing.T) { + repo := newLocalFixture(t) + // A dirty working tree: a changed tracked file and an untracked one. + repo.write("file.txt", "changed but not committed\n") + repo.write("untracked.txt", "untracked\n") + committed, err := localRev(repo.root, "HEAD") + if err != nil { + t.Fatal(err) + } + record := filepath.Join(t.TempDir(), "record.json") + // The step reads the file the commit holds, and the worktree it runs in does not have the + // uncommitted change. + plan := localFixturePlan("cat file.txt && test ! -e untracked.txt") + made, _, err := localVerify(localRunOptions(repo, plan, record), "", io.Discard) + if err != nil { + t.Fatal(err) + } + if made.HeadCommit != committed { + t.Errorf("head = %s, want the commit %s", made.HeadCommit, committed) + } + if made.Result != localPass { + t.Fatalf("the run over the commit failed: %#v", made.Jobs[0].Steps) + } + wantTree, err := localRev(repo.root, "HEAD^{tree}") + if err != nil { + t.Fatal(err) + } + if made.TreeHash != wantTree { + t.Errorf("tree = %s, want the commit's tree %s", made.TreeHash, wantTree) + } + // The dirty file is still dirty: the run did not touch the caller's checkout. + if data, err := os.ReadFile(filepath.Join(repo.root, "file.txt")); err != nil || string(data) != "changed but not committed\n" { + t.Errorf("the caller's working tree was changed: %q, %v", data, err) + } +} + +// The engine leaves no worktree behind, and the repository's worktree list is as it was. +func TestLocal_removes_its_clean_worktree(t *testing.T) { + repo := newLocalFixture(t) + before := repo.git("worktree", "list") + plan := localFixturePlan("echo hello") + if _, _, err := localVerify(localRunOptions(repo, plan, filepath.Join(t.TempDir(), "r.json")), "", io.Discard); err != nil { + t.Fatal(err) + } + after := repo.git("worktree", "list") + if before != after { + t.Errorf("the worktree list changed:\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +// The heavy gate wraps a heavy step's command and leaves a light one alone. +func TestLocal_heavy_gate_wraps_only_heavy_steps(t *testing.T) { + if got := localCommand("gate.sh", true, "make lint"); got != "gate.sh make lint" { + t.Errorf("heavy command = %q", got) + } + if got := localCommand("gate.sh", false, "make lint"); got != "make lint" { + t.Errorf("light command = %q", got) + } + if got := localCommand("", true, "make lint"); got != "make lint" { + t.Errorf("no gate: command = %q", got) + } +} diff --git a/internal/dev/ci/local_tools.go b/internal/dev/ci/local_tools.go new file mode 100644 index 0000000000..7a076e4403 --- /dev/null +++ b/internal/dev/ci/local_tools.go @@ -0,0 +1,123 @@ +//go:build dev + +package ci + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// CRW-964: the pinned tool versions and the ones this host actually has. The pins come from the +// files that fix them (go.mod's toolchain, ci.yml's Node, scripts/ci/secrets.sh's Gitleaks, the +// staticcheck module go.mod requires); a tool whose observed version differs from its pin is +// named in the record's pinMismatch and that record is never reused (answer 3). + +// localToolNames is the tools the record names, in a stable order. +var localToolNames = []string{"go", "node", "gitleaks", "staticcheck"} + +var ( + goModToolchain = regexp.MustCompile(`(?m)^toolchain go([0-9][^\s]*)$`) + goModRequire = regexp.MustCompile(`(?m)^\s*(honnef\.co/go/tools)\s+v([0-9][^\s]*)$`) + ciNodeVersion = regexp.MustCompile(`(?m)^\s*node-version: '([^']+)'$`) + secretsVersion = regexp.MustCompile(`(?m)^scan_version=([0-9][^\s]*)$`) + goVersionLine = regexp.MustCompile(`(?m)^go version go([0-9][^\s]*)`) +) + +// localToolPins reads the pinned versions from the verified tree. A pin the tree does not carry +// is left empty rather than guessed. +func localToolPins(root string) (map[string]string, error) { + pins := map[string]string{} + if data, err := os.ReadFile(filepath.Join(root, "go.mod")); err == nil { + if m := goModToolchain.FindSubmatch(data); m != nil { + pins["go"] = string(m[1]) + } + if m := goModRequire.FindSubmatch(data); m != nil { + pins["staticcheck"] = string(m[2]) + } + } + if data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")); err == nil { + if m := ciNodeVersion.FindSubmatch(data); m != nil { + pins["node"] = string(m[1]) + } + } + if data, err := os.ReadFile(filepath.Join(root, "scripts", "ci", "secrets.sh")); err == nil { + if m := secretsVersion.FindSubmatch(data); m != nil { + pins["gitleaks"] = string(m[1]) + } + } + return pins, nil +} + +// localToolVersions observes the tools this host has, resolving each through PATH. A tool that is +// absent is reported with an empty version, so a step that needs it fails as missing_tool rather +// than passing. +func localToolVersions(pathEnv string) map[string]string { + versions := map[string]string{} + for _, name := range localToolNames { + versions[name] = localObserveTool(name, pathEnv) + } + return versions +} + +// localObserveTool is one tool's version, or "" when it is not on PATH or does not answer. +func localObserveTool(name, pathEnv string) string { + var args []string + switch name { + case "go": + args = []string{"version"} + case "node": + args = []string{"--version"} + case "gitleaks": + args = []string{"version"} + case "staticcheck": + args = []string{"-version"} + default: + return "" + } + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "PATH="+pathEnv) + out, err := cmd.Output() + if err != nil { + return "" + } + return localParseToolVersion(name, string(out)) +} + +// localParseToolVersion reads a tool's version out of its own output. +func localParseToolVersion(name, output string) string { + text := strings.TrimSpace(output) + switch name { + case "go": + if m := goVersionLine.FindStringSubmatch(text); m != nil { + return m[1] + } + case "node": + return strings.TrimPrefix(strings.SplitN(text, "\n", 2)[0], "v") + case "gitleaks", "staticcheck": + // Both print their version last (gitleaks: "8.30.1"; staticcheck: "version 0.8.1"). + fields := strings.Fields(text) + if len(fields) > 0 { + return strings.TrimPrefix(fields[len(fields)-1], "v") + } + } + return "" +} + +// localPinMismatch names every tool whose observed version differs from its pin. A tool the tree +// pins but the host does not have is a mismatch too: the run cannot claim the pinned toolchain. +func localPinMismatch(pins, observed map[string]string) []string { + var mismatches []string + for _, name := range localToolNames { + pin, pinned := pins[name] + if !pinned || pin == "" { + continue + } + if observed[name] != pin { + mismatches = append(mismatches, name) + } + } + return localSortedUnique(mismatches) +} diff --git a/internal/dev/ci/testdata/local/ci.yml b/internal/dev/ci/testdata/local/ci.yml new file mode 100644 index 0000000000..ad95a391f6 --- /dev/null +++ b/internal/dev/ci/testdata/local/ci.yml @@ -0,0 +1,17 @@ +name: CRW CI fixture + +on: + push: + +jobs: + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - run: go build -tags dev -o "$RUNNER_TEMP/crw-dev" ./cmd/crw-dev + - run: '"$RUNNER_TEMP/crw-dev" ci validate' + - run: '"$RUNNER_TEMP/crw-dev" ci plugin' + - run: '"$RUNNER_TEMP/crw-dev" ci contracts' + - name: A step the local plan does not have + run: '"$RUNNER_TEMP/crw-dev" ci something-new' From be6eff2c13965e316b1dad6c8fc9b79a5bf54263 Mon Sep 17 00:00:00 2001 From: CRW-964 child Date: Thu, 8 Oct 2026 02:15:18 +0900 Subject: [PATCH 02/26] CRW-964: drop the symbols staticcheck reports as unused localHookMarker, localBlobLimit and localGitleaksEnv are named in the hook script's own text, not in Go, and localDigestOfFile was left behind by an earlier shape of the record. The hosted lint leg (staticcheck U1000) caught all four. --- internal/dev/ci/local_hook.go | 9 --------- internal/dev/ci/local_record.go | 16 ---------------- 2 files changed, 25 deletions(-) diff --git a/internal/dev/ci/local_hook.go b/internal/dev/ci/local_hook.go index 7603408c17..2e45497746 100644 --- a/internal/dev/ci/local_hook.go +++ b/internal/dev/ci/local_hook.go @@ -20,15 +20,6 @@ import ( const ( // localHookName is the hook this tool owns. localHookName = "pre-push" - // localHookMarker is the line the hook carries, so a reader can tell this tool's hook from a - // foreign one. The whole script is compared, not this line alone. - localHookMarker = "# crw-dev ci local pre-push hook (CRW-964)" - // localBlobLimit is the largest blob a push may carry, the same bound crw-dev ci validate - // applies (2 MiB). - localBlobLimit = 2 << 20 - // localGitleaksEnv names the Gitleaks binary the hook runs, so a host that keeps it outside - // PATH can still use the hook. - localGitleaksEnv = "CRW_CI_GITLEAKS" ) // localHookScript is the hook, written verbatim. It is a shell script so it can run before git diff --git a/internal/dev/ci/local_record.go b/internal/dev/ci/local_record.go index 9a85c4b49b..19fac37cc2 100644 --- a/internal/dev/ci/local_record.go +++ b/internal/dev/ci/local_record.go @@ -7,9 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -231,20 +229,6 @@ func localReuse(reused, current verificationRecord) (bool, string) { return true, "every key matches" } -// localDigestOfFile is the sha256 of a file, or "" when it is absent (a tree without go.sum or -// web/package-lock.json is not an error; the record then carries no digest for it). -func localDigestOfFile(path string) (string, error) { - data, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return "", nil - } - if err != nil { - return "", err - } - sum := sha256.Sum256(data) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} - // localHostOS and localHostArch are the platform the record names. func localHostOS() string { return runtime.GOOS } func localHostArch() string { return runtime.GOARCH } From 54a95520ee90181ef11a98ca690e3912cd749da8 Mon Sep 17 00:00:00 2001 From: CRW-964 child Date: Thu, 8 Oct 2026 02:55:30 +0900 Subject: [PATCH 03/26] CRW-964: run a step's command through bash -c with the gate prepended as argv The first full local run (against a dev commit) caught this: concatenating the gate with the command into one shell string broke a run text that carries its own quotes (the validate step became an unquoted word, exit 127) and mangled a block scalar. The gate's words are now argv before bash -c, and the command stays one element. Also judge dev-gate's aggregate step from the jobs already recorded rather than passing it unconditionally. --- internal/dev/ci/local.go | 41 ++++++++++++++++++++++++----------- internal/dev/ci/local_test.go | 25 +++++++++++++-------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/internal/dev/ci/local.go b/internal/dev/ci/local.go index 836b190dcf..424e76dc94 100644 --- a/internal/dev/ci/local.go +++ b/internal/dev/ci/local.go @@ -60,7 +60,7 @@ func Local(args []string, stdout, stderr io.Writer) int { base := flags.String("base", "", "the base commit the record names (default: merge-base with origin/dev)") record := flags.String("record", localRecordDefault, "where to write the verification record") reuse := flags.String("reuse", "", "answer this record instead of running, when every key matches") - heavyGate := flags.String("heavy-gate", "", "the heavy-check gate to run heavy steps through (default $@"+localHeavyGateEnv+")") + heavyGate := flags.String("heavy-gate", "", "the heavy-check gate to run heavy steps through (default $"+localHeavyGateEnv+")") runner := flags.String("runner", "local", "where this run happened; the record names it") keep := flags.Bool("keep", false, "keep the clean worktree for debugging") description := "Run every job and step of .github/workflows/ci.yml locally, in a clean worktree of the\n" + @@ -276,7 +276,7 @@ func localExecute(opts localOptions, plan []localJob, current verificationRecord // and records the rest as skipped, which is itself a failure (answer 5). entry.Result, entry.Reason = localSkipped, "an earlier step of this job did not pass" default: - entry.Result, entry.Reason = localRunStep(opts, step, worktree, leg, env) + entry.Result, entry.Reason = localRunStep(opts, step, worktree, leg, env, record.Jobs) if entry.Result != localPassed { failed = true } @@ -305,8 +305,9 @@ func localExecute(opts localOptions, plan []localJob, current verificationRecord return record, false, nil } -// localRunStep performs one step and returns its result and reason. -func localRunStep(opts localOptions, step localStep, worktree, leg string, env []string) (string, string) { +// localRunStep performs one step and returns its result and reason. done is the record's jobs so +// far, which the aggregate step judges. +func localRunStep(opts localOptions, step localStep, worktree, leg string, env []string, done []recordJob) (string, string) { if step.tool != "" && !localToolPresent(step.tool, localPathEnv(env)) { return localMissingTool, step.tool + " is not on PATH" } @@ -316,8 +317,13 @@ func localRunStep(opts localOptions, step localStep, worktree, leg string, env [ case localGo, localNode: return localPassed, step.tool + " " + localObserveTool(step.tool, localPathEnv(env)) case localAggregate: - // dev-gate: every prerequisite job must have succeeded, which is decided from the record's - // other jobs after they have run. + // dev-gate: every prerequisite job must have succeeded, judged from the jobs already + // recorded rather than from a hosted needs object. + for _, job := range done { + if job.Result != localPassed { + return localFailed, "prerequisite job " + job.Name + " did not pass" + } + } return localPassed, "every prerequisite job succeeded" } command := step.command @@ -326,8 +332,12 @@ func localRunStep(opts localOptions, step localStep, worktree, leg string, env [ dir = filepath.Join(worktree, step.workdir) } stepEnv := append(append([]string{}, env...), localStepEnvValues(step.env, opts, leg)...) - cmd := localCommand(opts.HeavyGate, step.heavy, command) - shell := exec.Command("bash", "-c", cmd) + // The command runs through bash -c, so a ci.yml run text that is a block scalar (a loop, a + // heredoc) runs as the runner runs it. A heavy step goes through the gate as + // bash -c : the command is one argv element, never concatenated into a shell + // word, so a run text that carries its own quotes keeps them. + argv := localGateArgv(opts.HeavyGate, step.heavy, command) + shell := exec.Command(argv[0], argv[1:]...) shell.Dir = dir shell.Env = stepEnv var output bytes.Buffer @@ -342,12 +352,17 @@ func localRunStep(opts localOptions, step localStep, worktree, leg string, env [ return localPassed, "" } -// localCommand is the command a step runs: a heavy step goes through the gate when one is set. -func localCommand(gate string, heavy bool, command string) string { - if heavy && strings.TrimSpace(gate) != "" { - return gate + " " + command +// localGateArgv is the argv a step runs: bash -c , with the heavy-check gate's own +// words prepended for a heavy step. The command stays one argv element, so a ci.yml run text that +// is a block scalar (a loop, a heredoc) or carries its own quotes reaches bash unchanged. +func localGateArgv(gate string, heavy bool, command string) []string { + argv := []string{"bash", "-c", command} + if heavy { + if words := strings.Fields(gate); len(words) > 0 { + argv = append(words, argv...) + } } - return command + return argv } // localStepEnv is the isolated environment every step runs in: HOME and the XDG directories point diff --git a/internal/dev/ci/local_test.go b/internal/dev/ci/local_test.go index 7c0edcf1ea..8013935fe2 100644 --- a/internal/dev/ci/local_test.go +++ b/internal/dev/ci/local_test.go @@ -391,15 +391,22 @@ func TestLocal_removes_its_clean_worktree(t *testing.T) { } } -// The heavy gate wraps a heavy step's command and leaves a light one alone. -func TestLocal_heavy_gate_wraps_only_heavy_steps(t *testing.T) { - if got := localCommand("gate.sh", true, "make lint"); got != "gate.sh make lint" { - t.Errorf("heavy command = %q", got) - } - if got := localCommand("gate.sh", false, "make lint"); got != "make lint" { - t.Errorf("light command = %q", got) +// The gate a heavy step goes through is prepended as argv, so a run text that is a block scalar +// (a loop, a heredoc) keeps its own quoting and runs through bash -c as the runner runs it. +func TestLocal_gate_prefixes_a_heavy_step_as_argv(t *testing.T) { + gate := localGateArgv("gate.sh --flag", true, "make lint") + expectEqual(t, "a heavy step's argv", gate, []string{"gate.sh", "--flag", "bash", "-c", "make lint"}) + light := localGateArgv("gate.sh", false, "make lint") + expectEqual(t, "a light step's argv", light, []string{"bash", "-c", "make lint"}) + none := localGateArgv("", true, "make lint") + expectEqual(t, "no gate", none, []string{"bash", "-c", "make lint"}) + + // A block scalar keeps its newlines and its quotes: the command is one argv element. + block := localGateArgv("gate.sh", true, localDistBuilds) + if len(block) != 4 || block[3] != localDistBuilds { + t.Fatalf("the block scalar is not one argv element: %#v", block) } - if got := localCommand("", true, "make lint"); got != "make lint" { - t.Errorf("no gate: command = %q", got) + if !strings.Contains(block[3], "\n") { + t.Error("the block scalar's newlines were lost") } } From 70a4f4f2df5085bff80c7b0058eeba813d2d7943 Mon Sep 17 00:00:00 2001 From: CRW-964 child Date: Thu, 8 Oct 2026 03:10:39 +0900 Subject: [PATCH 04/26] CRW-964: fold the Codex review findings Fixes the findings on the first head, all inside this issue's scope: - the YAML single quotes around a run scalar are removed by the workflow's parser, so the table carries the shell text and the reader unquotes it before comparing (P1). - the screen commands run in web/, as ci.yml's working-directory says (P1). - the base commit joins the reuse keys: the blob and secret steps judge base..head, so a different base is a different verification (P1). - the plan is held to the workflow of the verified commit, not the caller's working tree (P1). - a default base equal to the head is never chosen, so the range checks cannot judge nothing (P1). - the hook requires the pinned Gitleaks release (P1). - GOFLAGS and GOENV join the reuse keys, and the record names the repository (P1, P2). - the hook recognises a SHA-256 all-zero object id and an older hook this tool wrote (P2). - the two tools a step fetches (Gitleaks, staticcheck) are named from their pin when the host has no copy, so a normal checkout no longer reports a permanent pin mismatch (P2). - the sealed digest is printed (P2). --- internal/dev/ci/local.go | 54 +++++++++++---- internal/dev/ci/local_hook.go | 48 ++++++++++++-- internal/dev/ci/local_hook_test.go | 76 +++++++++++++++++++++ internal/dev/ci/local_plan.go | 42 ++++++++---- internal/dev/ci/local_record.go | 18 +++++ internal/dev/ci/local_test.go | 103 ++++++++++++++++++++++++++++- internal/dev/ci/local_tools.go | 24 ++++++- 7 files changed, 328 insertions(+), 37 deletions(-) diff --git a/internal/dev/ci/local.go b/internal/dev/ci/local.go index 424e76dc94..5454e3000e 100644 --- a/internal/dev/ci/local.go +++ b/internal/dev/ci/local.go @@ -103,14 +103,15 @@ func Local(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "local: reused %s (%s)\n", *reuse, made.Digest) return 0 } - if _, err := writeRecord(opts.Record, made); err != nil { + sealed, err := writeRecord(opts.Record, made) + if err != nil { return failf(stderr, "local: %s", err) } - if made.Result == localPass { - fmt.Fprintf(stdout, "local: pass at %s (%s)\n", made.HeadCommit, made.Digest) + if sealed.Result == localPass { + fmt.Fprintf(stdout, "local: pass at %s (%s)\n", sealed.HeadCommit, sealed.Digest) return 0 } - fmt.Fprintf(stderr, "local: fail at %s (%s)\n", made.HeadCommit, made.Digest) + fmt.Fprintf(stderr, "local: fail at %s (%s)\n", sealed.HeadCommit, sealed.Digest) for _, job := range made.Jobs { for _, step := range job.Steps { if step.Result != localPassed && step.Result != localNotApplicableResult { @@ -151,9 +152,11 @@ func localVerify(opts localOptions, reusePath string, stdout io.Writer) (verific fmt.Fprintf(stdout, "local: %s does not answer this tree: %s\n", reusePath, why) } // The workflow-coverage refusal guards the real table: a step ci.yml grows cannot go - // unverified. An injected table (tests) owns its own coverage. + // unverified. An injected table (tests) owns its own coverage. The workflow is read from the + // verified commit, not the caller's working tree, so a dirty edit to ci.yml cannot change what + // this run is held to. if opts.Plan == nil { - if err := checkWorkflow(opts.Root, plan); err != nil { + if err := checkWorkflowAt(opts.Root, current.HeadCommit, plan); err != nil { return verificationRecord{}, false, err } } @@ -184,19 +187,41 @@ func localCurrentKeys(opts localOptions) (verificationRecord, error) { } dependencies[name] = digest } + // The range the blob and secret steps judge is base..head, so the base is part of what the + // record covers: a different base is a different verification even at the same tree. + base := opts.Base + if base == "" { + base, err = localDefaultBase(opts.Root, head) + if err != nil { + return verificationRecord{}, err + } + } return verificationRecord{ Schema: recordSchema, Runner: opts.Runner, + Repository: localRepository(opts.Root), + BaseCommit: base, HeadCommit: head, TreeHash: tree, CiDigest: ciDigest, Tools: localToolVersions(localPathEnv(opts.Env)), + GoFlags: os.Getenv("GOFLAGS"), + GoEnv: os.Getenv("GOENV"), Dependencies: dependencies, OS: localHostOS(), Arch: localHostArch(), }, nil } +// localRepository is the origin remote's URL, the identity a record names. It is read from the +// checkout rather than written down, so the record travels with the repository it describes. +func localRepository(root string) string { + if out, err := runGit(root, "remote", "get-url", "origin"); err == nil { + return strings.TrimSpace(string(out)) + } + return "" +} + // localExecute runs the table in a clean worktree of the verified commit and assembles the record. func localExecute(opts localOptions, plan []localJob, current verificationRecord, stdout io.Writer) (verificationRecord, bool, error) { temp, err := os.MkdirTemp(localTempDir(), localTempPrefix) @@ -233,11 +258,8 @@ func localExecute(opts localOptions, plan []localJob, current verificationRecord } record := current record.Pins = pins - record.PinMismatch = localPinMismatch(pins, current.Tools) - record.BaseCommit = opts.Base - if record.BaseCommit == "" { - record.BaseCommit, _ = localDefaultBase(opts.Root, current.HeadCommit) - } + record.Tools = localObservedVersions(current.Tools, pins) + record.PinMismatch = localPinMismatch(pins, record.Tools) // The steps read the same base the record names (the blob and secret range) and the commit // itself (the changed-path decisions), so the environment carries the resolved values rather // than the caller's flags. @@ -417,15 +439,19 @@ func localStepEnvValues(values []string, opts localOptions, leg string) []string } // localDefaultBase is the commit a record names as its base: the merge base with origin/dev, or -// the commit's parent when that is not known. +// the commit's parent when that is not known. The head itself is never the base: a merge base that +// equals the head means the commit is already reachable from origin/dev, and base..head would then +// be empty, which would let the range-scoped blob and secret steps judge nothing. func localDefaultBase(root, head string) (string, error) { if out, err := runGit(root, "merge-base", "origin/dev", head); err == nil { - if base := strings.TrimSpace(string(out)); base != "" { + if base := strings.TrimSpace(string(out)); base != "" && base != head { return base, nil } } if out, err := runGit(root, "rev-parse", "--verify", "--quiet", head+"^"); err == nil { - return strings.TrimSpace(string(out)), nil + if base := strings.TrimSpace(string(out)); base != "" && base != head { + return base, nil + } } return "", nil } diff --git a/internal/dev/ci/local_hook.go b/internal/dev/ci/local_hook.go index 2e45497746..0f53878798 100644 --- a/internal/dev/ci/local_hook.go +++ b/internal/dev/ci/local_hook.go @@ -20,12 +20,23 @@ import ( const ( // localHookName is the hook this tool owns. localHookName = "pre-push" + // localHookMarker is the line the hook carries, so a reader can tell this tool's hook from a + // foreign one. The whole script is compared for the current version; the marker under the + // shebang is what recognises an older script this tool wrote. + localHookMarker = "# crw-dev ci local pre-push hook (CRW-964)" + // localGitleaksPin is the Gitleaks release the hook requires: the one scripts/ci/secrets.sh + // pins and the hosted secrets job runs. A different scanner has different rules, so the hook + // refuses it rather than approving a range the hosted job would reject after the push. + localGitleaksPin = "8.30.1" ) // localHookScript is the hook, written verbatim. It is a shell script so it can run before git // starts the push, and it fails closed: a missing scanner, an unreadable range or a finding each // block the push rather than letting it through. -const localHookScript = `#!/usr/bin/env bash +var localHookScript = strings.ReplaceAll(hookScriptTemplate, "@CRW964_GITLEAKS_PIN@", localGitleaksPin) + +// hookScriptTemplate is the hook before the pinned Gitleaks version is substituted. +const hookScriptTemplate = `#!/usr/bin/env bash # crw-dev ci local pre-push hook (CRW-964) # Refuses a push whose range brings a secret or a blob over 2 MiB into the history. The range is # ..; when the remote sha is the all-zeros object the whole local sha is @@ -36,14 +47,16 @@ limit=$((2 * 1024 * 1024)) root=$(git rev-parse --show-toplevel) git_dir=$(git rev-parse --absolute-git-dir) scanner=${CRW_CI_GITLEAKS:-gitleaks} +expected=${CRW_CI_GITLEAKS_VERSION:-@CRW964_GITLEAKS_PIN@} status=0 while read -r local_ref local_sha remote_ref remote_sha; do [ -n "${local_sha:-}" ] || continue case "$local_sha" in - 0000000000000000000000000000000000000000) continue ;; + *[!0]*) : ;; + *) continue ;; # an all-zero local sha (SHA-1 or SHA-256): the ref is being deleted esac - if [ "${remote_sha:-}" = 0000000000000000000000000000000000000000 ] || [ -z "${remote_sha:-}" ]; then + if [ -z "${remote_sha:-}" ] || [ -z "${remote_sha//0/}" ]; then range="$local_sha" else range="$remote_sha..$local_sha" @@ -72,11 +85,18 @@ while read -r local_ref local_sha remote_ref remote_sha; do status=1 fi - # The secret scan: the same Gitleaks the hosted secrets job pins, over the same range. + # The secret scan: the same Gitleaks the hosted secrets job pins, over the same range. The + # pinned release is required: an older scanner has different rules and could approve a range the + # hosted job rejects after the push has already left the host. if ! command -v "$scanner" >/dev/null 2>&1; then echo "pre-push: $scanner is not on PATH (set CRW_CI_GITLEAKS); refusing the push rather than scanning nothing" >&2 exit 1 fi + found=$("$scanner" version 2>/dev/null || true) + if [ "$found" != "$expected" ]; then + echo "pre-push: $scanner is version '$found', but this repository pins $expected; refusing the push" >&2 + exit 1 + fi ignore=$(mktemp) trap 'rm -f "$ignore"' EXIT if ! "$scanner" git "$git_dir" --config "$root/.gitleaks.toml" \ @@ -140,8 +160,9 @@ func localHookPath(dir string) (string, error) { return path, nil } -// localHookState is what the hook's path holds: installed (this tool's hook), absent, or foreign -// (a different hook, which this tool leaves alone). +// localHookState is what the hook's path holds: installed (this tool's hook, the current script or +// an older one it wrote), absent, or foreign (a hook this tool did not write, which it leaves +// alone). func localHookState(path string) string { data, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -153,9 +174,24 @@ func localHookState(path string) string { if bytes.Equal(data, []byte(localHookScript)) { return "installed" } + if localHookOwned(data) { + return "installed" + } return "foreign" } +// localHookOwned reports whether data is a hook this tool wrote: a shell script whose second line +// is the marker. The marker alone is not trusted (a foreign hook could carry the line by accident), +// so the file must also open like the script this tool writes. +func localHookOwned(data []byte) bool { + first, rest, _ := bytes.Cut(data, []byte("\n")) + if !bytes.Equal(bytes.TrimRight(first, "\r"), []byte("#!/usr/bin/env bash")) { + return false + } + second, _, _ := bytes.Cut(rest, []byte("\n")) + return bytes.Equal(bytes.TrimRight(second, "\r"), []byte(localHookMarker)) +} + // localHookInstall writes the hook, refusing to replace a hook this tool did not write. It is // idempotent: this tool's own hook is rewritten, so an updated script reaches a repository that // already carries an older one. diff --git a/internal/dev/ci/local_hook_test.go b/internal/dev/ci/local_hook_test.go index ddc53ee1e0..542f85b5a6 100644 --- a/internal/dev/ci/local_hook_test.go +++ b/internal/dev/ci/local_hook_test.go @@ -21,6 +21,10 @@ type localHookFixture struct { // localHookScanner is a stand-in Gitleaks: it fails when the range carries the marker, so the // test proves the hook's decision rather than Gitleaks' rules. const localHookScanner = `#!/bin/sh +if [ "$1" = version ]; then + echo "${CRW964_SCANNER_VERSION:-8.30.1}" + exit 0 +fi if [ "$1" != git ]; then echo "stand-in scanner: unexpected command $1" >&2 exit 2 @@ -217,3 +221,75 @@ func mustHookPath(t *testing.T, root string) string { } return path } + +// The hook requires the pinned Gitleaks release: a scanner of another version is refused rather +// than approving a range with different rules. +func TestLocalHook_refuses_a_scanner_of_another_version(t *testing.T) { + repo := newLocalHookFixture(t) + repo.write("clean.txt", "nothing to see\n") + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", repo.base, + []string{"CRW_CI_GITLEAKS=" + localHookScannerPath(t), "CRW964_SCANNER_VERSION=8.18.0"}) + if code == 0 { + t.Fatalf("a push went through with an unpinned scanner:\n%s", out) + } + if !strings.Contains(out, "8.18.0") || !strings.Contains(out, localGitleaksPin) { + t.Errorf("the refusal does not name both versions:\n%s", out) + } +} + +// An older hook this tool wrote is recognised so an upgrade replaces it, while a hook it did not +// write stays foreign. +func TestLocalHook_recognises_its_own_older_hook(t *testing.T) { + repo := newLocalHookFixture(t) + path := mustHookPath(t, repo.root) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + older := "#!/usr/bin/env bash\n" + localHookMarker + "\n# an older body this tool wrote\nexit 0\n" + if err := os.WriteFile(path, []byte(older), 0o755); err != nil { + t.Fatal(err) + } + if state := localHookState(path); state != "installed" { + t.Fatalf("this tool's own older hook reads %q, want installed", state) + } + if _, err := localHookInstall(path); err != nil { + t.Fatalf("an upgrade of this tool's own older hook was refused: %v", err) + } + // A file that merely mentions the marker is still foreign. + impostor := "#!/usr/bin/env bash\necho " + localHookMarker + "\nexit 0\n" + if err := os.WriteFile(path, []byte(impostor), 0o755); err != nil { + t.Fatal(err) + } + if state := localHookState(path); state != "foreign" { + t.Errorf("a hook with the marker in the wrong place reads %q, want foreign", state) + } + if _, err := localHookInstall(path); err == nil { + t.Error("a foreign hook was replaced") + } +} + +// A SHA-256 all-zero object id (a 64-character sentinel) is recognised as a new ref, so the whole +// local sha is judged rather than an invalid range. +func TestLocalHook_recognises_a_sha256_zero_remote(t *testing.T) { + repo := newLocalHookFixture(t) + repo.write("secret.txt", "token = CRW964-LEAKED-VALUE\n") + repo.commit() + head := strings.TrimSpace(repo.git("rev-parse", "HEAD")) + if _, err := localHookInstall(mustHookPath(t, repo.root)); err != nil { + t.Fatal(err) + } + zeros := strings.Repeat("0", 64) + code, out := localHookRun(repo.root, "refs/heads/main", head, "refs/heads/main", zeros, + []string{"CRW_CI_GITLEAKS=" + localHookScannerPath(t), "CRW964_MARKER=CRW964-LEAKED-VALUE"}) + if code == 0 { + t.Fatalf("a new-ref push carrying the marker went through:\n%s", out) + } + if !strings.Contains(out, "Gitleaks found a secret") { + t.Errorf("the refusal does not name the finding:\n%s", out) + } +} diff --git a/internal/dev/ci/local_plan.go b/internal/dev/ci/local_plan.go index ff7b072383..4413ae1808 100644 --- a/internal/dev/ci/local_plan.go +++ b/internal/dev/ci/local_plan.go @@ -4,9 +4,8 @@ package ci import ( "fmt" - "os" - "path/filepath" "regexp" + "strconv" "strings" ) @@ -156,11 +155,11 @@ func localPlan() []localJob { validate.steps = append(validate.steps, goToolchain) validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "go build -tags dev -o \"$RUNNER_TEMP/crw-dev\" ./cmd/crw-dev", tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) - validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci validate'", + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "\"$RUNNER_TEMP/crw-dev\" ci validate", tool: "go", scope: "range", heavy: true, legs: nil, env: []string{"BLOB_RANGE_BASE=" + localBaseEnv}}) - validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci plugin'", + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "\"$RUNNER_TEMP/crw-dev\" ci plugin", tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) - validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "'\"$RUNNER_TEMP/crw-dev\" ci contracts'", + validate.steps = append(validate.steps, localStep{name: "", kind: localRun, command: "\"$RUNNER_TEMP/crw-dev\" ci contracts", tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) plan = append(plan, validate) secrets := localJob{name: "secrets", legs: nil} @@ -177,7 +176,7 @@ func localPlan() []localJob { note: "the same changed-path decision ci.yml runs, recorded; the local run performs every step, so the answer skips nothing"}) skill_scripts_node.steps = append(skill_scripts_node.steps, nodeToolchain) skill_scripts_node.steps = append(skill_scripts_node.steps, localStep{name: "Run the staged skill-script tests", kind: localRun, command: localSkillTests, - tool: "go", scope: "full", heavy: true, legs: nil, env: []string{"SKILLS_ROOT=port/cxc/skills"}}) + tool: "node", scope: "full", heavy: true, legs: nil, env: []string{"SKILLS_ROOT=port/cxc/skills"}}) plan = append(plan, skill_scripts_node) gui := localJob{name: "gui", legs: nil} gui.steps = append(gui.steps, mirrorPair()...) @@ -187,11 +186,11 @@ func localPlan() []localJob { note: "the same changed-path decision ci.yml runs, recorded; the local run performs every step, so the answer skips nothing"}) gui.steps = append(gui.steps, goToolchain) gui.steps = append(gui.steps, nodeToolchain) - gui.steps = append(gui.steps, localStep{name: "Install the screen dependencies from the committed lockfile", kind: localRun, command: "npm ci", + gui.steps = append(gui.steps, localStep{name: "Install the screen dependencies from the committed lockfile", kind: localRun, command: "npm ci", workdir: "web", tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) - gui.steps = append(gui.steps, localStep{name: "Run the screen tests", kind: localRun, command: "npm test", + gui.steps = append(gui.steps, localStep{name: "Run the screen tests", kind: localRun, command: "npm test", workdir: "web", tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) - gui.steps = append(gui.steps, localStep{name: "Build the screens into a fresh tree", kind: localRun, command: "npm run build -- --outDir \"$RUNNER_TEMP/gui-built\" --emptyOutDir", + gui.steps = append(gui.steps, localStep{name: "Build the screens into a fresh tree", kind: localRun, command: "npm run build -- --outDir \"$RUNNER_TEMP/gui-built\" --emptyOutDir", workdir: "web", tool: "node", scope: "full", heavy: true, legs: nil, env: nil}) gui.steps = append(gui.steps, localStep{name: "Refuse a committed tree that is not a fresh build", kind: localRun, command: "go run -tags dev ./cmd/crw-dev ci gui-drift --built \"$RUNNER_TEMP/gui-built\"", tool: "go", scope: "full", heavy: true, legs: nil, env: nil}) @@ -295,7 +294,7 @@ func parseWorkflow(text string) ([]workflowJob, error) { step.uses = strings.Fields(m[2])[0] case "run": if m[2] != "|" && m[2] != "|-" && m[2] != ">" && m[2] != ">-" { - step.run = m[2] + step.run = localYAMLScalar(m[2]) break } // A literal block scalar: its body is the run text. The body sits deeper than the key @@ -331,6 +330,21 @@ func parseWorkflow(text string) ([]workflowJob, error) { return jobs, nil } +// localYAMLScalar is a run value as the workflow's YAML reader hands it to the runner: a quoted +// scalar loses its quotes (single-quoted text keeps everything else, with ” for one quote), and +// a plain scalar is itself. The table carries the shell text, so the comparison is like for like. +func localYAMLScalar(value string) string { + if len(value) >= 2 && strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") { + return strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + if len(value) >= 2 && strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted + } + } + return value +} + // workflowJobNamed is the parsed job called name, or nil. func workflowJobNamed(jobs []workflowJob, name string) *workflowJob { for i := range jobs { @@ -375,9 +389,11 @@ func localPlanProblems(plan []localJob, workflow []workflowJob) []string { return problems } -// checkWorkflow reads ci.yml from root and refuses a workflow the table does not cover. -func checkWorkflow(root string, plan []localJob) error { - data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) +// checkWorkflowAt reads ci.yml as the named commit has it and refuses a workflow the table does not +// cover. Reading it from the verified commit rather than the working tree is what keeps a dirty +// edit to ci.yml from changing what this run is held to. +func checkWorkflowAt(root, commit string, plan []localJob) error { + data, err := runGit(root, "show", commit+":.github/workflows/ci.yml") if err != nil { return err } diff --git a/internal/dev/ci/local_record.go b/internal/dev/ci/local_record.go index 19fac37cc2..4e9509007f 100644 --- a/internal/dev/ci/local_record.go +++ b/internal/dev/ci/local_record.go @@ -51,6 +51,8 @@ type verificationRecord struct { CiDigest string `json:"ciDigest"` Tools map[string]string `json:"tools"` Pins map[string]string `json:"pins"` + GoFlags string `json:"goFlags"` + GoEnv string `json:"goEnv"` PinMismatch []string `json:"pinMismatch"` Dependencies map[string]string `json:"dependencies"` OS string `json:"os"` @@ -204,6 +206,9 @@ func localReuse(reused, current verificationRecord) (bool, string) { }{ {"tree", reused.TreeHash, current.TreeHash}, {"ci.yml digest", reused.CiDigest, current.CiDigest}, + // The blob and secret steps judge base..head, so a different base is a different + // verification even when the tree is the same (the range a record covers). + {"base commit", reused.BaseCommit, current.BaseCommit}, {"os", reused.OS, current.OS}, {"arch", reused.Arch, current.Arch}, } { @@ -226,6 +231,19 @@ func localReuse(reused, current verificationRecord) (bool, string) { return false, fmt.Sprintf("the %s digest changed (%s -> %s)", name, was, current.Dependencies[name]) } } + // The inherited Go settings decide what the Go steps actually build, so a run with different + // ones is not the same verification. + for _, key := range []struct { + name string + was, isNow string + }{ + {"GOFLAGS", reused.GoFlags, current.GoFlags}, + {"GOENV", reused.GoEnv, current.GoEnv}, + } { + if key.was != key.isNow { + return false, fmt.Sprintf("%s changed (%q -> %q)", key.name, key.was, key.isNow) + } + } return true, "every key matches" } diff --git a/internal/dev/ci/local_test.go b/internal/dev/ci/local_test.go index 8013935fe2..7448ee89ae 100644 --- a/internal/dev/ci/local_test.go +++ b/internal/dev/ci/local_test.go @@ -252,11 +252,11 @@ func TestLocalRecord_carries_every_required_field(t *testing.T) { // C3: a record answers a second run when every key matches. func TestLocalReuse_every_key_matches_answers_the_record(t *testing.T) { base := verificationRecord{ - Schema: recordSchema, Result: localPass, TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + Schema: recordSchema, Result: localPass, TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", BaseCommit: "base", Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, Dependencies: map[string]string{"go.sum": "sum"}, } same := verificationRecord{ - TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", BaseCommit: "base", Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, Dependencies: map[string]string{"go.sum": "sum"}, } if ok, why := localReuse(base, same); !ok { @@ -270,6 +270,7 @@ func TestLocalReuse_one_changed_key_reruns(t *testing.T) { fresh := func() verificationRecord { return verificationRecord{ Schema: recordSchema, Result: localPass, TreeHash: "tree", CiDigest: "ci", OS: "linux", Arch: "amd64", + BaseCommit: "base", Tools: map[string]string{"go": "1.27.1", "node": "24.20.0"}, Dependencies: map[string]string{"go.sum": "sum", "web/package-lock.json": "lock"}, } @@ -286,6 +287,9 @@ func TestLocalReuse_one_changed_key_reruns(t *testing.T) { {"lockfile digest", func(r *verificationRecord) { r.Dependencies["web/package-lock.json"] = "other" }}, {"os", func(r *verificationRecord) { r.OS = "darwin" }}, {"arch", func(r *verificationRecord) { r.Arch = "arm64" }}, + {"base commit", func(r *verificationRecord) { r.BaseCommit = "other-base" }}, + {"GOFLAGS", func(r *verificationRecord) { r.GoFlags = "-tags=other" }}, + {"GOENV", func(r *verificationRecord) { r.GoEnv = "/tmp/goenv" }}, } { current := fresh() row.apply(¤t) @@ -330,7 +334,7 @@ func TestLocalTools_pin_mismatch_is_recorded_and_never_reused(t *testing.T) { t.Errorf("a record with a pin mismatch is reused (%s)", why) } // A tool the tree pins and the host does not have is a mismatch too. - if got := localPinMismatch(map[string]string{"gitleaks": "8.30.1"}, map[string]string{"gitleaks": ""}); len(got) != 1 { + if got := localPinMismatch(map[string]string{"go": "1.27.1"}, map[string]string{"go": ""}); len(got) != 1 { t.Errorf("a missing pinned tool is not a mismatch: %v", got) } // An unpinned tool is not a mismatch. @@ -410,3 +414,96 @@ func TestLocal_gate_prefixes_a_heavy_step_as_argv(t *testing.T) { t.Error("the block scalar's newlines were lost") } } + +// The two tools a step fetches rather than inherits are named from the pin when the host has no +// copy, so the record says what the step used; a host copy is still compared. +func TestLocalTools_a_fetched_tool_is_named_from_its_pin(t *testing.T) { + pins := map[string]string{"go": "1.27.1", "gitleaks": "8.30.1", "staticcheck": "0.8.1"} + observed := localObservedVersions(map[string]string{"go": "1.27.1", "gitleaks": "", "staticcheck": ""}, pins) + if observed["gitleaks"] != "8.30.1" || observed["staticcheck"] != "0.8.1" { + t.Errorf("the fetched tools are not named from their pins: %v", observed) + } + if got := localPinMismatch(pins, observed); len(got) != 0 { + t.Errorf("a host without the fetched tools reports a mismatch: %v", got) + } + // A host copy that differs from the pin is still a mismatch. + host := localObservedVersions(map[string]string{"go": "1.27.1", "gitleaks": "8.18.0"}, pins) + if got := localPinMismatch(pins, host); len(got) != 1 || got[0] != "gitleaks" { + t.Errorf("a host Gitleaks that differs from the pin is not a mismatch: %v", got) + } +} + +// The record names the repository it was made in, so two repositories' records are told apart. +func TestLocalRecord_names_the_repository(t *testing.T) { + repo := newLocalFixture(t) + repo.git("remote", "add", "origin", "https://example.invalid/owner/repo.git") + made, _, err := localVerify(localRunOptions(repo, localFixturePlan("echo hello"), filepath.Join(t.TempDir(), "r.json")), "", io.Discard) + if err != nil { + t.Fatal(err) + } + if made.Repository != "https://example.invalid/owner/repo.git" { + t.Errorf("repository = %q, want the origin URL", made.Repository) + } +} + +// A base equal to the head would scan an empty range, so it is never chosen as the default base. +func TestLocalDefaultBase_never_returns_the_head(t *testing.T) { + repo := newLocalFixture(t) + head, err := localRev(repo.root, "HEAD") + if err != nil { + t.Fatal(err) + } + // origin/dev does not exist here, so the parent is the base; a root commit has no parent and + // the base stays empty rather than becoming the head. + base, err := localDefaultBase(repo.root, head) + if err != nil { + t.Fatal(err) + } + if base == head { + t.Errorf("the default base is the head itself (%s)", head) + } +} + +// The heavy gate takes the plan's workdir into account: the screen commands run in web/. +func TestLocalPlan_the_screen_commands_run_in_web(t *testing.T) { + for _, job := range localPlan() { + if job.name != "gui" { + continue + } + for _, step := range job.steps { + switch step.name { + case "Install the screen dependencies from the committed lockfile", + "Run the screen tests", + "Build the screens into a fresh tree": + if step.workdir != "web" { + t.Errorf("%q runs in %q, want web", step.name, step.workdir) + } + } + } + } +} + +// A ci.yml run scalar written in YAML single quotes is unquoted by the parser, and the table +// carries the shell text, so the two compare as the runner sees them. +func TestLocalPlan_a_quoted_run_scalar_is_compared_unquoted(t *testing.T) { + workflow, err := parseWorkflow(localWorkflow(t)) + if err != nil { + t.Fatal(err) + } + job := workflowJobNamed(workflow, "validate") + if job == nil { + t.Fatal("ci.yml has no validate job") + } + found := false + for _, step := range job.steps { + if strings.Contains(step.run, "ci validate") { + found = true + if strings.HasPrefix(step.run, "'") { + t.Errorf("the reader kept the YAML quotes: %q", step.run) + } + } + } + if !found { + t.Error("validate has no ci validate step") + } +} diff --git a/internal/dev/ci/local_tools.go b/internal/dev/ci/local_tools.go index 7a076e4403..ea0f220296 100644 --- a/internal/dev/ci/local_tools.go +++ b/internal/dev/ci/local_tools.go @@ -62,6 +62,19 @@ func localToolVersions(pathEnv string) map[string]string { return versions } +// localObservedVersions is what the steps actually used. Two tools are fetched by the step rather +// than inherited: secrets.sh downloads the pinned Gitleaks, and the lint leg runs staticcheck +// through the module the tree requires. When the host has no copy, the record names the pin the +// step will fetch, so the field says what ran rather than staying empty. +func localObservedVersions(versions, pins map[string]string) map[string]string { + for _, name := range []string{"gitleaks", "staticcheck"} { + if versions[name] == "" && pins[name] != "" { + versions[name] = pins[name] + } + } + return versions +} + // localObserveTool is one tool's version, or "" when it is not on PATH or does not answer. func localObserveTool(name, pathEnv string) string { var args []string @@ -108,6 +121,11 @@ func localParseToolVersion(name, output string) string { // localPinMismatch names every tool whose observed version differs from its pin. A tool the tree // pins but the host does not have is a mismatch too: the run cannot claim the pinned toolchain. +// +// Two tools are fetched by the step rather than inherited from the host, so an absent host copy is +// not a mismatch: secrets.sh downloads the pinned Gitleaks and verifies its checksum itself, and +// the lint leg runs staticcheck through the module the tree requires. A host copy, when there is +// one, is still compared. func localPinMismatch(pins, observed map[string]string) []string { var mismatches []string for _, name := range localToolNames { @@ -115,7 +133,11 @@ func localPinMismatch(pins, observed map[string]string) []string { if !pinned || pin == "" { continue } - if observed[name] != pin { + seen := observed[name] + if seen == "" && (name == "gitleaks" || name == "staticcheck") { + continue + } + if seen != pin { mismatches = append(mismatches, name) } } From 18783648161995644c7d23fbb2a6f8ccb2e35283 Mon Sep 17 00:00:00 2001 From: CRW-964 child Date: Thu, 8 Oct 2026 03:49:20 +0900 Subject: [PATCH 05/26] CRW-964: run a step's command as a script file, not as a bash -c argument The measured local run caught this: the heavy-check gate sits between the engine and bash, and systemd-run expands the arguments it is handed, so a command that uses a shell variable (the staged skill-script test run, whose file-count expression became a bare token) was mangled. The step's command is now written to a script under the run's own temporary root and run as bash