Files
mathiasandClaude Sonnet 5 805b76d7c3
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
feat(oath): gate cad-atlas's own real candidate, not swedsl's toy stub (#8)
oathcandidate/ is a separate Go module (mirrors swedsl's own
oath/testdata/selfcandidate pattern, keeping var-go's transitive deps
out of the deployed atlas binary) whose Build() parses the committed
.gitea/workflows/cd.yml and checks the "oath" job exists and invokes
cmd/vargo-gate. TDD: passes against the real file, fails closed on a
fixture missing the job.

Rewires the oath CI job to go-run vargo-gate from its real module path
(git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0, unblocked
by swedsl#35/#38) against VARGO_CANDIDATE_DIR=oathcandidate, instead of
checking out swedsl and gating its hardcoded toy fixture. Private-module
auth via a short-lived GIT_ASKPASS script (token never in argv, never
written to git config, matches act_runner's env:-block-with-secrets
gotcha).

Discovered along the way: var-go's parser needs single-line,
period-separated oath sentences with no Given/When/Then/And keyword
stripping — this repo's older oaths (incl. #1) used an unverified
multi-line keyword-prefixed style. #8's oath uses the proven format.

Still not required by branch protection pending a real-PR confirmation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:33:17 +02:00

109 lines
3.4 KiB
Go

// 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 "<no such job>"
}); 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 := "<no such job>"
if ok {
foundJob = job
}
foundCmd := cmd
if !ok || !strings.Contains(run, cmd) {
foundCmd = "<not invoked>"
}
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
}