fix(merge gate): bind pr_merge and tbd_ship merges to the head commit the gate checked (TOCTOU)
CD / Lint / Test / Vet (pull_request) Successful in 12s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped

The gate evaluates CI for one head sha; a push to the PR branch between that check and the merge would have merged
unchecked code. MergePRArgs now carries head_commit_id (documented in Gitea 1.27.1's MergePullRequestOption) and both
tools send the sha they evaluated. Tests for both tools; each removal flip-tested red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
2026-10-08 08:12:45 +02:00
co-authored by Claude Sonnet 5.5
parent c3d8401604
commit 6efd309159
6 changed files with 42 additions and 4 deletions
+1 -1
View File
@@ -320,7 +320,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
}
// Green + unprotected → squash-merge, then delete the short-lived branch.
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash"}); err != nil {
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash", HeadCommitID: pr.Head.Sha}); err != nil {
if errors.Is(err, gitea.ErrConflict) {
result["reason"] = "base moved and the merge is not clean — PR left open, not merged; resolve the conflict and merge manually"
return textOK(result)