diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 3882d02..f12fefe 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -62,18 +62,23 @@ jobs: image-tag: ${{ steps.meta.outputs.sha-tag }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history + tags so `git describe` sees the SemVer tag - name: Derive image tags id: meta run: | SHA=$(git rev-parse --short HEAD) + VERSION=$(git describe --tags --always --dirty) echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - name: Build and push to local registry run: | REGISTRY="localhost:5000" REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" buildah build \ + --build-arg VERSION="${{ steps.meta.outputs.version }}" \ --label "org.opencontainers.image.revision=${{ github.sha }}" \ -t ${REF} \ -t ${REGISTRY}/${{ env.IMAGE }}:latest \ diff --git a/Dockerfile b/Dockerfile index ce70f67..79ab445 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,10 @@ RUN go install github.com/a-h/templ/cmd/templ@latest COPY go.mod ./ RUN go mod download COPY . . -RUN templ generate && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/cad-atlas +ARG VERSION=dev +RUN templ generate && CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X git.d-ma.be/mathias/cad-atlas/internal/version.Value=${VERSION}" \ + -o /out/app ./cmd/cad-atlas FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/app /app diff --git a/internal/atlas/model.go b/internal/atlas/model.go index 37a1f7e..74aa481 100644 --- a/internal/atlas/model.go +++ b/internal/atlas/model.go @@ -35,6 +35,7 @@ type Stage struct { // Atlas is the full data model the frontend renders. type Atlas struct { + Version string `json:"version,omitempty"` Substrate []Host `json:"substrate"` NS string `json:"ns,omitempty"` Stages []Stage `json:"stages"` diff --git a/internal/atlas/runs.go b/internal/atlas/runs.go index b07931c..6d46d1d 100644 --- a/internal/atlas/runs.go +++ b/internal/atlas/runs.go @@ -5,56 +5,107 @@ import ( "fmt" ) -// Run is one Gitea Actions workflow run — a real execution of the pipeline. -type Run struct { - Number int `json:"run_number"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - SHA string `json:"head_sha"` - Title string `json:"display_title"` - URL string `json:"url"` +// Job is one job within a workflow run (a Gitea Actions "task"). +type Job struct { + Name string + Status string + Conclusion string } -// State is the effective outcome: the conclusion if set, else the status. -func (r Run) State() string { - if r.Conclusion != "" { - return r.Conclusion +// State is the effective outcome: conclusion if set, else status. +func (j Job) State() string { + if j.Conclusion != "" { + return j.Conclusion } - return r.Status + return j.Status } -// RunNode renders a run as a stage node, coloured by outcome -// (success=green, failure/cancelled=coral, otherwise amber/in-progress). -func RunNode(r Run) Node { - pill := "var(--amber)" - switch r.State() { - case "success": - pill = "var(--green)" - case "failure", "cancelled": - pill = "var(--coral)" +// RunSummary is the newest workflow run and its per-job outcomes. +type RunSummary struct { + Number int + SHA string + Title string + Jobs []Job +} + +// State aggregates the jobs: failure if any failed, running if any not yet +// succeeded, else success. +func (s RunSummary) State() string { + allSucceeded := true + for _, j := range s.Jobs { + switch j.State() { + case "failure", "cancelled", "error": + return "failure" + case "success": + default: + allSucceeded = false + } } - sha := r.SHA + if allSucceeded { + return "success" + } + return "running" +} + +// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries, +// newest first) and returns the newest run with its jobs in pipeline order. +func LatestRunJobs(tasksJSON []byte) (RunSummary, error) { + var resp struct { + Tasks []struct { + RunNumber int `json:"run_number"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + SHA string `json:"head_sha"` + Title string `json:"display_title"` + } `json:"workflow_runs"` + } + if err := json.Unmarshal(tasksJSON, &resp); err != nil { + return RunSummary{}, fmt.Errorf("parse tasks: %w", err) + } + if len(resp.Tasks) == 0 { + return RunSummary{}, fmt.Errorf("no workflow tasks") + } + + latest := resp.Tasks[0] + s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title} + for _, t := range resp.Tasks { + if t.RunNumber == latest.RunNumber { + s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion}) + } + } + // Gitea lists newest (last-finished) first; reverse to pipeline order. + for i, j := 0, len(s.Jobs)-1; i < j; i, j = i+1, j-1 { + s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i] + } + return s, nil +} + +// RunNodes renders a run as stage nodes: a summary node followed by one node +// per job, each coloured by outcome. +func RunNodes(s RunSummary) []Node { + sha := s.SHA if len(sha) > 7 { sha = sha[:7] } - return Node{ - Title: fmt.Sprintf("▶ run #%d · %s", r.Number, r.State()), - Desc: r.Title, - Pill: pill, + nodes := []Node{{ + Title: fmt.Sprintf("▶ run #%d · %s", s.Number, s.State()), + Desc: s.Title, + Pill: statePill(s.State()), Tags: []string{"live · Gitea Actions", sha}, + }} + for _, j := range s.Jobs { + nodes = append(nodes, Node{Title: j.Name, Pill: statePill(j.State())}) } + return nodes } -// LatestRun parses a Gitea `/actions/tasks` response and returns the newest run. -func LatestRun(tasksJSON []byte) (Run, error) { - var resp struct { - Runs []Run `json:"workflow_runs"` +func statePill(state string) string { + switch state { + case "success": + return "var(--green)" + case "failure", "cancelled", "error": + return "var(--coral)" } - if err := json.Unmarshal(tasksJSON, &resp); err != nil { - return Run{}, fmt.Errorf("parse runs: %w", err) - } - if len(resp.Runs) == 0 { - return Run{}, fmt.Errorf("no workflow runs") - } - return resp.Runs[0], nil + return "var(--amber)" } diff --git a/internal/atlas/runs_test.go b/internal/atlas/runs_test.go index 7b7a622..e996d20 100644 --- a/internal/atlas/runs_test.go +++ b/internal/atlas/runs_test.go @@ -6,65 +6,67 @@ import ( "git.d-ma.be/mathias/cad-atlas/internal/atlas" ) -func TestLatestRun_ReturnsNewestRun(t *testing.T) { - tasks := []byte(`{"total_count":52,"workflow_runs":[ - {"run_number":27,"status":"success","conclusion":null,"head_sha":"633ba153f26","display_title":"Phase B tail","url":"https://git.d-ma.be/mathias/cad-atlas/actions/runs/27"}, - {"run_number":26,"status":"failure","head_sha":"aaa"} +func TestLatestRunJobs_GroupsNewestRunReversedToPipelineOrder(t *testing.T) { + // Gitea returns tasks newest-first (deploy finished last → appears first). + tasks := []byte(`{"workflow_runs":[ + {"run_number":28,"name":"Deploy via GitOps","status":"success","head_sha":"633ba153f26abc","display_title":"feat: x"}, + {"run_number":28,"name":"Build & Import","status":"success"}, + {"run_number":28,"name":"Lint / Test / Vet","status":"success"}, + {"run_number":27,"name":"Deploy via GitOps","status":"failure"} ]}`) - r, err := atlas.LatestRun(tasks) + s, err := atlas.LatestRunJobs(tasks) if err != nil { - t.Fatalf("LatestRun: %v", err) + t.Fatalf("LatestRunJobs: %v", err) } - if r.Number != 27 || r.SHA != "633ba153f26" || r.Title != "Phase B tail" { - t.Fatalf("run = %+v", r) + if s.Number != 28 || s.SHA != "633ba153f26abc" || s.Title != "feat: x" { + t.Fatalf("summary = %+v", s) } - if r.State() != "success" { - t.Fatalf("State = %q, want success", r.State()) + // only run 28's jobs, reversed to pipeline order (Lint → Build → Deploy) + got := []string{} + for _, j := range s.Jobs { + got = append(got, j.Name) + } + want := []string{"Lint / Test / Vet", "Build & Import", "Deploy via GitOps"} + if len(got) != 3 || got[0] != want[0] || got[2] != want[2] { + t.Fatalf("jobs = %v, want %v", got, want) + } + if s.State() != "success" { + t.Fatalf("state = %q, want success", s.State()) } } -func TestLatestRun_ConclusionWinsOverStatus(t *testing.T) { - tasks := []byte(`{"workflow_runs":[{"run_number":9,"status":"completed","conclusion":"failure"}]}`) - r, err := atlas.LatestRun(tasks) - if err != nil { - t.Fatalf("LatestRun: %v", err) - } - if r.State() != "failure" { - t.Fatalf("State = %q, want failure", r.State()) +func TestRunSummary_StateFailsIfAnyJobFailed(t *testing.T) { + s := atlas.RunSummary{Jobs: []atlas.Job{ + {Status: "success"}, {Status: "completed", Conclusion: "failure"}, + }} + if s.State() != "failure" { + t.Fatalf("state = %q, want failure", s.State()) } } -func TestRunNode_ColoursAndLabelsByOutcome(t *testing.T) { - green := atlas.RunNode(atlas.Run{Number: 27, Status: "success", SHA: "633ba153f26abc", Title: "Phase B tail"}) - if green.Pill != "var(--green)" { - t.Fatalf("success pill = %q, want var(--green)", green.Pill) +func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) { + s := atlas.RunSummary{Number: 28, SHA: "633ba153f26", Title: "feat: x", Jobs: []atlas.Job{ + {Name: "Lint / Test / Vet", Status: "success"}, + {Name: "Deploy via GitOps", Status: "failure"}, + }} + nodes := atlas.RunNodes(s) + if len(nodes) != 3 { + t.Fatalf("want 3 nodes (summary + 2 jobs), got %d", len(nodes)) } - if green.Title != "▶ run #27 · success" { - t.Fatalf("title = %q", green.Title) + if nodes[0].Title != "▶ run #28 · failure" || nodes[0].Pill != "var(--coral)" { + t.Fatalf("summary node = %+v", nodes[0]) } - if green.Desc != "Phase B tail" { - t.Fatalf("desc = %q", green.Desc) + if nodes[1].Title != "Lint / Test / Vet" || nodes[1].Pill != "var(--green)" { + t.Fatalf("job node 1 = %+v", nodes[1]) } - // short sha appears as a tag - found := false - for _, tag := range green.Tags { - if tag == "633ba15" { - found = true - } - } - if !found { - t.Fatalf("short sha tag missing: %v", green.Tags) - } - - red := atlas.RunNode(atlas.Run{Number: 5, Status: "completed", Conclusion: "failure"}) - if red.Pill != "var(--coral)" { - t.Fatalf("failure pill = %q, want var(--coral)", red.Pill) + if nodes[2].Pill != "var(--coral)" { + t.Fatalf("failed job pill = %q", nodes[2].Pill) } } -func TestLatestRun_ErrorsWhenNoRuns(t *testing.T) { - if _, err := atlas.LatestRun([]byte(`{"workflow_runs":[]}`)); err == nil { - t.Fatal("expected error on empty runs, got nil") +func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) { + if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil { + t.Fatal("expected error on empty, got nil") } } diff --git a/internal/gitea/client.go b/internal/gitea/client.go index cf446d9..480ce61 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -21,7 +21,7 @@ func base() string { // Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). func Runs() ([]byte, error) { - url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=1" + url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=20" client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client if err != nil { diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..80d60a9 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +// Package version holds the build version, injected at build time via +// -ldflags "-X .../internal/version.Value=$(git describe --tags --always)". +package version + +// Value is the build version. Defaults to "dev" for local/un-injected builds. +var Value = "dev" diff --git a/internal/web/handler.go b/internal/web/handler.go index 1e42521..92f9822 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -11,6 +11,7 @@ import ( "git.d-ma.be/mathias/cad-atlas/internal/atlas" "git.d-ma.be/mathias/cad-atlas/internal/cluster" "git.d-ma.be/mathias/cad-atlas/internal/gitea" + "git.d-ma.be/mathias/cad-atlas/internal/version" ) // atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load @@ -38,6 +39,7 @@ func NewHandler() http.Handler { http.Error(w, "atlas build failed", http.StatusInternalServerError) return } + a.Version = version.Value ld := liveOverlay() if len(ld.hosts) > 0 { a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts) @@ -46,10 +48,10 @@ func NewHandler() http.Handler { a.NS = "Tailscale mesh · " + ld.ns } if ld.run != nil { - node := atlas.RunNode(*ld.run) + nodes := atlas.RunNodes(*ld.run) for i := range a.Stages { if a.Stages[i].Generate == "ci-jobs" { - a.Stages[i].Nodes = append([]atlas.Node{node}, a.Stages[i].Nodes...) + a.Stages[i].Nodes = nodes } } } @@ -63,7 +65,7 @@ func NewHandler() http.Handler { type liveOverlayData struct { hosts []atlas.Host ns string - run *atlas.Run + run *atlas.RunSummary } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -96,7 +98,7 @@ func liveOverlay() liveOverlayData { } } if raw, err := gitea.Runs(); err == nil { - if r, err := atlas.LatestRun(raw); err == nil { + if r, err := atlas.LatestRunJobs(raw); err == nil { d.run = &r } } diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index c1ac6f6..01fe665 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -114,7 +114,7 @@

CAD Atlas · From Signal to Pod

- one human gate · everything up- and downstream is agents · v0.7 · Phase C: live CI runs (Gitea Actions) on the pipeline + one human gate · everything up- and downstream is agents · dev
@@ -233,6 +233,7 @@ async function init(){ if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate; if(typeof data.ns==='string') NS=data.ns; if(Array.isArray(data.stages)) STAGES=data.stages; + if(data.version) document.getElementById('ver').textContent=data.version; }catch(e){ console.error('atlas: failed to load /api/atlas.json —',e); document.getElementById('track').insertAdjacentHTML('beforeend',