From a64872a197b9898d70bbbaa9e037206e537df2e5 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 16 Apr 2026 17:49:03 +0200 Subject: [PATCH 1/2] feat: rename default project version from empty string to "v0" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default/unversioned project version was stored as "" in the database, causing friction in APIs, CLIs, and MCP tools that had to special-case empty strings. This renames it to "v0" everywhere: - Add DefaultVersionName constant in biz layer - Translate "" → "v0" in WorkflowRunUseCase.Create for backward compat - Update Ent schema default and remove empty-string validator exception - Add multi-layer guards preventing empty versions from being created - Database migration: rename existing "v0" → "v0.0", then "" → "v0" - Remove "none" fallback from CLI attestation status display Closes #3045 Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/attestation_status.go | 4 -- app/cli/cmd/workflow_workflow_run_list.go | 7 ++- app/controlplane/pkg/biz/projectversion.go | 9 +++- app/controlplane/pkg/biz/version_test.go | 4 ++ app/controlplane/pkg/biz/workflowrun.go | 5 ++ .../pkg/biz/workflowrun_integration_test.go | 48 +++++++++++++------ .../ent/migrate/migrations/20260416153232.sql | 17 +++++++ .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 2 +- .../pkg/data/ent/schema/projectversion.go | 11 ++--- app/controlplane/pkg/data/projectversion.go | 6 ++- app/controlplane/pkg/data/workflow.go | 4 +- 12 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260416153232.sql 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..6a8f768b4 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,9 @@ func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version return nil, NewErrInvalidUUID(err) } + 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/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) } } From 5bb5a0744090514dcb96ed322ab0b2d4e42fda94 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 16 Apr 2026 18:08:34 +0200 Subject: [PATCH 2/2] fix: translate empty version in ProjectVersionUseCase.Create and fix workflow test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backward-compat "" → "v0" translation in ProjectVersionUseCase.Create to match WorkflowRunUseCase.Create behavior. Update workflow integration test to look up the default version by DefaultVersionName instead of "". Signed-off-by: Miguel Martinez Trivino --- app/controlplane/pkg/biz/projectversion.go | 5 +++++ app/controlplane/pkg/biz/workflow_integration_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index 6a8f768b4..8d4303672 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.go @@ -94,6 +94,11 @@ 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 } 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) })