generated from mathias/template-go-web
feat(atlas): per-job CI status + build-injected version in the UI
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:
@@ -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 \
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.7 · Phase C: live CI runs (Gitea Actions) on the pipeline</em></span>
|
||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
|
||||
<div class="controls">
|
||||
<button id="replay"><span class="dot"></span> Replay</button>
|
||||
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user