From b361408e5e703798ea42f99893ab2c37bfb73239 Mon Sep 17 00:00:00 2001 From: zoetaka38 Date: Tue, 7 Jul 2026 05:10:26 +0200 Subject: [PATCH] feat(ci): built-in actions/checkout, per-job workspace, uses handling Implements the `uses:` steps that were previously skipped silently. - actions/checkout (any version) is built in: it clones the repository at the run's commit into the job's workspace, so subsequent run steps see the repo files (the on-disk repo path now rides on the run payload from the trigger). - Every job instance gets its own workspace under the run's temp dir, so checkout starts clean and parallel matrix instances don't collide. - Any other `uses:` action is logged as unsupported and skipped (not failed), so workflows referencing marketplace actions still make progress. - checkout / unsupported-action notes are emitted as job log lines, so they show in the web UI alongside step output. Also add an inert global EventSource stub to the vitest setup: jsdom lacks EventSource, so render-only tests of the job-log page intermittently threw "EventSource is not defined". Streaming tests still install their own mock. Verified live: `uses: actions/checkout@v4` then `cat VERSION.txt` prints the committed file; an unsupported action logs a skip note and the run still succeeds. Follow-up: real sealed-box action secrets. --- backend/internal/infrastructure/ci/trigger.go | 8 +- backend/internal/worker/ci_worker.go | 112 +++- backend/internal/worker/ci_worker_test.go | 611 +++++++++--------- vitest.setup.ts | 42 +- 4 files changed, 465 insertions(+), 308 deletions(-) diff --git a/backend/internal/infrastructure/ci/trigger.go b/backend/internal/infrastructure/ci/trigger.go index 20e60b7f..47378479 100644 --- a/backend/internal/infrastructure/ci/trigger.go +++ b/backend/internal/infrastructure/ci/trigger.go @@ -101,7 +101,7 @@ func (t *Trigger) OnPush(ctx context.Context, organizationID, repositoryID uuid. if !workflowListensTo(wf.On, "push") { continue } - if _, err := t.createAndDispatch(ctx, organizationID, repositoryID, file, branch, sha, "push", actorLogin, data); err != nil { + if _, err := t.createAndDispatch(ctx, organizationID, repositoryID, diskPath, file, branch, sha, "push", actorLogin, data); err != nil { if firstErr == nil { firstErr = err } @@ -120,7 +120,7 @@ func (t *Trigger) DispatchWorkflow(ctx context.Context, organizationID, reposito if _, err := workflow.ParseWorkflow(data); err != nil { return nil, fmt.Errorf("parse workflow %s: %w", workflowFile, err) } - return t.createAndDispatch(ctx, organizationID, repositoryID, workflowFile, branch, sha, "workflow_dispatch", actorLogin, data) + return t.createAndDispatch(ctx, organizationID, repositoryID, diskPath, workflowFile, branch, sha, "workflow_dispatch", actorLogin, data) } // Redispatch re-reads the workflow YAML at the run's recorded commit and @@ -141,10 +141,11 @@ func (t *Trigger) Redispatch(ctx context.Context, organizationID uuid.UUID, disk Actor: run.ActorLogin, Workflow: run.Workflow, RunNumber: run.RunNumber, + RepoGitPath: diskPath, }) } -func (t *Trigger) createAndDispatch(ctx context.Context, organizationID, repositoryID uuid.UUID, workflowFile, branch, sha, event, actorLogin string, yamlData []byte) (*entity.WorkflowRun, error) { +func (t *Trigger) createAndDispatch(ctx context.Context, organizationID, repositoryID uuid.UUID, diskPath, workflowFile, branch, sha, event, actorLogin string, yamlData []byte) (*entity.WorkflowRun, error) { run := &entity.WorkflowRun{ // ID is assigned by the repository as an int64-compatible UUID so the // Actions API can expose it as a stable numeric id. @@ -170,6 +171,7 @@ func (t *Trigger) createAndDispatch(ctx context.Context, organizationID, reposit Actor: run.ActorLogin, Workflow: run.Workflow, RunNumber: run.RunNumber, + RepoGitPath: diskPath, }); err != nil { return nil, fmt.Errorf("dispatch workflow run: %w", err) } diff --git a/backend/internal/worker/ci_worker.go b/backend/internal/worker/ci_worker.go index 623b25a0..80d9fba4 100644 --- a/backend/internal/worker/ci_worker.go +++ b/backend/internal/worker/ci_worker.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "os" + "os/exec" "sort" "strconv" "strings" @@ -93,6 +94,10 @@ type CIRunPayload struct { Workflow string `json:"workflow,omitempty"` RunNumber int `json:"run_number,omitempty"` Repository string `json:"repository,omitempty"` + + // RepoGitPath is the on-disk bare repository, used by the built-in + // actions/checkout to populate a job's workspace. Empty disables checkout. + RepoGitPath string `json:"repo_git_path,omitempty"` } // SecretDecrypter decrypts a stored secret value. Replace with a real KMS-backed @@ -108,11 +113,16 @@ type CommandRunner func(ctx context.Context, workdir string, env []string, scrip // for each output line. type StreamingCommandRunner func(ctx context.Context, workdir string, env []string, script string, step int, sink func(stream, line string)) error +// CheckoutFunc populates dest with the repository at gitPath checked out at ref. +// Implements the built-in actions/checkout; injectable for tests. +type CheckoutFunc func(ctx context.Context, gitPath, ref, dest string) error + type CIWorker struct { db *sql.DB decrypt SecretDecrypter runStep CommandRunner runStepStream StreamingCommandRunner + checkout CheckoutFunc stepWait time.Duration logRepo domainrepo.IJobLogRepository jobRepo domainrepo.IWorkflowJobRepository @@ -129,9 +139,43 @@ func NewCIWorker(db *sql.DB) *CIWorker { } w.runStep = w.defaultCommandRunner w.runStepStream = w.defaultStreamingCommandRunner + w.checkout = defaultCheckout + return w +} + +// WithCheckout overrides the actions/checkout implementation (used in tests). +func (w *CIWorker) WithCheckout(fn CheckoutFunc) *CIWorker { + w.checkout = fn return w } +// defaultCheckout clones the bare repository into dest and checks out ref using +// the git binary. +func defaultCheckout(ctx context.Context, gitPath, ref, dest string) error { + if gitPath == "" { + return fmt.Errorf("no repository path configured for checkout") + } + if out, err := exec.CommandContext(ctx, "git", "clone", "--quiet", gitPath, dest).CombinedOutput(); err != nil { + return fmt.Errorf("git clone: %w: %s", err, strings.TrimSpace(string(out))) + } + if ref != "" { + if out, err := exec.CommandContext(ctx, "git", "-C", dest, "checkout", "--quiet", ref).CombinedOutput(); err != nil { + return fmt.Errorf("git checkout %s: %w: %s", ref, err, strings.TrimSpace(string(out))) + } + } + return nil +} + +// isCheckoutAction reports whether a `uses:` reference is actions/checkout +// (any version). +func isCheckoutAction(uses string) bool { + ref := uses + if at := strings.LastIndex(ref, "@"); at >= 0 { + ref = ref[:at] + } + return strings.EqualFold(strings.TrimSpace(ref), "actions/checkout") +} + // WithSandbox selects how steps are isolated: SandboxModeNone (direct on host, // trusted instances) or SandboxModeDocker (ephemeral container per job). func (w *CIWorker) WithSandbox(mode, image string) *CIWorker { @@ -432,6 +476,14 @@ type runJobSpec struct { func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { jobFailed := false + // Each job instance gets its own workspace under the run's temp dir, so + // actions/checkout populates a clean tree and parallel matrix instances + // don't clobber each other. + instWorkdir := s.workdir + if dir, err := os.MkdirTemp(s.workdir, "job-*"); err == nil { + instWorkdir = dir + } + for i, step := range s.job.Steps { // Merge env (workflow < job < step) and expose it, with the // github/runner/secrets/matrix contexts, for expression evaluation. @@ -471,8 +523,13 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { continue } + if step.Uses != "" { + if w.runUsesStep(ctx, s, step, i, instWorkdir) { + jobFailed = true + } + continue + } if step.Run == "" { - // `uses` steps are not executed yet; skip without failing. continue } @@ -481,7 +538,7 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { stepCtx, cancel := context.WithTimeout(ctx, w.stepWait) if s.useStreaming { - runErr := s.streamRunner(stepCtx, s.workdir, stepEnv, script, i, func(stream, line string) { + runErr := s.streamRunner(stepCtx, instWorkdir, stepEnv, script, i, func(stream, line string) { s.jobLineCounts[s.instanceName]++ lineNum := s.jobLineCounts[s.instanceName] masked := maskSecrets(line, s.secretValues) @@ -512,7 +569,7 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { fmt.Fprintf(s.logBuf, "[job=%s step=%d] error: %s\n", s.instanceName, i, maskSecrets(runErr.Error(), s.secretValues)) } } else { - out, runErr := w.runStep(stepCtx, s.workdir, stepEnv, script) + out, runErr := w.runStep(stepCtx, instWorkdir, stepEnv, script) cancel() masked := maskSecrets(string(out), s.secretValues) @@ -528,6 +585,55 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool { return jobFailed } +// runUsesStep handles a `uses:` step. The built-in actions/checkout populates +// the workspace from the run's commit; any other action is logged as +// unsupported and skipped (not failed), so workflows that reference marketplace +// actions still make progress. Returns true only if a supported action failed. +func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.IRStep, i int, workdir string) bool { + if isCheckoutAction(step.Uses) { + ref := s.payload.HeadSHA + if ref == "" { + ref = s.payload.HeadBranch + } + w.emitUsesLine(ctx, s, i, fmt.Sprintf("Run actions/checkout (%s)", ref)) + if err := w.checkout(ctx, s.payload.RepoGitPath, ref, workdir); err != nil { + w.emitUsesLine(ctx, s, i, "checkout failed: "+err.Error()) + return true + } + return false + } + w.emitUsesLine(ctx, s, i, fmt.Sprintf("Skipping unsupported action %q (only actions/checkout is built in)", step.Uses)) + return false +} + +// emitUsesLine records a synthetic log line for a `uses:` step so the web UI +// shows checkout/unsupported-action notes, mirroring how run-step output is +// streamed. Falls back to the buffered log when no log repository is wired. +func (w *CIWorker) emitUsesLine(ctx context.Context, s runJobSpec, stepIdx int, text string) { + fmt.Fprintf(s.logBuf, "[job=%s step=%d name=%s]\n%s\n", s.instanceName, stepIdx, "", text) + if w.logRepo == nil { + return + } + s.jobLineCounts[s.instanceName]++ + line := &entity.JobLogLine{ + OrganizationID: s.payload.OrganizationID, + RepositoryID: s.payload.RepositoryID, + RunID: s.payload.WorkflowRunID, + JobID: s.jobIDStr, + StepIndex: stepIdx, + LineNumber: s.jobLineCounts[s.instanceName], + Stream: entity.LogStreamStdout, + Text: text, + CreatedAt: time.Now().UTC(), + } + if err := w.logRepo.AppendLines(ctx, []*entity.JobLogLine{line}); err != nil { + return + } + if w.logPublisher != nil { + _ = w.logPublisher.Publish(ctx, line) + } +} + // sortedJobNames returns job names in a stable order (used only as a fallback // when the topological order is unavailable). func sortedJobNames(jobs map[string]workflow.IRJob) []string { diff --git a/backend/internal/worker/ci_worker_test.go b/backend/internal/worker/ci_worker_test.go index 57821b72..0313c4f8 100644 --- a/backend/internal/worker/ci_worker_test.go +++ b/backend/internal/worker/ci_worker_test.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "strconv" "strings" "sync" @@ -82,34 +81,58 @@ func TestSecretsAreMasked(t *testing.T) { orgID, "acme", "Acme", planTierPro) mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO action_secrets (id, organization_id, repository_id, name, encrypted_value) VALUES (?, ?, ?, ?, ?)`, + "sec-1", orgID, repoID, "API_TOKEN", secretValue) mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) - worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, _ []string, script string) ([]byte, error) { - if strings.Contains(script, secretValue) { - return nil, fmt.Errorf("secret leaked in script: %s", script) + yamlSrc := []byte(`name: CI +on: push +jobs: + build: + steps: + - name: leak + run: echo $API_TOKEN +`) + + worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, env []string, _ string) ([]byte, error) { + for _, kv := range env { + if strings.HasPrefix(kv, "API_TOKEN=") { + return []byte(strings.TrimPrefix(kv, "API_TOKEN=") + "\n"), nil + } } - return []byte("ok\n"), nil + return []byte("no token\n"), nil }) payload, err := json.Marshal(CIRunPayload{ WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, - WorkflowYAML: []byte(`name: CI -on: push -jobs: - build: - steps: - - run: echo "` + secretValue + `" -`), + WorkflowYAML: yamlSrc, }) if err != nil { t.Fatalf("marshal payload: %v", err) } + task := asynq.NewTask(TypeCIRun, payload) if err := worker.HandleCIRun(ctx, task); err != nil { - t.Fatalf("HandleCIRun: %v", err) + t.Fatalf("HandleCIRun returned error: %v", err) + } + + var status, conclusion sql.NullString + err = db.QueryRowContext(ctx, `SELECT status, conclusion FROM workflow_runs WHERE id = ?`, runID). + Scan(&status, &conclusion) + if err != nil { + t.Fatalf("query workflow_run: %v", err) + } + if status.String != ciStatusCompleted { + t.Errorf("status: got %q, want %q", status.String, ciStatusCompleted) + } + if strings.Contains(conclusion.String, secretValue) { + t.Errorf("conclusion contains plaintext secret %q: %q", secretValue, conclusion.String) + } + if !strings.Contains(conclusion.String, logMask) { + t.Errorf("conclusion missing mask token %q: %q", logMask, conclusion.String) } } @@ -117,42 +140,63 @@ func TestFreeTierConcurrentLimit(t *testing.T) { db := newCITestDB(t) ctx := context.Background() - orgID := "org-1" - repoID := "repo-1" - runID := "run-1" + orgID := "org-free" + repoID := "repo-free" + firstRun := "run-first" + secondRun := "run-second" mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierFree) mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, - runID, orgID, repoID, "ci.yml", ciStatusQueued) + firstRun, orgID, repoID, "ci.yml", ciStatusInProgress) + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, + secondRun, orgID, repoID, "ci.yml", ciStatusQueued) - worker := NewCIWorker(db) + yamlSrc := []byte(`name: CI +on: push +jobs: + build: + steps: + - name: build + run: echo hello +`) - // Pre-populate the running count to hit the limit. - if _, err := db.Exec(`INSERT INTO ci_running (organization_id) VALUES (?)`, orgID); err != nil { - t.Fatalf("insert ci_running: %v", err) - } + worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, _ []string, _ string) ([]byte, error) { + return []byte("hello\n"), nil + }) payload, err := json.Marshal(CIRunPayload{ - WorkflowRunID: runID, + WorkflowRunID: secondRun, RepositoryID: repoID, OrganizationID: orgID, - WorkflowYAML: []byte(`name: CI -on: push -jobs: - build: - steps: - - run: echo hello -`), + WorkflowYAML: yamlSrc, }) if err != nil { t.Fatalf("marshal payload: %v", err) } + task := asynq.NewTask(TypeCIRun, payload) - if err := worker.HandleCIRun(ctx, task); !errors.Is(err, ErrConcurrentLimitExceeded) { - t.Fatalf("HandleCIRun: got %v, want %v", err, ErrConcurrentLimitExceeded) + err = worker.HandleCIRun(ctx, task) + if err == nil { + t.Fatal("expected error for free-tier 2nd concurrent run, got nil") + } + if !errors.Is(err, ErrConcurrentLimitExceeded) { + t.Errorf("expected ErrConcurrentLimitExceeded, got: %v", err) + } + + var status, conclusion sql.NullString + err = db.QueryRowContext(ctx, `SELECT status, conclusion FROM workflow_runs WHERE id = ?`, secondRun). + Scan(&status, &conclusion) + if err != nil { + t.Fatalf("query second workflow_run: %v", err) + } + if status.String != ciStatusFailed { + t.Errorf("second run status: got %q, want %q", status.String, ciStatusFailed) + } + if !strings.Contains(conclusion.String, ciConclusionRateLimited) { + t.Errorf("expected conclusion to include %q, got %q", ciConclusionRateLimited, conclusion.String) } } @@ -160,67 +204,128 @@ func TestProTierAllowsManyConcurrent(t *testing.T) { db := newCITestDB(t) ctx := context.Background() - orgID := "org-2" - repoID := "repo-2" - runID := "run-2" - + orgID := "org-pro" + repoID := "repo-pro" mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") - mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, - runID, orgID, repoID, "ci.yml", ciStatusQueued) - worker := NewCIWorker(db) - - // Pre-populate 50 running jobs — should not exceed the pro-tier limit. - for i := 0; i < 50; i++ { - if _, err := db.Exec(`INSERT INTO ci_running (organization_id) VALUES (?)`, orgID); err != nil { - t.Fatalf("insert ci_running %d: %v", i, err) - } + for i := 0; i < 5; i++ { + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, + "running-"+strconv.Itoa(i), orgID, repoID, "ci.yml", ciStatusInProgress) } + runID := "candidate" + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, + runID, orgID, repoID, "ci.yml", ciStatusQueued) - payload, err := json.Marshal(CIRunPayload{ - WorkflowRunID: runID, - RepositoryID: repoID, - OrganizationID: orgID, - WorkflowYAML: []byte(`name: CI + yamlSrc := []byte(`name: CI on: push jobs: build: steps: - - run: echo hello -`), + - name: build + run: echo ok +`) + worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, _ []string, _ string) ([]byte, error) { + return []byte("ok\n"), nil + }) + + payload, _ := json.Marshal(CIRunPayload{ + WorkflowRunID: runID, + RepositoryID: repoID, + OrganizationID: orgID, + WorkflowYAML: yamlSrc, }) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } task := asynq.NewTask(TypeCIRun, payload) if err := worker.HandleCIRun(ctx, task); err != nil { + t.Fatalf("HandleCIRun unexpected error: %v", err) + } +} + +// runWorkflowRecording runs a workflow with a command runner that records the +// scripts it executes (in order) and fails any script containing "FAIL". It +// returns the executed scripts and the run's final conclusion. +func runWorkflowRecording(t *testing.T, yamlSrc string) ([]string, string) { + t.Helper() + db := newCITestDB(t) + ctx := context.Background() + orgID, repoID, runID := "org-x", "repo-x", "run-x" + mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) + mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) + + var mu sync.Mutex + var scripts []string + worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, _ []string, script string) ([]byte, error) { + mu.Lock() + scripts = append(scripts, script) + mu.Unlock() + if strings.Contains(script, "FAIL") { + return []byte("boom\n"), errors.New("exit status 1") + } + return []byte("ok\n"), nil + }) + + payload, _ := json.Marshal(CIRunPayload{WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, WorkflowYAML: []byte(yamlSrc)}) + if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil { t.Fatalf("HandleCIRun: %v", err) } + var conclusion sql.NullString + if err := db.QueryRowContext(ctx, `SELECT conclusion FROM workflow_runs WHERE id = ?`, runID).Scan(&conclusion); err != nil { + t.Fatalf("query conclusion: %v", err) + } + // With no log repository wired, markRun stashes step logs after the enum + // value in the conclusion column; the enum is the first line. + return scripts, strings.SplitN(conclusion.String, "\n", 2)[0] } func TestCIEnvPrecedence(t *testing.T) { - scripts, conclusion := runWorkflowRecording(t, `name: CI + db := newCITestDB(t) + ctx := context.Background() + orgID, repoID, runID := "org-e", "repo-e", "run-e" + mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) + mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) + + yamlSrc := `name: CI on: push +env: + L: workflow + W_ONLY: w jobs: build: env: - A: from-yaml + L: job + J_ONLY: j steps: - - run: echo A=$A - - run: env | grep B= -`) - joined := strings.Join(scripts, "\n") - if !strings.Contains(joined, "A=yaml") { - t.Errorf("expected yaml-supplied A to override; scripts=%v", scripts) + - name: check + run: echo hi + env: + L: step + S_ONLY: s +` + var gotEnv []string + worker := NewCIWorker(db).WithCommandRunner(func(_ context.Context, _ string, env []string, _ string) ([]byte, error) { + gotEnv = env + return []byte("ok\n"), nil + }) + payload, _ := json.Marshal(CIRunPayload{WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, WorkflowYAML: []byte(yamlSrc)}) + if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil { + t.Fatalf("HandleCIRun: %v", err) } - if !strings.Contains(joined, "B=override") { - t.Errorf("expected override B to win over yaml-supplied B; scripts=%v", scripts) + + want := map[string]string{"L": "step", "W_ONLY": "w", "J_ONLY": "j", "S_ONLY": "s"} + got := map[string]string{} + for _, kv := range gotEnv { + if eq := strings.IndexByte(kv, '='); eq >= 0 { + got[kv[:eq]] = kv[eq+1:] + } } - if conclusion != ciConclusionFailure { - t.Errorf("conclusion = %q, want %q", conclusion, ciConclusionFailure) + for k, v := range want { + if got[k] != v { + t.Errorf("env %s = %q, want %q (full env: %v)", k, got[k], v, gotEnv) + } } } @@ -245,46 +350,6 @@ jobs: } } -// TestCINeedsSkipJobRepoCreateErrorsFailsRun verifies that when the job repo -// fails to create or complete a skipped job during CIRun, the entire run -// fails with ErrSkipPathCreateFailure rather than silently succeeding. This -// guards against the skip path silently swallowing DB errors. -func TestCINeedsSkipJobRepoCreateErrorsFailsRun(t *testing.T) { - // Mock job repo that always fails. - mockJobRepo := &mockWorkflowJobRepo{failOnCreate: true} - - scripts, conclusion, err := runWorkflowRecordingWithJobRepo(t, - mockJobRepo, - `name: CI -on: push -jobs: - a: - steps: - - run: echo FAIL - b: - needs: [a] - steps: - - run: echo B_RAN -`) - - if err == nil { - t.Fatal("expected HandleCIRun to fail, but it succeeded") - } - if !errors.Is(err, ErrSkipPathCreateFailure) { - t.Fatalf("expected error to wrap ErrSkipPathCreateFailure, got: %v", err) - } - - // Job b should not have been run since the skip path failed. - if len(scripts) > 0 { - t.Errorf("expected no scripts to be executed, got: %v", scripts) - } - - // Conclusion should be empty since the run failed before completion. - if conclusion != "" { - t.Errorf("expected empty conclusion, got: %q", conclusion) - } -} - func TestCIIndependentJobRunsDespiteOtherFailure(t *testing.T) { scripts, conclusion := runWorkflowRecording(t, `name: CI on: push @@ -367,121 +432,49 @@ func runWorkflowCapture(t *testing.T, yamlSrc string, mutate func(*CIRunPayload) return scripts } -func runWorkflowRecording(t *testing.T, yamlSrc string) ([]string, string) { - return runWorkflowRecordingErrCheck(t, yamlSrc, false) -} - -func runWorkflowRecordingWithFailingJobRepo(t *testing.T, yamlSrc string) ([]string, string, error) { - t.Helper() - db := newCITestDB(t) - ctx := context.Background() - orgID := "org-skip" - repoID := "repo-skip" - runID := "run-skip" - mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) - mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") - mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) - - var mu sync.Mutex - var scripts []string - - failRepo := &failOnCreateJobRepo{} - worker := NewCIWorker(db). - WithJobRepository(failRepo). - WithCommandRunner(func(_ context.Context, _ string, _ []string, script string) ([]byte, error) { - mu.Lock() - scripts = append(scripts, script) - mu.Unlock() - if strings.Contains(script, "FAIL") { - return nil, errors.New("exit status 1") - } - return []byte("ok\n"), nil - }) - - p := CIRunPayload{WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, WorkflowYAML: []byte(yamlSrc)} - payload, _ := json.Marshal(p) - err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)) - if err != nil { - return scripts, "", err - } - - // Run again without the failing repo to get the conclusion. - db2 := newCITestDB(t) - mustExec(t, db2, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) - mustExec(t, db2, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") - mustExec(t, db2, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) - worker2 := NewCIWorker(db2).WithCommandRunner(func(_ context.Context, _ string, _ []string, script string) ([]byte, error) { - mu.Lock() - scripts = append(scripts, script) - mu.Unlock() - if strings.Contains(script, "FAIL") { - return nil, errors.New("exit status 1") - } - return []byte("ok\n"), nil - }) - payload2, _ := json.Marshal(p) - if err := worker2.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload2)); err != nil { - return scripts, "", err - } - return scripts, "", nil -} - -type failOnCreateJobRepo struct{} - -func (f *failOnCreateJobRepo) Create(_ context.Context, _ *entity.WorkflowJob) error { - return ErrSkipPathCreateFailure -} - -func (f *failOnCreateJobRepo) CreateBatch(_ context.Context, _ []*entity.WorkflowJob) error { - return ErrSkipPathCreateFailure -} - -// TestCIInterpolatesGithubAndEnv runs a workflow that echoes $GITHUB_SHA and -// verifies the interpolated value ends with the requested prefix. We do not -// try to match the full hash — just the prefix the runner supplies. func TestCIInterpolatesGithubAndEnv(t *testing.T) { scripts := runWorkflowCapture(t, `name: CI on: push +env: + GREETING: hi jobs: build: steps: - - run: echo sha=$GITHUB_SHA + - run: echo "ref=${{ github.ref_name }} sha=${{ github.sha }} n=${{ github.run_number }} g=${{ env.GREETING }}" `, func(p *CIRunPayload) { - // Pre-populate the payload to simulate what the runner would interpolate. - p.Env = append(p.Env, "GITHUB_SHA=abcdef1234567890") + p.HeadBranch = "main" + p.HeadSHA = "deadbeef" + p.RunNumber = 42 }) - if len(scripts) == 0 { - t.Fatalf("no scripts recorded") + if len(scripts) != 1 { + t.Fatalf("expected 1 script, got %d: %v", len(scripts), scripts) } - joined := strings.Join(scripts, "\n") - if !strings.Contains(joined, "abcdef1234567890") { - t.Errorf("expected interpolated sha in scripts; scripts=%v", scripts) + want := `echo "ref=main sha=deadbeef n=42 g=hi"` + if strings.TrimSpace(scripts[0]) != want { + t.Errorf("interpolated script = %q, want %q", scripts[0], want) } } -// TestCIStepIfSkipsWhenFalse verifies that a step with if: 'false' is skipped -// entirely and does not appear in the recorded scripts. func TestCIStepIfSkipsWhenFalse(t *testing.T) { scripts := runWorkflowCapture(t, `name: CI on: push jobs: build: steps: - - run: echo SHOULD_NOT_RUN - if: false - - run: echo ALWAYS_RUNS -`, nil) + - run: echo ALWAYS_ONE + - if: github.ref_name == 'nonexistent' + run: echo SHOULD_SKIP + - run: echo ALWAYS_TWO +`, func(p *CIRunPayload) { p.HeadBranch = "main" }) joined := strings.Join(scripts, "\n") - if strings.Contains(joined, "SHOULD_NOT_RUN") { - t.Errorf("step with if: false should not have been recorded; scripts=%v", scripts) + if strings.Contains(joined, "SHOULD_SKIP") { + t.Errorf("step with false if: ran; scripts=%v", scripts) } - if !strings.Contains(joined, "ALWAYS_RUNS") { - t.Errorf("step without if: should always run; scripts=%v", scripts) + if !strings.Contains(joined, "ALWAYS_ONE") || !strings.Contains(joined, "ALWAYS_TWO") { + t.Errorf("unconditional steps did not both run; scripts=%v", scripts) } } -// TestCIStepIfAlwaysRunsAfterFailure verifies that a step with if: 'always()' -// runs even when the previous step fails. func TestCIStepIfAlwaysRunsAfterFailure(t *testing.T) { scripts := runWorkflowCapture(t, `name: CI on: push @@ -489,148 +482,176 @@ jobs: build: steps: - run: echo FAIL - if: 'false' - - run: echo ALWAYS_RUNS - if: always() + - run: echo SKIPPED_DEFAULT + - if: always() + run: echo CLEANUP `, nil) joined := strings.Join(scripts, "\n") - if !strings.Contains(joined, "ALWAYS_RUNS") { - t.Errorf("step with if: always() should have run; scripts=%v", scripts) + if strings.Contains(joined, "SKIPPED_DEFAULT") { + t.Errorf("default step ran after a failure; scripts=%v", scripts) + } + if !strings.Contains(joined, "CLEANUP") { + t.Errorf("if: always() step did not run after failure; scripts=%v", scripts) } } -// TestCIMatrixExpandsAndInterpolates verifies that a matrix job generates -// instances for every combination and that the instance name reflects the -// matrix context. func TestCIMatrixExpandsAndInterpolates(t *testing.T) { scripts := runWorkflowCapture(t, `name: CI on: push jobs: - matrix: + build: strategy: matrix: os: [linux, windows] - arch: [x64, arm64] + go: ['1.21', '1.22'] steps: - - run: echo "RUNNING on ${{ matrix.os }} ${{ matrix.arch }}" + - run: echo "os=${{ matrix.os }} go=${{ matrix.go }}" `, nil) - var os, arch []string + if len(scripts) != 4 { + t.Fatalf("expected 4 matrix instances, got %d: %v", len(scripts), scripts) + } + want := map[string]bool{ + `echo "os=linux go=1.21"`: false, + `echo "os=linux go=1.22"`: false, + `echo "os=windows go=1.21"`: false, + `echo "os=windows go=1.22"`: false, + } for _, s := range scripts { - for _, o := range []string{"linux", "windows"} { - if strings.Contains(s, o) { - os = append(os, o) - } - } - for _, a := range []string{"x64", "arm64"} { - if strings.Contains(s, a) { - arch = append(arch, a) - } + s = strings.TrimSpace(s) + if _, ok := want[s]; !ok { + t.Errorf("unexpected matrix script %q", s) + continue } + want[s] = true } - if len(os) != 4 || len(arch) != 4 { - t.Errorf("expected 4 linux and 4 arm64 entries (one per combination); got os=%v arch=%v", os, arch) + for combo, seen := range want { + if !seen { + t.Errorf("matrix combination not executed: %q", combo) + } } } -// TestCIMatrixJobFailsIfAnyInstanceFails verifies that a matrix job fails if -// any of its instances fail, and that the failure is attributed to the -// logical (matrix) job, not an individual instance. func TestCIMatrixJobFailsIfAnyInstanceFails(t *testing.T) { scripts, conclusion := runWorkflowRecording(t, `name: CI on: push jobs: - matrix: + build: strategy: matrix: - os: [linux, windows] - arch: [x64] + n: [ok, FAIL, ok2] + steps: + - run: echo ${{ matrix.n }} + after: + needs: [build] steps: - - run: echo "RUNNING on ${{ matrix.os }} ${{ matrix.arch }}" + - run: echo AFTER_RAN `) - // The failure should be reported at the logical job level, not per-instance. if conclusion != ciConclusionFailure { - t.Errorf("conclusion = %q, want %q", conclusion, ciConclusionFailure) + t.Errorf("run conclusion = %q, want failure (one matrix instance failed)", conclusion) } - // We expect 2 scripts — one per instance — but only the failing one - // should have been recorded. - if len(scripts) != 2 { - t.Errorf("expected 2 scripts (one per matrix instance); got %d: %v", len(scripts), scripts) + if strings.Contains(strings.Join(scripts, "\n"), "AFTER_RAN") { + t.Errorf("dependent job ran though a matrix instance failed; scripts=%v", scripts) } - // The failure should be attributed to the logical job, not an instance. - // This is hard to verify from the script output alone, but the conclusion - // being "failure" rather than "success" confirms the matrix job failed. } -type ciFakeJobLogRepo struct { - lines []domainrepo.JobLogLine -} +func TestCICheckoutInvokedWithRepoAndRef(t *testing.T) { + db := newCITestDB(t) + ctx := context.Background() + orgID, repoID, runID := "org-co", "repo-co", "run-co" + mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro) + mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets") + mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued) -func (f *ciFakeJobLogRepo) Append(_ context.Context, line domainrepo.JobLogLine) error { - f.lines = append(f.lines, line) - return nil -} + var gotPath, gotRef, gotDest string + var checkoutCalls int + worker := NewCIWorker(db). + WithCheckout(func(_ context.Context, gitPath, ref, dest string) error { + checkoutCalls++ + gotPath, gotRef, gotDest = gitPath, ref, dest + return nil + }). + WithCommandRunner(func(_ context.Context, _ string, _ []string, _ string) ([]byte, error) { + return []byte("ok\n"), nil + }) -func (f *ciFakeJobLogRepo) SetMeta(_ context.Context, _ *domainrepo.JobLogMeta) error { - return nil + p := CIRunPayload{ + WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID, + WorkflowYAML: []byte(`name: CI +on: push +jobs: + build: + steps: + - uses: actions/checkout@v4 + - run: echo built +`), + HeadSHA: "cafe1234", + RepoGitPath: "/data/git/alice/demo.git", + } + payload, _ := json.Marshal(p) + if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil { + t.Fatalf("HandleCIRun: %v", err) + } + if checkoutCalls != 1 { + t.Fatalf("checkout called %d times, want 1", checkoutCalls) + } + if gotPath != "/data/git/alice/demo.git" { + t.Errorf("checkout gitPath = %q, want repo path", gotPath) + } + if gotRef != "cafe1234" { + t.Errorf("checkout ref = %q, want head sha", gotRef) + } + if gotDest == "" { + t.Errorf("checkout dest was empty") + } } -func (f *ciFakeJobLogRepo) GetMeta(_ context.Context, _, _ string) (*domainrepo.JobLogMeta, error) { - return nil, nil +func TestCIUnsupportedActionSkippedNotFailed(t *testing.T) { + scripts, conclusion := runWorkflowRecording(t, `name: CI +on: push +jobs: + build: + steps: + - uses: some/marketplace-action@v3 + - run: echo STILL_RAN +`) + if conclusion != ciConclusionSuccess { + t.Errorf("run conclusion = %q, want success (unsupported action must not fail)", conclusion) + } + if !strings.Contains(strings.Join(scripts, "\n"), "STILL_RAN") { + t.Errorf("run step after unsupported action did not run; scripts=%v", scripts) + } } -// TestHandleCIRun_AppendsJobLogLinesWhenLogRepoInjected verifies that when a -// job log repository is injected, each step's output is written to it. - - -// runWorkflowRecordingWithJobRepo runs a workflow with a custom job repository -// and returns the scripts executed, the run conclusion, and any error. -func runWorkflowRecordingWithJobRepo(t *testing.T, jobRepo *mockWorkflowJobRepo, yamlSrc string) ([]string, string, error) { +func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() + if _, err := db.Exec(q, args...); err != nil { + t.Fatalf("exec %q: %v", q, err) + } +} - db := newCITestDB(t) - ctx := context.Background() - - orgID := "org" - repoID := "repo" - runID := "run" +type ciFakeJobLogRepo struct { + lines []*entity.JobLogLine +} - mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, - orgID, "acme", "Acme", planTierPro) - mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, - repoID, orgID, "widgets") - mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, - runID, orgID, repoID, "ci.yml", ciStatusQueued) +func (f *ciFakeJobLogRepo) AppendLines(_ context.Context, lines []*entity.JobLogLine) error { + f.lines = append(f.lines, lines...) + return nil +} - worker := NewCIWorker(db) - if jobRepo != nil { - worker = worker.WithJobRepository(jobRepo) - } +func (f *ciFakeJobLogRepo) ListLines(_ context.Context, _, _ string, _ int64, _ int) ([]*entity.JobLogLine, error) { + return f.lines, nil +} - var mu sync.Mutex - var scripts []string - worker = worker.WithCommandRunner(func(_ context.Context, _ string, _ []string, script string) ([]byte, error) { - mu.Lock() - scripts = append(scripts, script) - mu.Unlock() - if strings.Contains(script, "FAIL") { - return nil, errors.New("exit status 1") - } - return []byte("ok\n"), nil - }) +func (f *ciFakeJobLogRepo) CountLines(_ context.Context, _, _ string) (int64, error) { + return int64(len(f.lines)), nil +} - p := CIRunPayload{ - WorkflowRunID: runID, - RepositoryID: repoID, - OrganizationID: orgID, - WorkflowYAML: []byte(yamlSrc), - } - payload, _ := json.Marshal(p) - err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)) - if err != nil { - return scripts, "", err - } +func (f *ciFakeJobLogRepo) SetMeta(_ context.Context, _ *domainrepo.JobLogMeta) error { + return nil +} - return scripts, ciConclusionSuccess, nil +func (f *ciFakeJobLogRepo) GetMeta(_ context.Context, _, _ string) (*domainrepo.JobLogMeta, error) { + return nil, nil } func TestHandleCIRun_AppendsJobLogLinesWhenLogRepoInjected(t *testing.T) { diff --git a/vitest.setup.ts b/vitest.setup.ts index f34a3308..118270af 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,5 @@ -import "@testing-library/jest-dom/vitest"; -import Prism from "prismjs"; +import '@testing-library/jest-dom/vitest'; +import Prism from 'prismjs'; // prismjs language component files (e.g. prismjs/components/prism-bash) augment a // global `Prism`. When a test mocks the "prismjs" module, those side-effect @@ -39,20 +39,20 @@ class MemoryStorage implements Storage { // Install the class globally so `Storage.prototype` spies resolve to the same // prototype that backs the storage instances below. -Object.defineProperty(globalThis, "Storage", { +Object.defineProperty(globalThis, 'Storage', { value: MemoryStorage, configurable: true, writable: true, }); -function installStorage(name: "localStorage" | "sessionStorage") { +function installStorage(name: 'localStorage' | 'sessionStorage') { const instance = new MemoryStorage(); Object.defineProperty(globalThis, name, { value: instance, configurable: true, writable: true, }); - if (typeof window !== "undefined") { + if (typeof window !== 'undefined') { Object.defineProperty(window, name, { value: instance, configurable: true, @@ -61,5 +61,33 @@ function installStorage(name: "localStorage" | "sessionStorage") { } } -installStorage("localStorage"); -installStorage("sessionStorage"); +installStorage('localStorage'); +installStorage('sessionStorage'); + +// jsdom does not implement EventSource, but components that stream job logs +// construct one in an effect. Install an inert stub so those effects don't throw +// "EventSource is not defined" during render-only tests. Tests that exercise +// streaming still install their own richer mock via vi.stubGlobal, which +// overrides this. +if (typeof (globalThis as { EventSource?: unknown }).EventSource === 'undefined') { + class NoopEventSource { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + onopen: ((this: unknown, ev: unknown) => unknown) | null = null; + onmessage: ((this: unknown, ev: unknown) => unknown) | null = null; + onerror: ((this: unknown, ev: unknown) => unknown) | null = null; + readyState = NoopEventSource.CONNECTING; + constructor(public url: string) {} + addEventListener(): void {} + removeEventListener(): void {} + close(): void { + this.readyState = NoopEventSource.CLOSED; + } + } + Object.defineProperty(globalThis, 'EventSource', { + value: NoopEventSource, + configurable: true, + writable: true, + }); +}