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
8 changes: 8 additions & 0 deletions app/cli/cmd/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func newAttestationInitCmd() *cobra.Command {
newWorkflowcontract string
collectors []string
markAsLatest bool
prMode bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -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 {
Expand All @@ -132,6 +138,7 @@ func newAttestationInitCmd() *cobra.Command {
RequireExistingVersion: existingVersion,
Collectors: collectors,
MarkAsLatest: markAsLatestPtr,
PRMode: prModePtr,
})

return err
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/cli/pkg/action/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
123 changes: 120 additions & 3 deletions app/cli/pkg/action/attestation_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
7 changes: 7 additions & 0 deletions app/cli/pkg/action/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions pkg/attestation/crafter/crafter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down
53 changes: 53 additions & 0 deletions pkg/attestation/crafter/crafter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading