From c531730fa89554e859a8649f9520f44323b25ae3 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Mon, 5 Oct 2026 16:19:51 +0530 Subject: [PATCH] feat(cli): resolve attestation project from repository config Use projectName from repository config when --project is omitted, preserve explicit project and version choices, and report both accepted project sources when neither is set. Implements R-001, R-002, and the project portion of R-004. Refs: #3504 Assisted-by: pi Signed-off-by: Vibhav Bobade --- app/cli/cmd/attestation_init.go | 22 +-- ...attestation_init_repository_config_test.go | 132 ++++++++++++++++++ 2 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 app/cli/cmd/attestation_init_repository_config_test.go diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index 6f8781a4d..483d9d9d5 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -58,14 +58,19 @@ func newAttestationInitCmd() *cobra.Command { return errors.New("workflow name is required, set it via --workflow flag") } - // Load version from the repository config if not set and not using --latest-version. - if projectVersion == "" && !useLatestVersion { + // Load unresolved project metadata from the repository config. + if projectName == "" || (projectVersion == "" && !useLatestVersion) { cfg, path, err := repositoryconfig.LoadChainloopYML(".") if err != nil { logger.Debug().Msgf("failed to load chainloop config: %s", err) } else { - logger.Debug().Msgf("loaded version %s from config file %s", cfg.ProjectVersion, path) - projectVersion = cfg.ProjectVersion + logger.Debug().Msgf("loaded project metadata from config file %s", path) + if projectName == "" { + projectName = cfg.ProjectName + } + if projectVersion == "" && !useLatestVersion { + projectVersion = cfg.ProjectVersion + } } } @@ -91,6 +96,10 @@ func newAttestationInitCmd() *cobra.Command { } } + if projectName == "" { + return errors.New("project is required, set it via --project or projectName in .chainloop.yml") + } + return nil }, RunE: func(cmd *cobra.Command, _ []string) error { @@ -168,10 +177,6 @@ func newAttestationInitCmd() *cobra.Command { logger.Info().Msg("The attestation is being crafted in dry-run mode. It will not get stored once rendered") } - if projectName == "" { - logger.Warn().Msg("DEPRECATION WARNING: --project not set, this will be required in the near future") - } - return output.EncodeOutput(flagOutputFormat, res, fullStatusTable) }} @@ -189,7 +194,6 @@ func newAttestationInitCmd() *cobra.Command { cobra.CheckErr(cmd.Flags().MarkDeprecated("workflow-name", "please use --workflow instead")) cmd.Flags().StringVar(&projectName, "project", "", "name of the project of this workflow") - cobra.CheckErr(cmd.MarkFlagRequired("project")) 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") diff --git a/app/cli/cmd/attestation_init_repository_config_test.go b/app/cli/cmd/attestation_init_repository_config_test.go new file mode 100644 index 000000000..fb8ac415a --- /dev/null +++ b/app/cli/cmd/attestation_init_repository_config_test.go @@ -0,0 +1,132 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttestationInitLoadsProjectFromRepositoryConfig(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: web\nprojectVersion: 1.2.3\n"), 0o600)) + t.Chdir(dir) + + cmd := newAttestationInitCmd() + require.NoError(t, cmd.Flags().Set("workflow", "build")) + require.NoError(t, cmd.PreRunE(cmd, nil)) + + project, err := cmd.Flags().GetString("project") + require.NoError(t, err) + version, err := cmd.Flags().GetString("version") + require.NoError(t, err) + assert.Equal(t, "web", project) + assert.Equal(t, "1.2.3", version) +} + +func TestAttestationInitRepositoryConfigPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + project string + version string + latest bool + config string + wantProject string + wantVersion string + }{ + { + name: "project flag wins while version comes from config", + project: "flag-project", + config: "projectName: file-project\nprojectVersion: file-version\n", + wantProject: "flag-project", + wantVersion: "file-version", + }, + { + name: "version flag wins while project comes from config", + version: "flag-version", + config: "projectName: file-project\nprojectVersion: file-version\n", + wantProject: "file-project", + wantVersion: "flag-version", + }, + { + name: "latest version suppresses config version", + latest: true, + config: "projectName: file-project\nprojectVersion: file-version\n", + wantProject: "file-project", + }, + { + name: "resolved flags do not read malformed config", + project: "flag-project", + version: "flag-version", + config: ":\ninvalid: [yaml\n", + wantProject: "flag-project", + wantVersion: "flag-version", + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(tc.config), 0o600)) + t.Chdir(dir) + + cmd := newAttestationInitCmd() + require.NoError(t, cmd.Flags().Set("workflow", "build")) + if tc.project != "" { + require.NoError(t, cmd.Flags().Set("project", tc.project)) + } + if tc.version != "" { + require.NoError(t, cmd.Flags().Set("version", tc.version)) + } + if tc.latest { + require.NoError(t, cmd.Flags().Set("latest-version", "true")) + } + + require.NoError(t, cmd.PreRunE(cmd, nil)) + project, err := cmd.Flags().GetString("project") + require.NoError(t, err) + version, err := cmd.Flags().GetString("version") + require.NoError(t, err) + assert.Equal(t, tc.wantProject, project) + assert.Equal(t, tc.wantVersion, version) + }) + } +} + +func TestAttestationInitRequiresProjectFromFlagOrRepositoryConfig(t *testing.T) { + for _, tc := range []struct { + name string + config string + }{ + {name: "missing config"}, + {name: "config without project", config: "projectVersion: 1.2.3\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if tc.config != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(tc.config), 0o600)) + } + t.Chdir(dir) + + cmd := newAttestationInitCmd() + require.NoError(t, cmd.Flags().Set("workflow", "build")) + + assert.EqualError(t, cmd.PreRunE(cmd, nil), "project is required, set it via --project or projectName in .chainloop.yml") + }) + } +}