diff --git a/server/forge/github/fixtures/HookPushForced.json b/server/forge/github/fixtures/HookPushForced.json new file mode 100644 index 00000000000..a242bf82a9a --- /dev/null +++ b/server/forge/github/fixtures/HookPushForced.json @@ -0,0 +1,232 @@ +{ + "ref": "refs/heads/main", + "before": "5ea737297f2e2e88f4cdf796e72cbcb12d40fd9c", + "after": "f7220f1f753260bf6f1c533357c70213e9fd4abe", + "repository": { + "id": 1256442359, + "node_id": "R_kgDOSuPJ9w", + "name": "oxiduct", + "full_name": "6543/oxiduct", + "private": false, + "owner": { + "name": "6543", + "email": "6543@obermui.de", + "login": "6543", + "id": 24977596, + "node_id": "MDQ6VXNlcjI0OTc3NTk2", + "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/6543", + "html_url": "https://github.com/6543", + "followers_url": "https://api.github.com/users/6543/followers", + "following_url": "https://api.github.com/users/6543/following{/other_user}", + "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", + "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/6543/subscriptions", + "organizations_url": "https://api.github.com/users/6543/orgs", + "repos_url": "https://api.github.com/users/6543/repos", + "events_url": "https://api.github.com/users/6543/events{/privacy}", + "received_events_url": "https://api.github.com/users/6543/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/6543/oxiduct", + "description": "Pipe traffic through oxidized steel. [tcp proxy ;)]", + "fork": false, + "url": "https://api.github.com/repos/6543/oxiduct", + "forks_url": "https://api.github.com/repos/6543/oxiduct/forks", + "keys_url": "https://api.github.com/repos/6543/oxiduct/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/6543/oxiduct/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/6543/oxiduct/teams", + "hooks_url": "https://api.github.com/repos/6543/oxiduct/hooks", + "issue_events_url": "https://api.github.com/repos/6543/oxiduct/issues/events{/number}", + "events_url": "https://api.github.com/repos/6543/oxiduct/events", + "assignees_url": "https://api.github.com/repos/6543/oxiduct/assignees{/user}", + "branches_url": "https://api.github.com/repos/6543/oxiduct/branches{/branch}", + "tags_url": "https://api.github.com/repos/6543/oxiduct/tags", + "blobs_url": "https://api.github.com/repos/6543/oxiduct/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/6543/oxiduct/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/6543/oxiduct/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/6543/oxiduct/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/6543/oxiduct/statuses/{sha}", + "languages_url": "https://api.github.com/repos/6543/oxiduct/languages", + "stargazers_url": "https://api.github.com/repos/6543/oxiduct/stargazers", + "contributors_url": "https://api.github.com/repos/6543/oxiduct/contributors", + "subscribers_url": "https://api.github.com/repos/6543/oxiduct/subscribers", + "subscription_url": "https://api.github.com/repos/6543/oxiduct/subscription", + "commits_url": "https://api.github.com/repos/6543/oxiduct/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/6543/oxiduct/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/6543/oxiduct/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/6543/oxiduct/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/6543/oxiduct/contents/{+path}", + "compare_url": "https://api.github.com/repos/6543/oxiduct/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/6543/oxiduct/merges", + "archive_url": "https://api.github.com/repos/6543/oxiduct/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/6543/oxiduct/downloads", + "issues_url": "https://api.github.com/repos/6543/oxiduct/issues{/number}", + "pulls_url": "https://api.github.com/repos/6543/oxiduct/pulls{/number}", + "milestones_url": "https://api.github.com/repos/6543/oxiduct/milestones{/number}", + "notifications_url": "https://api.github.com/repos/6543/oxiduct/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/6543/oxiduct/labels{/name}", + "releases_url": "https://api.github.com/repos/6543/oxiduct/releases{/id}", + "deployments_url": "https://api.github.com/repos/6543/oxiduct/deployments", + "created_at": 1780342272, + "updated_at": "2026-06-01T19:58:00Z", + "pushed_at": 1780343974, + "git_url": "git://github.com/6543/oxiduct.git", + "ssh_url": "git@github.com:6543/oxiduct.git", + "clone_url": "https://github.com/6543/oxiduct.git", + "svn_url": "https://github.com/6543/oxiduct", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": "Rust", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "spdx_id": "MIT", + "url": "https://api.github.com/licenses/mit", + "node_id": "MDc6TGljZW5zZTEz" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "has_pull_requests": true, + "pull_request_creation_policy": "all", + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main", + "stargazers": 0, + "master_branch": "main" + }, + "pusher": { + "name": "6543", + "email": "6543@obermui.de" + }, + "forced": true, + "sender": { + "login": "6543", + "id": 24977596, + "node_id": "MDQ6VXNlcjI0OTc3NTk2", + "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/6543", + "html_url": "https://github.com/6543", + "followers_url": "https://api.github.com/users/6543/followers", + "following_url": "https://api.github.com/users/6543/following{/other_user}", + "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", + "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/6543/subscriptions", + "organizations_url": "https://api.github.com/users/6543/orgs", + "repos_url": "https://api.github.com/users/6543/repos", + "events_url": "https://api.github.com/users/6543/events{/privacy}", + "received_events_url": "https://api.github.com/users/6543/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "created": false, + "deleted": false, + "base_ref": null, + "compare": "https://github.com/6543/oxiduct/compare/5ea737297f2e...f7220f1f7532", + "commits": [ + { + "id": "f7220f1f753260bf6f1c533357c70213e9fd4abe", + "tree_id": "c1f2cf4d1c9e83a01b8335b29f8d0f60eadb05db", + "distinct": true, + "message": "feat: initial implementation\n\nTCP/UDP proxy skeleton with layered dead-connection detection.\n\nCo-authored-by: Claude ", + "timestamp": "2026-06-01T21:59:22+02:00", + "url": "https://github.com/6543/oxiduct/commit/f7220f1f753260bf6f1c533357c70213e9fd4abe", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "date": "2026-06-01T19:56:16Z", + "username": "6543" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "date": "2026-06-01T21:59:22+02:00", + "username": "6543" + }, + "added": [ + ".clippy.toml", + ".gitignore", + ".woodpecker/build.yml", + ".woodpecker/lint.yml", + "Cargo.lock", + "Cargo.toml", + "LICENSE", + "README.md", + "contrib/example.toml", + "rustfmt.toml", + "src/cli.rs", + "src/config.rs", + "src/main.rs", + "src/proxy/mod.rs", + "src/proxy/tcp.rs", + "src/proxy/udp.rs", + "src/socket_opts.rs" + ], + "removed": [], + "modified": [] + } + ], + "head_commit": { + "id": "f7220f1f753260bf6f1c533357c70213e9fd4abe", + "tree_id": "c1f2cf4d1c9e83a01b8335b29f8d0f60eadb05db", + "distinct": true, + "message": "feat: initial implementation\n\nTCP/UDP proxy skeleton with layered dead-connection detection.\n\nCo-authored-by: Claude ", + "timestamp": "2026-06-01T21:59:22+02:00", + "url": "https://github.com/6543/oxiduct/commit/f7220f1f753260bf6f1c533357c70213e9fd4abe", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "date": "2026-06-01T19:56:16Z", + "username": "6543" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "date": "2026-06-01T21:59:22+02:00", + "username": "6543" + }, + "added": [ + ".clippy.toml", + ".gitignore", + ".woodpecker/build.yml", + ".woodpecker/lint.yml", + "Cargo.lock", + "Cargo.toml", + "LICENSE", + "README.md", + "contrib/example.toml", + "rustfmt.toml", + "src/cli.rs", + "src/config.rs", + "src/main.rs", + "src/proxy/mod.rs", + "src/proxy/tcp.rs", + "src/proxy/udp.rs", + "src/socket_opts.rs" + ], + "removed": [], + "modified": [] + } +} diff --git a/server/forge/github/fixtures/hooks.go b/server/forge/github/fixtures/hooks.go index aa74ac4bc37..0e5126507c1 100644 --- a/server/forge/github/fixtures/hooks.go +++ b/server/forge/github/fixtures/hooks.go @@ -22,6 +22,13 @@ import _ "embed" //go:embed HookPush.json var HookPush string +// HookPushForced is a sample push hook from a force push. The "before" commit +// is unreachable after the history rewrite, so it must not be used to compare. +// https://developer.github.com/v3/activity/events/types/#pushevent +// +//go:embed HookPushForced.json +var HookPushForced string + // HookPushDeleted is a sample push hook that is marked as deleted, and is expected to be ignored. const HookPushDeleted = ` { diff --git a/server/forge/github/github.go b/server/forge/github/github.go index b51ddec8694..114ba3131fe 100644 --- a/server/forge/github/github.go +++ b/server/forge/github/github.go @@ -819,8 +819,7 @@ func (c *client) loadChangedFilesFromCommits(ctx context.Context, tmpRepo *model prev = "" fallthrough case "": - // For tag events, prev is empty, but we can still fetch the changed files using the current commit - log.Trace().Msg("GitHub tag event, fetching changed files using current commit") + log.Trace().Msg("GitHub force push or tag event, fetching changed files using current commit") } repo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName) diff --git a/server/forge/github/parse.go b/server/forge/github/parse.go index 0040b9db0ab..36f45e87a3c 100644 --- a/server/forge/github/parse.go +++ b/server/forge/github/parse.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/google/go-github/v88/github" + "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" @@ -126,6 +127,11 @@ func parsePushHook(hook *github.PushEvent) (_ *model.Repo, _ *model.Pipeline, cu return repo, pipeline, "", "" } + if hook.GetForced() { + log.Debug().Msgf("detected force push on push hook [%d]", hook.GetPushID()) + return repo, pipeline, hook.GetHeadCommit().GetID(), "" + } + return repo, pipeline, hook.GetHeadCommit().GetID(), hook.GetBefore() } diff --git a/server/forge/github/parse_test.go b/server/forge/github/parse_test.go index 4a61e02078f..bb8925aed0a 100644 --- a/server/forge/github/parse_test.go +++ b/server/forge/github/parse_test.go @@ -80,6 +80,22 @@ func Test_parseHook(t *testing.T) { sort.Strings(b.ChangedFiles) }) + t.Run("forced push hook", func(t *testing.T) { + // On a force push the "before" commit may be unreachable after the + // history rewrite. Comparing against it (CompareCommits) fails with a + // 404 on the forge, so the previous commit must be empty to make the + // downstream changed-files lookup fall back to the head commit only. + req := testHookRequest([]byte(fixtures.HookPushForced), hookPush) + p, r, b, cc, pc, err := parseHook(req, false) + assert.NoError(t, err) + assert.Equal(t, "f7220f1f753260bf6f1c533357c70213e9fd4abe", cc) + assert.Empty(t, pc, "forced push must not expose the unreachable before commit") + assert.Nil(t, p) + assert.NotNil(t, r) + assert.NotNil(t, b) + assert.Equal(t, model.EventPush, b.Event) + }) + t.Run("PR hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequest), hookPull) p, r, b, cc, pc, err := parseHook(req, false)