diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index e94da0875..e70584e80 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -41,6 +41,7 @@ func newAttestationInitCmd() *cobra.Command { newWorkflowcontract string collectors []string markAsLatest bool + prMode bool ) cmd := &cobra.Command{ @@ -117,6 +118,11 @@ func newAttestationInitCmd() *cobra.Command { markAsLatestPtr = &markAsLatest } + var prModePtr *bool + if cmd.Flags().Changed("pr") { + prModePtr = &prMode + } + var attestationID string err = runWithBackoffRetry( func() error { @@ -132,6 +138,7 @@ func newAttestationInitCmd() *cobra.Command { RequireExistingVersion: existingVersion, Collectors: collectors, MarkAsLatest: markAsLatestPtr, + PRMode: prModePtr, }) return err @@ -194,6 +201,7 @@ func newAttestationInitCmd() *cobra.Command { cmd.Flags().BoolVar(&existingVersion, "existing-version", false, "return an error if the version doesn't exist in the project") cmd.Flags().StringSliceVar(&collectors, "collectors", nil, "comma-separated list of additional collectors to enable (e.g. aiconfig)") cmd.Flags().BoolVar(&markAsLatest, "mark-latest", true, "explicitly mark the project version as latest (default: automatic for new versions; use =false to skip promotion)") + cmd.Flags().BoolVar(&prMode, "pr", false, "mark this attestation as a pull/merge request build (skips latest promotion and sets the chainloop.dev/is-pull-request annotation; auto-detected from CI env if not set)") return cmd } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 54db810e4..203bd9b4d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -348,6 +348,7 @@ Options -h, --help help for init --latest-version use the latest existing project version instead of specifying one --mark-latest explicitly mark the project version as latest (default: automatic for new versions; use =false to skip promotion) (default true) +--pr mark this attestation as a pull/merge request build (skips latest promotion and sets the chainloop.dev/is-pull-request annotation; auto-detected from CI env if not set) --project string name of the project of this workflow --release promote the provided version as a release --remote-state Store the attestation state remotely diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index a15bf9ce0..492e75c15 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -105,6 +105,11 @@ type AttestationInitRunOpts struct { // Collectors is a list of additional collector names to enable (e.g. "aiconfig") Collectors []string MarkAsLatest *bool + // PRMode overrides PR/MR auto-detection. + // nil → auto-detect from the CI runner environment. + // true → force PR mode on. + // false → force PR mode off. + PRMode *bool } func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRunOpts) (string, error) { @@ -188,6 +193,18 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun return "", ErrRunnerContextNotFound{err.Error()} } + // Resolve PR mode: explicit --pr flag wins, otherwise auto-detect from the + // CI runner environment. When in PR mode, default mark-latest=false unless + // the user explicitly passed --mark-latest=true. + isPR, err := action.resolvePRMode(ctx, discoveredRunner, opts.PRMode) + if err != nil { + action.Logger.Warn().Err(err).Msg("failed to detect PR context") + } + if isPR && !explicitMarkAsLatestTrue(opts.MarkAsLatest) { + falseVal := false + opts.MarkAsLatest = &falseVal + } + // Parse the raw contract to get V2 schema if available var schemaV2 *v1.CraftingSchemaV2 if contractVersion.GetRawContract() != nil { @@ -312,6 +329,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun CASBackend: casBackendInfo, Logger: &action.Logger, UIDashboardURL: uiDashboardURL, + PRMode: isPR, } if err := action.c.Init(ctx, initOpts); err != nil { @@ -536,3 +554,25 @@ func parseContractV2(rawContract *pb.WorkflowContractVersionItem_RawBody) *v1.Cr return schemaV2 } + +// resolvePRMode determines the final PR mode value. +// If override is non-nil it wins (explicit --pr flag); otherwise the CI runner +// environment is auto-detected via DetectPRContext. +func (action *AttestationInit) resolvePRMode(ctx context.Context, runner crafter.SupportedRunner, override *bool) (bool, error) { + if override != nil { + return *override, nil + } + + detected, _, err := crafter.DetectPRContext(ctx, runner) + if err != nil { + return false, err + } + return detected, nil +} + +// explicitMarkAsLatestTrue returns true only when the user explicitly passed +// --mark-latest=true. A nil pointer (flag not set) or an explicit false do not +// count, so PR mode can safely override them to false. +func explicitMarkAsLatestTrue(v *bool) bool { + return v != nil && *v +} diff --git a/app/cli/pkg/action/attestation_init_test.go b/app/cli/pkg/action/attestation_init_test.go index 898a9ec4c..a97ba0885 100644 --- a/app/cli/pkg/action/attestation_init_test.go +++ b/app/cli/pkg/action/attestation_init_test.go @@ -21,12 +21,15 @@ import ( "slices" "testing" - pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" - v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - craftingv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" + craftingv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" ) func TestEnrichMaterials(t *testing.T) { @@ -338,3 +341,117 @@ func TestParseContractV2(t *testing.T) { }) } } + +func TestExplicitMarkAsLatestTrue(t *testing.T) { + cases := []struct { + name string + v *bool + want bool + }{ + { + name: "nil pointer (flag not set) is not explicit-true", + v: nil, + want: false, + }, + { + name: "explicit false is not explicit-true", + v: boolPtr(false), + want: false, + }, + { + name: "explicit true is explicit-true", + v: boolPtr(true), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, explicitMarkAsLatestTrue(tc.v)) + }) + } +} + +func TestResolvePRMode(t *testing.T) { + // resolvePRMode uses the override when provided (explicit --pr flag), + // otherwise falls back to DetectPRContext from the runner environment. + // A generic runner with no PR env vars yields false from auto-detection. + var genericRunner crafter.SupportedRunner = runners.NewGeneric() + + l := zerolog.Nop() + action := &AttestationInit{ActionsOpts: &ActionsOpts{Logger: l}} + + cases := []struct { + name string + override *bool + // useGitHubPREnv sets up a GitHub Actions pull_request environment so + // that auto-detection returns true. + useGitHubPREnv bool + want bool + }{ + { + name: "explicit --pr=true overrides auto-detection", + override: boolPtr(true), + want: true, + }, + { + name: "explicit --pr=false overrides auto-detection even in PR env", + override: boolPtr(false), + want: false, + }, + { + name: "nil override auto-detects false in non-PR env", + override: nil, + useGitHubPREnv: false, + want: false, + }, + { + name: "nil override auto-detects true in GitHub PR env", + override: nil, + useGitHubPREnv: true, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runner := genericRunner + if tc.useGitHubPREnv { + setupGitHubPREnv(t) + testLogger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled) + runner = runners.NewGithubAction(context.Background(), &testLogger) + } + + got, err := action.resolvePRMode(context.Background(), runner, tc.override) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func boolPtr(v bool) *bool { + return &v +} + +// setupGitHubPREnv sets the env vars needed for GitHub Actions PR detection +// (GITHUB_EVENT_NAME=pull_request and a minimal event payload file). +func setupGitHubPREnv(t *testing.T) { + t.Helper() + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + + // Minimal event payload with a pull_request object + payload := `{"pull_request":{"number":42,"title":"test","body":"","html_url":"https://github.com/chainloop/chainloop/pull/42","user":{"login":"testuser","type":"User"}}}` + tmpFile := t.TempDir() + "/event.json" + require.NoError(t, os.WriteFile(tmpFile, []byte(payload), 0o600)) + t.Setenv("GITHUB_EVENT_PATH", tmpFile) + t.Setenv("GITHUB_REPOSITORY", "chainloop/chainloop") + t.Setenv("GITHUB_REPOSITORY_OWNER", "chainloop") + + // Required by GitHubAction.ResolveEnvVars + t.Setenv("GITHUB_RUN_ID", "123") + t.Setenv("GITHUB_ACTOR", "chainloop") + t.Setenv("GITHUB_REF", "refs/heads/main") + t.Setenv("GITHUB_SHA", "1234567890") + t.Setenv("RUNNER_NAME", "chainloop-runner") + t.Setenv("RUNNER_OS", "linux") +} diff --git a/app/cli/pkg/action/attestation_push.go b/app/cli/pkg/action/attestation_push.go index 08c2b61a5..f781efe01 100644 --- a/app/cli/pkg/action/attestation_push.go +++ b/app/cli/pkg/action/attestation_push.go @@ -133,6 +133,13 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru craftedAnnotations[v.Name] = v.Value } + // 1b - Preserve annotations already set on the attestation (e.g. the + // chainloop.dev/is-pull-request marker from PR mode). These are treated + // as non-overridable, same as contract annotations. + for k, v := range crafter.CraftingState.GetAttestation().GetAnnotations() { + craftedAnnotations[k] = v + } + // 2 - Populate annotation values from the ones provided at runtime // a) we do not allow overriding values that come from the contract // b) we allow runtime annotations not specified in the contract diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 4d97f0df9..e030a2012 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -86,6 +86,10 @@ type VersionedCraftingState struct { var ErrAttestationStateNotLoaded = errors.New("crafting state not loaded") +// AnnotationIsPullRequest is the well-known attestation annotation key that +// marks an attestation as originating from a pull/merge request build. +const AnnotationIsPullRequest = "chainloop.dev/is-pull-request" + type NewOpt func(c *Crafter) error func WithAuthRawToken(token string) NewOpt { @@ -212,6 +216,11 @@ type InitOpts struct { UIDashboardURL string // Logger for verification logging Logger *zerolog.Logger + // PRMode indicates the attestation is running in pull/merge request mode. + // When true, the well-known annotation chainloop.dev/is-pull-request=true + // is set on the attestation so downstream consumers can detect PR-originated + // attestations. + PRMode bool } type SigningOpts struct { @@ -471,6 +480,15 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error UiDashboardUrl: opts.UIDashboardURL, } + // Tag PR-mode attestations with a well-known annotation so downstream + // consumers (e.g. findings processing) can detect and skip them. + if opts.PRMode { + if craftingState.Attestation.Annotations == nil { + craftingState.Attestation.Annotations = make(map[string]string) + } + craftingState.Attestation.Annotations[AnnotationIsPullRequest] = "true" + } + // Set the appropriate schema if opts.SchemaV2 != nil { craftingState.Schema = &api.CraftingState_SchemaV2{ diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index 1a8feb196..36d987a4d 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -210,6 +210,59 @@ func (s *crafterSuite) TestInit() { } } +func (s *crafterSuite) TestInitPRMode() { + // PR mode sets the chainloop.dev/is-pull-request=true annotation on the + // attestation. When PR mode is off, no such annotation is present. + testCases := []struct { + name string + prMode bool + wantAnnotation bool + }{ + { + name: "PR mode on sets the is-pull-request annotation", + prMode: true, + wantAnnotation: true, + }, + { + name: "PR mode off does not set the annotation", + prMode: false, + wantAnnotation: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + contract, err := loadSchema("testdata/contracts/empty_generic.yaml") + require.NoError(s.T(), err) + + statePath := fmt.Sprintf("%s/attestation.json", s.T().TempDir()) + c, err := crafter.NewCrafter(testingStateManager(s.T(), statePath), nil) + require.NoError(s.T(), err) + + testLogger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled) + runner := crafter.NewRunner(schemaapi.CraftingSchema_Runner_RUNNER_TYPE_UNSPECIFIED, "", &testLogger) + + require.NoError(s.T(), c.Init(context.Background(), &crafter.InitOpts{ + SchemaV1: contract, + SchemaV2: nil, + WfInfo: s.workflowMetadata, + DryRun: true, + AttestationID: "", + Runner: runner, + PRMode: tc.prMode, + })) + + annotations := c.CraftingState.GetAttestation().GetAnnotations() + if tc.wantAnnotation { + s.Contains(annotations, crafter.AnnotationIsPullRequest) + s.Equal("true", annotations[crafter.AnnotationIsPullRequest]) + } else { + s.NotContains(annotations, crafter.AnnotationIsPullRequest) + } + }) + } +} + type testingCrafter struct { *crafter.Crafter }