Commit bc114f5

Eric Bower  ·  2026-05-08 09:05:04 -0400 EDT
parent a29c053
refactor: post-recieve hook dest
2 files changed,  +164, -47
M hooks/post-receive
+29, -26
 1@@ -8,44 +8,47 @@
 2 
 3 set -euo pipefail
 4 
 5-PIPE_HOST="${PIPE_HOST:-pipe}"
 6+PIPE_HOST="${PIPE_HOST:-pipe.pico.sh}"
 7 PGS_HOST="${PGS_HOST:-pgs.sh}"
 8+PGS_PROJECT_BASE="${PGS_PROJECT_BASE:-public-ci}"
 9 
10 log() {
11-   echo "[post-receive] $*" >&2
12+  echo "[post-receive] $*" >&2
13 }
14 
15 while read -r old_sha new_sha ref; do
16-   # Skip delete refs
17-   if [ "$new_sha" = "0000000000000000000000000000000000000000" ]; then
18-       log "skip delete ref: $ref"
19-       continue
20-   fi
21+  # Skip delete refs
22+  if [ "$new_sha" = "0000000000000000000000000000000000000000" ]; then
23+    log "skip delete ref: $ref"
24+    continue
25+  fi
26 
27-   # Extract branch name from ref (e.g. refs/heads/main → main)
28-   branch="${ref#refs/heads/}"
29+  # Extract branch name from ref (e.g. refs/heads/main → main)
30+  branch="${ref#refs/heads/}"
31 
32-   # Repo name and short SHA from bare repo
33-   repo="$(basename "$(pwd)" .git)"
34-   short_sha="${new_sha:0:8}"
35+  # Repo name and short SHA from bare repo
36+  repo="$(basename "$(pwd)" .git)"
37+  pgs_project="${PGS_PROJECT_BASE}-${repo}"
38+  short_sha="${new_sha:0:8}"
39 
40-   log "repo=$repo branch=$branch commit=$new_sha"
41+  log "repo=$repo branch=$branch commit=$new_sha"
42 
43-   tar_path="/private-ci/workspaces/${repo}_${short_sha}.tar"
44-   log "upload: git archive $new_sha → $PGS_HOST:$tar_path"
45+  pgs_path="/${pgs_project}/${short_sha}/"
46+  tar_path="${pgs_path}workspace.tar"
47+  log "upload: git archive $new_sha → $PGS_HOST:$tar_path"
48 
49-   if ! git archive "$new_sha" | ssh -T "$PGS_HOST" "$tar_path"; then
50-       log "ERROR: upload failed" >&2
51-       continue
52-   fi
53+  if ! git archive "$new_sha" | ssh -T "$PGS_HOST" "$tar_path"; then
54+    log "ERROR: upload failed" >&2
55+    continue
56+  fi
57 
58-   # Workspace points to the tar on pgs.sh
59-   workspace="${PGS_HOST}:${tar_path}"
60+  # Workspace points to the tar on pgs.sh
61+  workspace="${PGS_HOST}:${tar_path}"
62 
63-   event=$(printf '{"type":"git.push","name":"%s","workspace":"%s","branch":"%s","commit":"%s"}' \
64-       "$repo" "$workspace" "$branch" "$new_sha")
65+  event=$(printf '{"type":"git.push","name":"%s","workspace":"%s","branch":"%s","commit":"%s","artifact_dest":"%s"}' \
66+    "$repo" "$workspace" "$branch" "$new_sha" "$pgs_path")
67 
68-   log "publish: $event"
69-   echo "$event" | ssh -T "$PIPE_HOST" pub -b=false build.event
70-   log "done"
71+  log "publish: $event"
72+  echo "$event" | ssh -T "$PIPE_HOST" pub -b=false build.event
73+  log "done"
74 done
M main.go
+135, -21
  1@@ -62,7 +62,6 @@ type Event struct {
  2 	Branch       string `json:"branch"`
  3 	Commit       string `json:"commit"`
  4 	ArtifactDest string `json:"artifact_dest"`
  5-	ArtifactURL  string `json:"artifact_url"` // public URL for artifacts (e.g. https://{user}-artifacts.pgs.sh/{repo})
  6 }
  7 
  8 func NewCfg() (*Cfg, string, bool) {
  9@@ -239,7 +238,7 @@ EVENT JSON FIELDS
 10   name          (required) Repository name, used for session naming
 11   workspace     (required) SSH source path to rsync, e.g. "git@github.com:user/repo.git"
 12   artifact_dest  (optional) SSH destination for artifact sync, e.g. "user@host:/path"
 13-  artifact_url   (optional) Public URL for artifacts, e.g. "https://artifacts.pgs.sh/repo"
 14+
 15 
 16 EXAMPLE
 17   echo '{"type":"push","name":"myrepo","workspace":"git@github.com:user/myrepo.git"}' | pici runner
 18@@ -477,7 +476,6 @@ type StatusPayload struct {
 19 	Duration     string        `json:"duration,omitempty"`
 20 	StartedAt    string        `json:"started_at,omitempty"`
 21 	EndedAt      string        `json:"ended_at,omitempty"`
 22-	ArtifactURL  string        `json:"artifact_url,omitempty"`
 23 	SessionCount int           `json:"session_count"`
 24 	Sessions     []SessionInfo `json:"sessions"`
 25 }
 26@@ -941,7 +939,7 @@ func renderJobRunning(output io.Writer, name, jobID string, group []SessionInfo,
 27 }
 28 
 29 // renderJobFinal prints the final status for a completed job.
 30-func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir, artifactURL string) {
 31+func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir string) {
 32 	icon := map[string]string{"success": "✅", "failed": "❌"}[status]
 33 	fmt.Fprintf(output, "   %-12s %s %s (%s)\n", name, icon, status, duration) //nolint:errcheck
 34 
 35@@ -962,10 +960,7 @@ func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, d
 36 	}
 37 	artifactPath := filepath.Join(artifactDir, name, jobID)
 38 	fmt.Fprintf(output, "   artifacts:  %s\n", artifactPath) //nolint:errcheck
 39-	if artifactURL != "" {
 40-		fmt.Fprintf(output, "   url:        %s\n", artifactURL) //nolint:errcheck
 41-	}
 42-	fmt.Fprint(output, "\n") //nolint:errcheck
 43+	fmt.Fprint(output, "\n")                                 //nolint:errcheck
 44 }
 45 
 46 func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[string]*monitorJobState) error {
 47@@ -1000,7 +995,22 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
 48 		// not just when the job completes. This gives live progress
 49 		// snapshots while the job is running.
 50 		for _, s := range group {
 51-			html, err := fetchHistoryHTML(s.Name)
 52+			// Determine session status and timing
 53+			sessionStatus := "running"
 54+			sessionDuration := fmtDuration(s.Created, fmt.Sprintf("%d", time.Now().Unix()))
 55+			sessionExitCode := ""
 56+			if s.Ended != "" {
 57+				sessionDuration = fmtDuration(s.Created, s.Ended)
 58+				if s.ExitCode == "0" {
 59+					sessionStatus = "success"
 60+					sessionExitCode = "0"
 61+				} else {
 62+					sessionStatus = "failed"
 63+					sessionExitCode = s.ExitCode
 64+				}
 65+			}
 66+
 67+			html, err := fetchHistoryHTML(s.Name, name, jobID, sessionStatus, sessionDuration, sessionExitCode)
 68 			if err != nil {
 69 				log.Error("fetch history html", "session", s.Name, "err", err)
 70 				continue
 71@@ -1028,15 +1038,9 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
 72 			log.Error("stage index.txt", "err", err)
 73 		}
 74 
 75-		// Load event to get artifact URL
 76+		// Load event to get artifact destination
 77 		eventData, _ := loadEvent(cfg.ArtifactDir, name, jobID)
 78 
 79-		// Build artifact URL: artifact_url/jobID/index.html
 80-		var artifactURL string
 81-		if eventData.ArtifactURL != "" {
 82-			artifactURL = fmt.Sprintf("%s/%s/index.html", strings.TrimRight(eventData.ArtifactURL, "/"), jobID)
 83-		}
 84-
 85 		// Compute job-level timing from session timestamps
 86 		startedAt, endedAt, duration := computeJobTiming(group)
 87 
 88@@ -1053,7 +1057,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
 89 			log.Info("job finished", "status", status, "exit_code", exitCode)
 90 
 91 			if cfg.HumanOutput {
 92-				renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir, artifactURL)
 93+				renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir)
 94 			} else {
 95 				payload := StatusPayload{
 96 					Timestamp:    time.Now().UTC().Format(time.RFC3339),
 97@@ -1064,7 +1068,6 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
 98 					Duration:     duration,
 99 					StartedAt:    startedAt,
100 					EndedAt:      endedAt,
101-					ArtifactURL:  artifactURL,
102 					SessionCount: len(group),
103 					Sessions:     group,
104 				}
105@@ -1091,7 +1094,6 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
106 						ExitCode:     nil,
107 						Duration:     duration,
108 						StartedAt:    startedAt,
109-						ArtifactURL:  artifactURL,
110 						SessionCount: len(group),
111 						Sessions:     group,
112 					}
113@@ -1312,13 +1314,125 @@ func parseZMXList(output string) []SessionInfo {
114 	return sessions
115 }
116 
117-func fetchHistoryHTML(sessionName string) (string, error) {
118+// sessionHTMLTemplate wraps session history fragments in a full HTML document.
119+const sessionHTMLTemplate = `<!DOCTYPE html>
120+<html lang="en">
121+<head>
122+<meta charset="UTF-8">
123+<meta name="viewport" content="width=device-width, initial-scale=1.0">
124+<title>{{.SessionName}} — CI Session</title>
125+<style>
126+  * { box-sizing: border-box; }
127+  body {
128+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
129+    margin: 0; padding: 1.5rem;
130+    background: #1e1e2e;
131+    color: #cdd6f4;
132+    font-size: 14px;
133+    line-height: 1.5;
134+  }
135+  h1 {
136+    font-size: 1rem;
137+    font-weight: 600;
138+    margin: 0 0 1rem 0;
139+    color: #89b4fa;
140+    display: flex;
141+    align-items: center;
142+    gap: 0.75rem;
143+    flex-wrap: wrap;
144+  }
145+  .meta {
146+    color: #6c7086;
147+    font-size: 0.8rem;
148+    margin-bottom: 1rem;
149+    display: flex;
150+    gap: 1.5rem;
151+    flex-wrap: wrap;
152+  }
153+  .meta span { display: flex; align-items: center; gap: 0.3rem; }
154+  .status { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
155+  .status-running { background: #89b4fa33; color: #89b4fa; }
156+  .status-success { background: #a6e3a133; color: #a6e3a1; }
157+  .status-failed { background: #f38ba833; color: #f38ba8; }
158+  pre {
159+    background: #11111b;
160+    border-radius: 8px;
161+    padding: 1rem;
162+    overflow-x: auto;
163+    margin: 0;
164+    border: 1px solid #313244;
165+  }
166+  pre code { font-size: 13px; }
167+  .zmx-output { white-space: pre-wrap; word-break: break-word; }
168+  a { color: #89b4fa; }
169+  a:hover { text-decoration: underline; }
170+  .links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; }
171+</style>
172+</head>
173+<body>
174+<h1>
175+  {{.SessionShort}}
176+  <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span>
177+</h1>
178+<div class="meta">
179+  <span>Job: {{.JobName}}</span>
180+  <span>ID: {{.JobID}}</span>
181+  <span>Duration: {{.Duration}}</span>
182+  {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
183+</div>
184+<pre><code>{{.Content}}</code></pre>
185+<div class="links">
186+  <a href="index.html">&larr; Back to job</a>
187+</div>
188+</body>
189+</html>`
190+
191+// SessionArtifactData holds the data for rendering a session HTML artifact.
192+type SessionArtifactData struct {
193+	SessionName   string
194+	SessionShort  string
195+	SessionStatus string
196+	JobName       string
197+	JobID         string
198+	Duration      string
199+	ExitCode      string
200+	Content       string
201+}
202+
203+// fetchHistoryHTML fetches session history from zmx and wraps it in a full HTML document.
204+func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode string) (string, error) {
205 	cmd := exec.Command("zmx", "history", sessionName, "--html")
206 	output, err := cmd.Output()
207 	if err != nil {
208 		return "", err
209 	}
210-	return string(output), nil
211+
212+	prefix := "ci." + jobName + "." + jobID + "."
213+	shortName := strings.TrimPrefix(sessionName, prefix)
214+	shortName = strings.TrimPrefix(shortName, "step.")
215+
216+	data := SessionArtifactData{
217+		SessionName:   sessionName,
218+		SessionShort:  shortName,
219+		SessionStatus: status,
220+		JobName:       jobName,
221+		JobID:         jobID,
222+		Duration:      duration,
223+		ExitCode:      exitCode,
224+		Content:       string(output),
225+	}
226+
227+	tmpl, err := template.New("session").Parse(sessionHTMLTemplate)
228+	if err != nil {
229+		return "", fmt.Errorf("parse template: %w", err)
230+	}
231+
232+	var buf strings.Builder
233+	if err := tmpl.Execute(&buf, data); err != nil {
234+		return "", fmt.Errorf("execute template: %w", err)
235+	}
236+
237+	return buf.String(), nil
238 }
239 
240 func fetchHistoryPlain(sessionName string) (string, error) {