Skip to content
Merged
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
16 changes: 8 additions & 8 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,14 +610,14 @@ var (
ErrComponentWithAllFlagConflict = errors.New("component argument can't be used with --all flag")

// Terraform execution errors.
ErrTerraformExecFailed = errors.New("terraform execution failed")
ErrDescribeAffected = errors.New("describe affected failed")
ErrUploadRequiresPullRequestEvent = errors.New("upload requires a pull_request event")
ErrDescribeStacks = errors.New("describe stacks failed")
ErrBuildDepGraph = errors.New("build dependency graph failed")
ErrTopologicalOrder = errors.New("topological sort failed")
ErrFormatForLogging = errors.New("format affected for logging failed")
ErrQueryEvaluation = errors.New("query evaluation failed")
ErrTerraformExecFailed = errors.New("terraform execution failed")
ErrDescribeAffected = errors.New("describe affected failed")
ErrUploadRequiresSupportedEvent = errors.New("upload requires a supported CI event")
ErrDescribeStacks = errors.New("describe stacks failed")
ErrBuildDepGraph = errors.New("build dependency graph failed")
ErrTopologicalOrder = errors.New("topological sort failed")
ErrFormatForLogging = errors.New("format affected for logging failed")
ErrQueryEvaluation = errors.New("query evaluation failed")

// Cache-related errors.
ErrCacheLocked = errors.New("cache file is locked")
Expand Down
18 changes: 11 additions & 7 deletions internal/exec/describe_affected.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,15 +431,19 @@ func (d *describeAffectedExec) uploadableQuery(args *DescribeAffectedCmdArgs, re
return nil
}

// Validate that the CI event is a pull_request event when uploading.
// Atmos Pro only processes pull_request webhooks, so push events cannot be correlated.
if args.CIEventType != "" && args.CIEventType != "pull_request" && args.CIEventType != "pull_request_target" {
// Validate that the CI event is one Atmos Pro can correlate when uploading.
// Supported events: pull_request, pull_request_target, and merge_group (GitHub merge queue).
// Push events and other ad-hoc triggers cannot be correlated to a check run.
if args.CIEventType != "" &&
args.CIEventType != "pull_request" &&
args.CIEventType != "pull_request_target" &&
args.CIEventType != "merge_group" {
return errUtils.Build(
fmt.Errorf("%w: detected CI event %q, but Atmos Pro only supports pull_request events", errUtils.ErrUploadRequiresPullRequestEvent, args.CIEventType),
fmt.Errorf("%w: detected CI event %q, but Atmos Pro only supports pull_request, pull_request_target, and merge_group events", errUtils.ErrUploadRequiresSupportedEvent, args.CIEventType),
).
WithHint("Ensure your workflow triggers on pull_request events when using --upload.").
WithHint("Push events and other event types are not supported for Atmos Pro uploads.").
WithHint("See https://atmos.tools/integrations/pro for supported CI configurations.").
WithHint("Trigger your workflow on pull_request, pull_request_target, or merge_group events when using --upload.").
WithHint("Push events and other ad-hoc triggers cannot be correlated to an Atmos Pro check run.").
WithHint("See https://atmos.tools/cli/configuration/settings/pro for supported CI configurations.").
Err()
}

Expand Down
40 changes: 39 additions & 1 deletion internal/exec/describe_affected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2074,7 +2074,7 @@ func TestUploadAllowsPullRequestEvent(t *testing.T) {
baseRef := plumbing.NewHashReference("refs/heads/main", plumbing.NewHash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))

// This will fail at the API client creation step (no env vars set), but the event
// validation should pass — it should NOT return ErrUploadRequiresPullRequestEvent.
// validation should pass — it should NOT return ErrUploadRequiresSupportedEvent.
err := d.uploadableQuery(
&DescribeAffectedCmdArgs{
Upload: true,
Expand All @@ -2094,6 +2094,44 @@ func TestUploadAllowsPullRequestEvent(t *testing.T) {
assert.ErrorIs(t, err, errUtils.ErrFailedToCreateAPIClient)
}

// TestUploadAllowsMergeGroupEvent verifies that --upload does not error for merge_group events
// (GitHub merge queue). Atmos Pro creates check runs on the synthetic merge-queue commits, and
// the CLI must allow the upload to flow through under GITHUB_EVENT_NAME=merge_group.
// Note: the actual upload call requires an API client, so we only verify the event validation passes.
func TestUploadAllowsMergeGroupEvent(t *testing.T) {
d := describeAffectedExec{
atmosConfig: &schema.AtmosConfiguration{},
printOrWriteToFile: func(atmosConfig *schema.AtmosConfiguration, format, file string, data any) error {
return nil
},
IsTTYSupportForStdout: func() bool { return false },
pageCreator: pager.New(),
}

headRef := plumbing.NewHashReference("refs/heads/gh-readonly-queue/main/pr-42-headsha", plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
baseRef := plumbing.NewHashReference("refs/heads/main", plumbing.NewHash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))

// This will fail at the API client creation step (no env vars set), but the event
// validation should pass — it should NOT return ErrUploadRequiresSupportedEvent.
err := d.uploadableQuery(
&DescribeAffectedCmdArgs{
Upload: true,
Format: "json",
CIEventType: "merge_group",
HeadSHAOverride: "synthsha123456789012345678901234567890ab",
CLIConfig: &schema.AtmosConfiguration{},
},
"https://github.com/example/repo.git",
headRef,
baseRef,
[]schema.Affected{},
)
// Should NOT be an event validation error — it should fail at API client creation instead.
require.Error(t, err)
assert.NotErrorIs(t, err, errUtils.ErrUploadRequiresSupportedEvent)
assert.ErrorIs(t, err, errUtils.ErrFailedToCreateAPIClient)
}

// TestUploadNoEventTypeAllowed verifies that --upload works when CIEventType is empty
// (e.g., when not using CI auto-detection, or using explicit --ref/--sha flags).
func TestUploadNoEventTypeAllowed(t *testing.T) {
Expand Down
73 changes: 73 additions & 0 deletions internal/exec/describe_affected_upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/cloudposse/atmos/pkg/schema"
)
Expand Down Expand Up @@ -187,6 +188,78 @@ func TestStripAffectedForUpload_SettingsWithoutPro(t *testing.T) {
assert.Nil(t, result[0].Settings)
}

// TestStripAffectedForUpload_PreservesProEventSchema locks in the contract that
// stripSettings preserves the full settings.pro sub-tree opaquely. This is the
// only thing keeping settings.pro.merge_group.checks_requested.workflows alive
// from user YAML to the upload payload, and a future struct-tightening of
// Affected.Settings could silently drop it. Assert the per-event schema we
// support today: pull_request, release, drift_detection, and merge_group.
func TestStripAffectedForUpload_PreservesProEventSchema(t *testing.T) {
planWorkflows := map[string]interface{}{
"atmos-terraform-plan.yaml": map[string]interface{}{
"inputs": map[string]interface{}{
"component": "{{ .atmos_component }}",
"stack": "{{ .atmos_stack }}",
},
},
}
applyWorkflows := map[string]interface{}{
"atmos-terraform-apply.yaml": map[string]interface{}{
"inputs": map[string]interface{}{
"component": "{{ .atmos_component }}",
"stack": "{{ .atmos_stack }}",
},
},
}

affected := []schema.Affected{
{
Component: "vpc",
Stack: "plat-use2-dev",
Settings: schema.AtmosSectionMapType{
"pro": map[string]interface{}{
"enabled": true,
"pull_request": map[string]interface{}{
"opened": map[string]interface{}{"workflows": planWorkflows},
"synchronize": map[string]interface{}{"workflows": planWorkflows},
"reopened": map[string]interface{}{"workflows": planWorkflows},
"merged": map[string]interface{}{"workflows": applyWorkflows},
},
"release": map[string]interface{}{
"published": map[string]interface{}{"workflows": applyWorkflows},
},
"drift_detection": map[string]interface{}{
"enabled": true,
},
"merge_group": map[string]interface{}{
"checks_requested": map[string]interface{}{"workflows": planWorkflows},
},
},
},
},
}

result := StripAffectedForUpload(affected)

require.Len(t, result, 1)
pro, ok := result[0].Settings["pro"].(map[string]interface{})
require.True(t, ok, "settings.pro must survive stripping as map[string]interface{}")

// Each per-event block must round-trip verbatim.
assert.Equal(t, true, pro["enabled"])
assert.NotNil(t, pro["pull_request"], "settings.pro.pull_request must round-trip")
assert.NotNil(t, pro["release"], "settings.pro.release must round-trip")
assert.NotNil(t, pro["drift_detection"], "settings.pro.drift_detection must round-trip")
assert.NotNil(t, pro["merge_group"], "settings.pro.merge_group must round-trip — required for GitHub merge-queue support")

// Drill into merge_group to make sure the nested workflows survive too.
mg, ok := pro["merge_group"].(map[string]interface{})
require.True(t, ok)
cr, ok := mg["checks_requested"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, planWorkflows, cr["workflows"])
}

func TestStripAffectedForUpload_EmptyInput(t *testing.T) {
affected := []schema.Affected{}

Expand Down
108 changes: 105 additions & 3 deletions pkg/ci/providers/github/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/cloudposse/atmos/pkg/ci/internal/provider"
"github.com/cloudposse/atmos/pkg/git"
Expand All @@ -19,6 +20,8 @@ const (
eventPush = "push"
// PayloadKeyPullRequest is the top-level key in the event payload for PR events.
payloadKeyPullRequest = "pull_request"
// PayloadKeyMergeGroup is the top-level key in the event payload for merge_group events.
payloadKeyMergeGroup = "merge_group"
// EnvGitHubBaseRef is the environment variable for the PR target branch.
envGitHubBaseRef = "GITHUB_BASE_REF"
// SourceDefault is the source label for default fallback resolution.
Expand All @@ -27,6 +30,14 @@ const (
sourceGitHubBaseRef = "GITHUB_BASE_REF"
// SourcePayloadBaseSHA is the source label when falling back to event.pull_request.base.sha.
sourcePayloadBaseSHA = "event.pull_request.base.sha"
// SourceMergeGroupBaseSHA is the source label when resolving from event.merge_group.base_sha.
sourceMergeGroupBaseSHA = "event.merge_group.base_sha"
// SourceMergeGroupBaseRef is the source label when falling back to event.merge_group.base_ref.
sourceMergeGroupBaseRef = "event.merge_group.base_ref"
// EventMergeGroup is the GitHub Actions merge_group event name.
eventMergeGroup = "merge_group"
// RefsHeadsPrefix is the prefix on fully-qualified branch refs in event payloads.
refsHeadsPrefix = "refs/heads/"
)

// ErrEventPathNotSet is returned when $GITHUB_EVENT_PATH is not set.
Expand Down Expand Up @@ -55,7 +66,7 @@ func (p *Provider) ResolveBase() (*provider.BaseResolution, error) {
return resolvePRBase(eventName)
case eventPush:
return resolvePushBase()
case "merge_group":
case eventMergeGroup:
return resolveMergeGroupBase(), nil
default:
return &provider.BaseResolution{
Expand Down Expand Up @@ -277,9 +288,100 @@ func resolvePushBase() (*provider.BaseResolution, error) {
}, nil
}

// resolveMergeGroupBase resolves the base commit for merge group events.
// resolveMergeGroupBase resolves the base commit for merge_group events.
//
// GitHub creates a synthetic merge commit on a temporary
// `gh-readonly-queue/<base>/pr-<N>-<sha>` branch when a PR enters the merge
// queue, and re-runs all required status checks against that new SHA. The
// event payload exposes:
// - merge_group.base_sha — the target-branch commit the synthetic commit
// was merged on top of (the right diff base for "affected").
// - merge_group.head_sha — the synthetic merge commit (used for upload
// correlation, parallel to pull_request.head.sha).
// - merge_group.base_ref — the fully-qualified target branch ref
// (e.g. "refs/heads/main").
//
// Strategy:
// 1. Read the event payload and use base_sha / head_sha / base_ref directly.
// 2. If the payload is missing or fields are empty (e.g. test environments
// without a real GitHub event file), fall back to GITHUB_BASE_REF and
// ultimately to refs/remotes/origin/HEAD.
func resolveMergeGroupBase() *provider.BaseResolution {
return resolveFromBaseRef("merge_group")
payload, err := readEventPayload()
if err != nil {
// Without a payload we cannot read merge_group.base_sha — fall back
// to env-only resolution rather than failing the whole describe-affected
// run. This preserves test-environment ergonomics.
log.Debug("merge_group: event payload unavailable, falling back to GITHUB_BASE_REF", "error", err)
return resolveFromBaseRef(eventMergeGroup)
}

baseSHA := extractMergeGroupBaseSHA(payload)
headSHA := extractMergeGroupHeadSHA(payload)
targetBranch := extractMergeGroupTargetBranch(payload)

if baseSHA != "" {
return &provider.BaseResolution{
SHA: baseSHA,
HeadSHA: headSHA,
TargetBranch: targetBranch,
Source: sourceMergeGroupBaseSHA,
EventType: eventMergeGroup,
}
}

// Payload is present but lacks merge_group.base_sha — last-resort env fallback.
res := resolveFromBaseRef(eventMergeGroup)
// If GITHUB_BASE_REF was empty but the payload supplied merge_group.base_ref,
// promote the payload value to res.Ref instead of leaving the default.
if res.Ref == defaultRef && targetBranch != "" {
res.Ref = "refs/remotes/origin/" + targetBranch
res.Source = sourceMergeGroupBaseRef
}
if headSHA != "" {
res.HeadSHA = headSHA
}
if targetBranch != "" {
res.TargetBranch = targetBranch
}
Comment thread
osterman marked this conversation as resolved.
return res
}

// extractMergeGroupBaseSHA extracts merge_group.base_sha from the event payload.
// Returns empty string if absent.
func extractMergeGroupBaseSHA(payload map[string]any) string {
mg, _ := payload[payloadKeyMergeGroup].(map[string]any)
if mg == nil {
return ""
}
sha, _ := mg["base_sha"].(string)
return sha
}

// extractMergeGroupHeadSHA extracts merge_group.head_sha from the event payload.
// This is the synthetic merge commit SHA Atmos Pro indexes by; used for upload
// correlation. Returns empty string if absent.
func extractMergeGroupHeadSHA(payload map[string]any) string {
mg, _ := payload[payloadKeyMergeGroup].(map[string]any)
if mg == nil {
return ""
}
sha, _ := mg["head_sha"].(string)
return sha
}

// extractMergeGroupTargetBranch extracts the target branch name from
// merge_group.base_ref (e.g. "refs/heads/main" → "main"). Falls back to
// $GITHUB_BASE_REF, then empty.
func extractMergeGroupTargetBranch(payload map[string]any) string {
mg, _ := payload[payloadKeyMergeGroup].(map[string]any)
if mg != nil {
ref, _ := mg["base_ref"].(string)
if ref != "" {
return strings.TrimPrefix(ref, refsHeadsPrefix)
}
}
return os.Getenv(envGitHubBaseRef)
}

// readEventPayload reads and parses the GitHub event payload from $GITHUB_EVENT_PATH.
Expand Down
Loading
Loading