diff --git a/backend/internal/infrastructure/workflow/parser.go b/backend/internal/infrastructure/workflow/parser.go index 7bf6301..6a1807c 100644 --- a/backend/internal/infrastructure/workflow/parser.go +++ b/backend/internal/infrastructure/workflow/parser.go @@ -73,6 +73,7 @@ type WorkflowIR struct { type IRJob struct { RunsOn string Needs []string + If string MatrixExpansion []map[string]any Steps []IRStep Env map[string]string @@ -287,6 +288,7 @@ func ParseWorkflowFull(data []byte) (*WorkflowIR, []Diagnostic, error) { irJob := IRJob{ RunsOn: nodeToString(job.RunsOn), Needs: decodeNeeds(job.Needs), + If: job.If, Env: job.Env, } diff --git a/backend/internal/worker/ci_worker.go b/backend/internal/worker/ci_worker.go index 80d9fba..b21265c 100644 --- a/backend/internal/worker/ci_worker.go +++ b/backend/internal/worker/ci_worker.go @@ -323,6 +323,30 @@ func (w *CIWorker) HandleCIRun(ctx context.Context, task *asynq.Task) error { order = sortedJobNames(ir.Jobs) } + // skipJob records a job as skipped (status completed / conclusion skipped) + // and notes the reason. Used for both unmet `needs` and a false job-level + // `if:`. + skipJob := func(jobName, reason string) error { + jobResults[jobName] = jobResultSkipped + jobLogStatus[jobName] = jobResultSkipped + fmt.Fprintf(logBuf, "[job=%s] skipped: %s\n", jobName, reason) + if w.jobRepo != nil { + skipUUID := int64CompatibleUUID() + jobIDs[jobName] = skipUUID.String() + now := time.Now().UTC() + if createErr := w.jobRepo.Create(ctx, &entity.WorkflowJob{ + ID: skipUUID, WorkflowRunID: &runUUID, OrganizationID: orgUUID, RepositoryID: repoUUID, + Name: jobName, Status: entity.WorkflowJobStatusInProgress, StartedAt: &now, CreatedAt: now, + }); createErr != nil { + return fmt.Errorf("create workflow job: %w", createErr) + } + if completeErr := w.jobRepo.Complete(ctx, skipUUID, entity.WorkflowJobConclusionSkipped, time.Now().UTC()); completeErr != nil { + return fmt.Errorf("complete workflow job: %w", completeErr) + } + } + return nil + } + for _, jobName := range order { job := ir.Jobs[jobName] @@ -330,24 +354,32 @@ func (w *CIWorker) HandleCIRun(ctx context.Context, task *asynq.Task) error { // Actions. The dependency's own failure already set the run conclusion, // so a skip adds no new failure — but independent jobs still run. if dep, unmet := firstUnsatisfiedNeed(job.Needs, jobResults); unmet { - jobResults[jobName] = jobResultSkipped - jobLogStatus[jobName] = jobResultSkipped - fmt.Fprintf(logBuf, "[job=%s] skipped: dependency %q did not succeed\n", jobName, dep) - if w.jobRepo != nil { - skipUUID := int64CompatibleUUID() - jobIDs[jobName] = skipUUID.String() - now := time.Now().UTC() - if createErr := w.jobRepo.Create(ctx, &entity.WorkflowJob{ - ID: skipUUID, WorkflowRunID: &runUUID, OrganizationID: orgUUID, RepositoryID: repoUUID, - Name: jobName, Status: entity.WorkflowJobStatusInProgress, StartedAt: &now, CreatedAt: now, - }); createErr != nil { - return fmt.Errorf("create workflow job: %w", createErr) + if err := skipJob(jobName, fmt.Sprintf("dependency %q did not succeed", dep)); err != nil { + return err + } + continue + } + + // A false job-level `if:` skips the whole job. Evaluate it against the + // github context and the workflow+job env. + if job.If != "" { + jobEnv := mergeStringMaps(ir.Env, job.Env) + ec := &workflow.EvalContext{Contexts: map[string]map[string]string{ + "github": githubCtx, "runner": runnerCtx, "secrets": secretsCtx, "env": jobEnv, "matrix": {}, + }} + run, ifErr := workflow.EvaluateCondition(job.If, ec) + if ifErr != nil { + if err := skipJob(jobName, "invalid job if: "+ifErr.Error()); err != nil { + return err } - if completeErr := w.jobRepo.Complete(ctx, skipUUID, entity.WorkflowJobConclusionSkipped, time.Now().UTC()); completeErr != nil { - return fmt.Errorf("complete workflow job: %w", completeErr) + continue + } + if !run { + if err := skipJob(jobName, fmt.Sprintf("job if condition %q evaluated false", job.If)); err != nil { + return err } + continue } - continue } // Expand the matrix into one instance per combination; a job without a diff --git a/backend/internal/worker/ci_worker_test.go b/backend/internal/worker/ci_worker_test.go index 0313c4f..2b13aac 100644 --- a/backend/internal/worker/ci_worker_test.go +++ b/backend/internal/worker/ci_worker_test.go @@ -495,6 +495,27 @@ jobs: } } +func TestCIJobLevelIfSkipsWholeJob(t *testing.T) { + scripts := runWorkflowCapture(t, `name: CI +on: push +jobs: + always: + steps: + - run: echo ALWAYS_JOB + gated: + if: github.ref_name == 'nope' + steps: + - run: echo GATED_SHOULD_NOT_RUN +`, func(p *CIRunPayload) { p.HeadBranch = "main" }) + joined := strings.Join(scripts, "\n") + if strings.Contains(joined, "GATED_SHOULD_NOT_RUN") { + t.Errorf("job with false job-level if: ran; scripts=%v", scripts) + } + if !strings.Contains(joined, "ALWAYS_JOB") { + t.Errorf("ungated job did not run; scripts=%v", scripts) + } +} + func TestCIMatrixExpandsAndInterpolates(t *testing.T) { scripts := runWorkflowCapture(t, `name: CI on: push