Commit 0842022

Eric Bower  ·  2026-06-13 09:57:41 -0400 EDT
parent 21a5703
feat: producer can set job_id

This allows users to set a deterministic job id like git commit sha
3 files changed,  +94, -7
+5, -4
 1@@ -63,13 +63,14 @@ while read -r old_sha new_sha ref; do
 2 
 3   workspace="${upload_host}:${tar_path}"
 4   artifact_dest="${upload_host}:${upload_dir}"
 5+  job_id="$short_sha"
 6 
 7   if [ "$event_type" = "git.tag" ]; then
 8-    event=$(printf '{"type":"%s","name":"%s","tag":"%s","commit":"%s","workspace":"%s","artifact_dest":"%s"}' \
 9-      "$event_type" "$repo" "$ref_name" "$new_sha" "$workspace" "$artifact_dest")
10+    event=$(printf '{"type":"%s","name":"%s","job_id":"%s","tag":"%s","commit":"%s","workspace":"%s","artifact_dest":"%s"}' \
11+      "$event_type" "$repo" "$job_id" "$ref_name" "$new_sha" "$workspace" "$artifact_dest")
12   else
13-    event=$(printf '{"type":"%s","name":"%s","branch":"%s","commit":"%s","workspace":"%s","artifact_dest":"%s"}' \
14-      "$event_type" "$repo" "$ref_name" "$new_sha" "$workspace" "$artifact_dest")
15+    event=$(printf '{"type":"%s","name":"%s","job_id":"%s","branch":"%s","commit":"%s","workspace":"%s","artifact_dest":"%s"}' \
16+      "$event_type" "$repo" "$job_id" "$ref_name" "$new_sha" "$workspace" "$artifact_dest")
17   fi
18 
19   log "publish: $event"
+37, -3
 1@@ -65,6 +65,7 @@ type Cfg struct {
 2 type Event struct {
 3 	Type         string `json:"type"`
 4 	Name         string `json:"name"`
 5+	JobID        string `json:"job_id,omitempty"` // optional producer-set ID
 6 	Workspace    string `json:"workspace"`
 7 	Branch       string `json:"branch"`
 8 	Tag          string `json:"tag"`
 9@@ -580,11 +581,21 @@ func (eng *JobEngine) FindManifest() (string, error) {
10 func eventHandler(cfg *Cfg, eventData *Event) error {
11 	log := cfg.Logger.With("repo", eventData.Name, "type", eventData.Type)
12 
13+	jobID := resolveJobID(eventData.Name, eventData.Workspace, eventData.JobID)
14+	log = log.With("job_id", jobID)
15+
16 	// Cancel any existing job for this repo before starting a new one
17 	cancelRunningJobs(cfg, log, eventData.Name)
18 
19-	jobID := generateJobID(eventData.Name, eventData.Workspace)
20-	log = log.With("job_id", jobID)
21+	// Clean up any artifacts from a previous run with the same job ID
22+	// (e.g. duplicate event, re-trigger of the same commit).
23+	eventDir := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID)
24+	if _, err := os.Stat(eventDir); err == nil {
25+		if err := os.RemoveAll(eventDir); err != nil {
26+			log.Warn("failed to clean old job directory", "err", err, "dir", eventDir)
27+		}
28+	}
29+
30 	eventBytes, _ := json.Marshal(eventData)
31 	fmt.Fprintf(os.Stdout, "🚀 starting job ci.%s.%s\n", eventData.Name, jobID)                                              //nolint:errcheck
32 	fmt.Fprintf(os.Stdout, "   event: type=%s name=%s workspace=%s\n", eventData.Type, eventData.Name, eventData.Workspace) //nolint:errcheck
33@@ -611,7 +622,6 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
34 	fmt.Fprintf(os.Stdout, "✅ workspace ready %s\n", eng.Wk.GetDir()) //nolint:errcheck
35 
36 	// Store the event in the artifact directory so the monitor can access it
37-	eventDir := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID)
38 	artifactsDir := filepath.Join(eventDir, "artifacts")
39 	if err := os.MkdirAll(artifactsDir, 0755); err != nil {
40 		log.Error("create artifacts dir", "err", err)
41@@ -1358,6 +1368,30 @@ func generateJobID(name, workspace string) string {
42 	return jobIDFor(name, workspace, time.Now().UnixNano())
43 }
44 
45+// resolveJobID returns the producer-provided job ID if set and valid,
46+// otherwise generates one from name + workspace + timestamp.
47+func resolveJobID(name, workspace, providedID string) string {
48+	if providedID != "" && validateJobID(providedID) {
49+		return providedID
50+	}
51+	return generateJobID(name, workspace)
52+}
53+
54+// validateJobID checks that the ID is safe to use in session names and paths.
55+// Constraints: alphanumeric + hyphens only, no dots (breaks parseJobPrefix),
56+// 1-16 chars to keep session names reasonable.
57+func validateJobID(id string) bool {
58+	if len(id) == 0 || len(id) > 16 {
59+		return false
60+	}
61+	for _, r := range id {
62+		if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-') {
63+			return false
64+		}
65+	}
66+	return true
67+}
68+
69 func runtimeOS() string {
70 	return runtime.GOOS
71 }
+52, -0
 1@@ -416,6 +416,58 @@ func TestGenerateJobID(t *testing.T) {
 2 	}
 3 }
 4 
 5+func TestValidateJobID(t *testing.T) {
 6+	tests := []struct {
 7+		id   string
 8+		want bool
 9+	}{
10+		{"a3f2b8c1", true},           // 8-char hex (commit short SHA)
11+		{"abc-123", true},            // alphanumeric + hyphen
12+		{"PR42", true},               // uppercase + digits
13+		{"a", true},                  // single char
14+		{"abcdefghij123456", true},   // 16 chars (max)
15+		{"", false},                  // empty
16+		{"abcdefghij1234567", false}, // 17 chars (too long)
17+		{"abc.def", false},           // contains dot (breaks parseJobPrefix)
18+		{"abc/def", false},           // contains slash
19+		{"abc def", false},           // contains space
20+		{"abc_def", false},           // contains underscore
21+	}
22+
23+	for _, tt := range tests {
24+		got := validateJobID(tt.id)
25+		if got != tt.want {
26+			t.Errorf("validateJobID(%q) = %v, want %v", tt.id, got, tt.want)
27+		}
28+	}
29+}
30+
31+func TestResolveJobID(t *testing.T) {
32+	// Valid producer ID is used as-is
33+	id := resolveJobID("myrepo", "/workspace", "a3f2b8c1")
34+	if id != "a3f2b8c1" {
35+		t.Errorf("resolveJobID with valid ID = %q, want %q", id, "a3f2b8c1")
36+	}
37+
38+	// Empty producer ID falls back to generated
39+	id = resolveJobID("myrepo", "/workspace", "")
40+	if id == "" || len(id) != 8 {
41+		t.Errorf("resolveJobID with empty ID = %q, want 8-char generated ID", id)
42+	}
43+
44+	// Invalid producer ID (contains dot) falls back to generated
45+	id = resolveJobID("myrepo", "/workspace", "1.0.0")
46+	if id == "1.0.0" || len(id) != 8 {
47+		t.Errorf("resolveJobID with invalid ID = %q, want 8-char generated ID", id)
48+	}
49+
50+	// Invalid producer ID (too long) falls back to generated
51+	id = resolveJobID("myrepo", "/workspace", "this_is_way_too_long_for_a_job_id")
52+	if len(id) != 8 {
53+		t.Errorf("resolveJobID with too-long ID = %q, want 8-char generated ID", id)
54+	}
55+}
56+
57 func TestAllCompleted(t *testing.T) {
58 	tests := []struct {
59 		name     string