Commit 2f49d2d95e

2f49d2d95eb60632a067ebba5a3fcf009b633b7a

parent: d7a0d77892

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-05 20:03 UTC

ci: filtered jobs record a skipped status instead of nothing

A job a path filter excludes now sets ci/<job> to "skipped" with a
description naming the reason, rather than leaving the commit with no
status at all. require_checks refused a merge request whose every job
was filtered out with "requires green checks and none were reported";
CombinedStatus already treats anything but error/failure/pending as
green, so skipped satisfies the gate without changing the reduction.

Tag jobs, scheduled jobs, and jobs already built for the commit on
another branch are unaffected — only the path-filtered case records a
status.

Widens the commit_statuses state CHECK constraint (migration 0041);
the column being TEXT does not make it unconstrained.

Closes #172

Layout: unified · split

e2e/ci_skipped_test.go added +70
@@ -0,0 +1,70 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// A repository with require_checks on and a job whose paths-ignore excludes
11// a docs-only change used to be unmergeable: the push queued nothing, so the
12// head carried no statuses at all, and the gate refuses that outright. The
13// filtered job now records a "skipped" status instead, which the gate reads
14// as green (#172).
15func TestSkippedStatusSatisfiesRequireChecks(t *testing.T) {
16 inst := startInstance(t)
17 aliceKey := inst.newKey(t, "alice")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
19 "--email", "alice@example.test", "--verified")
20
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-checks", "alice/app", "on"); code != 0 {
25 t.Fatalf("require-checks on: %s", errOut)
26 }
27
28 work := t.TempDir()
29 env := inst.gitEnv(aliceKey)
30 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
31 dir := filepath.Join(work, "w")
32 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
33 os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
34 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
35 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
36 os.WriteFile(filepath.Join(dir, "docs", "x.md"), []byte("# x\n"), 0o644)
37 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "base")
40 mustGit(t, dir, env, "push", "-q", "origin", "main")
41
42 // A docs-only feature branch: the job's paths-ignore excludes every
43 // file this push touches.
44 mustGit(t, dir, env, "checkout", "-q", "-b", "docs-fix")
45 os.WriteFile(filepath.Join(dir, "docs", "x.md"), []byte("# x, fixed\n"), 0o644)
46 mustGit(t, dir, env, "add", ".")
47 mustGit(t, dir, env, "commit", "-q", "-m", "fix a typo")
48 mustGit(t, dir, env, "push", "-q", "origin", "docs-fix")
49 headSHA := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
50
51 status, _, code := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", headSHA)
52 if code != 0 {
53 t.Fatalf("status list: %s", status)
54 }
55 if !strings.Contains(status, "ci/unit") || !strings.Contains(status, "skipped") {
56 t.Fatalf("no skipped ci/unit status on %s:\n%s", headSHA, status)
57 }
58
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
60 "--source", "docs-fix", "--target", "main", "--title", "'fix a typo'"); code != 0 {
61 t.Fatalf("mr create: %s", errOut)
62 }
63
64 // Before #172 this failed with "requires green checks and none were
65 // reported": the filtered push left the head with no statuses at all.
66 if out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1",
67 "--strategy", "merge"); code != 0 {
68 t.Fatalf("mr merge refused a docs-only change under require_checks: %s %s", out, errOut)
69 }
70}
internal/control/build.go +24
@@ -532,6 +532,26 @@ func QueueMRBuilds(
532532 queueJobs(st, root, siteURL, repo, userID, mrHeadRef(n), "", sha, time.Now(), false, false, false)
533533}
534534
535// skipReason names why a job's path filters excluded this push, mirroring
536// the order ci.Selected checks them in: an unmatched paths list rules a
537// job out before paths-ignore is even considered.
538func skipReason(j ci.Job, changed []string) string {
539 if len(j.Paths) > 0 {
540 hit := false
541 for _, f := range changed {
542 for _, p := range j.Paths {
543 if ci.Match(p, f) {
544 hit = true
545 }
546 }
547 }
548 if !hit {
549 return "no changed file matches paths"
550 }
551 }
552 return "every changed file matched paths-ignore"
553}
554
535555func queueJobs(
536556 st *store.Store, root, siteURL string,
537557 repo store.Repo, userID int64, ref, old, sha string, now time.Time,
@@ -609,7 +629,11 @@ func queueJobs(
609629 }
610630 continue
611631 }
632 // A filter that excludes this push is not silence: it satisfies
633 // require_checks with a skipped status instead of leaving the
634 // commit with none at all, which the gate refuses outright (#172).
612635 if filtered && !ci.Selected(j, changed) {
636 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "skipped", skipReason(j, changed), "", userID)
613637 continue
614638 }
615639 steps, _ := json.Marshal(j.Steps)
internal/control/build_test.go +150
@@ -301,6 +301,156 @@ func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
301301 }
302302}
303303
304// A job a path filter excludes records a ci/<job> status of "skipped"
305// naming the reason, rather than leaving the commit with no status for
306// that job at all (#172).
307func TestQueueBranchBuildsFilteredJobRecordsSkippedStatus(t *testing.T) {
308 st, repo, uid := newQueueTestRepo(t)
309 git := gitRunner(t)
310 root := t.TempDir()
311
312 src := filepath.Join(root, "src")
313 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
314 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
315 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
316 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
317 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
318 git(root, "init", "-q", "-b", "main", "src")
319 git(src, "add", ".")
320 git(src, "commit", "-q", "-m", "base")
321 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
322
323 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
324 git(src, "add", ".")
325 git(src, "commit", "-q", "-m", "docs only")
326 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
327
328 dir := RepoDir(root, repo.OwnerName, repo.Name)
329 os.MkdirAll(filepath.Dir(dir), 0o755)
330 git(root, "clone", "-q", "--bare", src, dir)
331
332 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
333
334 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
335 if err != nil {
336 t.Fatal(err)
337 }
338 if len(statuses) != 1 || statuses[0].Context != "ci/unit" {
339 t.Fatalf("statuses on %s: %+v", newSHA, statuses)
340 }
341 if statuses[0].State != "skipped" {
342 t.Fatalf("filtered job status: got %q, want skipped", statuses[0].State)
343 }
344 if statuses[0].Description == "" {
345 t.Fatal("skipped status carries no reason")
346 }
347}
348
349// A tag job and a scheduled job are not push jobs at all: neither records
350// a skipped status, filtered push or not. Only the ordinary job that a
351// path filter actually excluded does.
352func TestQueueBranchBuildsTagAndScheduledJobsRecordNoSkippedStatus(t *testing.T) {
353 st, repo, uid := newQueueTestRepo(t)
354 git := gitRunner(t)
355 root := t.TempDir()
356
357 src := filepath.Join(root, "src")
358 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
359 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
360 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(strings.Join([]string{
361 "jobs:",
362 " unit:",
363 " paths-ignore:",
364 " - docs/**",
365 " steps:",
366 " - echo hi",
367 " release:",
368 " tags: 'v*'",
369 " steps:",
370 " - echo release",
371 " nightly:",
372 " schedule: '0 0 * * *'",
373 " steps:",
374 " - echo nightly",
375 "",
376 }, "\n")), 0o644)
377 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
378 git(root, "init", "-q", "-b", "main", "src")
379 git(src, "add", ".")
380 git(src, "commit", "-q", "-m", "base")
381 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
382
383 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
384 git(src, "add", ".")
385 git(src, "commit", "-q", "-m", "docs only")
386 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
387
388 dir := RepoDir(root, repo.OwnerName, repo.Name)
389 os.MkdirAll(filepath.Dir(dir), 0o755)
390 git(root, "clone", "-q", "--bare", src, dir)
391
392 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
393
394 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
395 if err != nil {
396 t.Fatal(err)
397 }
398 if len(statuses) != 1 || statuses[0].Context != "ci/unit" || statuses[0].State != "skipped" {
399 t.Fatalf("statuses on %s: %+v", newSHA, statuses)
400 }
401}
402
403// A job already built for this commit on another branch is a fact about
404// the commit, not something a filter excluded: it keeps whatever status
405// that build reported (or none, if the run is still queued elsewhere) and
406// must not be overwritten with "skipped".
407func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
408 st, repo, uid := newQueueTestRepo(t)
409 git := gitRunner(t)
410 root := t.TempDir()
411
412 src := filepath.Join(root, "src")
413 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
414 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
415 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
416 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
417 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
418 git(root, "init", "-q", "-b", "main", "src")
419 git(src, "add", ".")
420 git(src, "commit", "-q", "-m", "base")
421 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
422
423 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
424 git(src, "add", ".")
425 git(src, "commit", "-q", "-m", "docs only")
426 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
427
428 dir := RepoDir(root, repo.OwnerName, repo.Name)
429 os.MkdirAll(filepath.Dir(dir), 0o755)
430 git(root, "clone", "-q", "--bare", src, dir)
431
432 // The same commit already has a build for "unit" from another branch,
433 // still pending. Its filter would exclude this push too, so the only
434 // way to tell the two paths apart is that this one must record nothing.
435 if _, err := st.CreateBuild(repo.ID, "unit", newSHA, "other", `["echo hi"]`, true); err != nil {
436 t.Fatal(err)
437 }
438
439 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
440
441 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
442 if err != nil {
443 t.Fatal(err)
444 }
445 if len(statuses) != 0 {
446 t.Fatalf("already-built job recorded a status: %+v", statuses)
447 }
448 builds, err := st.ListBuilds(repo.ID, 10)
449 if err != nil || len(builds) != 1 {
450 t.Fatalf("expected only the pre-existing build: %v %v", builds, err)
451 }
452}
453
304454// QueueMRBuilds keeps failing open with no diff base at all: deriving a
305455// merge base for the MR head is deliberately out of scope here (#172 —
306456// filtering a head down to zero jobs leaves it with no statuses, which
internal/httpd/mrpage_test.go +22
@@ -109,3 +109,25 @@ func TestMRAsideTimestamps(t *testing.T) {
109109 }
110110 }
111111}
112
113// A job a path filter excluded has no build behind its status: the page
114// must show it as skipped rather than linking to a build that never ran
115// (#172).
116func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) {
117 out := renderMR(t, testMR("open"), nil, []store.Check{
118 {CommitStatus: store.CommitStatus{Context: "ci/unit", State: "skipped",
119 Description: "every changed file matched paths-ignore", UpdatedAt: "2026-08-27T14:05:00.000Z"}},
120 })
121 row := ""
122 for _, line := range strings.Split(out, "\n") {
123 if strings.Contains(line, "ci/unit") {
124 row = line
125 }
126 }
127 if row == "" || !strings.Contains(row, ">skipped<") {
128 t.Fatalf("skipped check not rendered:\n%s", out)
129 }
130 if strings.Contains(row, "<a href") {
131 t.Errorf("skipped check with no build linked anyway: %s", row)
132 }
133}
internal/store/migrations/0041_status_skipped.down.sql added +24
@@ -0,0 +1,24 @@
1ALTER TABLE commit_statuses RENAME TO commit_statuses_old;
2
3CREATE TABLE commit_statuses (
4 id INTEGER PRIMARY KEY,
5 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 commit_sha TEXT NOT NULL,
7 context TEXT NOT NULL,
8 state TEXT NOT NULL CHECK (state IN ('pending','success','failure','error')),
9 description TEXT NOT NULL DEFAULT '',
10 target_url TEXT NOT NULL DEFAULT '',
11 creator_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
12 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
13 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
14 UNIQUE (repo_id, commit_sha, context)
15);
16
17INSERT INTO commit_statuses (id, repo_id, commit_sha, context, state, description, target_url, creator_id, created_at, updated_at)
18 SELECT id, repo_id, commit_sha, context, state, description, target_url, creator_id, created_at, updated_at
19 FROM commit_statuses_old
20 WHERE state != 'skipped';
21
22DROP TABLE commit_statuses_old;
23
24CREATE INDEX commit_statuses_sha ON commit_statuses(repo_id, commit_sha);
internal/store/migrations/0041_status_skipped.up.sql added +23
@@ -0,0 +1,23 @@
1ALTER TABLE commit_statuses RENAME TO commit_statuses_old;
2
3CREATE TABLE commit_statuses (
4 id INTEGER PRIMARY KEY,
5 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 commit_sha TEXT NOT NULL,
7 context TEXT NOT NULL,
8 state TEXT NOT NULL CHECK (state IN ('pending','success','failure','error','skipped')),
9 description TEXT NOT NULL DEFAULT '',
10 target_url TEXT NOT NULL DEFAULT '',
11 creator_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
12 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
13 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
14 UNIQUE (repo_id, commit_sha, context)
15);
16
17INSERT INTO commit_statuses (id, repo_id, commit_sha, context, state, description, target_url, creator_id, created_at, updated_at)
18 SELECT id, repo_id, commit_sha, context, state, description, target_url, creator_id, created_at, updated_at
19 FROM commit_statuses_old;
20
21DROP TABLE commit_statuses_old;
22
23CREATE INDEX commit_statuses_sha ON commit_statuses(repo_id, commit_sha);
internal/store/statuses.go +1 −1
@@ -7,7 +7,7 @@ import (
77
88type CommitStatus struct {
99 Context string
10 State string // pending | success | failure | error
10 State string // pending | success | failure | error | skipped
1111 Description string
1212 TargetURL string
1313 Creator string
internal/store/statuses_test.go +26
@@ -57,3 +57,29 @@ func TestChecksForCommit(t *testing.T) {
5757 }
5858 }
5959}
60
61// A job a path filter excluded records "skipped" rather than nothing at
62// all (#172). It must satisfy require_checks like any other green state,
63// without a failure or pending status elsewhere being masked by it.
64func TestCombinedStatusTreatsSkippedAsGreen(t *testing.T) {
65 only := []CommitStatus{{Context: "ci/unit", State: "skipped"}}
66 if got := CombinedStatus(only); got != "success" {
67 t.Fatalf("all-skipped combined status: %s", got)
68 }
69
70 withPending := []CommitStatus{
71 {Context: "ci/unit", State: "skipped"},
72 {Context: "ci/lint", State: "pending"},
73 }
74 if got := CombinedStatus(withPending); got != "pending" {
75 t.Fatalf("skipped plus pending combined status: %s", got)
76 }
77
78 withFailure := []CommitStatus{
79 {Context: "ci/unit", State: "skipped"},
80 {Context: "ci/lint", State: "failure"},
81 }
82 if got := CombinedStatus(withFailure); got != "failure" {
83 t.Fatalf("skipped plus failure combined status: %s", got)
84 }
85}
internal/web/static/style.css +1
@@ -1053,6 +1053,7 @@ table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: under
10531053.badge.check-pending { --chip: var(--warn); }
10541054.badge.check-failure, .badge.check-error { --chip: var(--bad); }
10551055.badge.check-cancelled, .chip.check-cancelled { --chip: var(--neutral); }
1056.badge.check-skipped, .chip.check-skipped { --chip: var(--neutral); }
10561057.badge.check-running, .chip.check-running { --chip: var(--warn); }
10571058.chip.check-success { --chip: var(--ok); }
10581059.chip.check-pending { --chip: var(--warn); }