Skip to content

Commit c258f1e

Browse files
authored
feat(cli): use repository config for attestations (#3502)
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent 3314bc9 commit c258f1e

10 files changed

Lines changed: 347 additions & 397 deletions

File tree

‎app/cli/cmd/attestation_init.go‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323

2424
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
25+
"github.com/chainloop-dev/chainloop/app/cli/internal/repositoryconfig"
2526
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
2627
"github.com/spf13/cobra"
2728
"github.com/spf13/viper"
@@ -57,19 +58,15 @@ func newAttestationInitCmd() *cobra.Command {
5758
return errors.New("workflow name is required, set it via --workflow flag")
5859
}
5960

60-
// load version from the file if not set and not using --latest-version
61+
// Load version from the repository config if not set and not using --latest-version.
6162
if projectVersion == "" && !useLatestVersion {
62-
// load the cfg from the file
63-
cfg, path, err := loadDotChainloopConfigWithParentTraversal()
64-
// we do gracefully load, if not found, or any other error we continue
63+
cfg, path, err := repositoryconfig.LoadChainloopYML(".")
6564
if err != nil {
6665
logger.Debug().Msgf("failed to load chainloop config: %s", err)
67-
return nil
66+
} else {
67+
logger.Debug().Msgf("loaded version %s from config file %s", cfg.ProjectVersion, path)
68+
projectVersion = cfg.ProjectVersion
6869
}
69-
70-
logger.Debug().Msgf("loaded version %s from config file %s", cfg.ProjectVersion, path)
71-
72-
projectVersion = cfg.ProjectVersion
7370
}
7471

7572
if useLatestVersion && projectVersion != "" {

‎app/cli/cmd/attestation_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,35 @@ func TestOrgFromLocalState(t *testing.T) {
7777
})
7878
}
7979

80+
func TestAttestationInitValidatesFlagsRegardlessOfRepositoryConfig(t *testing.T) {
81+
for _, tc := range []struct {
82+
name string
83+
setup func(t *testing.T, dir string)
84+
}{
85+
{name: "missing"},
86+
{name: "unreadable", setup: func(t *testing.T, dir string) {
87+
require.NoError(t, os.Mkdir(filepath.Join(dir, ".chainloop.yml"), 0o700))
88+
}},
89+
{name: "present", setup: func(t *testing.T, dir string) {
90+
require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("{}\n"), 0o600))
91+
}},
92+
} {
93+
t.Run(tc.name, func(t *testing.T) {
94+
dir := t.TempDir()
95+
if tc.setup != nil {
96+
tc.setup(t, dir)
97+
}
98+
t.Chdir(dir)
99+
100+
cmd := newAttestationInitCmd()
101+
require.NoError(t, cmd.Flags().Set("workflow", "build"))
102+
require.NoError(t, cmd.Flags().Set("release", "true"))
103+
104+
assert.EqualError(t, cmd.PreRunE(cmd, nil), "project version is required when using --release")
105+
})
106+
}
107+
}
108+
80109
func TestExtractAnnotations(t *testing.T) {
81110
testCases := []struct {
82111
input []string

‎app/cli/cmd/config.go‎

Lines changed: 1 addition & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,7 @@
1515

1616
package cmd
1717

18-
import (
19-
"fmt"
20-
"os"
21-
"path/filepath"
22-
23-
"github.com/spf13/cobra"
24-
"gopkg.in/yaml.v2"
25-
)
18+
import "github.com/spf13/cobra"
2619

2720
// Map of all the possible configuration options that we expect viper to handle
2821
var confOptions = struct {
@@ -76,93 +69,3 @@ func newConfigCmd() *cobra.Command {
7669
cmd.AddCommand(newConfigSaveCmd(), newConfigViewCmd(), newConfigResetCmd(), newPluginCmd())
7770
return cmd
7871
}
79-
80-
// Configuration file used in repositories ot modify the attestation process
81-
const (
82-
dotChainloopConfigFilename = ".chainloop"
83-
)
84-
85-
var (
86-
errDotChainloopConfigNotFound = fmt.Errorf("attestation config file %s.[yaml|yml] not found", dotChainloopConfigFilename)
87-
)
88-
89-
// LoadDotChainloopConfig loads the chainloop config file from the current directory all the way up to the root directory
90-
// or until a .git directory is found
91-
// It supports both .yaml and .yml extensions
92-
func loadDotChainloopConfigWithParentTraversal() (*DotChainloopConfig, string, error) {
93-
searchPaths := getConfigSearchPaths()
94-
logger.Debug().Msgf("searching %s.[yaml|yml] file in %q", dotChainloopConfigFilename, searchPaths)
95-
96-
for _, currentDir := range searchPaths {
97-
for _, ext := range []string{".yaml", ".yml"} {
98-
configPath := filepath.Join(currentDir, fmt.Sprintf("%s%s", dotChainloopConfigFilename, ext))
99-
// Check if the file exists
100-
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
101-
// Load from the YAML file
102-
file, err := os.Open(configPath)
103-
if err != nil {
104-
return nil, "", fmt.Errorf("opening attestation config file: %w", err)
105-
}
106-
defer file.Close()
107-
108-
cfg := &DotChainloopConfig{}
109-
decoder := yaml.NewDecoder(file)
110-
if err := decoder.Decode(cfg); err != nil {
111-
return nil, "", fmt.Errorf("decoding attestation config file: %w", err)
112-
}
113-
114-
return cfg, configPath, nil
115-
}
116-
}
117-
}
118-
119-
return nil, "", errDotChainloopConfigNotFound
120-
}
121-
122-
// getConfigSearchPaths returns a slice of directory paths to search for the attestation configuration file.
123-
// It starts from the current working directory and traverses up the directory tree up all the way to the root
124-
// or until it finds a .git directory. The search paths are returned in order, with the current directory first.
125-
// Based out of golangci-lint traverse mechanism
126-
func getConfigSearchPaths() []string {
127-
absPath, err := filepath.Abs(".")
128-
if err != nil {
129-
absPath = filepath.Clean(".")
130-
}
131-
132-
var currentDir string
133-
if isDir(absPath) {
134-
currentDir = absPath
135-
} else {
136-
currentDir = filepath.Dir(absPath)
137-
}
138-
139-
// find all dirs from it up to the root
140-
searchPaths := []string{"./"}
141-
142-
for {
143-
searchPaths = append(searchPaths, currentDir)
144-
145-
parent := filepath.Dir(currentDir)
146-
if currentDir == parent || parent == "" {
147-
break
148-
}
149-
150-
// We also terminate if there is a .git directory
151-
if _, err := os.Stat(filepath.Join(currentDir, ".git")); err == nil {
152-
break
153-
}
154-
155-
currentDir = parent
156-
}
157-
158-
return searchPaths
159-
}
160-
161-
func isDir(filename string) bool {
162-
fi, err := os.Stat(filename)
163-
return err == nil && fi.IsDir()
164-
}
165-
166-
type DotChainloopConfig struct {
167-
ProjectVersion string `yaml:"projectVersion"`
168-
}

‎app/cli/cmd/trace_init.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"path/filepath"
2626
"strings"
2727

28+
"github.com/chainloop-dev/chainloop/app/cli/internal/repositoryconfig"
2829
"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
2930
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/config"
3031
tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git"
@@ -482,7 +483,7 @@ func resolveTraceInitConfig(cmd *cobra.Command, repoRoot, projectFlag string) (*
482483
// A project may still be missing here. resolveTraceIdentity either asks for
483484
// one or, when nobody can be asked, reports that it is required.
484485
if cfg.project == "" {
485-
cfg.project = config.LoadProjectFromYML(repoRoot)
486+
cfg.project = repositoryconfig.LoadProjectFromYML(repoRoot)
486487
}
487488

488489
// --org is inherited from the root command, so it is only meant for this
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
// Package repositoryconfig reads the Chainloop configuration committed to a repository.
17+
package repositoryconfig
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"os"
23+
"path/filepath"
24+
25+
tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git"
26+
"gopkg.in/yaml.v3"
27+
)
28+
29+
const (
30+
chainloopYMLFile = ".chainloop.yml"
31+
chainloopYAMLFile = ".chainloop.yaml"
32+
)
33+
34+
// ErrChainloopYMLNotFound indicates that neither supported config filename exists.
35+
var ErrChainloopYMLNotFound = errors.New(".chainloop.yml not found")
36+
37+
// ChainloopYML represents the relevant fields in .chainloop.yml.
38+
type ChainloopYML struct {
39+
ProjectName string `yaml:"projectName"`
40+
ProjectVersion string `yaml:"projectVersion"`
41+
Organization string `yaml:"organization,omitempty"`
42+
RequireTrace *bool `yaml:"requireTrace,omitempty"`
43+
WorkflowName string `yaml:"workflowName,omitempty"`
44+
}
45+
46+
// LoadChainloopYML loads the nearest .chainloop.yml (or .chainloop.yaml),
47+
// walking from dir to the git repository root. In each directory, .yml takes
48+
// precedence over .yaml. A config without projectName is still valid.
49+
func LoadChainloopYML(dir string) (*ChainloopYML, string, error) {
50+
dir = resolveDir(dir)
51+
_, repoRoot, err := tracegit.FindGitDirAndRootFrom(dir)
52+
if err != nil {
53+
if !errors.Is(err, tracegit.ErrNotARepository) {
54+
return nil, "", err
55+
}
56+
repoRoot = dir
57+
}
58+
repoRoot = resolveDir(repoRoot)
59+
60+
for {
61+
cfg, path, found, err := loadChainloopYMLFromDir(dir)
62+
if err != nil {
63+
return nil, path, err
64+
}
65+
if found {
66+
return cfg, path, nil
67+
}
68+
if dir == repoRoot {
69+
break
70+
}
71+
parent := filepath.Dir(dir)
72+
if parent == dir {
73+
break
74+
}
75+
dir = parent
76+
}
77+
78+
return nil, "", ErrChainloopYMLNotFound
79+
}
80+
81+
// FindChainloopYML returns the nearest repository config, or nil when it
82+
// cannot be loaded. Call LoadChainloopYML when the path or error is needed.
83+
func FindChainloopYML(dir string) *ChainloopYML {
84+
cfg, _, err := LoadChainloopYML(dir)
85+
if err != nil {
86+
return nil
87+
}
88+
return cfg
89+
}
90+
91+
// LoadProjectFromYML returns projectName from the nearest repository config.
92+
func LoadProjectFromYML(dir string) string {
93+
if cfg := FindChainloopYML(dir); cfg != nil {
94+
return cfg.ProjectName
95+
}
96+
return ""
97+
}
98+
99+
func resolveDir(dir string) string {
100+
abs, err := filepath.Abs(dir)
101+
if err != nil {
102+
return dir
103+
}
104+
resolved, err := filepath.EvalSymlinks(abs)
105+
if err != nil {
106+
return abs
107+
}
108+
return resolved
109+
}
110+
111+
func loadChainloopYMLFromDir(dir string) (*ChainloopYML, string, bool, error) {
112+
for _, name := range []string{chainloopYMLFile, chainloopYAMLFile} {
113+
path := filepath.Join(dir, name)
114+
data, err := os.ReadFile(path)
115+
if errors.Is(err, os.ErrNotExist) {
116+
continue
117+
}
118+
if err != nil {
119+
return nil, path, true, fmt.Errorf("read %s: %w", filepath.Base(path), err)
120+
}
121+
122+
var cfg ChainloopYML
123+
if err := yaml.Unmarshal(data, &cfg); err != nil {
124+
return nil, path, true, fmt.Errorf("parse %s: %w", filepath.Base(path), err)
125+
}
126+
return &cfg, path, true, nil
127+
}
128+
return nil, "", false, nil
129+
}

0 commit comments

Comments
 (0)