feat(atlas): per-job CI status + build-injected version in the UI
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped

Phase C: the CI stage now shows the latest run's per-JOB status (Lint/Test,
Build, Deploy, …), each coloured by outcome, from the Gitea /actions/tasks
per-job entries — replacing the static cd.yml job-id list when live.
LatestRunJobs/RunNodes + RunSummary.State aggregate, all test-first.

Version: injected at build via -ldflags from `git describe --tags` (CI checkout
now fetch-depth:0 + --build-arg), served in /api/atlas.json, shown in the header.
No more hand-maintained version label drifting from the git tag.

Verified: build/vet/lint(0)/test green; version ldflag served correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:21:52 +02:00
co-authored by Claude Opus 4.8
parent 02a23a02dd
commit 3ff922a4d5
9 changed files with 159 additions and 88 deletions
+1
View File
@@ -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"`
+89 -38
View File
@@ -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)"
}
+45 -43
View File
@@ -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")
}
}