diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index f46eb9967..e70584e80 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -201,7 +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 (sets the chainloop.dev/is-pull-request annotation; auto-detected from CI env if not set)") + 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 89558b5b5..b0eb5cf65 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -350,7 +350,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 (sets the chainloop.dev/is-pull-request annotation; auto-detected from CI env if not set) +--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 f75c5bc82..492e75c15 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -194,13 +194,16 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun } // Resolve PR mode: explicit --pr flag wins, otherwise auto-detect from the - // CI runner environment. PR mode only tags the attestation with the - // chainloop.dev/is-pull-request annotation; it does not alter latest - // promotion (use --mark-latest=false explicitly to skip promotion). + // 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 @@ -566,3 +569,10 @@ func (action *AttestationInit) resolvePRMode(ctx context.Context, runner crafter } 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 3452cba13..a97ba0885 100644 --- a/app/cli/pkg/action/attestation_init_test.go +++ b/app/cli/pkg/action/attestation_init_test.go @@ -342,6 +342,36 @@ 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. diff --git a/app/controlplane/api/controlplane/v1/workflow_run.pb.go b/app/controlplane/api/controlplane/v1/workflow_run.pb.go index d87662b4c..be7ed7de2 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_run.pb.go @@ -604,9 +604,11 @@ type AttestationServiceInitRequest struct { // Mutually exclusive with project_version. UseLatestVersion bool `protobuf:"varint,8,opt,name=use_latest_version,json=useLatestVersion,proto3" json:"use_latest_version,omitempty"` // Optional flag to control whether the version should be marked as the latest. - // Omitted: default behavior (new versions become latest). - // true: force promote to latest (only pre-release versions). - // false: skip latest promotion. + // Omitted (nil): new versions become latest, and an existing newest + // pre-release version is promoted to latest when it is not already latest. + // true: force promotion of a pre-release version to latest (released + // versions are rejected). + // false: skip latest promotion (also the sentinel sent by PR mode). // Mutually exclusive with use_latest_version. MarkAsLatest *bool `protobuf:"varint,9,opt,name=mark_as_latest,json=markAsLatest,proto3,oneof" json:"mark_as_latest,omitempty"` unknownFields protoimpl.UnknownFields diff --git a/app/controlplane/api/controlplane/v1/workflow_run.proto b/app/controlplane/api/controlplane/v1/workflow_run.proto index f29492c05..7481b04c3 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.proto +++ b/app/controlplane/api/controlplane/v1/workflow_run.proto @@ -128,9 +128,11 @@ message AttestationServiceInitRequest { // Mutually exclusive with project_version. bool use_latest_version = 8; // Optional flag to control whether the version should be marked as the latest. - // Omitted: default behavior (new versions become latest). - // true: force promote to latest (only pre-release versions). - // false: skip latest promotion. + // Omitted (nil): new versions become latest, and an existing newest + // pre-release version is promoted to latest when it is not already latest. + // true: force promotion of a pre-release version to latest (released + // versions are rejected). + // false: skip latest promotion (also the sentinel sent by PR mode). // Mutually exclusive with use_latest_version. optional bool mark_as_latest = 9; } diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts index 5e68be807..d6b9835e2 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts @@ -112,9 +112,11 @@ export interface AttestationServiceInitRequest { useLatestVersion: boolean; /** * Optional flag to control whether the version should be marked as the latest. - * Omitted: default behavior (new versions become latest). - * true: force promote to latest (only pre-release versions). - * false: skip latest promotion. + * Omitted (nil): new versions become latest, and an existing newest + * pre-release version is promoted to latest when it is not already latest. + * true: force promotion of a pre-release version to latest (released + * versions are rejected). + * false: skip latest promotion (also the sentinel sent by PR mode). * Mutually exclusive with use_latest_version. */ markAsLatest?: boolean | undefined; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json index 45ffc9097..418d9d742 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json @@ -12,7 +12,7 @@ "type": "string" }, "^(mark_as_latest)$": { - "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted: default behavior (new versions become latest).\n true: force promote to latest (only pre-release versions).\n false: skip latest promotion.\n Mutually exclusive with use_latest_version.", + "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted (nil): new versions become latest, and an existing newest\n pre-release version is promoted to latest when it is not already latest.\n true: force promotion of a pre-release version to latest (released\n versions are rejected).\n false: skip latest promotion (also the sentinel sent by PR mode).\n Mutually exclusive with use_latest_version.", "type": "boolean" }, "^(project_name)$": { @@ -46,7 +46,7 @@ "type": "string" }, "markAsLatest": { - "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted: default behavior (new versions become latest).\n true: force promote to latest (only pre-release versions).\n false: skip latest promotion.\n Mutually exclusive with use_latest_version.", + "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted (nil): new versions become latest, and an existing newest\n pre-release version is promoted to latest when it is not already latest.\n true: force promotion of a pre-release version to latest (released\n versions are rejected).\n false: skip latest promotion (also the sentinel sent by PR mode).\n Mutually exclusive with use_latest_version.", "type": "boolean" }, "projectName": { diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json index 47b2fade3..92d45e621 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json @@ -12,7 +12,7 @@ "type": "string" }, "^(markAsLatest)$": { - "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted: default behavior (new versions become latest).\n true: force promote to latest (only pre-release versions).\n false: skip latest promotion.\n Mutually exclusive with use_latest_version.", + "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted (nil): new versions become latest, and an existing newest\n pre-release version is promoted to latest when it is not already latest.\n true: force promotion of a pre-release version to latest (released\n versions are rejected).\n false: skip latest promotion (also the sentinel sent by PR mode).\n Mutually exclusive with use_latest_version.", "type": "boolean" }, "^(projectName)$": { @@ -46,7 +46,7 @@ "type": "string" }, "mark_as_latest": { - "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted: default behavior (new versions become latest).\n true: force promote to latest (only pre-release versions).\n false: skip latest promotion.\n Mutually exclusive with use_latest_version.", + "description": "Optional flag to control whether the version should be marked as the latest.\n Omitted (nil): new versions become latest, and an existing newest\n pre-release version is promoted to latest when it is not already latest.\n true: force promotion of a pre-release version to latest (released\n versions are rejected).\n false: skip latest promotion (also the sentinel sent by PR mode).\n Mutually exclusive with use_latest_version.", "type": "boolean" }, "project_name": { diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 4eb9199a3..559e5bce5 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -826,16 +826,7 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { }) s.T().Run("multiple new versions with mark-as-latest=false — none are latest", func(_ *testing.T) { - // Use a fresh workflow/project to start with a clean slate - wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ - Name: "ml-none-latest-wf", OrgID: s.org.ID, Project: "ml-none-latest-project", - }) - s.Require().NoError(err) - - // Delete the auto-created default version so we start with zero latest - _, err = s.Data.DB.ProjectVersion.Delete(). - Where(entProjectVersion.ProjectID(wf.ProjectID)).Exec(ctx) - s.Require().NoError(err) + wf := s.createIsolatedWorkflow(ctx, "ml-none-latest-wf", "ml-none-latest-project") markFalse := false run1, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ @@ -863,14 +854,7 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { }) s.T().Run("mark-as-latest=false then mark-as-latest=true on second version", func(_ *testing.T) { - wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ - Name: "ml-false-then-true-wf", OrgID: s.org.ID, Project: "ml-false-then-true-project", - }) - s.Require().NoError(err) - - _, err = s.Data.DB.ProjectVersion.Delete(). - Where(entProjectVersion.ProjectID(wf.ProjectID)).Exec(ctx) - s.Require().NoError(err) + wf := s.createIsolatedWorkflow(ctx, "ml-false-then-true-wf", "ml-false-then-true-project") // First version: not latest markFalse := false @@ -978,6 +962,132 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { s.Require().NoError(err) s.False(run.ProjectVersion.Latest) }) + + s.T().Run("omitted promotion repairs newest existing pre-release version", func(_ *testing.T) { + wf := s.createIsolatedWorkflow(ctx, "ml-repair-wf", "ml-repair-project") + + // 1) v1 created normally -> latest=true. + runV1, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "repair-v1", + }) + s.Require().NoError(err) + s.True(runV1.ProjectVersion.Latest) + + // 2) v2 created with mark-as-latest=false (PR/explicit-false sentinel) -> v2 not latest, v1 stays latest. + markFalse := false + runV2PR, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "repair-v2", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(runV2PR.ProjectVersion.Latest) + v1AfterPR, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "repair-v1") + s.Require().NoError(err) + s.True(v1AfterPR.Latest) + + // 3) Re-attest v2 with mark-as-latest=nil (omitted) -> v2 is the newest active pre-release and not latest, so it is repaired to latest, and v1 becomes non-latest. + runV2Repair, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "repair-v2", + }) + s.Require().NoError(err) + s.True(runV2Repair.ProjectVersion.Latest) + v1AfterRepair, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "repair-v1") + s.Require().NoError(err) + s.False(v1AfterRepair.Latest) + }) + + s.T().Run("omitted promotion does not promote an older existing version over a newer one", func(_ *testing.T) { + wf := s.createIsolatedWorkflow(ctx, "ml-repair-older-wf", "ml-repair-older-project") + + // v1 then v2, both created with nil (v2 becomes latest). + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "older-v1", + }) + s.Require().NoError(err) + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "older-v2", + }) + s.Require().NoError(err) + + // Re-attest the older v1 with nil -> must NOT promote it over newer v2. + runOlder, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "older-v1", + }) + s.Require().NoError(err) + s.False(runOlder.ProjectVersion.Latest) + v2After, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "older-v2") + s.Require().NoError(err) + s.True(v2After.Latest) + }) + + s.T().Run("explicit false suppression wins over automatic repair on newer version", func(_ *testing.T) { + wf := s.createIsolatedWorkflow(ctx, "ml-repair-suppress-wf", "ml-repair-suppress-project") + + // v1 not latest, then v2 explicit-true -> v2 latest. + markFalse := false + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "suppress-v1", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + markTrue := true + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "suppress-v2", MarkAsLatest: &markTrue, + }) + s.Require().NoError(err) + + // Explicitly restore v1 as latest. + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "suppress-v1", MarkAsLatest: &markTrue, + }) + s.Require().NoError(err) + v1Restored, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "suppress-v1") + s.Require().NoError(err) + s.True(v1Restored.Latest) + + // Re-attest the newer v2 with mark-as-latest=false (PR/explicit-false sentinel) -> v1 stays latest, v2 not promoted. + runV2Suppressed, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "suppress-v2", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(runV2Suppressed.ProjectVersion.Latest) + v1Final, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "suppress-v1") + s.Require().NoError(err) + s.True(v1Final.Latest) + }) + + s.T().Run("omitted promotion on newest released version does not auto-promote", func(_ *testing.T) { + wf := s.createIsolatedWorkflow(ctx, "ml-repair-released-wf", "ml-repair-released-project") + + // Create the newest version as non-latest (mark=false), then release it. + // This isolates the released-version guard from the already-latest short-circuit. + markFalse := false + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "released-newest", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run.ProjectVersion.Latest) + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + // Re-attest with nil (omitted) without the org released-version block -> succeeds, no auto-promotion. + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "released-newest", + }) + s.Require().NoError(err) + // Released versions are never auto-promoted; latest flag is unchanged. + s.False(run2.ProjectVersion.Latest) + }) } func (s *workflowRunIntegrationTestSuite) TestContractInformation() { @@ -1023,6 +1133,20 @@ func TestWorkflowRunUseCase(t *testing.T) { suite.Run(t, new(workflowRunIntegrationTestSuite)) } +// createIsolatedWorkflow creates a fresh workflow in its own project and +// deletes the auto-created default version, so a subtest starts with a clean +// latest-version slate. The caller drives the full lifecycle under test. +func (s *workflowRunIntegrationTestSuite) createIsolatedWorkflow(ctx context.Context, name, project string) *biz.Workflow { + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: name, OrgID: s.org.ID, Project: project, + }) + s.Require().NoError(err) + _, err = s.Data.DB.ProjectVersion.Delete(). + Where(entProjectVersion.ProjectID(wf.ProjectID)).Exec(ctx) + s.Require().NoError(err) + return wf +} + // Utility struct to hold the test suite type workflowRunIntegrationTestSuite struct { testhelpers.UseCasesEachTestSuite diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index 6406a184f..7f9094983 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -99,9 +99,11 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC return fmt.Errorf("creating version: %w", err) } versionCreated = true - case opts.BlockReleasedVersions || markAsLatest: - // Re-read the version inside the transaction with a row lock so a - // concurrent release can't slip past these checks. + default: + // Re-read the existing version inside the transaction with a row lock + // so a concurrent release or soft deletion can't slip past the checks + // below. The fresh row is the authoritative source for all subsequent + // promotion decisions in this transaction. fresh, err := tx.ProjectVersion.Query().ForUpdate(). Where(projectversion.ID(version.ID), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()). Only(ctx) @@ -111,20 +113,52 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } return fmt.Errorf("loading version: %w", err) } + version = fresh // Reject new attestations against an already-released (immutable) version. if opts.BlockReleasedVersions && !fresh.Prerelease { return biz.NewErrReleasedVersionImmutable(fresh.Version) } - if markAsLatest { + switch { + case markAsLatest: + // Explicit --mark-latest=true: force promotion of a pre-release + // version. Released versions are rejected. if !fresh.Prerelease { return biz.NewErrValidationStr("cannot promote a released version to latest") } - if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { return fmt.Errorf("promoting version to latest: %w", err) } + case opts.MarkAsLatest != nil: + // Explicit --mark-latest=false (also the PR-mode sentinel): make no + // latest-state change. This suppression wins over automatic repair. + default: + // Omitted promotion intent (nil): auto-repair latest when the + // existing version is the newest active pre-release version in the + // project and it is not already latest. "Newest" means no other + // active version has a strictly greater immutable created_at, the + // same ordering the latest-version migration used. Released and + // already-latest versions are left untouched; released versions are + // never auto-promoted (only explicit true attempts promotion and is + // rejected above). + if fresh.Prerelease && !fresh.Latest { + newerExists, err := tx.ProjectVersion.Query(). + Where( + projectversion.ProjectID(wf.ProjectID), + projectversion.DeletedAtIsNil(), + projectversion.CreatedAtGT(fresh.CreatedAt), + ). + Exist(ctx) + if err != nil { + return fmt.Errorf("checking for newer versions: %w", err) + } + if !newerExists { + if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { + return fmt.Errorf("promoting version to latest: %w", err) + } + } + } } }