@@ -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
4651func 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
143179func (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
238329func (ghw * GitHubWorkflow ) ArtifactStores () []store.Store {
239330 d , err := store .New (
0 commit comments