fix(ci): run push workflows when a push brings no new commit
A new branch at an existing commit, or a branch rewound to an ancestor, gave an empty range and an error that wrapped nil, so nothing ran. The head commit is read alone, with no changed file, as GitHub does.
- commit
- 5aba7f2d3064eea6e191baff2b30481aa8908126
- author
- brice · · 2026-10-12 19:07 UTC
- parent
- cfd70eb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +43 -2
M internal/app/ci_flow_test.go +24 -0
| @@ -556,3 +556,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) { | |||
| 556 | 556 | require.NoError(t, err) | |
| 557 | 557 | c.sentinel(t, home, "sentinel") | |
| 558 | 558 | } | |
| 559 | + | ||
| 560 | + | // A new branch at a commit that already exists, and a branch rewound to an | |
| 561 | + | // ancestor, bring no new commit: GitHub still runs the push workflows (those | |
| 562 | + | // without a paths filter, as no file changed). | |
| 563 | + | func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) { | |
| 564 | + | c := newCIHarness(t, nil) | |
| 565 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 566 | + | c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "") | |
| 567 | + | c.waitFor("main runs", func() bool { return len(c.runs()) == 1 }) | |
| 568 | + | first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 569 | + | ||
| 570 | + | // A new branch at the commit main already has. | |
| 571 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same") | |
| 572 | + | c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 }) | |
| 573 | + | assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef()) | |
| 574 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |
| 575 | + | ||
| 576 | + | // A branch moved forward, then rewound to its ancestor (a forced push). | |
| 577 | + | c.push("main", map[string]string{"a.txt": "a\n"}, "") | |
| 578 | + | c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 }) | |
| 579 | + | c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main") | |
| 580 | + | c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 }) | |
| 581 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |
| 582 | + | } | |
| @@ -556,3 +556,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) { | |||||
| 556 | require.NoError(t, err) | 556 | require.NoError(t, err) | ||
| 557 | c.sentinel(t, home, "sentinel") | 557 | c.sentinel(t, home, "sentinel") | ||
| 558 | } | 558 | } | ||
| 559 | + | ||||
| 560 | + | // A new branch at a commit that already exists, and a branch rewound to an | |||
| 561 | + | // ancestor, bring no new commit: GitHub still runs the push workflows (those | |||
| 562 | + | // without a paths filter, as no file changed). | |||
| 563 | + | func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) { | |||
| 564 | + | c := newCIHarness(t, nil) | |||
| 565 | + | c.registerRunner("alpha", "ubuntu-latest") | |||
| 566 | + | c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "") | |||
| 567 | + | c.waitFor("main runs", func() bool { return len(c.runs()) == 1 }) | |||
| 568 | + | first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |||
| 569 | + | ||||
| 570 | + | // A new branch at the commit main already has. | |||
| 571 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same") | |||
| 572 | + | c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 }) | |||
| 573 | + | assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef()) | |||
| 574 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |||
| 575 | + | ||||
| 576 | + | // A branch moved forward, then rewound to its ancestor (a forced push). | |||
| 577 | + | c.push("main", map[string]string{"a.txt": "a\n"}, "") | |||
| 578 | + | c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 }) | |||
| 579 | + | c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main") | |||
| 580 | + | c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 }) | |||
| 581 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |||
| 582 | + | } | |||
M internal/usecase/ci_trigger.go +19 -2
| @@ -177,9 +177,12 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||
| 177 | 177 | f.commit = res.CommitID | |
| 178 | 178 | if isTag { | |
| 179 | 179 | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 180 | - | if err != nil || len(cs) == 0 { | |
| 180 | + | if err != nil { | |
| 181 | 181 | return f, fmt.Errorf("read the commit of the tag: %w", err) | |
| 182 | 182 | } | |
| 183 | + | if len(cs) == 0 { | |
| 184 | + | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |
| 185 | + | } | |
| 183 | 186 | f.head = cs[0] | |
| 184 | 187 | return f, nil | |
| 185 | 188 | } | |
| @@ -199,9 +202,23 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||
| 199 | 202 | exclude = base | |
| 200 | 203 | } | |
| 201 | 204 | cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit) | |
| 202 | - | if err != nil || len(cs) == 0 { | |
| 205 | + | if err != nil { | |
| 203 | 206 | return f, fmt.Errorf("read the commits of the push: %w", err) | |
| 204 | 207 | } | |
| 208 | + | if len(cs) == 0 { | |
| 209 | + | // A new branch at a commit the default branch has, or a branch rewound | |
| 210 | + | // to an ancestor: no new commit, no changed file. GitHub still runs | |
| 211 | + | // the push workflows that have no paths filter, for the head commit. | |
| 212 | + | head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 213 | + | if err != nil { | |
| 214 | + | return f, fmt.Errorf("read the head commit of the push: %w", err) | |
| 215 | + | } | |
| 216 | + | if len(head) == 0 { | |
| 217 | + | return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound) | |
| 218 | + | } | |
| 219 | + | f.head = head[0] | |
| 220 | + | return f, nil | |
| 221 | + | } | |
| 205 | 222 | f.head = cs[0] | |
| 206 | 223 | n := min(len(cs), maxPushCommits) | |
| 207 | 224 | f.commits = slices.Clone(cs[:n]) | |
| @@ -177,9 +177,12 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||||
| 177 | f.commit = res.CommitID | 177 | f.commit = res.CommitID | ||
| 178 | if isTag { | 178 | if isTag { | ||
| 179 | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | 179 | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | ||
| 180 | - | if err != nil || len(cs) == 0 { | 180 | + | if err != nil { |
| 181 | return f, fmt.Errorf("read the commit of the tag: %w", err) | 181 | return f, fmt.Errorf("read the commit of the tag: %w", err) | ||
| 182 | } | 182 | } | ||
| 183 | + | if len(cs) == 0 { | |||
| 184 | + | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |||
| 185 | + | } | |||
| 183 | f.head = cs[0] | 186 | f.head = cs[0] | ||
| 184 | return f, nil | 187 | return f, nil | ||
| 185 | } | 188 | } | ||
| @@ -199,9 +202,23 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||||
| 199 | exclude = base | 202 | exclude = base | ||
| 200 | } | 203 | } | ||
| 201 | cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit) | 204 | cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit) | ||
| 202 | - | if err != nil || len(cs) == 0 { | 205 | + | if err != nil { |
| 203 | return f, fmt.Errorf("read the commits of the push: %w", err) | 206 | return f, fmt.Errorf("read the commits of the push: %w", err) | ||
| 204 | } | 207 | } | ||
| 208 | + | if len(cs) == 0 { | |||
| 209 | + | // A new branch at a commit the default branch has, or a branch rewound | |||
| 210 | + | // to an ancestor: no new commit, no changed file. GitHub still runs | |||
| 211 | + | // the push workflows that have no paths filter, for the head commit. | |||
| 212 | + | head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return f, fmt.Errorf("read the head commit of the push: %w", err) | |||
| 215 | + | } | |||
| 216 | + | if len(head) == 0 { | |||
| 217 | + | return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound) | |||
| 218 | + | } | |||
| 219 | + | f.head = head[0] | |||
| 220 | + | return f, nil | |||
| 221 | + | } | |||
| 205 | f.head = cs[0] | 222 | f.head = cs[0] | ||
| 206 | n := min(len(cs), maxPushCommits) | 223 | n := min(len(cs), maxPushCommits) | ||
| 207 | f.commits = slices.Clone(cs[:n]) | 224 | f.commits = slices.Clone(cs[:n]) | ||