Skip to content

Commit 447ea9e

Browse files
waveywavesclaude
andcommitted
fix(crafter): use actual PR head commit instead of merge commit in GitHub Actions
GitHub Actions creates a temporary merge commit for pull_request events, so .git/HEAD (and GITHUB_SHA) points to that synthetic commit instead of the actual PR branch head. This causes the attestation to reference a ghost commit that doesn't exist on any branch, breaking the referral graph when cross-referencing with local or agentic attestations. Read the actual PR head SHA from the GitHub event payload (pull_request.head.sha in GITHUB_EVENT_PATH) and resolve that commit from the local repo instead. Falls back gracefully to the merge commit if the override SHA can't be resolved. Fixes: #3064 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent 67e01b2 commit 447ea9e

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package crafter
17+
18+
import (
19+
"encoding/json"
20+
"os"
21+
22+
"github.com/go-git/go-git/v6"
23+
"github.com/go-git/go-git/v6/plumbing"
24+
"github.com/rs/zerolog"
25+
)
26+
27+
// resolveGitHubPRHeadSHA returns the actual PR branch head SHA when running
28+
// in a GitHub Actions pull_request event.
29+
//
30+
// GitHub Actions creates a temporary merge commit for PR workflows, so
31+
// .git/HEAD (and GITHUB_SHA) points to the merge commit instead of the
32+
// actual PR head. The real SHA is available in the event payload at
33+
// pull_request.head.sha.
34+
//
35+
// Note: pull_request_target is intentionally excluded because it checks out
36+
// the base branch, not the PR branch — the PR head commit may not be
37+
// available in the local checkout at all.
38+
//
39+
// Returns "" when not in a GitHub Actions PR context, or if the event
40+
// payload is missing/unreadable.
41+
func resolveGitHubPRHeadSHA() string {
42+
eventName := os.Getenv("GITHUB_EVENT_NAME")
43+
// Only handle pull_request events. pull_request_target checks out the
44+
// base branch so the PR head is unlikely to be locally available.
45+
if eventName != "pull_request" {
46+
return ""
47+
}
48+
49+
eventPath := os.Getenv("GITHUB_EVENT_PATH")
50+
if eventPath == "" {
51+
return ""
52+
}
53+
54+
data, err := os.ReadFile(eventPath)
55+
if err != nil {
56+
return ""
57+
}
58+
59+
var event struct {
60+
PullRequest struct {
61+
Head struct {
62+
SHA string `json:"sha"`
63+
} `json:"head"`
64+
} `json:"pull_request"`
65+
}
66+
67+
if err := json.Unmarshal(data, &event); err != nil {
68+
return ""
69+
}
70+
71+
return event.PullRequest.Head.SHA
72+
}
73+
74+
// overrideHeadWithPRCommit overrides headCommit's hash with the actual PR
75+
// head SHA from the GitHub event payload. It attempts to look up the full
76+
// commit metadata from the local repo (author, message, date). If the
77+
// commit object is not available locally (common with shallow clones from
78+
// actions/checkout depth=1), it still overrides the hash — which is the
79+
// critical field for the referral graph — and keeps the existing metadata
80+
// from the merge commit.
81+
func overrideHeadWithPRCommit(headCommit *HeadCommit, path, actualSHA string, logger *zerolog.Logger) {
82+
if logger == nil {
83+
l := zerolog.Nop()
84+
logger = &l
85+
}
86+
87+
// Try to resolve full commit metadata from the local repo
88+
repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
89+
DetectDotGit: true,
90+
})
91+
if err != nil {
92+
// Can't open repo — just override the hash
93+
logger.Debug().Err(err).Str("sha", actualSHA).Msg("could not open repo for PR head metadata, overriding hash only")
94+
headCommit.Hash = actualSHA
95+
return
96+
}
97+
98+
hash := plumbing.NewHash(actualSHA)
99+
commit, err := repo.CommitObject(hash)
100+
if err != nil {
101+
// Commit object not available (shallow clone). Override hash, keep
102+
// the merge commit's metadata as best-effort.
103+
logger.Debug().Err(err).Str("sha", actualSHA).Msg("PR head commit not in local store (shallow clone?), overriding hash only")
104+
headCommit.Hash = actualSHA
105+
return
106+
}
107+
108+
// Full commit available — override everything
109+
headCommit.Hash = commit.Hash.String()
110+
headCommit.AuthorEmail = commit.Author.Email
111+
headCommit.AuthorName = commit.Author.Name
112+
headCommit.Date = commit.Author.When
113+
headCommit.Message = commit.Message
114+
headCommit.Signature = commit.Signature
115+
116+
logger.Debug().Str("sha", actualSHA).Msg("resolved actual PR head commit instead of merge commit")
117+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package crafter
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
)
25+
26+
func TestResolveGitHubPRHeadSHA(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
eventName string
30+
eventJSON string
31+
wantSHA string
32+
}{
33+
{
34+
name: "not a PR event returns empty",
35+
eventName: "push",
36+
wantSHA: "",
37+
},
38+
{
39+
name: "no event name returns empty",
40+
eventName: "",
41+
wantSHA: "",
42+
},
43+
{
44+
name: "pull_request event returns head SHA",
45+
eventName: "pull_request",
46+
eventJSON: `{"pull_request":{"head":{"sha":"abc123def456"}}}`,
47+
wantSHA: "abc123def456",
48+
},
49+
{
50+
name: "pull_request_target is excluded (checks out base branch)",
51+
eventName: "pull_request_target",
52+
eventJSON: `{"pull_request":{"head":{"sha":"deadbeef1234"}}}`,
53+
wantSHA: "",
54+
},
55+
{
56+
name: "malformed JSON returns empty",
57+
eventName: "pull_request",
58+
eventJSON: `{invalid`,
59+
wantSHA: "",
60+
},
61+
{
62+
name: "missing head.sha returns empty",
63+
eventName: "pull_request",
64+
eventJSON: `{"pull_request":{"number":42}}`,
65+
wantSHA: "",
66+
},
67+
}
68+
69+
for _, tc := range tests {
70+
t.Run(tc.name, func(t *testing.T) {
71+
// Set env vars for this test
72+
t.Setenv("GITHUB_EVENT_NAME", tc.eventName)
73+
74+
if tc.eventJSON != "" {
75+
eventFile := filepath.Join(t.TempDir(), "event.json")
76+
err := os.WriteFile(eventFile, []byte(tc.eventJSON), 0o600)
77+
assert.NoError(t, err)
78+
t.Setenv("GITHUB_EVENT_PATH", eventFile)
79+
} else {
80+
t.Setenv("GITHUB_EVENT_PATH", "")
81+
}
82+
83+
got := resolveGitHubPRHeadSHA()
84+
assert.Equal(t, tc.wantSHA, got)
85+
})
86+
}
87+
}

pkg/attestation/crafter/crafter.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,17 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error
405405
return nil, fmt.Errorf("getting git commit hash: %w", err)
406406
}
407407

408+
// In CI environments that create synthetic merge commits (e.g., GitHub Actions
409+
// pull_request events), the local HEAD points to the merge commit instead of
410+
// the actual PR branch head. Override the hash (and metadata when available)
411+
// from the CI event payload so the attestation references the correct SHA.
412+
// See chainloop-dev/chainloop#3064.
413+
if headCommit != nil {
414+
if actualSHA := resolveGitHubPRHeadSHA(); actualSHA != "" && actualSHA != headCommit.Hash {
415+
overrideHeadWithPRCommit(headCommit, cwd, actualSHA, opts.Logger)
416+
}
417+
}
418+
408419
var headCommitP *api.Commit
409420
if headCommit != nil {
410421
// Attempt platform verification

0 commit comments

Comments
 (0)