Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Fix: Native CI Post-Merge Uploads Use the PR Head SHA

**Date:** 2026-06-10

## Issue

A native CI `atmos describe affected --upload` run triggered from a raw GitHub
`pull_request.closed` event after a PR was merged could upload affected-stack
results with the pre-merge PR head SHA instead of the commit that landed on the
base branch.

In the reported run, `actions/checkout` had checked out the expected `main`
commit, but the upload payload was correlated to the PR SHA. Atmos Pro then
associated the post-merge plan with the wrong commit, which showed up as a
false-positive drift signal.

This is separate from the long-standing Atmos Pro synthetic
`settings.pro.pull_request.merged` event. That synthetic event remains the
preferred semantic event for merged PR workflows and is unchanged by this fix.

## Root Cause

`pkg/ci/providers/github/base.go:resolvePRBase` extracted
`event.pull_request.head.sha` for every `pull_request` event and exposed it as
the upload correlation SHA through `BaseResolution.HeadSHA`.

That is correct for open PR events (`opened`, `synchronize`, `reopened`) because
Atmos Pro indexes open PR checks by the PR head SHA. It is not correct for a raw
GitHub `pull_request.closed` event where `pull_request.merged == true`: after a
merge, especially a squash merge, the commit on the base branch is different
from the original PR head SHA.

GitHub exposes the landed commit in `pull_request.merge_commit_sha` for merged
PRs:

- merge commit strategy: the merge commit SHA
- squash strategy: the squashed commit SHA on the base branch
- rebase strategy: the commit the base branch was updated to

The base-diff resolution was not the problem. The existing chain still chooses
the comparison base through merge-base, `HEAD~1`, payload `base.sha`, or the
target ref fallback. The bug was only the SHA sent in the upload payload for
native CI correlation.

## Fix

The GitHub CI provider now treats `BaseResolution.HeadSHA` as the upload
correlation SHA, not always as the PR head SHA:

- open PR events continue to use `event.pull_request.head.sha`;
- raw merged `pull_request.closed` events use
`event.pull_request.merge_commit_sha`;
- merge queue events continue to use `event.merge_group.head_sha`;
- if a raw merged PR payload is missing `merge_commit_sha`, the override is left
empty so the upload path falls back to the checked-out local `HEAD`.

The provider also records diagnostic metadata on `BaseResolution`:

- event action;
- `pull_request.merged`;
- `pull_request.head.sha`;
- `pull_request.merge_commit_sha`;
- checked-out local `HEAD`;
- final upload correlation SHA.

`internal/exec/describe_affected.go` logs these fields when CI base detection
runs and logs the final upload `head_sha` before calling Atmos Pro. This makes
future SHA mismatches visible in GitHub Actions logs without changing the upload
wire format.

The native CI base-resolution PRD was updated to document the event-aware upload
SHA behavior and to explicitly call out that the synthetic
`settings.pro.pull_request.merged` event is not changed.

## Verification

Added tests:

- `pkg/ci/providers/github/base_test.go` verifies raw merged PR-closed events
use `merge_commit_sha` for upload correlation.
- `pkg/ci/providers/github/base_test.go` verifies a missing `merge_commit_sha`
leaves the override empty so local `HEAD` is used.
- `internal/exec/describe_affected_test.go` verifies CI auto-detection propagates
the merged commit SHA into `DescribeAffectedCmdArgs.HeadSHAOverride`.

Commands run:

```shell
go test ./pkg/ci/providers/github ./internal/exec
go test ./pkg/ci/...
```
9 changes: 5 additions & 4 deletions docs/prd/native-ci/framework/base-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The purpose of base resolution is to answer: "what is the fork point — the com
- Only correct when the workflow checks out the **merge commit** (not the PR head) AND the merge strategy is merge or squash.
- **Breaks for rebase merges with multiple commits**: `merge_commit_sha` points to the tip of the rebased commits, so `HEAD~1` is the previous rebased commit — not the target branch state.
- **Breaks entirely when the workflow checks out `head.sha`**: `HEAD~1` is the parent commit on the PR branch, which for multi-commit PRs is just the second-to-last PR commit — completely wrong.
- **Breaks the Atmos Pro upload correlation**: Atmos Pro indexes by `event.pull_request.head.sha` from the webhook. If the workflow checks out the merge commit (required for HEAD~1 to work), the upload SHA doesn't match what Atmos Pro expects. This creates conflicting requirements — you can't satisfy both HEAD~1 correctness and Atmos Pro SHA correlation with the same checkout.
- **Breaks the Atmos Pro upload correlation for open PRs**: Atmos Pro indexes open PR uploads by `event.pull_request.head.sha` from the webhook. If the workflow checks out the merge commit (required for HEAD~1 to work), the upload SHA doesn't match what Atmos Pro expects. This creates conflicting requirements for open PR checks — you can't satisfy both HEAD~1 correctness and open-PR upload correlation with the same checkout.

**`event.pull_request.base.sha` (from webhook payload)**:
- Points to the correct branch but can be **stale**: if other PRs merge into the target branch between when the PR was created/updated and when it merges, `base.sha` points to an older commit on the target branch.
Expand Down Expand Up @@ -114,7 +114,7 @@ Each strategy is tried in order; the first success is used:

### Atmos Pro upload correlation

For `--upload` mode, the CLI also extracts `event.pull_request.head.sha` from the event payload. This SHA is used as the `HeadSHA` in the upload request, ensuring it matches what Atmos Pro indexed from the webhook — regardless of which commit the workflow has checked out locally.
For `--upload` mode, the CLI also extracts an event-aware upload correlation SHA from the payload. Open PR events use `event.pull_request.head.sha`, matching the webhook SHA Atmos Pro indexes for PR checks. Raw `pull_request.closed` events with `pull_request.merged == true` use `event.pull_request.merge_commit_sha`, matching the commit that landed on the base branch for native CI post-merge uploads. This does not change Atmos Pro's synthetic `settings.pro.pull_request.merged` event.

Push events are rejected when `--upload` is set, since Atmos Pro only processes `pull_request` webhooks and cannot correlate push event uploads.

Expand All @@ -123,7 +123,7 @@ Push events are rejected when `--upload` is set, since Atmos Pro only processes
| Event | Action | Primary Strategy | Fallback | Type | Source |
|-------|--------|-----------------|----------|------|--------|
| `pull_request` | opened / synchronize | `MergeBaseWithAutoFetch(HEAD, origin/<target>)` | `event.pull_request.base.sha` → `GITHUB_BASE_REF` ref (warn) | SHA or ref | `event.pull_request.base.ref` → `git merge-base` |
| `pull_request` | closed (merged) | `MergeBaseWithAutoFetch(HEAD, origin/<target>)` | `HEAD~1` → `event.pull_request.base.sha` → `GITHUB_BASE_REF` ref (warn) | SHA or ref | `event.pull_request.base.ref` → `git merge-base` |
| `pull_request` | closed (merged) | `MergeBaseWithAutoFetch(HEAD, origin/<target>)` | `HEAD~1` → `event.pull_request.base.sha` → `GITHUB_BASE_REF` ref (warn) | SHA or ref | `event.pull_request.base.ref` → `git merge-base`; upload SHA from `event.pull_request.merge_commit_sha` |
| `pull_request_target` | any | `MergeBaseWithAutoFetch(HEAD, origin/<target>)` | `event.pull_request.base.sha` → `GITHUB_BASE_REF` ref (warn) | SHA or ref | `event.pull_request.base.ref` → `git merge-base` |
| `push` | normal | `event.before` | — | SHA | `$GITHUB_EVENT_PATH` |
| `push` | force-push (`event.forced`) | `HEAD~1` | `origin/HEAD` ref | SHA or ref | git resolution |
Expand All @@ -140,7 +140,8 @@ Push events are rejected when `--upload` is set, since Atmos Pro only processes

- `action` — PR action (opened, synchronize, closed).
- `pull_request.base.ref` — target branch name for merge-base computation.
- `pull_request.head.sha` — PR head commit SHA for Atmos Pro upload correlation.
- `pull_request.head.sha` — PR head commit SHA for open PR upload correlation.
- `pull_request.merged` and `pull_request.merge_commit_sha` — used to correlate native CI post-merge uploads from raw `pull_request.closed` events.
- `before` — previous HEAD SHA for push events.
- `forced` — whether push was a force-push.

Expand Down
4 changes: 3 additions & 1 deletion examples/demo-localstack/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@

## Notes

When running Terraform with LocalStack, if you get the following warning from Terraform, you should not enable `skip_requesting_account_id`. Older versions of LocalStack required this, but not anymore.
When running Terraform with LocalStack, this example uses path-style S3 on `http://localhost:4566` and enables `skip_requesting_account_id` so the AWS provider does not block on account identity lookups or wildcard DNS/TLS resolution in CI. Without these settings, Terraform can hang before printing the first plan when LocalStack-backed provider setup is slow or incomplete.

The following Terraform warning is expected with this setting:

```console
Warning: AWS account ID not found for provider
Expand Down
11 changes: 6 additions & 5 deletions examples/demo-localstack/stacks/mixins/localstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ terraform:
region: "us-east-1"
access_key: "test"
secret_key: "test"
s3_use_path_style: false
s3_use_path_style: true
skip_credentials_validation: true
skip_metadata_api_check: true
skip_requesting_account_id: true
endpoints:
# An alias for the default LocalStack URL
sts: &localstack_url "https://localhost.localstack.cloud:4566"
# An alias for the default LocalStack URL exposed by the GitHub Actions service.
sts: &localstack_url "http://localhost:4566"

# S3 is an exception, and requires a TLS endpoint
s3: "https://localhost.localstack.cloud:4566"
# Use path-style S3 on localhost to avoid wildcard DNS/TLS flakiness in CI.
s3: *localstack_url

# Everything else can use HTTP on localhost
apigateway: *localstack_url
Expand Down
24 changes: 18 additions & 6 deletions internal/exec/describe_affected.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type DescribeAffectedCmdArgs struct {
ExcludeLocked bool
AuthManager auth.AuthManager // Optional: Auth manager for credential management (from --identity flag).
AuthDisabled bool // True when --identity=false (or alias) explicitly disables authentication; routes stack resolution to ExecuteDescribeStacksWithAuthDisabled.
HeadSHAOverride string // PR head SHA from CI event payload, used for upload correlation with Atmos Pro.
HeadSHAOverride string // CI-provided SHA used for upload correlation with Atmos Pro.
CIEventType string // CI event type (e.g., "pull_request", "push") for upload validation.
TargetBranch string // PR target branch (e.g., "main") used to auto-fetch when refs are missing locally.
}
Expand Down Expand Up @@ -305,8 +305,14 @@ func resolveBaseFromCI(describe *DescribeAffectedCmdArgs) {
log.Info("Auto-detected CI base",
"provider", p.Name(),
"event", resolution.EventType,
"action", resolution.EventAction,
"base", base,
"source", resolution.Source)
"source", resolution.Source,
"pull_request_merged", resolution.PullRequestMerged,
"pull_request_head_sha", resolution.PullRequestHeadSHA,
"merge_commit_sha", resolution.PullRequestMergeCommitSHA,
"checked_out_head_sha", resolution.LocalHeadSHA,
"upload_head_sha", resolution.HeadSHA)
}

// Execute executes `describe affected` command.
Expand Down Expand Up @@ -473,14 +479,20 @@ func (d *describeAffectedExec) uploadableQuery(args *DescribeAffectedCmdArgs, re
Err()
}

// Use the PR head SHA from the CI event payload when available.
// This ensures the upload SHA matches what Atmos Pro indexed from the webhook,
// regardless of which commit the workflow has checked out (e.g., merge commit vs PR head).
// Use the CI provider's upload correlation SHA when available. For open PR
// events this is usually pull_request.head.sha; for raw merged PR-closed
// events it can be pull_request.merge_commit_sha; for merge_group events it
// is merge_group.head_sha.
headSHA := headHead.Hash().String()
if args.HeadSHAOverride != "" {
headSHA = args.HeadSHAOverride
log.Debug("Using PR head SHA for upload correlation", "headSHA", headSHA, "localHEAD", headHead.Hash().String())
log.Debug("Using CI-provided SHA for upload correlation", "headSHA", headSHA, "localHEAD", headHead.Hash().String())
}
log.Debug("Resolved upload correlation SHA",
"event", args.CIEventType,
"headSHA", headSHA,
"overrideSHA", args.HeadSHAOverride,
"localHEAD", headHead.Hash().String())

req := dtos.UploadAffectedStacksRequest{
HeadSHA: headSHA,
Expand Down
39 changes: 39 additions & 0 deletions internal/exec/describe_affected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,45 @@ func TestResolveBaseFromCI(t *testing.T) {
assert.Empty(t, describe.Ref, "auto-detect must not fall back to refs/remotes/origin/<target> (causes false positives)")
})

t.Run("GitHub Actions raw merged PR event uses merge commit for upload correlation", func(t *testing.T) {
// Reset and register provider for this test only.
ci.Reset()
t.Cleanup(ci.Reset)
ci.Register(githubCI.NewProvider())

t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("GITHUB_EVENT_NAME", "pull_request")
t.Setenv("GITHUB_BASE_REF", "main")

eventPayload := `{
"action": "closed",
"pull_request": {
"merged": true,
"merge_commit_sha": "mergedsha3456789012345678901234567890abcd",
"head": {
"sha": "headsha123456789012345678901234567890ab"
},
"base": {
"ref": "main",
"sha": "abc123def456789012345678901234567890abcd"
}
}
}`
eventPath := filepath.Join(t.TempDir(), "event.json")
err := os.WriteFile(eventPath, []byte(eventPayload), 0o644)
require.NoError(t, err)
t.Setenv("GITHUB_EVENT_PATH", eventPath)

describe := &DescribeAffectedCmdArgs{
CLIConfig: &schema.AtmosConfiguration{},
}
resolveBaseFromCI(describe)
assert.Equal(t, "pull_request", describe.CIEventType)
assert.Equal(t, "mergedsha3456789012345678901234567890abcd", describe.HeadSHAOverride)
assert.Equal(t, "main", describe.TargetBranch)
assert.NotEmpty(t, describe.SHA)
})

t.Run("GitHub Actions push event with before SHA", func(t *testing.T) {
// Reset and register provider for this test only.
ci.Reset()
Expand Down
4 changes: 4 additions & 0 deletions lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ exclude = [
"tldp\\.org",
# Linux Foundation specs (slow response times)
"refspecs\\.linuxfoundation\\.org",
# Open Group POSIX specs (slow response times, intermittent timeouts in CI)
"pubs\\.opengroup\\.org",
# OpenBSD man pages (slow response times, intermittent timeouts in CI)
"man\\.openbsd\\.org",
# Kubernetes.io (rate limits and connection resets in CI)
Expand All @@ -52,6 +54,8 @@ exclude = [
"runatlantis\\.io",
# GitHub docs (intermittent 503s in CI)
"docs\\.github\\.com",
# Algolia DocSearch docs (intermittent timeouts in CI)
"docsearch\\.algolia\\.com",
# Cloudinary image resizing proxy
"img\\.cloudposse\\.com",
# atmos.tools URLs (can't verify website links locally)
Expand Down
25 changes: 22 additions & 3 deletions pkg/ci/internal/provider/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,30 @@ type BaseResolution struct {
// SHA is a git commit hash. Mutually exclusive with Ref.
SHA string

// HeadSHA is the PR head commit SHA for upload correlation with Atmos Pro.
// Populated for pull_request events from event.pull_request.head.sha.
// Empty for non-PR events (push, merge_group, etc.).
// HeadSHA is the commit SHA used for upload correlation with Atmos Pro.
// For open PR events this is usually event.pull_request.head.sha. For
// merged PR-closed events it can be event.pull_request.merge_commit_sha.
// For merge_group events it is event.merge_group.head_sha.
HeadSHA string

// PullRequestHeadSHA is the original event.pull_request.head.sha when known.
// It is diagnostic metadata only; HeadSHA is the upload correlation SHA.
PullRequestHeadSHA string

// PullRequestMergeCommitSHA is event.pull_request.merge_commit_sha when known.
// It is diagnostic metadata only; HeadSHA is the upload correlation SHA.
PullRequestMergeCommitSHA string

// PullRequestMerged is event.pull_request.merged when known.
PullRequestMerged bool

// EventAction is the provider-specific event action (e.g., "closed").
EventAction string

// LocalHeadSHA is the checked-out git HEAD when the provider resolved base.
// It is diagnostic metadata only.
LocalHeadSHA string

// TargetBranch is the PR target branch name (e.g., "main") when known.
// Used by callers to recover from missing local refs by running a
// targeted git fetch. Empty when the event has no notion of a target
Expand Down
Loading
Loading