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
| ... | ... | @@ -8,44 +8,47 @@ | |
| 8 | 8 | ||
| 9 | 9 | set -euo pipefail | |
| 10 | 10 | ||
| 11 | - | PIPE_HOST="${PIPE_HOST:-pipe}" | |
| 11 | + | PIPE_HOST="${PIPE_HOST:-pipe.pico.sh}" | |
| 12 | 12 | PGS_HOST="${PGS_HOST:-pgs.sh}" | |
| 13 | + | PGS_PROJECT_BASE="${PGS_PROJECT_BASE:-public-ci}" | |
| 13 | 14 | ||
| 14 | 15 | log() { | |
| 15 | - | echo "[post-receive] $*" >&2 | |
| 16 | + | echo "[post-receive] $*" >&2 | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | while read -r old_sha new_sha ref; do | |
| 19 | - | # Skip delete refs | |
| 20 | - | if [ "$new_sha" = "0000000000000000000000000000000000000000" ]; then | |
| 21 | - | log "skip delete ref: $ref" | |
| 22 | - | continue | |
| 23 | - | fi | |
| 20 | + | # Skip delete refs | |
| 21 | + | if [ "$new_sha" = "0000000000000000000000000000000000000000" ]; then | |
| 22 | + | log "skip delete ref: $ref" | |
| 23 | + | continue | |
| 24 | + | fi | |
| 24 | 25 | ||
| 25 | - | # Extract branch name from ref (e.g. refs/heads/main → main) | |
| 26 | - | branch="${ref#refs/heads/}" | |
| 26 | + | # Extract branch name from ref (e.g. refs/heads/main → main) | |
| 27 | + | branch="${ref#refs/heads/}" | |
| 27 | 28 | ||
| 28 | - | # Repo name and short SHA from bare repo | |
| 29 | - | repo="$(basename "$(pwd)" .git)" | |
| 30 | - | short_sha="${new_sha:0:8}" | |
| 29 | + | # Repo name and short SHA from bare repo | |
| 30 | + | repo="$(basename "$(pwd)" .git)" | |
| 31 | + | pgs_project="${PGS_PROJECT_BASE}-${repo}" | |
| 32 | + | short_sha="${new_sha:0:8}" | |
| 31 | 33 | ||
| 32 | - | log "repo=$repo branch=$branch commit=$new_sha" | |
| 34 | + | log "repo=$repo branch=$branch commit=$new_sha" | |
| 33 | 35 | ||
| 34 | - | tar_path="/private-ci/workspaces/${repo}_${short_sha}.tar" | |
| 35 | - | log "upload: git archive $new_sha → $PGS_HOST:$tar_path" | |
| 36 | + | pgs_path="/${pgs_project}/${short_sha}/" | |
| 37 | + | tar_path="${pgs_path}workspace.tar" | |
| 38 | + | log "upload: git archive $new_sha → $PGS_HOST:$tar_path" | |
| 36 | 39 | ||
| 37 | - | if ! git archive "$new_sha" | ssh -T "$PGS_HOST" "$tar_path"; then | |
| 38 | - | log "ERROR: upload failed" >&2 | |
| 39 | - | continue | |
| 40 | - | fi | |
| 40 | + | if ! git archive "$new_sha" | ssh -T "$PGS_HOST" "$tar_path"; then | |
| 41 | + | log "ERROR: upload failed" >&2 | |
| 42 | + | continue | |
| 43 | + | fi | |
| 41 | 44 | ||
| 42 | - | # Workspace points to the tar on pgs.sh | |
| 43 | - | workspace="${PGS_HOST}:${tar_path}" | |
| 45 | + | # Workspace points to the tar on pgs.sh | |
| 46 | + | workspace="${PGS_HOST}:${tar_path}" | |
| 44 | 47 | ||
| 45 | - | event=$(printf '{"type":"git.push","name":"%s","workspace":"%s","branch":"%s","commit":"%s"}' \ | |
| 46 | - | "$repo" "$workspace" "$branch" "$new_sha") | |
| 48 | + | event=$(printf '{"type":"git.push","name":"%s","workspace":"%s","branch":"%s","commit":"%s","artifact_dest":"%s"}' \ | |
| 49 | + | "$repo" "$workspace" "$branch" "$new_sha" "$pgs_path") | |
| 47 | 50 | ||
| 48 | - | log "publish: $event" | |
| 49 | - | echo "$event" | ssh -T "$PIPE_HOST" pub -b=false build.event | |
| 50 | - | log "done" | |
| 51 | + | log "publish: $event" | |
| 52 | + | echo "$event" | ssh -T "$PIPE_HOST" pub -b=false build.event | |
| 53 | + | log "done" | |
| 51 | 54 | done |
M
main.go
+135,
-21
| ... | ... | @@ -62,7 +62,6 @@ type Event struct { | |
| 62 | 62 | Branch string `json:"branch"` | |
| 63 | 63 | Commit string `json:"commit"` | |
| 64 | 64 | ArtifactDest string `json:"artifact_dest"` | |
| 65 | - | ArtifactURL string `json:"artifact_url"` // public URL for artifacts (e.g. https://{user}-artifacts.pgs.sh/{repo}) | |
| 66 | 65 | } | |
| 67 | 66 | ||
| 68 | 67 | func NewCfg() (*Cfg, string, bool) { |
| ... | ... | @@ -239,7 +238,7 @@ EVENT JSON FIELDS | |
| 239 | 238 | name (required) Repository name, used for session naming | |
| 240 | 239 | workspace (required) SSH source path to rsync, e.g. "git@github.com:user/repo.git" | |
| 241 | 240 | artifact_dest (optional) SSH destination for artifact sync, e.g. "user@host:/path" | |
| 242 | - | artifact_url (optional) Public URL for artifacts, e.g. "https://artifacts.pgs.sh/repo" | |
| 241 | + | ||
| 243 | 242 | ||
| 244 | 243 | EXAMPLE | |
| 245 | 244 | echo '{"type":"push","name":"myrepo","workspace":"git@github.com:user/myrepo.git"}' | pici runner |
| ... | ... | @@ -477,7 +476,6 @@ type StatusPayload struct { | |
| 477 | 476 | Duration string `json:"duration,omitempty"` | |
| 478 | 477 | StartedAt string `json:"started_at,omitempty"` | |
| 479 | 478 | EndedAt string `json:"ended_at,omitempty"` | |
| 480 | - | ArtifactURL string `json:"artifact_url,omitempty"` | |
| 481 | 479 | SessionCount int `json:"session_count"` | |
| 482 | 480 | Sessions []SessionInfo `json:"sessions"` | |
| 483 | 481 | } |
| ... | ... | @@ -941,7 +939,7 @@ func renderJobRunning(output io.Writer, name, jobID string, group []SessionInfo, | |
| 941 | 939 | } | |
| 942 | 940 | ||
| 943 | 941 | // renderJobFinal prints the final status for a completed job. | |
| 944 | - | func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir, artifactURL string) { | |
| 942 | + | func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir string) { | |
| 945 | 943 | icon := map[string]string{"success": "✅", "failed": "❌"}[status] | |
| 946 | 944 | fmt.Fprintf(output, " %-12s %s %s (%s)\n", name, icon, status, duration) //nolint:errcheck | |
| 947 | 945 |
| ... | ... | @@ -962,10 +960,7 @@ func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, d | |
| 962 | 960 | } | |
| 963 | 961 | artifactPath := filepath.Join(artifactDir, name, jobID) | |
| 964 | 962 | fmt.Fprintf(output, " artifacts: %s\n", artifactPath) //nolint:errcheck | |
| 965 | - | if artifactURL != "" { | |
| 966 | - | fmt.Fprintf(output, " url: %s\n", artifactURL) //nolint:errcheck | |
| 967 | - | } | |
| 968 | - | fmt.Fprint(output, "\n") //nolint:errcheck | |
| 963 | + | fmt.Fprint(output, "\n") //nolint:errcheck | |
| 969 | 964 | } | |
| 970 | 965 | ||
| 971 | 966 | func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[string]*monitorJobState) error { |
| ... | ... | @@ -1000,7 +995,22 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1000 | 995 | // not just when the job completes. This gives live progress | |
| 1001 | 996 | // snapshots while the job is running. | |
| 1002 | 997 | for _, s := range group { | |
| 1003 | - | html, err := fetchHistoryHTML(s.Name) | |
| 998 | + | // Determine session status and timing | |
| 999 | + | sessionStatus := "running" | |
| 1000 | + | sessionDuration := fmtDuration(s.Created, fmt.Sprintf("%d", time.Now().Unix())) | |
| 1001 | + | sessionExitCode := "" | |
| 1002 | + | if s.Ended != "" { | |
| 1003 | + | sessionDuration = fmtDuration(s.Created, s.Ended) | |
| 1004 | + | if s.ExitCode == "0" { | |
| 1005 | + | sessionStatus = "success" | |
| 1006 | + | sessionExitCode = "0" | |
| 1007 | + | } else { | |
| 1008 | + | sessionStatus = "failed" | |
| 1009 | + | sessionExitCode = s.ExitCode | |
| 1010 | + | } | |
| 1011 | + | } | |
| 1012 | + | ||
| 1013 | + | html, err := fetchHistoryHTML(s.Name, name, jobID, sessionStatus, sessionDuration, sessionExitCode) | |
| 1004 | 1014 | if err != nil { | |
| 1005 | 1015 | log.Error("fetch history html", "session", s.Name, "err", err) | |
| 1006 | 1016 | continue |
| ... | ... | @@ -1028,15 +1038,9 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1028 | 1038 | log.Error("stage index.txt", "err", err) | |
| 1029 | 1039 | } | |
| 1030 | 1040 | ||
| 1031 | - | // Load event to get artifact URL | |
| 1041 | + | // Load event to get artifact destination | |
| 1032 | 1042 | eventData, _ := loadEvent(cfg.ArtifactDir, name, jobID) | |
| 1033 | 1043 | ||
| 1034 | - | // Build artifact URL: artifact_url/jobID/index.html | |
| 1035 | - | var artifactURL string | |
| 1036 | - | if eventData.ArtifactURL != "" { | |
| 1037 | - | artifactURL = fmt.Sprintf("%s/%s/index.html", strings.TrimRight(eventData.ArtifactURL, "/"), jobID) | |
| 1038 | - | } | |
| 1039 | - | ||
| 1040 | 1044 | // Compute job-level timing from session timestamps | |
| 1041 | 1045 | startedAt, endedAt, duration := computeJobTiming(group) | |
| 1042 | 1046 |
| ... | ... | @@ -1053,7 +1057,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1053 | 1057 | log.Info("job finished", "status", status, "exit_code", exitCode) | |
| 1054 | 1058 | ||
| 1055 | 1059 | if cfg.HumanOutput { | |
| 1056 | - | renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir, artifactURL) | |
| 1060 | + | renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir) | |
| 1057 | 1061 | } else { | |
| 1058 | 1062 | payload := StatusPayload{ | |
| 1059 | 1063 | Timestamp: time.Now().UTC().Format(time.RFC3339), |
| ... | ... | @@ -1064,7 +1068,6 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1064 | 1068 | Duration: duration, | |
| 1065 | 1069 | StartedAt: startedAt, | |
| 1066 | 1070 | EndedAt: endedAt, | |
| 1067 | - | ArtifactURL: artifactURL, | |
| 1068 | 1071 | SessionCount: len(group), | |
| 1069 | 1072 | Sessions: group, | |
| 1070 | 1073 | } |
| ... | ... | @@ -1091,7 +1094,6 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1091 | 1094 | ExitCode: nil, | |
| 1092 | 1095 | Duration: duration, | |
| 1093 | 1096 | StartedAt: startedAt, | |
| 1094 | - | ArtifactURL: artifactURL, | |
| 1095 | 1097 | SessionCount: len(group), | |
| 1096 | 1098 | Sessions: group, | |
| 1097 | 1099 | } |
| ... | ... | @@ -1312,13 +1314,125 @@ func parseZMXList(output string) []SessionInfo { | |
| 1312 | 1314 | return sessions | |
| 1313 | 1315 | } | |
| 1314 | 1316 | ||
| 1315 | - | func fetchHistoryHTML(sessionName string) (string, error) { | |
| 1317 | + | // sessionHTMLTemplate wraps session history fragments in a full HTML document. | |
| 1318 | + | const sessionHTMLTemplate = `<!DOCTYPE html> | |
| 1319 | + | <html lang="en"> | |
| 1320 | + | <head> | |
| 1321 | + | <meta charset="UTF-8"> | |
| 1322 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 1323 | + | <title>{{.SessionName}} — CI Session</title> | |
| 1324 | + | <style> | |
| 1325 | + | * { box-sizing: border-box; } | |
| 1326 | + | body { | |
| 1327 | + | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| 1328 | + | margin: 0; padding: 1.5rem; | |
| 1329 | + | background: #1e1e2e; | |
| 1330 | + | color: #cdd6f4; | |
| 1331 | + | font-size: 14px; | |
| 1332 | + | line-height: 1.5; | |
| 1333 | + | } | |
| 1334 | + | h1 { | |
| 1335 | + | font-size: 1rem; | |
| 1336 | + | font-weight: 600; | |
| 1337 | + | margin: 0 0 1rem 0; | |
| 1338 | + | color: #89b4fa; | |
| 1339 | + | display: flex; | |
| 1340 | + | align-items: center; | |
| 1341 | + | gap: 0.75rem; | |
| 1342 | + | flex-wrap: wrap; | |
| 1343 | + | } | |
| 1344 | + | .meta { | |
| 1345 | + | color: #6c7086; | |
| 1346 | + | font-size: 0.8rem; | |
| 1347 | + | margin-bottom: 1rem; | |
| 1348 | + | display: flex; | |
| 1349 | + | gap: 1.5rem; | |
| 1350 | + | flex-wrap: wrap; | |
| 1351 | + | } | |
| 1352 | + | .meta span { display: flex; align-items: center; gap: 0.3rem; } | |
| 1353 | + | .status { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; } | |
| 1354 | + | .status-running { background: #89b4fa33; color: #89b4fa; } | |
| 1355 | + | .status-success { background: #a6e3a133; color: #a6e3a1; } | |
| 1356 | + | .status-failed { background: #f38ba833; color: #f38ba8; } | |
| 1357 | + | pre { | |
| 1358 | + | background: #11111b; | |
| 1359 | + | border-radius: 8px; | |
| 1360 | + | padding: 1rem; | |
| 1361 | + | overflow-x: auto; | |
| 1362 | + | margin: 0; | |
| 1363 | + | border: 1px solid #313244; | |
| 1364 | + | } | |
| 1365 | + | pre code { font-size: 13px; } | |
| 1366 | + | .zmx-output { white-space: pre-wrap; word-break: break-word; } | |
| 1367 | + | a { color: #89b4fa; } | |
| 1368 | + | a:hover { text-decoration: underline; } | |
| 1369 | + | .links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; } | |
| 1370 | + | </style> | |
| 1371 | + | </head> | |
| 1372 | + | <body> | |
| 1373 | + | <h1> | |
| 1374 | + | {{.SessionShort}} | |
| 1375 | + | <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span> | |
| 1376 | + | </h1> | |
| 1377 | + | <div class="meta"> | |
| 1378 | + | <span>Job: {{.JobName}}</span> | |
| 1379 | + | <span>ID: {{.JobID}}</span> | |
| 1380 | + | <span>Duration: {{.Duration}}</span> | |
| 1381 | + | {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}} | |
| 1382 | + | </div> | |
| 1383 | + | <pre><code>{{.Content}}</code></pre> | |
| 1384 | + | <div class="links"> | |
| 1385 | + | <a href="index.html">← Back to job</a> | |
| 1386 | + | </div> | |
| 1387 | + | </body> | |
| 1388 | + | </html>` | |
| 1389 | + | ||
| 1390 | + | // SessionArtifactData holds the data for rendering a session HTML artifact. | |
| 1391 | + | type SessionArtifactData struct { | |
| 1392 | + | SessionName string | |
| 1393 | + | SessionShort string | |
| 1394 | + | SessionStatus string | |
| 1395 | + | JobName string | |
| 1396 | + | JobID string | |
| 1397 | + | Duration string | |
| 1398 | + | ExitCode string | |
| 1399 | + | Content string | |
| 1400 | + | } | |
| 1401 | + | ||
| 1402 | + | // fetchHistoryHTML fetches session history from zmx and wraps it in a full HTML document. | |
| 1403 | + | func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode string) (string, error) { | |
| 1316 | 1404 | cmd := exec.Command("zmx", "history", sessionName, "--html") | |
| 1317 | 1405 | output, err := cmd.Output() | |
| 1318 | 1406 | if err != nil { | |
| 1319 | 1407 | return "", err | |
| 1320 | 1408 | } | |
| 1321 | - | return string(output), nil | |
| 1409 | + | ||
| 1410 | + | prefix := "ci." + jobName + "." + jobID + "." | |
| 1411 | + | shortName := strings.TrimPrefix(sessionName, prefix) | |
| 1412 | + | shortName = strings.TrimPrefix(shortName, "step.") | |
| 1413 | + | ||
| 1414 | + | data := SessionArtifactData{ | |
| 1415 | + | SessionName: sessionName, | |
| 1416 | + | SessionShort: shortName, | |
| 1417 | + | SessionStatus: status, | |
| 1418 | + | JobName: jobName, | |
| 1419 | + | JobID: jobID, | |
| 1420 | + | Duration: duration, | |
| 1421 | + | ExitCode: exitCode, | |
| 1422 | + | Content: string(output), | |
| 1423 | + | } | |
| 1424 | + | ||
| 1425 | + | tmpl, err := template.New("session").Parse(sessionHTMLTemplate) | |
| 1426 | + | if err != nil { | |
| 1427 | + | return "", fmt.Errorf("parse template: %w", err) | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | var buf strings.Builder | |
| 1431 | + | if err := tmpl.Execute(&buf, data); err != nil { | |
| 1432 | + | return "", fmt.Errorf("execute template: %w", err) | |
| 1433 | + | } | |
| 1434 | + | ||
| 1435 | + | return buf.String(), nil | |
| 1322 | 1436 | } | |
| 1323 | 1437 | ||
| 1324 | 1438 | func fetchHistoryPlain(sessionName string) (string, error) { |