Commit bc114f5
Eric Bower
·
2026-05-08 09:05:04 -0400 EDT
parent a29c053
refactor: post-recieve hook dest
2 files changed,
+164,
-47
+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">← 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) {