diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index ed4525ee8..e94da0875 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -40,6 +40,7 @@ func newAttestationInitCmd() *cobra.Command { existingVersion bool newWorkflowcontract string collectors []string + markAsLatest bool ) cmd := &cobra.Command{ @@ -50,7 +51,7 @@ func newAttestationInitCmd() *cobra.Command { supportsFederatedAuthAnnotation: trueString, confirmWhenUserToken: trueString, }, - PreRunE: func(_ *cobra.Command, _ []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) error { if workflowName == "" { return errors.New("workflow name is required, set it via --workflow flag") } @@ -74,6 +75,10 @@ func newAttestationInitCmd() *cobra.Command { return errors.New("--latest-version and --version are mutually exclusive") } + if cmd.Flags().Changed("mark-latest") && useLatestVersion { + return errors.New("--mark-latest and --latest-version are mutually exclusive") + } + if projectVersion == "" && projectVersionRelease { return errors.New("project version is required when using --release") } @@ -107,6 +112,11 @@ func newAttestationInitCmd() *cobra.Command { return fmt.Errorf("failed to initialize attestation: %w", err) } + var markAsLatestPtr *bool + if cmd.Flags().Changed("mark-latest") { + markAsLatestPtr = &markAsLatest + } + var attestationID string err = runWithBackoffRetry( func() error { @@ -121,6 +131,7 @@ func newAttestationInitCmd() *cobra.Command { ProjectVersionMarkAsReleased: projectVersionRelease, RequireExistingVersion: existingVersion, Collectors: collectors, + MarkAsLatest: markAsLatestPtr, }) return err @@ -182,6 +193,7 @@ func newAttestationInitCmd() *cobra.Command { cmd.Flags().BoolVar(&projectVersionRelease, "release", false, "promote the provided version as a release") 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)") return cmd } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 2e82debd3..361431e03 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -328,6 +328,7 @@ Options --existing-version return an error if the version doesn't exist in the project -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) --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 46edc2bf8..89689421f 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -103,7 +103,8 @@ type AttestationInitRunOpts struct { WorkflowName string NewWorkflowContractRef string // Collectors is a list of additional collector names to enable (e.g. "aiconfig") - Collectors []string + Collectors []string + MarkAsLatest *bool } func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRunOpts) (string, error) { @@ -218,6 +219,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun ProjectVersion: opts.ProjectVersion, UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, + MarkAsLatest: opts.MarkAsLatest, }, ) if err != nil { diff --git a/app/controlplane/api/controlplane/v1/response_messages.pb.go b/app/controlplane/api/controlplane/v1/response_messages.pb.go index d35ded0c0..bb31e3d26 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.pb.go +++ b/app/controlplane/api/controlplane/v1/response_messages.pb.go @@ -1048,6 +1048,7 @@ type ProjectVersion struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // when it was marked as released ReleasedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=released_at,json=releasedAt,proto3" json:"released_at,omitempty"` + Latest bool `protobuf:"varint,6,opt,name=latest,proto3" json:"latest,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1117,6 +1118,13 @@ func (x *ProjectVersion) GetReleasedAt() *timestamppb.Timestamp { return nil } +func (x *ProjectVersion) GetLatest() bool { + if x != nil { + return x.Latest + } + return false +} + // PolicyStatusSummary bundles the canonical PolicyStatus with per-evaluation // counters. It is surfaced on both WorkflowRunItem (list response) and // AttestationItem.PolicyEvaluationStatus (describe response) and is computed @@ -3113,7 +3121,7 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\aversion\x18\r \x01(\v2\x1f.controlplane.v1.ProjectVersionR\aversion\x12;\n" + "\x15has_policy_violations\x18\x0e \x01(\bB\x02\x18\x01H\x00R\x13hasPolicyViolations\x88\x01\x01\x12K\n" + "\x0epolicy_summary\x18\x0f \x01(\v2$.controlplane.v1.PolicyStatusSummaryR\rpolicySummaryB\x18\n" + - "\x16_has_policy_violations\"\xd2\x01\n" + + "\x16_has_policy_violations\"\xea\x01\n" + "\x0eProjectVersion\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x1e\n" + @@ -3123,7 +3131,8 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" + "\vreleased_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "releasedAt\"\xed\x01\n" + + "releasedAt\x12\x16\n" + + "\x06latest\x18\x06 \x01(\bR\x06latest\"\xed\x01\n" + "\x13PolicyStatusSummary\x125\n" + "\x06status\x18\x01 \x01(\x0e2\x1d.controlplane.v1.PolicyStatusR\x06status\x12\x14\n" + "\x05total\x18\x02 \x01(\x05R\x05total\x12\x16\n" + diff --git a/app/controlplane/api/controlplane/v1/response_messages.proto b/app/controlplane/api/controlplane/v1/response_messages.proto index bcfa6e8a4..c1450d7f1 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.proto +++ b/app/controlplane/api/controlplane/v1/response_messages.proto @@ -87,6 +87,7 @@ message ProjectVersion { google.protobuf.Timestamp created_at = 4; // when it was marked as released google.protobuf.Timestamp released_at = 5; + bool latest = 6; } enum RunStatus { diff --git a/app/controlplane/api/controlplane/v1/workflow_run.pb.go b/app/controlplane/api/controlplane/v1/workflow_run.pb.go index 7c19e9dff..c5d4fa2e1 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_run.pb.go @@ -603,8 +603,14 @@ type AttestationServiceInitRequest struct { // Use the latest project version instead of specifying one explicitly. // Mutually exclusive with project_version. UseLatestVersion bool `protobuf:"varint,8,opt,name=use_latest_version,json=useLatestVersion,proto3" json:"use_latest_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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. + // 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 + sizeCache protoimpl.SizeCache } func (x *AttestationServiceInitRequest) Reset() { @@ -693,6 +699,13 @@ func (x *AttestationServiceInitRequest) GetUseLatestVersion() bool { return false } +func (x *AttestationServiceInitRequest) GetMarkAsLatest() bool { + if x != nil && x.MarkAsLatest != nil { + return *x.MarkAsLatest + } + return false +} + type AttestationServiceInitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *AttestationServiceInitResponse_Result `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -1794,7 +1807,7 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\x06result\x18\x01 \x01(\v2=.controlplane.v1.AttestationServiceGetContractResponse.ResultR\x06result\x1a\x8d\x01\n" + "\x06Result\x129\n" + "\bworkflow\x18\x01 \x01(\v2\x1d.controlplane.v1.WorkflowItemR\bworkflow\x12H\n" + - "\bcontract\x18\x02 \x01(\v2,.controlplane.v1.WorkflowContractVersionItemR\bcontract\"\x9f\x03\n" + + "\bcontract\x18\x02 \x01(\v2,.controlplane.v1.WorkflowContractVersionItemR\bcontract\"\xdd\x03\n" + "\x1dAttestationServiceInitRequest\x12+\n" + "\x11contract_revision\x18\x01 \x01(\x05R\x10contractRevision\x12\x17\n" + "\ajob_url\x18\x02 \x01(\tR\x06jobUrl\x12M\n" + @@ -1803,7 +1816,9 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\fproject_name\x18\x05 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\vprojectName\x12'\n" + "\x0fproject_version\x18\x06 \x01(\tR\x0eprojectVersion\x128\n" + "\x18require_existing_version\x18\a \x01(\bR\x16requireExistingVersion\x12,\n" + - "\x12use_latest_version\x18\b \x01(\bR\x10useLatestVersion\"\x94\x05\n" + + "\x12use_latest_version\x18\b \x01(\bR\x10useLatestVersion\x12)\n" + + "\x0emark_as_latest\x18\t \x01(\bH\x00R\fmarkAsLatest\x88\x01\x01B\x11\n" + + "\x0f_mark_as_latest\"\x94\x05\n" + "\x1eAttestationServiceInitResponse\x12N\n" + "\x06result\x18\x01 \x01(\v26.controlplane.v1.AttestationServiceInitResponse.ResultR\x06result\x1a\xb8\x03\n" + "\x06Result\x12C\n" + @@ -2010,6 +2025,7 @@ func file_controlplane_v1_workflow_run_proto_init() { } file_controlplane_v1_pagination_proto_init() file_controlplane_v1_response_messages_proto_init() + file_controlplane_v1_workflow_run_proto_msgTypes[9].OneofWrappers = []any{} file_controlplane_v1_workflow_run_proto_msgTypes[11].OneofWrappers = []any{} file_controlplane_v1_workflow_run_proto_msgTypes[17].OneofWrappers = []any{ (*WorkflowRunServiceViewRequest_Id)(nil), diff --git a/app/controlplane/api/controlplane/v1/workflow_run.proto b/app/controlplane/api/controlplane/v1/workflow_run.proto index 2c8e76127..e02508e41 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.proto +++ b/app/controlplane/api/controlplane/v1/workflow_run.proto @@ -127,6 +127,12 @@ message AttestationServiceInitRequest { // Use the latest project version instead of specifying one explicitly. // 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. + // Mutually exclusive with use_latest_version. + optional bool mark_as_latest = 9; } message AttestationServiceInitResponse { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts index 4f82a34f0..0ff6e3514 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts @@ -559,6 +559,7 @@ export interface ProjectVersion { createdAt?: Date; /** when it was marked as released */ releasedAt?: Date; + latest: boolean; } /** @@ -1512,7 +1513,7 @@ export const WorkflowRunItem = { }; function createBaseProjectVersion(): ProjectVersion { - return { id: "", version: "", prerelease: false, createdAt: undefined, releasedAt: undefined }; + return { id: "", version: "", prerelease: false, createdAt: undefined, releasedAt: undefined, latest: false }; } export const ProjectVersion = { @@ -1532,6 +1533,9 @@ export const ProjectVersion = { if (message.releasedAt !== undefined) { Timestamp.encode(toTimestamp(message.releasedAt), writer.uint32(42).fork()).ldelim(); } + if (message.latest === true) { + writer.uint32(48).bool(message.latest); + } return writer; }, @@ -1577,6 +1581,13 @@ export const ProjectVersion = { message.releasedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); continue; + case 6: + if (tag !== 48) { + break; + } + + message.latest = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1593,6 +1604,7 @@ export const ProjectVersion = { prerelease: isSet(object.prerelease) ? Boolean(object.prerelease) : false, createdAt: isSet(object.createdAt) ? fromJsonTimestamp(object.createdAt) : undefined, releasedAt: isSet(object.releasedAt) ? fromJsonTimestamp(object.releasedAt) : undefined, + latest: isSet(object.latest) ? Boolean(object.latest) : false, }; }, @@ -1603,6 +1615,7 @@ export const ProjectVersion = { message.prerelease !== undefined && (obj.prerelease = message.prerelease); message.createdAt !== undefined && (obj.createdAt = message.createdAt.toISOString()); message.releasedAt !== undefined && (obj.releasedAt = message.releasedAt.toISOString()); + message.latest !== undefined && (obj.latest = message.latest); return obj; }, @@ -1617,6 +1630,7 @@ export const ProjectVersion = { message.prerelease = object.prerelease ?? false; message.createdAt = object.createdAt ?? undefined; message.releasedAt = object.releasedAt ?? undefined; + message.latest = object.latest ?? false; return message; }, }; 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 de1a394ec..08562204a 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts @@ -110,6 +110,14 @@ export interface AttestationServiceInitRequest { * Mutually exclusive with project_version. */ 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. + * Mutually exclusive with use_latest_version. + */ + markAsLatest?: boolean | undefined; } export interface AttestationServiceInitResponse { @@ -1094,6 +1102,7 @@ function createBaseAttestationServiceInitRequest(): AttestationServiceInitReques projectVersion: "", requireExistingVersion: false, useLatestVersion: false, + markAsLatest: undefined, }; } @@ -1123,6 +1132,9 @@ export const AttestationServiceInitRequest = { if (message.useLatestVersion === true) { writer.uint32(64).bool(message.useLatestVersion); } + if (message.markAsLatest !== undefined) { + writer.uint32(72).bool(message.markAsLatest); + } return writer; }, @@ -1189,6 +1201,13 @@ export const AttestationServiceInitRequest = { message.useLatestVersion = reader.bool(); continue; + case 9: + if (tag !== 72) { + break; + } + + message.markAsLatest = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1208,6 +1227,7 @@ export const AttestationServiceInitRequest = { projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", requireExistingVersion: isSet(object.requireExistingVersion) ? Boolean(object.requireExistingVersion) : false, useLatestVersion: isSet(object.useLatestVersion) ? Boolean(object.useLatestVersion) : false, + markAsLatest: isSet(object.markAsLatest) ? Boolean(object.markAsLatest) : undefined, }; }, @@ -1221,6 +1241,7 @@ export const AttestationServiceInitRequest = { message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); message.requireExistingVersion !== undefined && (obj.requireExistingVersion = message.requireExistingVersion); message.useLatestVersion !== undefined && (obj.useLatestVersion = message.useLatestVersion); + message.markAsLatest !== undefined && (obj.markAsLatest = message.markAsLatest); return obj; }, @@ -1240,6 +1261,7 @@ export const AttestationServiceInitRequest = { message.projectVersion = object.projectVersion ?? ""; message.requireExistingVersion = object.requireExistingVersion ?? false; message.useLatestVersion = object.useLatestVersion ?? false; + message.markAsLatest = object.markAsLatest ?? undefined; return message; }, }; 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 cec86633a..45ffc9097 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json @@ -11,6 +11,10 @@ "^(job_url)$": { "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.", + "type": "boolean" + }, "^(project_name)$": { "minLength": 1, "type": "string" @@ -41,6 +45,10 @@ "jobUrl": { "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.", + "type": "boolean" + }, "projectName": { "minLength": 1, "type": "string" 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 6f2c78c2b..47b2fade3 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json @@ -11,6 +11,10 @@ "^(jobUrl)$": { "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.", + "type": "boolean" + }, "^(projectName)$": { "minLength": 1, "type": "string" @@ -41,6 +45,10 @@ "job_url": { "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.", + "type": "boolean" + }, "project_name": { "minLength": 1, "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.jsonschema.json index b61d22749..4bb2d5d47 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.jsonschema.json @@ -18,6 +18,9 @@ "id": { "type": "string" }, + "latest": { + "type": "boolean" + }, "prerelease": { "type": "boolean" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.schema.json index 2135bea03..03d49b43b 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ProjectVersion.schema.json @@ -18,6 +18,9 @@ "id": { "type": "string" }, + "latest": { + "type": "boolean" + }, "prerelease": { "type": "boolean" }, diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 27cbbeeae..afef06789 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -205,6 +205,7 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer ProjectVersion: req.GetProjectVersion(), UseLatestVersion: req.GetUseLatestVersion(), RequireExistingVersion: req.GetRequireExistingVersion(), + MarkAsLatest: req.MarkAsLatest, } run, err := s.wrUseCase.Create(ctx, opts) diff --git a/app/controlplane/internal/service/workflowrun.go b/app/controlplane/internal/service/workflowrun.go index 4934ed6e5..768172349 100644 --- a/app/controlplane/internal/service/workflowrun.go +++ b/app/controlplane/internal/service/workflowrun.go @@ -363,6 +363,7 @@ func bizProjectVersionToPb(v *biz.ProjectVersion) *pb.ProjectVersion { Id: v.ID.String(), Version: v.Version, Prerelease: v.Prerelease, + Latest: v.Latest, } if v.CreatedAt != nil { diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index 45b2cb947..21b3e90ec 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.go @@ -38,6 +38,8 @@ type ProjectVersion struct { Version string // Prerelease indicates whether the version is a prerelease. Prerelease bool + // Latest indicates whether this is the latest version of the project. + Latest bool // TotalWorkflowRuns is the total number of workflow runs for this version. TotalWorkflowRuns int // CreatedAt is the time when the project version was created. @@ -53,6 +55,7 @@ type ProjectVersionRepo interface { FindByProjectAndVersion(ctx context.Context, projectID uuid.UUID, version string) (*ProjectVersion, error) Update(ctx context.Context, versionID uuid.UUID, updates *ProjectVersionUpdateOpts) (*ProjectVersion, error) Create(ctx context.Context, projectID uuid.UUID, version string, prerelease bool) (*ProjectVersion, error) + MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error } type ProjectVersionUseCase struct { @@ -97,6 +100,25 @@ func (uc *ProjectVersionUseCase) UpdateReleaseStatus(ctx context.Context, versio return uc.projectRepo.Update(ctx, versionUUID, &ProjectVersionUpdateOpts{Prerelease: &preReleaseValue}) } +// MarkAsLatest promotes a pre-release version to latest. The platform repo builds the +// "project version mark-latest" CLI command and service endpoint on top of this method. +func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, versionID string) error { + ctx, span := otelx.Start(ctx, projectVersionTracer, "ProjectVersionUseCase.MarkAsLatest") + defer span.End() + + projectUUID, err := uuid.Parse(projectID) + if err != nil { + return NewErrInvalidUUID(err) + } + + versionUUID, err := uuid.Parse(versionID) + if err != nil { + return NewErrInvalidUUID(err) + } + + return uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID) +} + func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version string, prerelease bool) (*ProjectVersion, error) { ctx, span := otelx.Start(ctx, projectVersionTracer, "ProjectVersionUseCase.Create") defer span.End() diff --git a/app/controlplane/pkg/biz/projectversion_integration_test.go b/app/controlplane/pkg/biz/projectversion_integration_test.go index 00dee076f..927a95ab5 100644 --- a/app/controlplane/pkg/biz/projectversion_integration_test.go +++ b/app/controlplane/pkg/biz/projectversion_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -113,6 +113,91 @@ func (s *ProjectVersionIntegrationTestSuite) SetupTest() { require.NoError(t, err) } +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatest() { + t := s.T() + ctx := context.Background() + + // Create two pre-release versions — the second one becomes latest by default + v1, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "1.0.0", true) + require.NoError(t, err) + require.True(t, v1.Latest) + + v2, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "2.0.0", true) + require.NoError(t, err) + require.True(t, v2.Latest) + + // v1 should no longer be latest after v2 was created + v1After, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.project.ID.String(), "1.0.0") + require.NoError(t, err) + require.False(t, v1After.Latest) + + // Promote v1 back to latest + err = s.ProjectVersion.MarkAsLatest(ctx, s.project.ID.String(), v1.ID.String()) + require.NoError(t, err) + + // Verify v1 is now latest + v1Promoted, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.project.ID.String(), "1.0.0") + require.NoError(t, err) + require.True(t, v1Promoted.Latest) + + // Verify v2 was demoted + v2Demoted, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.project.ID.String(), "2.0.0") + require.NoError(t, err) + require.False(t, v2Demoted.Latest) +} + +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestReleasedVersionError() { + t := s.T() + ctx := context.Background() + + // Create a version and release it + v, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "1.0.0", true) + require.NoError(t, err) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, v.ID.String(), true) + require.NoError(t, err) + + // Attempting to mark a released version as latest should fail + err = s.ProjectVersion.MarkAsLatest(ctx, s.project.ID.String(), v.ID.String()) + require.Error(t, err) + require.True(t, biz.IsErrValidation(err)) +} + +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestIdempotent() { + t := s.T() + ctx := context.Background() + + v, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "1.0.0", true) + require.NoError(t, err) + require.True(t, v.Latest) + + // Promoting a version that is already latest should succeed (idempotent) + err = s.ProjectVersion.MarkAsLatest(ctx, s.project.ID.String(), v.ID.String()) + require.NoError(t, err) + + reloaded, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.project.ID.String(), "1.0.0") + require.NoError(t, err) + require.True(t, reloaded.Latest) +} + +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestNonExistentVersion() { + t := s.T() + ctx := context.Background() + + nonExistentID := "00000000-0000-0000-0000-000000000099" + err := s.ProjectVersion.MarkAsLatest(ctx, s.project.ID.String(), nonExistentID) + require.Error(t, err) + require.True(t, biz.IsNotFound(err)) +} + +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestInvalidUUID() { + t := s.T() + ctx := context.Background() + + err := s.ProjectVersion.MarkAsLatest(ctx, "invalid", "invalid") + require.Error(t, err) +} + func TestProjectVersionUseCase(t *testing.T) { suite.Run(t, new(ProjectVersionIntegrationTestSuite)) } diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index be88d561e..b77afe329 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -234,6 +234,7 @@ type WorkflowRunCreateOpts struct { ProjectVersion string UseLatestVersion bool RequireExistingVersion bool + MarkAsLatest *bool } type WorkflowRunRepoCreateOpts struct { @@ -244,6 +245,7 @@ type WorkflowRunRepoCreateOpts struct { ProjectVersion string UseLatestVersion bool RequireExistingVersion bool + MarkAsLatest *bool } // Create will add a new WorkflowRun, associate it to a schemaVersion and increment the counter in the associated workflow @@ -270,6 +272,10 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat return nil, NewErrValidationStr("cannot specify both a project version and use-latest-version") } + if opts.MarkAsLatest != nil && opts.UseLatestVersion { + return nil, NewErrValidationStr("--mark-latest and --latest-version are mutually exclusive") + } + // Treat empty version as the default for backward compatibility with old clients if opts.ProjectVersion == "" && !opts.UseLatestVersion { opts.ProjectVersion = DefaultVersionName @@ -296,6 +302,7 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat ProjectVersion: opts.ProjectVersion, UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, + MarkAsLatest: opts.MarkAsLatest, }) if err != nil { return nil, err diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index cf415c5e0..3b188dd6b 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "os" "testing" + "time" schemav1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" @@ -497,6 +498,364 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { s.True(biz.IsErrValidation(err)) s.Contains(err.Error(), "cannot specify both") }) + + s.T().Run("mark-as-latest nil — new version becomes latest (default behavior)", func(_ *testing.T) { + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nil-test", + }) + s.Require().NoError(err) + s.True(run.ProjectVersion.Latest) + }) + + s.T().Run("mark-as-latest true — new version becomes latest", func(_ *testing.T) { + markTrue := true + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-true-test", MarkAsLatest: &markTrue, + }) + s.Require().NoError(err) + s.True(run.ProjectVersion.Latest) + }) + + s.T().Run("mark-as-latest false — new version does NOT become latest", func(_ *testing.T) { + // Create a version that IS latest first + runLatest, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-false-before", + }) + s.Require().NoError(err) + s.True(runLatest.ProjectVersion.Latest) + + markFalse := false + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-false-test", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run.ProjectVersion.Latest) + + // Verify the previous version is still latest + prevVersion, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), "ml-false-before") + s.Require().NoError(err) + s.True(prevVersion.Latest) + }) + + s.T().Run("mark-as-latest true with existing pre-release version — promotes it", func(_ *testing.T) { + // Create two versions, the second one is latest + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-promote-v1", + }) + s.Require().NoError(err) + + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-promote-v2", + }) + s.Require().NoError(err) + + // Now re-attest against v1 with mark-as-latest=true to promote it + markTrue := true + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-promote-v1", MarkAsLatest: &markTrue, + }) + s.Require().NoError(err) + s.True(run.ProjectVersion.Latest) + + // v2 should no longer be latest + v2, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), "ml-promote-v2") + s.Require().NoError(err) + s.False(v2.Latest) + }) + + s.T().Run("mark-as-latest and use-latest-version are mutually exclusive", func(_ *testing.T) { + markTrue := true + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", UseLatestVersion: true, MarkAsLatest: &markTrue, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "mutually exclusive") + }) + + s.T().Run("mark-as-latest true on released version returns error", func(_ *testing.T) { + // Create a version and release it + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-released", + }) + s.Require().NoError(err) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + markTrue := true + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-released", MarkAsLatest: &markTrue, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "cannot promote a released version") + }) + + s.T().Run("mark-as-latest true re-reads version inside transaction", func(_ *testing.T) { + // Create a pre-release version + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-reread-test", + }) + s.Require().NoError(err) + s.True(run.ProjectVersion.Prerelease) + + // Release the version — simulates a concurrent release between lookup and tx + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + // Attempt to promote with mark-as-latest=true — the in-tx re-read should catch the release + markTrue := true + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-reread-test", MarkAsLatest: &markTrue, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "cannot promote a released version") + }) + + s.T().Run("mark-as-latest true on soft-deleted version returns not found", func(_ *testing.T) { + // Create a version + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-deleted-test", + }) + s.Require().NoError(err) + + // Soft-delete the version — simulates concurrent deletion between lookup and tx + _, err = s.Data.DB.ProjectVersion.UpdateOneID(run.ProjectVersion.ID). + SetDeletedAt(time.Now()).Save(ctx) + s.Require().NoError(err) + + markTrue := true + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-deleted-test", MarkAsLatest: &markTrue, + }) + // The pre-tx lookup won't find the soft-deleted version, so it creates a new one — this is fine + s.Require().NoError(err) + }) + + s.T().Run("mark-as-latest false on existing version — no promotion change", func(_ *testing.T) { + // Create two versions — v2 is latest + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nochange-v1", + }) + s.Require().NoError(err) + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nochange-v2", + }) + s.Require().NoError(err) + + // Re-attest v1 with mark-as-latest=false — should NOT promote v1 + markFalse := false + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nochange-v1", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run.ProjectVersion.Latest) + + // v2 should still be latest + v2, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), "ml-nochange-v2") + s.Require().NoError(err) + s.True(v2.Latest) + }) + + s.T().Run("mark-as-latest nil on existing version — no promotion change", func(_ *testing.T) { + // Create two versions — v2 is latest + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nilexist-v1", + }) + s.Require().NoError(err) + _, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nilexist-v2", + }) + s.Require().NoError(err) + + // Re-attest v1 with mark-as-latest=nil (omitted) — should NOT promote v1 + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nilexist-v1", + }) + s.Require().NoError(err) + s.False(run.ProjectVersion.Latest) + + // v2 should still be latest + v2, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), "ml-nilexist-v2") + s.Require().NoError(err) + s.True(v2.Latest) + }) + + 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) + + markFalse := false + run1, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "a1", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run1.ProjectVersion.Latest) + + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "a2", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run2.ProjectVersion.Latest) + + // Verify neither is latest + v1, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "a1") + s.Require().NoError(err) + s.False(v1.Latest) + + v2, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "a2") + s.Require().NoError(err) + s.False(v2.Latest) + }) + + 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) + + // First version: not latest + markFalse := false + run1, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "b1", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run1.ProjectVersion.Latest) + + // Second version: explicitly latest + markTrue := true + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wf.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "b2", MarkAsLatest: &markTrue, + }) + s.Require().NoError(err) + s.True(run2.ProjectVersion.Latest) + + // First version should still not be latest + v1, err := s.ProjectVersion.FindByProjectAndVersion(ctx, wf.ProjectID.String(), "b1") + s.Require().NoError(err) + s.False(v1.Latest) + }) + + s.T().Run("mark-as-latest nil on existing released version — no change", func(_ *testing.T) { + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nil-released", + }) + s.Require().NoError(err) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + // Re-attest with nil (omitted) — should succeed, no promotion change + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-nil-released", + }) + s.Require().NoError(err) + // The version was latest before release and remains so — nil doesn't touch it + s.NotNil(run2.ProjectVersion) + }) + + s.T().Run("mark-as-latest false on existing released version — no change", func(_ *testing.T) { + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-false-released", + }) + s.Require().NoError(err) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + // Re-attest with mark-as-latest=false — should succeed, no promotion change + markFalse := false + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-false-released", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.NotNil(run2.ProjectVersion) + }) + + s.T().Run("mark-as-latest false combined with require-existing-version", func(_ *testing.T) { + // Create a version first — it becomes latest by default + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-require-existing", + }) + s.Require().NoError(err) + + // Re-attest requiring existing version + mark-as-latest=false + // "false" means "don't promote" — the version keeps its current status (latest=true) + markFalse := false + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-require-existing", + RequireExistingVersion: true, MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + // Version was already latest, mark-as-latest=false means "no change" — it stays latest + s.True(run.ProjectVersion.Latest) + }) + + s.T().Run("mark-as-latest true combined with require-existing-version on non-existent version", func(_ *testing.T) { + markTrue := true + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-does-not-exist", + RequireExistingVersion: true, MarkAsLatest: &markTrue, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "not found") + }) + + s.T().Run("mark-as-latest false with use-latest-version false is allowed", func(_ *testing.T) { + markFalse := false + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-false-no-latest-ver", MarkAsLatest: &markFalse, + }) + s.Require().NoError(err) + s.False(run.ProjectVersion.Latest) + }) } func (s *workflowRunIntegrationTestSuite) TestContractInformation() { diff --git a/app/controlplane/pkg/data/projectversion.go b/app/controlplane/pkg/data/projectversion.go index 5228afb9a..eb4908ad3 100644 --- a/app/controlplane/pkg/data/projectversion.go +++ b/app/controlplane/pkg/data/projectversion.go @@ -107,7 +107,7 @@ func (r *ProjectVersionRepo) Create(ctx context.Context, projectID uuid.UUID, ve var res *ent.ProjectVersion if err := WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { var err error - res, err = createProjectVersionWithTx(ctx, tx, projectID, version, prerelease) + res, err = createProjectVersionWithTx(ctx, tx, projectID, version, prerelease, nil) return err }); err != nil { return nil, err @@ -116,27 +116,71 @@ func (r *ProjectVersionRepo) Create(ctx context.Context, projectID uuid.UUID, ve return entProjectVersionToBiz(res), nil } -func createProjectVersionWithTx(ctx context.Context, tx *ent.Tx, projectID uuid.UUID, version string, prerelease bool) (*ent.ProjectVersion, error) { +func createProjectVersionWithTx(ctx context.Context, tx *ent.Tx, projectID uuid.UUID, version string, prerelease bool, markAsLatest *bool) (*ent.ProjectVersion, error) { if version == "" { return nil, biz.NewErrValidationStr("version must not be empty") } - // Update all existing versions of this project to not be the latest + // nil means the caller didn't opt in/out, so new versions default to latest (preserves original behavior) + shouldBeLatest := markAsLatest == nil || *markAsLatest + + if shouldBeLatest { + // Update all existing versions of this project to not be the latest + if err := tx.ProjectVersion.Update(). + Where( + projectversion.ProjectID(projectID), + projectversion.DeletedAtIsNil(), + projectversion.Latest(true), + ).SetLatest(false).Exec(ctx); err != nil { + return nil, err + } + } + + return tx.ProjectVersion.Create(). + SetProjectID(projectID). + SetVersion(version). + SetPrerelease(prerelease). + SetLatest(shouldBeLatest). + Save(ctx) +} + +func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error { + ctx, span := otelx.Start(ctx, projectVersionRepoTracer, "ProjectVersionRepo.MarkAsLatest") + defer span.End() + + return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + v, err := tx.ProjectVersion.Query(). + Where(projectversion.ID(versionID), projectversion.ProjectID(projectID), projectversion.DeletedAtIsNil()). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return biz.NewErrNotFound("Version") + } + return err + } + + if !v.Prerelease { + return biz.NewErrValidationStr("cannot promote a released version to latest") + } + + return promoteVersionToLatestWithTx(ctx, tx, projectID, versionID) + }) +} + +func promoteVersionToLatestWithTx(ctx context.Context, tx *ent.Tx, projectID, versionID uuid.UUID) error { if err := tx.ProjectVersion.Update(). Where( projectversion.ProjectID(projectID), projectversion.DeletedAtIsNil(), projectversion.Latest(true), ).SetLatest(false).Exec(ctx); err != nil { - return nil, err + return err } - return tx.ProjectVersion.Create(). - SetProjectID(projectID). - SetVersion(version). - SetPrerelease(prerelease). + return tx.ProjectVersion.UpdateOneID(versionID). SetLatest(true). - Save(ctx) + SetUpdatedAt(time.Now()). + Exec(ctx) } func findProjectVersionWithClient(ctx context.Context, client *ent.Client, projectID uuid.UUID, version string) (*ent.ProjectVersion, error) { @@ -153,6 +197,7 @@ func entProjectVersionToBiz(v *ent.ProjectVersion) *biz.ProjectVersion { ID: v.ID, Version: v.Version, Prerelease: v.Prerelease, + Latest: v.Latest, TotalWorkflowRuns: v.WorkflowRunCount, CreatedAt: toTimePtr(v.CreatedAt), ReleasedAt: toTimePtr(v.ReleasedAt), diff --git a/app/controlplane/pkg/data/workflow.go b/app/controlplane/pkg/data/workflow.go index f2fb6d130..0d6a3a314 100644 --- a/app/controlplane/pkg/data/workflow.go +++ b/app/controlplane/pkg/data/workflow.go @@ -110,7 +110,7 @@ func (r *WorkflowRepo) Create(ctx context.Context, opts *biz.WorkflowCreateOpts) return fmt.Errorf("finding project version: %w", err) } - if _, err := createProjectVersionWithTx(ctx, tx, projectID, biz.DefaultVersionName, true); err != nil { + if _, err := createProjectVersionWithTx(ctx, tx, projectID, biz.DefaultVersionName, true, nil); err != nil { return fmt.Errorf("creating project version: %w", err) } } diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index 964522b0f..f7150cf9f 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -92,11 +92,30 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC // Create version and workflow in a transaction if err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { if version == nil { - version, err = createProjectVersionWithTx(ctx, tx, wf.ProjectID, opts.ProjectVersion, true) + version, err = createProjectVersionWithTx(ctx, tx, wf.ProjectID, opts.ProjectVersion, true, opts.MarkAsLatest) if err != nil { return fmt.Errorf("creating version: %w", err) } versionCreated = true + } else if opts.MarkAsLatest != nil && *opts.MarkAsLatest { + // Re-read version inside the transaction with a row lock to avoid promoting a concurrently released version + fresh, err := tx.ProjectVersion.Query().ForUpdate(). + Where(projectversion.ID(version.ID), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return biz.NewErrNotFound("Version") + } + return fmt.Errorf("loading version for promotion: %w", err) + } + + 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) + } } // Create workflow run