Skip to content

Commit 7216b66

Browse files
committed
feat: introduce PR mode for attestations
Add a --pr flag to chainloop attestation init that marks an attestation as originating from a pull/merge request build. When PR mode is active: - Sets the chainloop.dev/is-pull-request=true annotation on the attestation so downstream consumers can detect and skip processing. - Defaults mark-latest=false to skip latest version promotion, replacing the manual --mark-latest=false workaround. An explicit --mark-latest=true still wins. PR mode is auto-detected from the CI runner environment (GitHub Actions GITHUB_EVENT_NAME=pull_request, GitLab CI_PIPELINE_SOURCE=merge_request_event) and can be explicitly overridden via --pr/--pr=false. Fixes #3265 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev> Chainloop-Trace-Sessions: ses_0c7fc994cffe1d68UjAPS6QDNj
1 parent 9c48e5c commit 7216b66

7 files changed

Lines changed: 247 additions & 3 deletions

File tree

‎app/cli/cmd/attestation_init.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func newAttestationInitCmd() *cobra.Command {
4141
newWorkflowcontract string
4242
collectors []string
4343
markAsLatest bool
44+
prMode bool
4445
)
4546

4647
cmd := &cobra.Command{
@@ -117,6 +118,11 @@ func newAttestationInitCmd() *cobra.Command {
117118
markAsLatestPtr = &markAsLatest
118119
}
119120

121+
var prModePtr *bool
122+
if cmd.Flags().Changed("pr") {
123+
prModePtr = &prMode
124+
}
125+
120126
var attestationID string
121127
err = runWithBackoffRetry(
122128
func() error {
@@ -132,6 +138,7 @@ func newAttestationInitCmd() *cobra.Command {
132138
RequireExistingVersion: existingVersion,
133139
Collectors: collectors,
134140
MarkAsLatest: markAsLatestPtr,
141+
PRMode: prModePtr,
135142
})
136143

137144
return err
@@ -194,6 +201,7 @@ func newAttestationInitCmd() *cobra.Command {
194201
cmd.Flags().BoolVar(&existingVersion, "existing-version", false, "return an error if the version doesn't exist in the project")
195202
cmd.Flags().StringSliceVar(&collectors, "collectors", nil, "comma-separated list of additional collectors to enable (e.g. aiconfig)")
196203
cmd.Flags().BoolVar(&markAsLatest, "mark-latest", true, "explicitly mark the project version as latest (default: automatic for new versions; use =false to skip promotion)")
204+
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)")
197205

198206
return cmd
199207
}

‎app/cli/documentation/cli-reference.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ Options
348348
-h, --help help for init
349349
--latest-version use the latest existing project version instead of specifying one
350350
--mark-latest explicitly mark the project version as latest (default: automatic for new versions; use =false to skip promotion) (default true)
351+
--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)
351352
--project string name of the project of this workflow
352353
--release promote the provided version as a release
353354
--remote-state Store the attestation state remotely

‎app/cli/pkg/action/attestation_init.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ type AttestationInitRunOpts struct {
105105
// Collectors is a list of additional collector names to enable (e.g. "aiconfig")
106106
Collectors []string
107107
MarkAsLatest *bool
108+
// PRMode overrides PR/MR auto-detection.
109+
// nil → auto-detect from the CI runner environment.
110+
// true → force PR mode on.
111+
// false → force PR mode off.
112+
PRMode *bool
108113
}
109114

110115
func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRunOpts) (string, error) {
@@ -188,6 +193,18 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
188193
return "", ErrRunnerContextNotFound{err.Error()}
189194
}
190195

196+
// Resolve PR mode: explicit --pr flag wins, otherwise auto-detect from the
197+
// CI runner environment. When in PR mode, default mark-latest=false unless
198+
// the user explicitly passed --mark-latest=true.
199+
isPR, err := action.resolvePRMode(ctx, discoveredRunner, opts.PRMode)
200+
if err != nil {
201+
action.Logger.Warn().Err(err).Msg("failed to detect PR context")
202+
}
203+
if isPR && !explicitMarkAsLatestTrue(opts.MarkAsLatest) {
204+
falseVal := false
205+
opts.MarkAsLatest = &falseVal
206+
}
207+
191208
// Parse the raw contract to get V2 schema if available
192209
var schemaV2 *v1.CraftingSchemaV2
193210
if contractVersion.GetRawContract() != nil {
@@ -312,6 +329,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
312329
CASBackend: casBackendInfo,
313330
Logger: &action.Logger,
314331
UIDashboardURL: uiDashboardURL,
332+
PRMode: isPR,
315333
}
316334

317335
if err := action.c.Init(ctx, initOpts); err != nil {
@@ -536,3 +554,25 @@ func parseContractV2(rawContract *pb.WorkflowContractVersionItem_RawBody) *v1.Cr
536554

537555
return schemaV2
538556
}
557+
558+
// resolvePRMode determines the final PR mode value.
559+
// If override is non-nil it wins (explicit --pr flag); otherwise the CI runner
560+
// environment is auto-detected via DetectPRContext.
561+
func (action *AttestationInit) resolvePRMode(ctx context.Context, runner crafter.SupportedRunner, override *bool) (bool, error) {
562+
if override != nil {
563+
return *override, nil
564+
}
565+
566+
detected, _, err := crafter.DetectPRContext(ctx, runner)
567+
if err != nil {
568+
return false, err
569+
}
570+
return detected, nil
571+
}
572+
573+
// explicitMarkAsLatestTrue returns true only when the user explicitly passed
574+
// --mark-latest=true. A nil pointer (flag not set) or an explicit false do not
575+
// count, so PR mode can safely override them to false.
576+
func explicitMarkAsLatestTrue(v *bool) bool {
577+
return v != nil && *v
578+
}

‎app/cli/pkg/action/attestation_init_test.go‎

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ import (
2121
"slices"
2222
"testing"
2323

24-
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
25-
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
26-
craftingv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2724
"github.com/rs/zerolog"
2825
"github.com/stretchr/testify/assert"
2926
"github.com/stretchr/testify/require"
27+
28+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
29+
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
30+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
31+
craftingv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
32+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners"
3033
)
3134

3235
func TestEnrichMaterials(t *testing.T) {
@@ -338,3 +341,117 @@ func TestParseContractV2(t *testing.T) {
338341
})
339342
}
340343
}
344+
345+
func TestExplicitMarkAsLatestTrue(t *testing.T) {
346+
cases := []struct {
347+
name string
348+
v *bool
349+
want bool
350+
}{
351+
{
352+
name: "nil pointer (flag not set) is not explicit-true",
353+
v: nil,
354+
want: false,
355+
},
356+
{
357+
name: "explicit false is not explicit-true",
358+
v: boolPtr(false),
359+
want: false,
360+
},
361+
{
362+
name: "explicit true is explicit-true",
363+
v: boolPtr(true),
364+
want: true,
365+
},
366+
}
367+
368+
for _, tc := range cases {
369+
t.Run(tc.name, func(t *testing.T) {
370+
assert.Equal(t, tc.want, explicitMarkAsLatestTrue(tc.v))
371+
})
372+
}
373+
}
374+
375+
func TestResolvePRMode(t *testing.T) {
376+
// resolvePRMode uses the override when provided (explicit --pr flag),
377+
// otherwise falls back to DetectPRContext from the runner environment.
378+
// A generic runner with no PR env vars yields false from auto-detection.
379+
var genericRunner crafter.SupportedRunner = runners.NewGeneric()
380+
381+
l := zerolog.Nop()
382+
action := &AttestationInit{ActionsOpts: &ActionsOpts{Logger: l}}
383+
384+
cases := []struct {
385+
name string
386+
override *bool
387+
// useGitHubPREnv sets up a GitHub Actions pull_request environment so
388+
// that auto-detection returns true.
389+
useGitHubPREnv bool
390+
want bool
391+
}{
392+
{
393+
name: "explicit --pr=true overrides auto-detection",
394+
override: boolPtr(true),
395+
want: true,
396+
},
397+
{
398+
name: "explicit --pr=false overrides auto-detection even in PR env",
399+
override: boolPtr(false),
400+
want: false,
401+
},
402+
{
403+
name: "nil override auto-detects false in non-PR env",
404+
override: nil,
405+
useGitHubPREnv: false,
406+
want: false,
407+
},
408+
{
409+
name: "nil override auto-detects true in GitHub PR env",
410+
override: nil,
411+
useGitHubPREnv: true,
412+
want: true,
413+
},
414+
}
415+
416+
for _, tc := range cases {
417+
t.Run(tc.name, func(t *testing.T) {
418+
runner := genericRunner
419+
if tc.useGitHubPREnv {
420+
setupGitHubPREnv(t)
421+
testLogger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled)
422+
runner = runners.NewGithubAction(context.Background(), &testLogger)
423+
}
424+
425+
got, err := action.resolvePRMode(context.Background(), runner, tc.override)
426+
require.NoError(t, err)
427+
assert.Equal(t, tc.want, got)
428+
})
429+
}
430+
}
431+
432+
func boolPtr(v bool) *bool {
433+
return &v
434+
}
435+
436+
// setupGitHubPREnv sets the env vars needed for GitHub Actions PR detection
437+
// (GITHUB_EVENT_NAME=pull_request and a minimal event payload file).
438+
func setupGitHubPREnv(t *testing.T) {
439+
t.Helper()
440+
t.Setenv("GITHUB_EVENT_NAME", "pull_request")
441+
442+
// Minimal event payload with a pull_request object
443+
payload := `{"pull_request":{"number":42,"title":"test","body":"","html_url":"https://github.com/chainloop/chainloop/pull/42","user":{"login":"testuser","type":"User"}}}`
444+
tmpFile := t.TempDir() + "/event.json"
445+
require.NoError(t, os.WriteFile(tmpFile, []byte(payload), 0o600))
446+
t.Setenv("GITHUB_EVENT_PATH", tmpFile)
447+
t.Setenv("GITHUB_REPOSITORY", "chainloop/chainloop")
448+
t.Setenv("GITHUB_REPOSITORY_OWNER", "chainloop")
449+
450+
// Required by GitHubAction.ResolveEnvVars
451+
t.Setenv("GITHUB_RUN_ID", "123")
452+
t.Setenv("GITHUB_ACTOR", "chainloop")
453+
t.Setenv("GITHUB_REF", "refs/heads/main")
454+
t.Setenv("GITHUB_SHA", "1234567890")
455+
t.Setenv("RUNNER_NAME", "chainloop-runner")
456+
t.Setenv("RUNNER_OS", "linux")
457+
}

‎app/cli/pkg/action/attestation_push.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
133133
craftedAnnotations[v.Name] = v.Value
134134
}
135135

136+
// 1b - Preserve annotations already set on the attestation (e.g. the
137+
// chainloop.dev/is-pull-request marker from PR mode). These are treated
138+
// as non-overridable, same as contract annotations.
139+
for k, v := range crafter.CraftingState.GetAttestation().GetAnnotations() {
140+
craftedAnnotations[k] = v
141+
}
142+
136143
// 2 - Populate annotation values from the ones provided at runtime
137144
// a) we do not allow overriding values that come from the contract
138145
// b) we allow runtime annotations not specified in the contract

‎pkg/attestation/crafter/crafter.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ type VersionedCraftingState struct {
8686

8787
var ErrAttestationStateNotLoaded = errors.New("crafting state not loaded")
8888

89+
// AnnotationIsPullRequest is the well-known attestation annotation key that
90+
// marks an attestation as originating from a pull/merge request build.
91+
const AnnotationIsPullRequest = "chainloop.dev/is-pull-request"
92+
8993
type NewOpt func(c *Crafter) error
9094

9195
func WithAuthRawToken(token string) NewOpt {
@@ -212,6 +216,11 @@ type InitOpts struct {
212216
UIDashboardURL string
213217
// Logger for verification logging
214218
Logger *zerolog.Logger
219+
// PRMode indicates the attestation is running in pull/merge request mode.
220+
// When true, the well-known annotation chainloop.dev/is-pull-request=true
221+
// is set on the attestation so downstream consumers can detect PR-originated
222+
// attestations.
223+
PRMode bool
215224
}
216225

217226
type SigningOpts struct {
@@ -471,6 +480,15 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error
471480
UiDashboardUrl: opts.UIDashboardURL,
472481
}
473482

483+
// Tag PR-mode attestations with a well-known annotation so downstream
484+
// consumers (e.g. findings processing) can detect and skip them.
485+
if opts.PRMode {
486+
if craftingState.Attestation.Annotations == nil {
487+
craftingState.Attestation.Annotations = make(map[string]string)
488+
}
489+
craftingState.Attestation.Annotations[AnnotationIsPullRequest] = "true"
490+
}
491+
474492
// Set the appropriate schema
475493
if opts.SchemaV2 != nil {
476494
craftingState.Schema = &api.CraftingState_SchemaV2{

‎pkg/attestation/crafter/crafter_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,59 @@ func (s *crafterSuite) TestInit() {
210210
}
211211
}
212212

213+
func (s *crafterSuite) TestInitPRMode() {
214+
// PR mode sets the chainloop.dev/is-pull-request=true annotation on the
215+
// attestation. When PR mode is off, no such annotation is present.
216+
testCases := []struct {
217+
name string
218+
prMode bool
219+
wantAnnotation bool
220+
}{
221+
{
222+
name: "PR mode on sets the is-pull-request annotation",
223+
prMode: true,
224+
wantAnnotation: true,
225+
},
226+
{
227+
name: "PR mode off does not set the annotation",
228+
prMode: false,
229+
wantAnnotation: false,
230+
},
231+
}
232+
233+
for _, tc := range testCases {
234+
s.Run(tc.name, func() {
235+
contract, err := loadSchema("testdata/contracts/empty_generic.yaml")
236+
require.NoError(s.T(), err)
237+
238+
statePath := fmt.Sprintf("%s/attestation.json", s.T().TempDir())
239+
c, err := crafter.NewCrafter(testingStateManager(s.T(), statePath), nil)
240+
require.NoError(s.T(), err)
241+
242+
testLogger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled)
243+
runner := crafter.NewRunner(schemaapi.CraftingSchema_Runner_RUNNER_TYPE_UNSPECIFIED, "", &testLogger)
244+
245+
require.NoError(s.T(), c.Init(context.Background(), &crafter.InitOpts{
246+
SchemaV1: contract,
247+
SchemaV2: nil,
248+
WfInfo: s.workflowMetadata,
249+
DryRun: true,
250+
AttestationID: "",
251+
Runner: runner,
252+
PRMode: tc.prMode,
253+
}))
254+
255+
annotations := c.CraftingState.GetAttestation().GetAnnotations()
256+
if tc.wantAnnotation {
257+
s.Contains(annotations, crafter.AnnotationIsPullRequest)
258+
s.Equal("true", annotations[crafter.AnnotationIsPullRequest])
259+
} else {
260+
s.NotContains(annotations, crafter.AnnotationIsPullRequest)
261+
}
262+
})
263+
}
264+
}
265+
213266
type testingCrafter struct {
214267
*crafter.Crafter
215268
}

0 commit comments

Comments
 (0)