diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index e346ed8fe..ed4525ee8 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -35,6 +35,7 @@ func newAttestationInitCmd() *cobra.Command { workflowName string projectName string projectVersion string + useLatestVersion bool projectVersionRelease bool existingVersion bool newWorkflowcontract string @@ -54,8 +55,8 @@ func newAttestationInitCmd() *cobra.Command { return errors.New("workflow name is required, set it via --workflow flag") } - // load version from the file if not set - if projectVersion == "" { + // load version from the file if not set and not using --latest-version + if projectVersion == "" && !useLatestVersion { // load the cfg from the file cfg, path, err := loadDotChainloopConfigWithParentTraversal() // we do gracefully load, if not found, or any other error we continue @@ -69,6 +70,10 @@ func newAttestationInitCmd() *cobra.Command { projectVersion = cfg.ProjectVersion } + if useLatestVersion && projectVersion != "" { + return errors.New("--latest-version and --version are mutually exclusive") + } + if projectVersion == "" && projectVersionRelease { return errors.New("project version is required when using --release") } @@ -110,6 +115,7 @@ func newAttestationInitCmd() *cobra.Command { ContractRevision: contractRevision, ProjectName: projectName, ProjectVersion: projectVersion, + UseLatestVersion: useLatestVersion, WorkflowName: workflowName, NewWorkflowContractRef: newWorkflowcontract, ProjectVersionMarkAsReleased: projectVersionRelease, @@ -172,6 +178,7 @@ func newAttestationInitCmd() *cobra.Command { cmd.Flags().StringVar(&newWorkflowcontract, "contract", "", "name of an existing contract or the path/URL to a contract file, to attach it to the auto-created workflow (it doesn't update an existing one)") cmd.Flags().StringVar(&projectVersion, "version", "", "project version, i.e 0.1.0") + cmd.Flags().BoolVar(&useLatestVersion, "latest-version", false, "use the latest existing project version instead of specifying one") 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)") diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 1262ba788..c8d3914c6 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -327,6 +327,7 @@ Options --dry-run do not record attestation in the control plane, useful for development --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 --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 6595014e8..ddad251d2 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -97,6 +97,7 @@ type AttestationInitRunOpts struct { ContractRevision int ProjectName string ProjectVersion string + UseLatestVersion bool ProjectVersionMarkAsReleased bool RequireExistingVersion bool WorkflowName string @@ -170,7 +171,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun ContractName: workflow.ContractName, } - if opts.ProjectVersion != "" { + if opts.ProjectVersion != "" || opts.UseLatestVersion { workflowMeta.Version = &clientAPI.ProjectVersion{ Version: opts.ProjectVersion, MarkAsReleased: opts.ProjectVersionMarkAsReleased, @@ -215,6 +216,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun WorkflowName: opts.WorkflowName, ProjectName: opts.ProjectName, ProjectVersion: opts.ProjectVersion, + UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, }, ) @@ -235,6 +237,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun uiDashboardURL = result.GetUiDashboardUrl() if v := workflowMeta.Version; v != nil && workflowRun.GetVersion() != nil { + v.Version = workflowRun.GetVersion().GetVersion() v.Prerelease = workflowRun.GetVersion().GetPrerelease() } diff --git a/app/controlplane/api/controlplane/v1/workflow_run.pb.go b/app/controlplane/api/controlplane/v1/workflow_run.pb.go index 38f48d79d..bc1a9f373 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_run.pb.go @@ -600,8 +600,11 @@ type AttestationServiceInitRequest struct { ProjectVersion string `protobuf:"bytes,6,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` // Optional flag to require that the project version already exists RequireExistingVersion bool `protobuf:"varint,7,opt,name=require_existing_version,json=requireExistingVersion,proto3" json:"require_existing_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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 } func (x *AttestationServiceInitRequest) Reset() { @@ -683,6 +686,13 @@ func (x *AttestationServiceInitRequest) GetRequireExistingVersion() bool { return false } +func (x *AttestationServiceInitRequest) GetUseLatestVersion() bool { + if x != nil { + return x.UseLatestVersion + } + return false +} + type AttestationServiceInitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *AttestationServiceInitResponse_Result `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -1784,7 +1794,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\"\xf1\x02\n" + + "\bcontract\x18\x02 \x01(\v2,.controlplane.v1.WorkflowContractVersionItemR\bcontract\"\x9f\x03\n" + "\x1dAttestationServiceInitRequest\x12+\n" + "\x11contract_revision\x18\x01 \x01(\x05R\x10contractRevision\x12\x17\n" + "\ajob_url\x18\x02 \x01(\tR\x06jobUrl\x12M\n" + @@ -1792,7 +1802,8 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\rworkflow_name\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fworkflowName\x12*\n" + "\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\"\x94\x05\n" + + "\x18require_existing_version\x18\a \x01(\bR\x16requireExistingVersion\x12,\n" + + "\x12use_latest_version\x18\b \x01(\bR\x10useLatestVersion\"\x94\x05\n" + "\x1eAttestationServiceInitResponse\x12N\n" + "\x06result\x18\x01 \x01(\v26.controlplane.v1.AttestationServiceInitResponse.ResultR\x06result\x1a\xb8\x03\n" + "\x06Result\x12C\n" + diff --git a/app/controlplane/api/controlplane/v1/workflow_run.proto b/app/controlplane/api/controlplane/v1/workflow_run.proto index 7b1decfe3..76aba91aa 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.proto +++ b/app/controlplane/api/controlplane/v1/workflow_run.proto @@ -124,6 +124,9 @@ message AttestationServiceInitRequest { string project_version = 6; // Optional flag to require that the project version already exists bool require_existing_version = 7; + // Use the latest project version instead of specifying one explicitly. + // Mutually exclusive with project_version. + bool use_latest_version = 8; } message AttestationServiceInitResponse { 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 aabc5cacc..e93832f03 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts @@ -99,6 +99,11 @@ export interface AttestationServiceInitRequest { projectVersion: string; /** Optional flag to require that the project version already exists */ requireExistingVersion: boolean; + /** + * Use the latest project version instead of specifying one explicitly. + * Mutually exclusive with project_version. + */ + useLatestVersion: boolean; } export interface AttestationServiceInitResponse { @@ -1081,6 +1086,7 @@ function createBaseAttestationServiceInitRequest(): AttestationServiceInitReques projectName: "", projectVersion: "", requireExistingVersion: false, + useLatestVersion: false, }; } @@ -1107,6 +1113,9 @@ export const AttestationServiceInitRequest = { if (message.requireExistingVersion === true) { writer.uint32(56).bool(message.requireExistingVersion); } + if (message.useLatestVersion === true) { + writer.uint32(64).bool(message.useLatestVersion); + } return writer; }, @@ -1166,6 +1175,13 @@ export const AttestationServiceInitRequest = { message.requireExistingVersion = reader.bool(); continue; + case 8: + if (tag !== 64) { + break; + } + + message.useLatestVersion = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1184,6 +1200,7 @@ export const AttestationServiceInitRequest = { projectName: isSet(object.projectName) ? String(object.projectName) : "", projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", requireExistingVersion: isSet(object.requireExistingVersion) ? Boolean(object.requireExistingVersion) : false, + useLatestVersion: isSet(object.useLatestVersion) ? Boolean(object.useLatestVersion) : false, }; }, @@ -1196,6 +1213,7 @@ export const AttestationServiceInitRequest = { message.projectName !== undefined && (obj.projectName = message.projectName); message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); message.requireExistingVersion !== undefined && (obj.requireExistingVersion = message.requireExistingVersion); + message.useLatestVersion !== undefined && (obj.useLatestVersion = message.useLatestVersion); return obj; }, @@ -1214,6 +1232,7 @@ export const AttestationServiceInitRequest = { message.projectName = object.projectName ?? ""; message.projectVersion = object.projectVersion ?? ""; message.requireExistingVersion = object.requireExistingVersion ?? false; + message.useLatestVersion = object.useLatestVersion ?? false; 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 85c42f1fc..5e6e99f59 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.jsonschema.json @@ -23,6 +23,10 @@ "description": "Optional flag to require that the project version already exists", "type": "boolean" }, + "^(use_latest_version)$": { + "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", + "type": "boolean" + }, "^(workflow_name)$": { "minLength": 1, "type": "string" @@ -73,6 +77,10 @@ } ] }, + "useLatestVersion": { + "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", + "type": "boolean" + }, "workflowName": { "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 707469a66..51049ed78 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationServiceInitRequest.schema.json @@ -23,6 +23,10 @@ "description": "Optional flag to require that the project version already exists", "type": "boolean" }, + "^(useLatestVersion)$": { + "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", + "type": "boolean" + }, "^(workflowName)$": { "minLength": 1, "type": "string" @@ -73,6 +77,10 @@ } ] }, + "use_latest_version": { + "description": "Use the latest project version instead of specifying one explicitly.\n Mutually exclusive with project_version.", + "type": "boolean" + }, "workflow_name": { "minLength": 1, "type": "string" diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index ece70348e..856cffc32 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -201,6 +201,7 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer RunnerType: req.GetRunner().String(), CASBackendID: backend.ID, ProjectVersion: req.GetProjectVersion(), + UseLatestVersion: req.GetUseLatestVersion(), RequireExistingVersion: req.GetRequireExistingVersion(), } diff --git a/app/controlplane/pkg/biz/version_test.go b/app/controlplane/pkg/biz/version_test.go index 5ba65af9a..e4ba5affb 100644 --- a/app/controlplane/pkg/biz/version_test.go +++ b/app/controlplane/pkg/biz/version_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 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. diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 20e0a4b98..56228b55a 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -220,6 +220,7 @@ type WorkflowRunCreateOpts struct { RunnerType string CASBackendID uuid.UUID ProjectVersion string + UseLatestVersion bool RequireExistingVersion bool } @@ -229,6 +230,7 @@ type WorkflowRunRepoCreateOpts struct { Backends []uuid.UUID LatestRevision, UsedRevision int ProjectVersion string + UseLatestVersion bool RequireExistingVersion bool } @@ -249,6 +251,10 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat contractRevision := opts.ContractRevision + if opts.UseLatestVersion && opts.ProjectVersion != "" { + return nil, NewErrValidationStr("cannot specify both a project version and use-latest-version") + } + if opts.ProjectVersion != "" { if err := ValidateVersion(opts.ProjectVersion); err != nil { return nil, err @@ -268,6 +274,7 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat LatestRevision: contractRevision.Contract.LatestRevision, UsedRevision: contractRevision.Version.Revision, ProjectVersion: opts.ProjectVersion, + UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, }) if err != nil { diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 843f24085..68041ca0d 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 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. @@ -26,6 +26,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" attestation2 "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/attestation" + entProjectVersion "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/projectversion" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/chainloop-dev/chainloop/pkg/attestation" "github.com/chainloop-dev/chainloop/pkg/credentials" @@ -339,6 +340,58 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { s.Equal(pv.ID, run.ProjectVersion.ID) } }) + + s.T().Run("use-latest-version resolves to version with latest=true", func(_ *testing.T) { + // Create a named version first so we know which one is latest. + namedRun, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "latest-target", + }) + s.Require().NoError(err) + latestVersion := namedRun.ProjectVersion + + // Now create a run with UseLatestVersion — should resolve to "latest-target" + run, 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, + }) + s.Require().NoError(err) + s.Equal(latestVersion.ID, run.ProjectVersion.ID) + s.Equal("latest-target", run.ProjectVersion.Version) + }) + + s.T().Run("use-latest-version with no versions returns error", func(_ *testing.T) { + // Create a new workflow in a fresh project (which auto-creates a default version) + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: "no-versions-workflow", OrgID: s.org.ID, Project: "empty-project", + }) + s.Require().NoError(err) + + // Delete all versions for this project so resolution fails + _, err = s.Data.DB.ProjectVersion.Delete(). + Where( + entProjectVersion.ProjectID(wf.ProjectID), + ).Exec(ctx) + 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", UseLatestVersion: true, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "no project version exists") + }) + + s.T().Run("use-latest-version and project-version are mutually exclusive", func(_ *testing.T) { + _, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "v1.0", UseLatestVersion: true, + }) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "cannot specify both") + }) } func (s *workflowRunIntegrationTestSuite) TestContractInformation() { diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index 64183f872..d39f2b6e0 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.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. @@ -55,16 +55,30 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC return nil, fmt.Errorf("getting workflow: %w", err) } - // load the version in advance to prevent locking if it already exists - version, err := r.data.DB.ProjectVersion.Query(). - Where(projectversion.Version(opts.ProjectVersion), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()).First(ctx) - if err != nil && !ent.IsNotFound(err) { - return nil, fmt.Errorf("checking existing version: %w", err) - } + var version *ent.ProjectVersion + if opts.UseLatestVersion { + // Resolve to the project version with latest=true + version, err = r.data.DB.ProjectVersion.Query(). + Where(projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil(), projectversion.Latest(true)). + First(ctx) + if err != nil && !ent.IsNotFound(err) { + return nil, fmt.Errorf("resolving latest version: %w", err) + } + if version == nil { + return nil, biz.NewErrValidationStr("no project version exists; create one before attesting with --latest-version") + } + } else { + // load the version in advance to prevent locking if it already exists + version, err = r.data.DB.ProjectVersion.Query(). + Where(projectversion.Version(opts.ProjectVersion), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()).First(ctx) + if err != nil && !ent.IsNotFound(err) { + return nil, fmt.Errorf("checking existing version: %w", err) + } - // If RequireExistingVersion is set, fail if the version doesn't exist - if opts.RequireExistingVersion && version == nil { - return nil, biz.NewErrValidationStr(fmt.Errorf("project version %q not found", opts.ProjectVersion).Error()) + // If RequireExistingVersion is set, fail if the version doesn't exist + if opts.RequireExistingVersion && version == nil { + return nil, biz.NewErrValidationStr(fmt.Errorf("project version %q not found", opts.ProjectVersion).Error()) + } } var p *ent.WorkflowRun