diff --git a/.context/PROJECT.md b/.context/PROJECT.md index c4135e0..bc9f0ea 100644 --- a/.context/PROJECT.md +++ b/.context/PROJECT.md @@ -54,16 +54,14 @@ assessor-loop ledger) → `06 PR → CI` (go test/vet/lint/govulncheck + **var-g This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its own build increments are governed by a **var-go Oath** embedded in their spec issues (see the -Stage-03 tracking issue). Bootstrapping honesty (per swedsl honest-stub discipline): `cmd/vargo-gate` -is wired into `.gitea/workflows/cd.yml`'s `oath` job (issue #1, verified with a real PR) — it runs -on every pull_request, fetches the linked issue's oath, and posts a `var-go/oath` commit status. -But its candidate is still swedsl's hardcoded toy self-test registry, not cad-atlas's own — it -fails closed against any real oath. var-go's Executor/Reviewer path is **not** the blocker (that -was explicitly killed by swedsl's ADR-0002, swedsl#27 — var-go is gate-only by design, each -consumer supplies its own candidate). The real blocker is `swedsl/oath`'s import path -(`swedsl#35`); once fixed, cad-atlas writes its own candidate (`#8`). The status is **not** -required by branch protection, so it can't block merges yet. Disclosed in the CI config comment, -this doc, and `docs/INCEPTION-OATH.md`. +Stage-03 tracking issue). `cmd/vargo-gate` is wired into `.gitea/workflows/cd.yml`'s `oath` job — +on every pull_request it fetches the linked issue's oath and gates cad-atlas's **own real +candidate** (`oathcandidate/`, #8): it parses the committed `.gitea/workflows/cd.yml` and checks +the `oath` job actually exists and invokes `cmd/vargo-gate`, then posts the `var-go/oath` commit +status. This is a real check (TDD'd: passes on the real file, fails closed on a fixture missing +the job), not swedsl's toy self-test stub — swedsl#35 (import path) and swedsl#38 (real-candidate +subprocess gating) unblocked it. Still **not** required by branch protection until proven green on +a real PR (#8). Disclosed in the CI config comment, this doc, and `docs/INCEPTION-OATH.md`. ## Brain references (source of truth — `brain_get `) diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index e7d39f5..8e15890 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -53,50 +53,73 @@ jobs: - name: Run checks run: task check + - name: oathcandidate module — vet + test (private dep, short-lived askpass) + working-directory: oathcandidate + run: | + set -euo pipefail + export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}' + ASKPASS=$(mktemp) + { echo '#!/bin/sh' + echo 'case "$1" in' + echo ' *Username*) echo oauth2 ;;' + echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;' + echo 'esac' + } > "$ASKPASS" + chmod 700 "$ASKPASS" + export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be + go vet ./... + go test ./... + rm -f "$ASKPASS" + oath: name: var-go/oath needs: guard - # Only a real pull_request event carries a linked-issue oath to gate (mirrors - # swedsl's own oath job, .gitea/workflows/ci.yml). v1 simplification (swedsl#30): - # the oath issue number is the PR's OWN number. - # - # DISCLOSED LIMITATION (honest-stub discipline, see docs/INCEPTION-OATH.md S3 and - # knowledge/swedsl-vargo-sprint1-enforcement-teeth-verdict.md): cmd/vargo-gate's - # candidate is a hardcoded toy self-test registry (swedsl's own #9 fixture - # vocabulary), not a real PR-diff checker. It will fail closed against any oath - # that isn't that toy vocabulary — which is every real oath, including this repo's - # own #1. A red or green "var-go/oath" status here currently proves the WIRING - # (fetch issue -> gate -> post commit status) runs end-to-end on a real PR, not - # that the PR satisfies its linked issue's oath. Deliberately NOT required by - # branch protection until cad-atlas has its own candidate matching its real oath - # vocabulary (#8, blocked on swedsl/oath's import path, swedsl#35) — making it - # required now would permanently block every cad-atlas PR. + # cad-atlas's own real candidate (#8): oathcandidate/ parses the committed + # .gitea/workflows/cd.yml and gates it against cad-atlas#8's oath — replacing the + # earlier wiring-only proof (#1) that always gated swedsl's toy self-test fixture + # and always failed closed. cmd/vargo-gate (swedsl#35/#37/#38) now go-installs + # cleanly from its real module path and runs the candidate module in a sandboxed + # subprocess (SubprocessGate, ADR-0003) — a green status here means "the committed + # CI config satisfies its oath", not merely "the wiring ran". Still NOT required by + # branch protection (#8) until proven green on a real PR. if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request' runs-on: self-hosted steps: - - name: Checkout swedsl (var-go source — not go-installable, module path isn't a real import path) - uses: actions/checkout@v4 - with: - repository: mathias/swedsl - path: swedsl - token: ${{ secrets.DMABE_GITEA_API_TOKEN }} + - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: swedsl/oath/go.mod + go-version-file: oathcandidate/go.mod cache: false - - name: Run vargo-gate (fetch -> gate -> post status against this PR) - working-directory: swedsl/oath + - name: Run vargo-gate (fetch linked oath -> sandboxed-gate the real candidate -> post status) env: VARGO_GITEA_BASEURL: ${{ github.server_url }} VARGO_GITEA_OWNER: ${{ github.repository_owner }} VARGO_GITEA_REPO: cad-atlas + # Oath issue resolution (swedsl#38): a "Closes #NN" reference in the PR body + # picks the linked oath issue; VARGO_GITEA_ISSUE is the fallback (PR's own + # number, correct only for a PR filed directly against its oath issue). + VARGO_PR_BODY: ${{ github.event.pull_request.body }} VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }} VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }} + VARGO_CANDIDATE_DIR: oathcandidate + # Sandbox is ON by default (untrusted PR code runs in a fresh user+net + # namespace, swedsl#37); no need to set VARGO_SANDBOX here. run: | + set -euo pipefail export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}' - go run ./cmd/vargo-gate + ASKPASS=$(mktemp) + { echo '#!/bin/sh' + echo 'case "$1" in' + echo ' *Username*) echo oauth2 ;;' + echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;' + echo 'esac' + } > "$ASKPASS" + chmod 700 "$ASKPASS" + export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be + go run git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0 + rm -f "$ASKPASS" build: name: Build & Import diff --git a/docs/INCEPTION-OATH.md b/docs/INCEPTION-OATH.md index 5f01fa9..45bc92c 100644 --- a/docs/INCEPTION-OATH.md +++ b/docs/INCEPTION-OATH.md @@ -4,8 +4,9 @@ The acceptance contract for standing up cad-atlas. The sprint is finalized only Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`. > **Status of enforcement:** this Oath is currently **advisory** (human-verified). `var-go/oath` -> is wired (issue #1) and runs on every PR, but its candidate is a toy self-test — it fails closed -> against any real oath and is not required by branch protection. See the honesty rule below. +> gates a real candidate now (`oathcandidate/`, #8 — parses the committed CI workflow, TDD'd +> pass/fail-closed) but isn't yet required by branch protection pending a real-PR confirmation. +> See the honesty rule below. ## General clauses (any inception sprint) @@ -25,7 +26,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath |---|--------|--------|----------| | S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` | | S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas | -| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **wired, not enforcing → #8** | `oath` job runs + posts status (#1, verified with a real PR). Not branch-protection-required: candidate is still swedsl's toy self-test, fails closed on every real oath. Blocked on `swedsl/oath` import path (swedsl#35) → cad-atlas writing its own candidate (#8). Not blocked on an Executor — that path was killed by swedsl's ADR-0002. See honesty rule. | +| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **real candidate wired, not enforcing → #8** | `oathcandidate/` gates the real `.gitea/workflows/cd.yml` (TDD green: passes real file, fails closed on a fixture missing the job) via swedsl's sandboxed `SubprocessGate` (swedsl#35/#38). Not yet branch-protection-required — awaiting confirmation on a real PR. See honesty rule. | ## Deployment @@ -38,9 +39,13 @@ namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathia A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** — a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3's real -enforcement depends on `swedsl/oath` becoming importable (swedsl#35) and cad-atlas writing its own -candidate (#8); it is tracked there, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret -is pre-provisioned so #1 landed without a secret-write. +candidate is wired (#8) but branch-protection enforcement waits on a real-PR confirmation, tracked +there, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret is pre-provisioned so #1 and +#8 both landed without a secret-write. + +Also surfaced by #8: this file's own "Oath (advisory form)" below predates the discovery that +var-go's parser requires single-line, period-separated sentences with no `Given`/`Then`/`And` +keyword stripping — it has never been machine-gated and would need reformatting first if it ever is. ## The Oath (advisory form) diff --git a/oathcandidate/build.go b/oathcandidate/build.go new file mode 100644 index 0000000..08b919d --- /dev/null +++ b/oathcandidate/build.go @@ -0,0 +1,108 @@ +// Package oathcandidate supplies cad-atlas's own real var-go candidate (cad-atlas#8): +// steps that gate its own CI-workflow oath by actually parsing the committed +// .gitea/workflows/cd.yml, not a stub that hardcodes an unrelated toy vocabulary. +// var-go injects and owns the gate across the subprocess boundary (SubprocessGate, +// ADR-0003), so this package supplies only the prose->behaviour binding and never a +// verdict — it cannot self-certify. +package oathcandidate + +import ( + "os" + "path/filepath" + "strings" + + oath "git.d-ma.be/mathias/swedsl/oath" + "gopkg.in/yaml.v3" +) + +// workflowState is the candidate's domain: the job names and concatenated step-run +// scripts parsed out of one Gitea Actions workflow file. +type workflowState struct { + jobNames map[string]bool + jobRuns map[string]string // job name -> every step's `run:` script, concatenated +} + +type workflowFile struct { + Jobs map[string]struct { + Steps []struct { + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` +} + +// Build returns cad-atlas's candidate registry. cmd/vargo-gate runs the generated +// harness with cwd = this module's own directory (SubprocessGate's +// cmd.Dir = candidateModuleDir contract) — one level under the cad-atlas repo root +// in cad-atlas's real layout — so a workflow path in the oath text like +// ".gitea/workflows/cd.yml" is read relative to "..". +func Build() *oath.Registry[workflowState] { + reg := oath.NewRegistry[workflowState]() + + if err := reg.Stimulus(`the CI workflow file {string} is parsed`, + func(_ workflowState, path string) workflowState { + return parseWorkflow(path) + }); err != nil { + panic(err) + } + + if err := reg.Sensor(`it defines a job named {string}`, + func(s workflowState, name string) string { + if s.jobNames[name] { + return name + } + return "" + }); err != nil { + panic(err) + } + + // Checks what the workflow file can actually attest to: the job's run script + // invokes the gate binary. The "var-go/oath" commit-status context string + // itself lives in vargo-gate's Go code, not the YAML — not something this + // file-level check can see, so it isn't what's asserted here. + if err := reg.Sensor(`the job named {string} invokes {string}`, + func(s workflowState, job, cmd string) (string, string) { + run, ok := s.jobRuns[job] + foundJob := "" + if ok { + foundJob = job + } + foundCmd := cmd + if !ok || !strings.Contains(run, cmd) { + foundCmd = "" + } + return foundJob, foundCmd + }); err != nil { + panic(err) + } + + return reg +} + +// parseWorkflow reads and parses a Gitea Actions workflow file relative to the +// repo root (see Build's doc comment for the cwd contract). A read or parse +// failure returns an empty state — every sensor then observes "not found", +// which fails the gate closed rather than silently skipping the check. +func parseWorkflow(repoRelativePath string) workflowState { + state := workflowState{jobNames: map[string]bool{}, jobRuns: map[string]string{}} + + data, err := os.ReadFile(filepath.Join("..", repoRelativePath)) + if err != nil { + return state + } + + var wf workflowFile + if err := yaml.Unmarshal(data, &wf); err != nil { + return state + } + + for name, job := range wf.Jobs { + state.jobNames[name] = true + var runs strings.Builder + for _, step := range job.Steps { + runs.WriteString(step.Run) + runs.WriteString("\n") + } + state.jobRuns[name] = runs.String() + } + return state +} diff --git a/oathcandidate/build_test.go b/oathcandidate/build_test.go new file mode 100644 index 0000000..b9acb8b --- /dev/null +++ b/oathcandidate/build_test.go @@ -0,0 +1,79 @@ +package oathcandidate + +import ( + "os" + "path/filepath" + "testing" + + oath "git.d-ma.be/mathias/swedsl/oath" +) + +// realOath is cad-atlas#8's actual oath text — the same var block committed to +// that issue. Gating it against the REAL checked-out .gitea/workflows/cd.yml +// proves the candidate reads real CI config, not a fixture standing in for it. +// +// Format note (discovered writing this test): var-go's parser requires a +// SINGLE-LINE paragraph — sentences are split by "." within that line, not by +// newline — and does NOT strip Given/When/Then/And keywords before matching a +// step. cad-atlas's older oaths (e.g. issue #1) use a multi-line, keyword-prefixed +// style that was never actually exercised against this parser (every prior gate +// run errored before reaching real sentence matching). Plain declarative +// sentences, period-separated, one line — see swedsl's own gate_test.go fixtures. +const realOath = "```var\n" + + `the CI workflow file ".gitea/workflows/cd.yml" is parsed. it defines a job named "oath". the job named "oath" invokes "cmd/vargo-gate".` + + "\n```\n" + +// TestBuild_GatesRealWorkflow is named before Build existed (TDD): it fails to +// compile until Build() and the workflow-parsing steps exist, and fails to pass +// until they parse the REAL committed cd.yml correctly — this is the file that +// must go from red to green, not a mock. +func TestBuild_GatesRealWorkflow(t *testing.T) { + // go test's cwd is already this package's dir (oathcandidate/), matching + // SubprocessGate's cmd.Dir = candidateModuleDir contract exactly — no chdir + // needed to reproduce it here. + verdict, err := oath.Gate([]byte(realOath), Build()) + if err != nil { + t.Fatalf("Gate returned error: %v", err) + } + if !verdict.Pass { + if verdict.Failure != nil { + t.Fatalf("Gate did not pass: failure=%+v", *verdict.Failure) + } + t.Fatalf("Gate did not pass against the real committed cd.yml: %+v", verdict) + } +} + +// TestBuild_FailsClosedOnMissingJob proves the candidate is a REAL check, not a +// rubber stamp: gating a workflow file that has no "oath" job must fail. +func TestBuild_FailsClosedOnMissingJob(t *testing.T) { + dir := t.TempDir() + workflowsDir := filepath.Join(dir, ".gitea", "workflows") + if err := os.MkdirAll(workflowsDir, 0o755); err != nil { + t.Fatal(err) + } + noOathJob := "jobs:\n check:\n steps:\n - run: go test ./...\n" + if err := os.WriteFile(filepath.Join(workflowsDir, "cd.yml"), []byte(noOathJob), 0o644); err != nil { + t.Fatal(err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // SubprocessGate always runs the candidate with cmd.Dir = candidateModuleDir, + // one level under the repo root (cad-atlas's real layout) — reproduce that by + // chdir-ing into a sibling "candidate/" dir under the fixture root. + candDir := filepath.Join(dir, "candidate") + if err := os.MkdirAll(candDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chdir(candDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + verdict, err := oath.Gate([]byte(realOath), Build()) + if err == nil && verdict.Pass { + t.Fatalf("expected the gate to fail closed on a workflow with no oath job, got Pass=true") + } +} diff --git a/oathcandidate/go.mod b/oathcandidate/go.mod new file mode 100644 index 0000000..456cddc --- /dev/null +++ b/oathcandidate/go.mod @@ -0,0 +1,18 @@ +// Package oathcandidate is cad-atlas's committed real candidate: the STEPS that +// gate its own CI-workflow oath (cad-atlas#8). Deliberately a separate module (not +// part of the main cad-atlas module) so var-go's transitive deps (cucumber-expressions, +// goldmark) never link into the deployed atlas binary — mirrors swedsl's own +// oath/testdata/selfcandidate pattern. +module oathcandidate + +go 1.26.4 + +require ( + git.d-ma.be/mathias/swedsl/oath v0.28.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect +) diff --git a/oathcandidate/go.sum b/oathcandidate/go.sum new file mode 100644 index 0000000..d2231ca --- /dev/null +++ b/oathcandidate/go.sum @@ -0,0 +1,16 @@ +git.d-ma.be/mathias/swedsl/oath v0.28.0 h1:q4WXlGtMlDymhmuw9Pdc025OTLs1wl8THsrz/raeMxs= +git.d-ma.be/mathias/swedsl/oath v0.28.0/go.mod h1:kEOX7Wubf3g/HTKzuoHD4fm6zNSl55cSV/qgy+ezMoI= +github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 h1:zvZFnbmtQxwHq6ru5gHxpfBloLq9wmjoKbdwOzt/XNA= +github.com/cucumber/cucumber-expressions/go/v18 v18.1.0/go.mod h1:+Qe2kvmilsdGRFJ+zlkjXp84rPEf6O/idcoOsvnIORY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=