Skip to content

Commit e6f5281

Browse files
authored
Merge pull request #617 from puerco/watch-jobs
Implement job-level observers
2 parents 37cd7c6 + 030609e commit e6f5281

8 files changed

Lines changed: 307 additions & 34 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
module sigs.k8s.io/tejolote
22

3-
go 1.26.1
3+
go 1.26.2
44

55
require (
66
cloud.google.com/go/pubsub/v2 v2.5.1
77
cloud.google.com/go/storage v1.62.0
88
github.com/carabiner-dev/vcslocator v0.4.2
99
github.com/go-git/go-git/v5 v5.17.2
1010
github.com/google/go-containerregistry v0.21.5
11+
github.com/google/go-github/v84 v84.0.0
1112
github.com/in-toto/attestation v1.2.0
1213
github.com/magefile/mage v1.17.1
1314
github.com/protobom/protobom v0.5.4
@@ -158,7 +159,6 @@ require (
158159
github.com/google/gnostic-models v0.7.0 // indirect
159160
github.com/google/go-cmp v0.7.0 // indirect
160161
github.com/google/go-github/v73 v73.0.0 // indirect
161-
github.com/google/go-github/v84 v84.0.0 // indirect
162162
github.com/google/go-querystring v1.2.0 // indirect
163163
github.com/google/s2a-go v0.1.9 // indirect
164164
github.com/google/uuid v1.6.0 // indirect

internal/cmd/attest.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"os"
2424
"slices"
25+
"strings"
2526

2627
"github.com/sirupsen/logrus"
2728
"github.com/spf13/cobra"
@@ -39,8 +40,16 @@ type attestOptions struct {
3940
encodedSnapshots string
4041
slsaVersion string
4142
artifacts []string
43+
watchJobs []string
4244
}
4345

46+
const (
47+
githubEnvVarJob = "GITHUB_JOB"
48+
githubEnvVarActions = "GITHUB_ACTIONS"
49+
githubEnvVarRepo = "GITHUB_REPOSITORY"
50+
githubEnvVarRunID = "GITHUB_RUN_ID"
51+
)
52+
4453
var slsaVersions = []string{"1", "1.0", "0.2"}
4554

4655
func (o *attestOptions) Verify() error {
@@ -136,6 +145,22 @@ build data and generates the provenance attestation.
136145

137146
w.Options.WaitForBuild = attestOpts.waitForBuild
138147
w.Options.SLSAVersion = attestOpts.slsaVersion
148+
w.Options.WatchJobs = attestOpts.watchJobs
149+
150+
// Auto detect if tejolote is running inside a GitHub Actions
151+
// workflow and the spec URL points to the same run. The we automatically
152+
// enable job-level watching to avoid deadlock where the run never
153+
// completes because the attester job is part of it.
154+
if isSameActionsRun(args[0]) {
155+
// Get our own job name:
156+
currentJob := os.Getenv(githubEnvVarJob)
157+
if len(w.Options.WatchJobs) == 0 {
158+
logrus.Infof("Same-run detected (job: %q), will watch all other jobs", currentJob)
159+
} else {
160+
logrus.Infof("Same-run detected (job: %q), watching specified jobs: %v", currentJob, w.Options.WatchJobs)
161+
}
162+
w.Options.ExcludeJob = currentJob
163+
}
139164

140165
if !attestOpts.waitForBuild {
141166
logrus.Warn("watcher will not wait for build, data may be incomplete")
@@ -154,7 +179,7 @@ build data and generates the provenance attestation.
154179
return fmt.Errorf("fetching run: %w", err)
155180
}
156181

157-
// Watch the run run :)
182+
// Watch the run, run :)
158183
if err := w.Watch(r); err != nil {
159184
return fmt.Errorf("waiting for the run to finish: %w", err)
160185
}
@@ -304,5 +329,45 @@ build data and generates the provenance attestation.
304329
_ = attestCmd.PersistentFlags().MarkHidden("encoded-attestation")
305330
_ = attestCmd.PersistentFlags().MarkHidden("encoded-snapshots")
306331

332+
attestCmd.PersistentFlags().StringSliceVar(
333+
&attestOpts.watchJobs,
334+
"watch-jobs",
335+
[]string{},
336+
"watch specific jobs (by name) instead of the entire run",
337+
)
338+
307339
parentCmd.AddCommand(attestCmd)
308340
}
341+
342+
// isSameActionsRun checks if tejolote is running inside the same GitHub Actions
343+
// workflow run that it is being asked to observe.
344+
//
345+
// It compares the spec URL against the GITHUB_REPOSITORY and GITHUB_RUN_ID
346+
// environment variables set by the runner.
347+
func isSameActionsRun(specURL string) bool {
348+
if os.Getenv(githubEnvVarActions) != "true" {
349+
return false
350+
}
351+
352+
ghRepo := os.Getenv(githubEnvVarRepo)
353+
ghRunID := os.Getenv(githubEnvVarRunID)
354+
if ghRepo == "" || ghRunID == "" {
355+
return false
356+
}
357+
358+
// Parse org/repo and run ID from the spec URL (e.g. github://org/repo/12345)
359+
parts := strings.SplitN(specURL, "://", 2)
360+
if len(parts) != 2 || parts[0] != "github" {
361+
return false
362+
}
363+
364+
pathParts := strings.SplitN(parts[1], "/", 3)
365+
if len(pathParts) != 3 {
366+
return false
367+
}
368+
369+
specRepo := pathParts[0] + "/" + pathParts[1]
370+
specRunID := strings.TrimSuffix(pathParts[2], "/")
371+
372+
return specRepo == ghRepo && specRunID == ghRunID
373+
}

pkg/builder/builder.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ func (b *Builder) Snap() error {
5454
return nil
5555
}
5656

57+
// Driver returns the underlying build system driver. This allows callers
58+
// to type-assert for optional interfaces like driver.JobWatcher.
59+
func (b *Builder) Driver() driver.BuildSystem {
60+
return b.driver
61+
}
62+
5763
func (b *Builder) GetRun(identifier string) (*run.Run, error) {
5864
return b.driver.GetRun(identifier)
5965
}

pkg/builder/driver/driver.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ type BuildSystem interface {
3838
ArtifactStores() []store.Store
3939
}
4040

41+
// JobWatcher is an optional interface that ba uild system can implement
42+
// to support watching individual jobs rather than the entire run.
43+
type JobWatcher interface {
44+
// AreJobsCompleted checks if the specified jobs are done. If jobNames
45+
// is empty, it checks all jobs except excludeJob.
46+
AreJobsCompleted(jobNames []string, excludeJob string) (completed bool, err error)
47+
}
48+
4149
func NewFromSpecURL(specURL string) (BuildSystem, error) {
4250
u, err := url.Parse(specURL)
4351
if err != nil {

pkg/builder/driver/github.go

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"strings"
2828
"time"
2929

30+
gogithub "github.com/google/go-github/v84/github"
3031
intoto "github.com/in-toto/attestation/go/v1"
3132
"github.com/sirupsen/logrus"
3233
"sigs.k8s.io/tejolote/pkg/attestation"
@@ -41,6 +42,10 @@ type GitHubWorkflow struct {
4142
Organization string
4243
Repository string
4344
RunID int
45+
46+
// workflow caches the parsed workflow YAML data to avoid
47+
// repeated fetches when building predicates or discovering jobs.
48+
workflow *github.WorkflowData
4449
}
4550

4651
func parseGitHubURL(specURL string) (org, repo string, runID int64, err error) {
@@ -139,6 +144,37 @@ func (ghw *GitHubWorkflow) RefreshRun(r *run.Run) error {
139144
return nil
140145
}
141146

147+
// GetWorkflow returns the parsed workflow YAML data, fetching and caching
148+
// it on first call. Requires that RefreshRun has been called at least once
149+
// so that Organization, Repository and the run's SystemData are populated.
150+
func (ghw *GitHubWorkflow) GetWorkflow(r *run.Run) (*github.WorkflowData, error) {
151+
if ghw.workflow != nil {
152+
return ghw.workflow, nil
153+
}
154+
155+
ghrun, ok := r.SystemData.(*github.Run)
156+
if !ok {
157+
return nil, fmt.Errorf("run system data is not a GitHub run")
158+
}
159+
160+
wf, err := github.FetchWorkflow(
161+
ghw.Organization, ghw.Repository, ghrun.Path, ghrun.HeadSHA,
162+
)
163+
if err != nil {
164+
return nil, fmt.Errorf("fetching workflow: %w", err)
165+
}
166+
167+
ghw.workflow = wf
168+
return wf, nil
169+
}
170+
171+
// GetRunJobs fetches the jobs for this workflow run from the GitHub API.
172+
func (ghw *GitHubWorkflow) GetRunJobs() ([]*gogithub.WorkflowJob, error) {
173+
return github.GetRunJobs(
174+
ghw.Organization, ghw.Repository, int64(ghw.RunID),
175+
)
176+
}
177+
142178
// BuildPredicate builds a predicate from the run data
143179
func (ghw *GitHubWorkflow) BuildPredicate(
144180
r *run.Run, draft attestation.Predicate,
@@ -189,12 +225,13 @@ func (ghw *GitHubWorkflow) BuildPredicate(
189225
},
190226
)
191227

192-
// Fetch the workflow YAML and compute effective inputs
193-
definedInputs, err := github.FetchWorkflowInputs(org, repo, ghrun.Path, ghrun.HeadSHA)
228+
// Fetch the workflow YAML (cached) and compute effective inputs
229+
wf, err := ghw.GetWorkflow(r)
194230
if err != nil {
195-
return nil, fmt.Errorf("fetching workflow inputs: %w", err)
231+
return nil, fmt.Errorf("fetching workflow: %w", err)
196232
}
197233

234+
definedInputs := wf.Inputs()
198235
if len(definedInputs) > 0 {
199236
effective := github.EffectiveInputs(definedInputs, ghrun.Inputs)
200237
for k, v := range effective {
@@ -234,6 +271,60 @@ func (ghw *GitHubWorkflow) BuildPredicate(
234271
return predicate, nil
235272
}
236273

274+
// AreJobsCompleted checks whether the specified jobs (by name) have all
275+
// completed. If jobNames is empty, all jobs in the run are checked except
276+
// the one matching excludeJob (useful for excluding the attester's own job).
277+
// Job name matching is prefix-based to handle reusable workflow jobs whose
278+
// API names are formatted as "caller_job / inner_job".
279+
func (ghw *GitHubWorkflow) AreJobsCompleted(jobNames []string, excludeJob string) (bool, error) {
280+
jobs, err := ghw.GetRunJobs()
281+
if err != nil {
282+
return false, fmt.Errorf("fetching run jobs: %w", err)
283+
}
284+
285+
for _, job := range jobs {
286+
name := job.GetName()
287+
status := job.GetStatus()
288+
289+
// Skip the excluded job (our own attester job)
290+
if excludeJob != "" && matchJobName(name, excludeJob) {
291+
logrus.Debugf("Skipping excluded job %q", name)
292+
continue
293+
}
294+
295+
// If specific jobs were requested, only check those
296+
if len(jobNames) > 0 && !matchesAnyJobName(name, jobNames) {
297+
continue
298+
}
299+
300+
if status != "completed" {
301+
logrus.Infof("Job %q status: %s — still running", name, status)
302+
return false, nil
303+
}
304+
305+
logrus.Debugf("Job %q completed with conclusion: %s", name, job.GetConclusion())
306+
}
307+
308+
return true, nil
309+
}
310+
311+
// matchJobName checks if an API job name matches a YAML job key.
312+
// GitHub Actions formats reusable workflow job names as "caller_key / inner_job",
313+
// so we match if the API name equals the key or starts with "key / ".
314+
func matchJobName(apiName, yamlKey string) bool {
315+
return apiName == yamlKey || strings.HasPrefix(apiName, yamlKey+" / ")
316+
}
317+
318+
// matchesAnyJobName checks if an API job name matches any of the provided names.
319+
func matchesAnyJobName(apiName string, names []string) bool {
320+
for _, n := range names {
321+
if matchJobName(apiName, n) {
322+
return true
323+
}
324+
}
325+
return false
326+
}
327+
237328
// ArtifactStores returns the native artifact store of github actions
238329
func (ghw *GitHubWorkflow) ArtifactStores() []store.Store {
239330
d, err := store.New(

pkg/github/github.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package github
1818

1919
import (
2020
"context"
21+
"encoding/json"
2122
"errors"
2223
"fmt"
2324
"io"
@@ -26,6 +27,7 @@ import (
2627
"strings"
2728
"time"
2829

30+
gogithub "github.com/google/go-github/v84/github"
2931
"github.com/sirupsen/logrus"
3032
khttp "sigs.k8s.io/release-utils/http"
3133
)
@@ -83,6 +85,31 @@ func APIGetRequest(url string) (*http.Response, error) {
8385
return res, nil
8486
}
8587

88+
// GetRunJobs fetches the jobs for a given workflow run from the GitHub API.
89+
func GetRunJobs(org, repo string, runID int64) ([]*gogithub.WorkflowJob, error) {
90+
u := fmt.Sprintf(
91+
"https://api.github.com/repos/%s/%s/actions/runs/%d/jobs",
92+
org, repo, runID,
93+
)
94+
res, err := APIGetRequest(u)
95+
if err != nil {
96+
return nil, fmt.Errorf("querying jobs API: %w", err)
97+
}
98+
defer res.Body.Close()
99+
100+
rawData, err := io.ReadAll(res.Body)
101+
if err != nil {
102+
return nil, fmt.Errorf("reading jobs response: %w", err)
103+
}
104+
105+
var jobsResp gogithub.Jobs
106+
if err := json.Unmarshal(rawData, &jobsResp); err != nil {
107+
return nil, fmt.Errorf("unmarshalling jobs response: %w", err)
108+
}
109+
110+
return jobsResp.Jobs, nil
111+
}
112+
86113
func Download(url string, f io.Writer) error {
87114
agent := NewAgent()
88115
return agent.GetToWriter(f, url)

0 commit comments

Comments
 (0)