diff --git a/app/cli/cmd/attestation_status.go b/app/cli/cmd/attestation_status.go index 69b6ea019..fa3c825fd 100644 --- a/app/cli/cmd/attestation_status.go +++ b/app/cli/cmd/attestation_status.go @@ -102,10 +102,6 @@ func attestationStatusTableOutput(status *action.AttestationStatusResult, w io.W gt.AppendRow(table.Row{"Name", meta.Name}) gt.AppendRow(table.Row{"Project", meta.Project}) projectVersion := versionStringAttestation(meta.ProjectVersion, status.IsPushed) - if projectVersion == "" { - projectVersion = "none" - } - gt.AppendRow(table.Row{"Version", projectVersion}) gt.AppendRow(table.Row{"Contract", fmt.Sprintf("%s (revision %s)", meta.ContractName, meta.ContractRevision)}) if status.RunnerContext.JobURL != "" { diff --git a/app/cli/cmd/workflow_workflow_run_list.go b/app/cli/cmd/workflow_workflow_run_list.go index 6021536a8..891edaade 100644 --- a/app/cli/cmd/workflow_workflow_run_list.go +++ b/app/cli/cmd/workflow_workflow_run_list.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. @@ -130,13 +130,12 @@ func workflowRunListTableOutput(runs []*action.WorkflowRunItem) error { } func versionString(p *action.ProjectVersion) string { - versionString := p.Version - if versionString == "" { + if p.Version == "" { return "" } if !p.Prerelease { - return versionString + return p.Version } return fmt.Sprintf("%s (prerelease)", p.Version) diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index da6d7dd47..8d4303672 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.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. @@ -25,6 +25,9 @@ import ( "github.com/google/uuid" ) +// DefaultVersionName is the canonical name for the default/unversioned project version. +const DefaultVersionName = "v0" + type ProjectVersion struct { // ID is the UUID of the project version. ID uuid.UUID @@ -91,5 +94,14 @@ func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version return nil, NewErrInvalidUUID(err) } + // Treat empty version as the default for backward compatibility + if version == "" { + version = DefaultVersionName + } + + if err := ValidateVersion(version); err != nil { + return nil, err + } + return uc.projectRepo.Create(ctx, projectUUID, version, prerelease) } diff --git a/app/controlplane/pkg/biz/version_test.go b/app/controlplane/pkg/biz/version_test.go index e4ba5affb..5f619adc4 100644 --- a/app/controlplane/pkg/biz/version_test.go +++ b/app/controlplane/pkg/biz/version_test.go @@ -26,6 +26,10 @@ type versionTestSuite struct { suite.Suite } +func (s *versionTestSuite) TestDefaultVersionNameIsValid() { + s.NoError(biz.ValidateVersion(biz.DefaultVersionName)) +} + func (s *versionTestSuite) TestValidateVersion() { testCases := []struct { name string diff --git a/app/controlplane/pkg/biz/workflow_integration_test.go b/app/controlplane/pkg/biz/workflow_integration_test.go index fdc3b7581..a53b12647 100644 --- a/app/controlplane/pkg/biz/workflow_integration_test.go +++ b/app/controlplane/pkg/biz/workflow_integration_test.go @@ -210,7 +210,7 @@ func (s *workflowIntegrationTestSuite) TestCreate() { s.NotEmpty(got.ContractID) s.NotEmpty(got.ContractName) // There is a project version created - pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, got.ProjectID.String(), "") + pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, got.ProjectID.String(), biz.DefaultVersionName) s.NoError(err) s.NotNil(pv) }) diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 56228b55a..5947a3578 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -255,6 +255,11 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat return nil, NewErrValidationStr("cannot specify both a project version and use-latest-version") } + // Treat empty version as the default for backward compatibility with old clients + if opts.ProjectVersion == "" && !opts.UseLatestVersion { + opts.ProjectVersion = DefaultVersionName + } + if opts.ProjectVersion != "" { if err := ValidateVersion(opts.ProjectVersion); 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 68041ca0d..e3880b47a 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -310,8 +310,8 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { RunnerType: "runnerType", RunnerRunURL: "runURL", }) s.Require().NoError(err) - // Load project version - pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), "") + // Load project version — empty version is translated to DefaultVersionName + pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), biz.DefaultVersionName) s.Require().NoError(err) s.Equal("runnerType", run.RunnerType) s.Equal("runURL", run.RunURL) @@ -321,26 +321,46 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() { s.T().Run("find or create version", func(_ *testing.T) { testCases := []struct { - version string + name string + version string + expectedVersion string }{ - {version: ""}, - {version: "custom"}, + {name: "empty string maps to default", version: "", expectedVersion: biz.DefaultVersionName}, + {name: "custom version", version: "custom", expectedVersion: "custom"}, } for _, tc := range testCases { - 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: tc.version, + s.T().Run(tc.name, 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: tc.version, + }) + s.Require().NoError(err) + s.Equal(tc.expectedVersion, run.ProjectVersion.Version) + pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), tc.expectedVersion) + s.Require().NoError(err) + s.Equal(pv.ID, run.ProjectVersion.ID) }) - s.Require().NoError(err) - // Load project version - s.Equal(tc.version, run.ProjectVersion.Version) - pv, err := s.ProjectVersion.FindByProjectAndVersion(ctx, s.workflowOrg1.ProjectID.String(), tc.version) - s.Require().NoError(err) - s.Equal(pv.ID, run.ProjectVersion.ID) } }) + s.T().Run("explicit v0 uses same default version", func(_ *testing.T) { + // First create a run without version (gets default "v0") + runDefault, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + }) + s.Require().NoError(err) + s.Equal(biz.DefaultVersionName, runDefault.ProjectVersion.Version) + + // Now explicitly specify "v0" — should find the same version record + runExplicit, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + ProjectVersion: biz.DefaultVersionName, + }) + s.Require().NoError(err) + s.Equal(runDefault.ProjectVersion.ID, runExplicit.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{ diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260416153232.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260416153232.sql new file mode 100644 index 000000000..88ef6dcf8 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260416153232.sql @@ -0,0 +1,17 @@ +-- atlas:txmode none + +-- Step 1: Rename any existing user-created "v0" versions to "v0.0" +-- (avoids conflict when the empty-string default is renamed to "v0") +UPDATE project_versions +SET version = 'v0.0' +WHERE version = 'v0' + AND deleted_at IS NULL; + +-- Step 2: Rename all default "" versions to "v0" +UPDATE project_versions +SET version = 'v0' +WHERE version = '' + AND deleted_at IS NULL; + +-- Step 3: Change column default from '' to 'v0' +ALTER TABLE "project_versions" ALTER COLUMN "version" SET DEFAULT 'v0'; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index cb233c18f..75f6daade 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:/HATckRi5Q/sEHMczVjDJJ9VOwgJWiAp0Lpk8tmRVWk= +h1:sNY7GgdTnqEyDG2nzVPtN7Vb4WM2agXX+GKsQJQvnCg= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -128,3 +128,4 @@ h1:/HATckRi5Q/sEHMczVjDJJ9VOwgJWiAp0Lpk8tmRVWk= 20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA= 20260318160301.sql h1:kH88s6pOi7Vprydb7xrzgY55JhMxfzY32txpQ8a1wEE= 20260408122048.sql h1:imfswpfmBlpP1l149/wCLN5HkN3/sGIQ3GnxaSnwOZE= +20260416153232.sql h1:xjEfZuMOo1lgZm3VUYGHpNOhpJixncVZuMRg0jiH+7A= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 8da75ff6b..0786697a2 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -497,7 +497,7 @@ var ( // ProjectVersionsColumns holds the columns for the "project_versions" table. ProjectVersionsColumns = []*schema.Column{ {Name: "id", Type: field.TypeUUID, Unique: true}, - {Name: "version", Type: field.TypeString, Default: ""}, + {Name: "version", Type: field.TypeString, Default: "v0"}, {Name: "created_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"}, {Name: "updated_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"}, {Name: "deleted_at", Type: field.TypeTime, Nullable: true}, diff --git a/app/controlplane/pkg/data/ent/schema/projectversion.go b/app/controlplane/pkg/data/ent/schema/projectversion.go index 44bfaac95..be8839379 100644 --- a/app/controlplane/pkg/data/ent/schema/projectversion.go +++ b/app/controlplane/pkg/data/ent/schema/projectversion.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. @@ -36,13 +36,8 @@ type ProjectVersion struct { func (ProjectVersion) Fields() []ent.Field { return []ent.Field{ field.UUID("id", uuid.UUID{}).Default(uuid.New).Unique(), - // empty version means no defined version - field.String("version").Default("").Validate(func(s string) error { - if s == "" { - return nil - } - return biz.ValidateVersion(s) - }), + // v0 is the default unversioned project version + field.String("version").Default(biz.DefaultVersionName).Validate(biz.ValidateVersion), field.Time("created_at"). Default(time.Now). Immutable(). diff --git a/app/controlplane/pkg/data/projectversion.go b/app/controlplane/pkg/data/projectversion.go index 88ceae4e5..d8ce8d98c 100644 --- a/app/controlplane/pkg/data/projectversion.go +++ b/app/controlplane/pkg/data/projectversion.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. @@ -105,6 +105,10 @@ func (r *ProjectVersionRepo) Create(ctx context.Context, projectID uuid.UUID, ve } func createProjectVersionWithTx(ctx context.Context, tx *ent.Tx, projectID uuid.UUID, version string, prerelease 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 if err := tx.ProjectVersion.Update(). Where( diff --git a/app/controlplane/pkg/data/workflow.go b/app/controlplane/pkg/data/workflow.go index f548c8fb5..d99efae94 100644 --- a/app/controlplane/pkg/data/workflow.go +++ b/app/controlplane/pkg/data/workflow.go @@ -99,12 +99,12 @@ func (r *WorkflowRepo) Create(ctx context.Context, opts *biz.WorkflowCreateOpts) } // Find or create the default project version - if _, err := findProjectVersionWithClient(ctx, tx.Client(), projectID, ""); err != nil { + if _, err := findProjectVersionWithClient(ctx, tx.Client(), projectID, biz.DefaultVersionName); err != nil { if !ent.IsNotFound(err) { return fmt.Errorf("finding project version: %w", err) } - if _, err := createProjectVersionWithTx(ctx, tx, projectID, "", true); err != nil { + if _, err := createProjectVersionWithTx(ctx, tx, projectID, biz.DefaultVersionName, true); err != nil { return fmt.Errorf("creating project version: %w", err) } }