From 13b5e958c53f65eecb9cf13b75b9f3a50c65517e Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Tue, 21 Apr 2026 15:50:35 +0530 Subject: [PATCH] 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: chainloop-dev/chainloop#3064 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Vibhav Bobade --- pkg/attestation/crafter/cioverride.go | 118 +++++++++++++++++++++ pkg/attestation/crafter/cioverride_test.go | 87 +++++++++++++++ pkg/attestation/crafter/crafter.go | 11 ++ 3 files changed, 216 insertions(+) create mode 100644 pkg/attestation/crafter/cioverride.go create mode 100644 pkg/attestation/crafter/cioverride_test.go diff --git a/pkg/attestation/crafter/cioverride.go b/pkg/attestation/crafter/cioverride.go new file mode 100644 index 000000000..baed563e4 --- /dev/null +++ b/pkg/attestation/crafter/cioverride.go @@ -0,0 +1,118 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crafter + +import ( + "encoding/json" + "os" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/rs/zerolog" +) + +// resolveGitHubPRHeadSHA returns the actual PR branch head SHA when running +// in a GitHub Actions pull_request event. +// +// GitHub Actions creates a temporary merge commit for PR workflows, so +// .git/HEAD (and GITHUB_SHA) points to the merge commit instead of the +// actual PR head. The real SHA is available in the event payload at +// pull_request.head.sha. +// +// Note: pull_request_target is intentionally excluded because it checks out +// the base branch, not the PR branch — the PR head commit may not be +// available in the local checkout at all. +// +// Returns "" when not in a GitHub Actions PR context, or if the event +// payload is missing/unreadable. +func resolveGitHubPRHeadSHA() string { + eventName := os.Getenv("GITHUB_EVENT_NAME") + // Only handle pull_request events. pull_request_target checks out the + // base branch so the PR head is unlikely to be locally available. + if eventName != "pull_request" { + return "" + } + + eventPath := os.Getenv("GITHUB_EVENT_PATH") + if eventPath == "" { + return "" + } + + data, err := os.ReadFile(eventPath) + if err != nil { + return "" + } + + var event struct { + PullRequest struct { + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } `json:"pull_request"` + } + + if err := json.Unmarshal(data, &event); err != nil { + return "" + } + + return event.PullRequest.Head.SHA +} + +// overrideHeadWithPRCommit overrides headCommit's hash with the actual PR +// head SHA from the GitHub event payload. It attempts to look up the full +// commit metadata from the local repo (author, message, date). If the +// commit object is not available locally (common with shallow clones from +// actions/checkout depth=1), it still overrides the hash — which is the +// critical field for the referral graph — and keeps the existing metadata +// from the merge commit. +func overrideHeadWithPRCommit(headCommit *HeadCommit, path, actualSHA string, logger *zerolog.Logger) { + if logger == nil { + l := zerolog.Nop() + logger = &l + } + + // Try to resolve full commit metadata from the local repo + repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{ + DetectDotGit: true, + EnableDotGitCommonDir: true, + }) + if err != nil { + // Can't open repo — just override the hash + logger.Debug().Err(err).Str("sha", actualSHA).Msg("could not open repo for PR head metadata, overriding hash only") + headCommit.Hash = actualSHA + return + } + + hash := plumbing.NewHash(actualSHA) + commit, err := repo.CommitObject(hash) + if err != nil { + // Commit object not available (shallow clone). Override hash, keep + // the merge commit's metadata as best-effort. + logger.Debug().Err(err).Str("sha", actualSHA).Msg("PR head commit not in local store (shallow clone?), overriding hash only") + headCommit.Hash = actualSHA + return + } + + // Full commit available — override everything + headCommit.Hash = commit.Hash.String() + headCommit.AuthorEmail = commit.Author.Email + headCommit.AuthorName = commit.Author.Name + headCommit.Date = commit.Author.When + headCommit.Message = commit.Message + headCommit.Signature = commit.PGPSignature + + logger.Debug().Str("sha", actualSHA).Msg("resolved actual PR head commit instead of merge commit") +} diff --git a/pkg/attestation/crafter/cioverride_test.go b/pkg/attestation/crafter/cioverride_test.go new file mode 100644 index 000000000..e1ec6d514 --- /dev/null +++ b/pkg/attestation/crafter/cioverride_test.go @@ -0,0 +1,87 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crafter + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveGitHubPRHeadSHA(t *testing.T) { + tests := []struct { + name string + eventName string + eventJSON string + wantSHA string + }{ + { + name: "not a PR event returns empty", + eventName: "push", + wantSHA: "", + }, + { + name: "no event name returns empty", + eventName: "", + wantSHA: "", + }, + { + name: "pull_request event returns head SHA", + eventName: "pull_request", + eventJSON: `{"pull_request":{"head":{"sha":"abc123def456"}}}`, + wantSHA: "abc123def456", + }, + { + name: "pull_request_target is excluded (checks out base branch)", + eventName: "pull_request_target", + eventJSON: `{"pull_request":{"head":{"sha":"deadbeef1234"}}}`, + wantSHA: "", + }, + { + name: "malformed JSON returns empty", + eventName: "pull_request", + eventJSON: `{invalid`, + wantSHA: "", + }, + { + name: "missing head.sha returns empty", + eventName: "pull_request", + eventJSON: `{"pull_request":{"number":42}}`, + wantSHA: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Set env vars for this test + t.Setenv("GITHUB_EVENT_NAME", tc.eventName) + + if tc.eventJSON != "" { + eventFile := filepath.Join(t.TempDir(), "event.json") + err := os.WriteFile(eventFile, []byte(tc.eventJSON), 0o600) + assert.NoError(t, err) + t.Setenv("GITHUB_EVENT_PATH", eventFile) + } else { + t.Setenv("GITHUB_EVENT_PATH", "") + } + + got := resolveGitHubPRHeadSHA() + assert.Equal(t, tc.wantSHA, got) + }) + } +} diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 652c51cbc..9fbbd2104 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -433,6 +433,17 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error return nil, fmt.Errorf("getting git commit hash: %w", err) } + // In CI environments that create synthetic merge commits (e.g., GitHub Actions + // pull_request events), the local HEAD points to the merge commit instead of + // the actual PR branch head. Override the hash (and metadata when available) + // from the CI event payload so the attestation references the correct SHA. + // See chainloop-dev/chainloop#3064. + if headCommit != nil { + if actualSHA := resolveGitHubPRHeadSHA(); actualSHA != "" && actualSHA != headCommit.Hash { + overrideHeadWithPRCommit(headCommit, cwd, actualSHA, opts.Logger) + } + } + var headCommitP *api.Commit if headCommit != nil { // Attempt platform verification