feat(cli)!: subcommand CLI, graph tree mode, local include resolution

BREAKING CHANGES:
- `glint <file>` removed; use `glint check <file>`
- `--graph <mode>` removed; use `glint graph [mode]`
- `--graph-out` renamed to `--out` on `glint graph`

feat(cli): ruff-style subcommands — `glint check` and `glint graph [mode]`
feat(graph): `glint graph tree` — terminal job tree with context annotations
feat(graph): context flags (--branch/--tag/--source/--var) on `glint graph`
feat(resolver): recursive local include resolution from disk
fix(resolver): extends unknown base emits warning instead of fatal error
fix(model): script/before_script/after_script accept block scalar string form
test(linter): Samba project CI fixtures as integration tests
chore(build): fix .gitignore to not exclude cmd/glint/ directory
docs: update CHANGELOG, README, ROADMAP for v0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:27:28 +02:00
parent aca37de2b1
commit 88f20165db
23 changed files with 1772 additions and 258 deletions
+209 -70
View File
@@ -2,12 +2,183 @@ package graph
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// Includes returns a Mermaid flowchart showing include file dependencies.
// sourcePath is the path to the main pipeline file; rawIncludes is Pipeline.Include.
func Includes(sourcePath string, rawIncludes []any) string {
// Includes returns a Mermaid flowchart of the full include dependency tree.
// It recurses into project:, component:, and local: includes to expose
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
b := &treeBuilder{
visited: map[string]bool{},
cfg: cfg,
baseDir: filepath.Dir(sourcePath),
}
root := &treeNode{id: "root", label: mermaidLabel(sourcePath), class: "main"}
b.buildChildren(root, rawIncludes)
return renderTree(root)
}
type treeNode struct {
id string
label string
class string
children []*treeNode
}
// treeBuilder accumulates state while recursively traversing include entries.
type treeBuilder struct {
counter int
visited map[string]bool // prevents infinite loops on circular includes
cfg fetcher.GitLabConfig
baseDir string // directory of the current pipeline file; changes for local includes
}
func (b *treeBuilder) nextID() string {
b.counter++
return fmt.Sprintf("inc%d", b.counter)
}
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
for _, entry := range rawIncludes {
for _, child := range b.parseEntry(entry) {
parent.children = append(parent.children, child)
}
}
}
func (b *treeBuilder) parseEntry(entry any) []*treeNode {
switch v := entry.(type) {
case string:
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(v), class: "local"}
b.recurseLocal(node, v)
return []*treeNode{node}
case map[string]any:
return b.parseMap(v)
}
return nil
}
func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
if comp, ok := m["component"].(string); ok {
node := &treeNode{
id: b.nextID(),
label: "component:<br>" + mermaidLabel(comp),
class: "component",
}
b.recurseComponent(node, comp)
return []*treeNode{node}
}
if proj, ok := m["project"].(string); ok {
ref, _ := m["ref"].(string)
if ref == "" {
ref = "HEAD"
}
files := includeFileList(m["file"])
if len(files) == 0 {
return []*treeNode{{
id: b.nextID(),
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
class: "project",
}}
}
var nodes []*treeNode
for _, f := range files {
node := &treeNode{
id: b.nextID(),
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
class: "project",
}
b.recurseProject(node, proj, f, ref)
nodes = append(nodes, node)
}
return nodes
}
if local, ok := m["local"].(string); ok {
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(local), class: "local"}
b.recurseLocal(node, local)
return []*treeNode{node}
}
if remote, ok := m["remote"].(string); ok {
return []*treeNode{{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
}
if tmpl, ok := m["template"].(string); ok {
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
}
return nil
}
func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
absPath := filepath.Join(b.baseDir, path)
key := "local:" + absPath
if b.visited[key] {
return
}
b.visited[key] = true
data, err := os.ReadFile(absPath)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
return
}
orig := b.baseDir
b.baseDir = filepath.Dir(absPath)
b.buildChildren(node, p.Include)
b.baseDir = orig
}
func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref string) {
key := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref)
if b.visited[key] || !b.cfg.HasToken() || filePath == "" {
return
}
b.visited[key] = true
data, err := b.cfg.FetchFile(project, filePath, ref)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
}
func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
if strings.ContainsRune(ref, '$') {
return
}
key := "component:" + ref
if b.visited[key] {
return
}
b.visited[key] = true
host, project, component, version, err := parseComponentRef(ref)
if err != nil {
return
}
data, err := fetchComponentFile(b.cfg.ForHost(host), project, component, version)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
}
func renderTree(root *treeNode) string {
var sb strings.Builder
w := func(s string) { sb.WriteString(s + "\n") }
wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) }
@@ -23,84 +194,52 @@ func Includes(sourcePath string, rawIncludes []any) string {
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
w("")
wf(" root[\"%s\"]:::main", mermaidLabel(sourcePath))
if len(rawIncludes) == 0 {
return sb.String()
}
w("")
counter := 0
for _, entry := range rawIncludes {
for _, n := range parseIncludeEntry(entry, &counter) {
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
wf(" root --> %s", n.id)
var emit func(n *treeNode)
emit = func(n *treeNode) {
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
for _, child := range n.children {
emit(child)
wf(" %s --> %s", n.id, child.id)
}
}
emit(root)
return sb.String()
}
type incNode struct {
id, label, class string
// parseComponentRef parses a CI/CD component reference of the form
// <host>/<project-path>/<component-name>@<version>.
func parseComponentRef(ref string) (host, project, component, version string, err error) {
atIdx := strings.LastIndex(ref, "@")
if atIdx < 0 || atIdx == len(ref)-1 {
err = fmt.Errorf("component reference %q must include a version", ref)
return
}
version = ref[atIdx+1:]
path := ref[:atIdx]
parts := strings.Split(path, "/")
if len(parts) < 3 {
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
return
}
host = parts[0]
component = parts[len(parts)-1]
project = strings.Join(parts[1:len(parts)-1], "/")
return
}
func parseIncludeEntry(entry any, counter *int) []incNode {
newID := func() string {
*counter++
return fmt.Sprintf("inc%d", *counter)
// fetchComponentFile fetches a component's template YAML, trying the single-file
// layout first and the directory layout as fallback.
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
if data, err := cfg.FetchFile(project, "templates/"+component+".yml", version); err == nil {
return data, nil
}
switch v := entry.(type) {
case string:
return []incNode{{id: newID(), label: mermaidLabel(v), class: "local"}}
case map[string]any:
return parseIncludeMap(v, newID)
data, err := cfg.FetchFile(project, "templates/"+component+"/template.yml", version)
if err != nil {
return nil, fmt.Errorf("component %s/%s@%s not found", project, component, version)
}
return nil
}
func parseIncludeMap(m map[string]any, newID func() string) []incNode {
if comp, ok := m["component"].(string); ok {
return []incNode{{
id: newID(),
label: "component:<br>" + mermaidLabel(comp),
class: "component",
}}
}
if proj, ok := m["project"].(string); ok {
ref, _ := m["ref"].(string)
if ref == "" {
ref = "HEAD"
}
files := includeFileList(m["file"])
if len(files) == 0 {
return []incNode{{
id: newID(),
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
class: "project",
}}
}
var nodes []incNode
for _, f := range files {
nodes = append(nodes, incNode{
id: newID(),
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
class: "project",
})
}
return nodes
}
if local, ok := m["local"].(string); ok {
return []incNode{{id: newID(), label: "local: " + mermaidLabel(local), class: "local"}}
}
if remote, ok := m["remote"].(string); ok {
return []incNode{{id: newID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
}
if tmpl, ok := m["template"].(string); ok {
return []incNode{{id: newID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
}
return nil
return data, nil
}
func includeFileList(v any) []string {
+113
View File
@@ -0,0 +1,113 @@
package graph
import (
"sort"
"strings"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// Tree returns a tree-command-style text representation of pipeline jobs
// grouped by stage.
//
// Hidden template jobs (names starting with ".") are excluded.
// When ctx is non-nil and non-empty, each job is annotated with its evaluated
// state ([skipped] or [manual]); active jobs carry no annotation.
// Without a context, job-type annotations ([manual], [delayed], [trigger]) are
// shown instead.
func Tree(p *model.Pipeline, ctx *cicontext.Context) string {
var sb strings.Builder
// Collect visible jobs.
var visible []string
for name := range p.Jobs {
if !strings.HasPrefix(name, ".") {
visible = append(visible, name)
}
}
sort.Strings(visible)
// Group by stage.
byStage := make(map[string][]string)
for _, name := range visible {
stage := p.Jobs[name].Stage
if stage == "" {
stage = "test"
}
byStage[stage] = append(byStage[stage], name)
}
// Build ordered stage list: declared first, then extras.
seen := make(map[string]bool)
var stages []string
for _, s := range p.Stages {
if len(byStage[s]) > 0 && !seen[s] {
stages = append(stages, s)
seen[s] = true
}
}
for _, name := range visible {
stage := p.Jobs[name].Stage
if stage == "" {
stage = "test"
}
if !seen[stage] {
stages = append(stages, stage)
seen[stage] = true
}
}
sb.WriteString("pipeline\n")
for si, stage := range stages {
lastStage := si == len(stages)-1
stagePrefix, childPrefix := branchChars(lastStage)
sb.WriteString(stagePrefix + stage + "\n")
jobs := byStage[stage]
sort.Strings(jobs)
for ji, name := range jobs {
lastJob := ji == len(jobs)-1
jobBranch, _ := branchChars(lastJob)
sb.WriteString(childPrefix + jobBranch + jobLabel(p.Jobs[name], name, ctx) + "\n")
}
}
return sb.String()
}
func branchChars(last bool) (branch, continuation string) {
if last {
return "└── ", " "
}
return "├── ", "│ "
}
func jobLabel(job model.Job, name string, ctx *cicontext.Context) string {
if ctx != nil && !ctx.IsEmpty() {
switch cicontext.EvalJob(job, ctx) {
case cicontext.JobSkipped:
return name + " [skipped]"
case cicontext.JobManual:
return name + " [manual]"
}
return name
}
// No context: annotate by job type.
var tags []string
if job.When == "manual" {
tags = append(tags, "manual")
}
if job.When == "delayed" {
tags = append(tags, "delayed")
}
if job.Trigger != nil {
tags = append(tags, "trigger")
}
if len(tags) == 0 {
return name
}
return name + " [" + strings.Join(tags, ", ") + "]"
}
+1 -1
View File
@@ -264,7 +264,7 @@ func checkTrigger(name string, job model.Job) []Finding {
return nil
}
var findings []Finding
if len(job.Script) > 0 {
if scriptNonEmpty(job.Script) {
findings = append(findings, Finding{
Severity: Error,
Job: name,
+13 -1
View File
@@ -88,7 +88,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
// After extends resolution, a job with no script/run is an error.
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
hasScript := len(job.Script) > 0 || job.Run != nil
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
findings = append(findings, Finding{
Severity: Error,
@@ -139,3 +139,15 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
return findings
}
// scriptNonEmpty reports whether a script/before_script/after_script field
// (which may be a []any list or a plain string) is non-empty.
func scriptNonEmpty(v any) bool {
switch s := v.(type) {
case []any:
return len(s) > 0
case string:
return s != ""
}
return false
}
+89
View File
@@ -0,0 +1,89 @@
package linter_test
import (
"path/filepath"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// TestSambaCI verifies that the Samba project's .gitlab-ci.yml (a real-world
// pipeline that is valid on GitLab) produces no Error findings.
// These files exercise local include resolution and multi-level extends chains.
func TestSambaCI(t *testing.T) {
entryPoint := "../../samba-testdata/.gitlab-ci.yml"
p, err := model.Parse(entryPoint)
if err != nil {
t.Fatalf("Parse: %v", err)
}
rootDir := filepath.Dir(filepath.Clean(entryPoint))
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
for _, w := range incWarnings {
t.Logf("include warning: %s", w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
for _, w := range extWarnings {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding on valid Samba CI: %s", f)
}
}
}
// TestSambaCIEntryFiles verifies all of the Samba entry-point files
// (files that can each act as the top-level CI file) lint without errors.
func TestSambaCIEntryFiles(t *testing.T) {
entryPoints := []struct {
name string
path string
}{
{"default", "../../samba-testdata/.gitlab-ci.yml"},
{"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"},
{"private", "../../samba-testdata/.gitlab-ci-private.yml"},
}
for _, tc := range entryPoints {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p, err := model.Parse(tc.path)
if err != nil {
t.Fatalf("Parse: %v", err)
}
rootDir := filepath.Dir(filepath.Clean(tc.path))
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
for _, w := range incWarnings {
t.Logf("include warning: %s", w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
for _, w := range extWarnings {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f)
}
}
})
}
}
+4 -4
View File
@@ -31,10 +31,10 @@ type Workflow struct {
type Job struct {
Name string // set by parser, not from YAML
Stage string `yaml:"stage"`
Script []string `yaml:"script"`
Run any `yaml:"run"` // alternative to script (CI steps)
BeforeScript []string `yaml:"before_script"`
AfterScript []string `yaml:"after_script"`
Script any `yaml:"script"` // []string or string (block scalar)
Run any `yaml:"run"` // alternative to script (CI steps)
BeforeScript any `yaml:"before_script"` // []string or string
AfterScript any `yaml:"after_script"` // []string or string
Image any `yaml:"image"`
Services []any `yaml:"services"`
Variables map[string]string `yaml:"variables"`
+20 -8
View File
@@ -10,33 +10,45 @@ import (
// Resolve resolves all extends: references in p.Jobs in place.
// Jobs are merged depth-first so that base definitions are resolved before
// derived ones. Mutates p.Jobs with the fully merged Job structs.
func Resolve(p *model.Pipeline) error {
//
// The first return value lists extends references whose base job could not be
// found (e.g. it lives in a remote include that was not fetched). Those jobs
// are left unmerged but still passed to the linter. A non-nil error is only
// returned for unrecoverable situations such as circular dependencies.
func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
var extWarnings []ExtendWarning
// Build extends graph: jobName -> ordered list of base job names.
extendsGraph := make(map[string][]string, len(p.Jobs))
for name, job := range p.Jobs {
bases, err := parseExtends(job.Extends)
if err != nil {
return fmt.Errorf("job %q: invalid extends: %w", name, err)
return extWarnings, fmt.Errorf("job %q: invalid extends: %w", name, err)
}
if len(bases) == 0 {
continue
}
skip := false
for _, base := range bases {
if _, ok := p.RawJobs[base]; !ok {
return fmt.Errorf("job %q extends unknown job %q", name, base)
extWarnings = append(extWarnings, ExtendWarning{Job: name, Base: base})
skip = true
}
}
if skip {
continue // leave this job unmerged; still linted with its own fields
}
extendsGraph[name] = bases
}
if len(extendsGraph) == 0 {
return nil
return extWarnings, nil
}
// Topological sort — bases must be resolved before derived jobs.
order, err := topoSort(extendsGraph)
if err != nil {
return err
return extWarnings, err
}
// resolved holds the final merged raw map for each processed job.
@@ -61,17 +73,17 @@ func Resolve(p *model.Pipeline) error {
// Re-decode the merged map into a Job struct.
data, err := yaml.Marshal(merged)
if err != nil {
return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
return extWarnings, fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
}
var j model.Job
if err := yaml.Unmarshal(data, &j); err != nil {
return fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
}
j.Name = name
p.Jobs[name] = j
}
return nil
return extWarnings, nil
}
// parseExtends normalises the extends field (string or []any) into []string.
+105 -42
View File
@@ -2,18 +2,18 @@ package resolver
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// IncludeWarning describes a remote include entry that could not be resolved.
// IncludeWarning describes an include entry that could not be resolved.
// These are surfaced to the user as [WARNING] lines before the lint findings.
type IncludeWarning struct {
// Label is a short human-readable identifier shown in the warning message,
// e.g. "project my-group/templates:/ci.yml@main" or
// "component gitlab.com/components/golang/build@v1.0".
// Label is a short human-readable identifier shown in the warning message.
Label string
Err error // nil when Skipped is true
Skipped bool // no token available — skipped without attempting a network call
@@ -30,46 +30,114 @@ func (w IncludeWarning) String() string {
w.Label, w.Err)
}
// ExtendWarning describes an extends: reference whose base job could not be
// found. This typically means the base is in a remote include that was not
// fetched (e.g. no token). The job's extends chain is skipped; the job itself
// is still linted with whatever fields it directly defines.
type ExtendWarning struct {
Job string // job that declares the extends
Base string // the unknown base job name
}
// ResolveIncludes processes the pipeline's include: block.
//
// Project includes (include: project: ...) require authentication and are
// skipped with a warning when no token is configured.
// rootDir is the repository root directory used to resolve local: includes.
// In practice this is the directory of the top-level pipeline file being linted.
//
// Component includes (include: component: ...) attempt the fetch
// unauthenticated first, so public CI/CD catalog components work without a
// token. A warning is emitted when the fetch fails.
// Local includes are read from disk and merged recursively.
// Project includes require authentication and are skipped with a warning when
// no token is configured. Component includes are attempted unauthenticated.
// Remote and template includes are silently skipped (resolved by GitLab at runtime).
//
// Non-resolvable include forms (local, remote, template) are silently skipped.
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig) []IncludeWarning {
// The second return value carries extends warnings discovered while recursively
// processing included files (forwarded from resolver.Resolve calls).
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig, rootDir string) ([]IncludeWarning, []ExtendWarning) {
visited := map[string]bool{}
return resolveIncludes(p, p.Include, cfg, rootDir, visited)
}
// resolveIncludes is the recursive core of ResolveIncludes.
func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
var warnings []IncludeWarning
for _, inc := range p.Include {
var extWarnings []ExtendWarning
for _, inc := range includes {
entry, ok := normaliseInclude(inc)
if !ok {
continue
}
if project, _ := entry["project"].(string); project != "" {
warnings = append(warnings, resolveProjectInclude(p, entry, project, cfg)...)
w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
}
if compRef, _ := entry["component"].(string); compRef != "" {
if w, ok := resolveComponentInclude(p, compRef, cfg); ok {
w, ew, hadErr := resolveComponentInclude(p, compRef, cfg, rootDir, visited)
if hadErr {
warnings = append(warnings, w)
}
extWarnings = append(extWarnings, ew...)
continue
}
// local, remote, template — resolved by GitLab at runtime, skip silently.
if local, _ := entry["local"].(string); local != "" {
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
}
// remote, template — resolved by GitLab at runtime, skip silently.
}
return warnings
return warnings, extWarnings
}
// resolveLocalInclude reads a local file from disk (paths are always relative
// to the repository root, with or without a leading slash), recursively
// resolves its own includes, and merges it into p.
func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
relPath := strings.TrimPrefix(rawPath, "/")
absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath
if visited[absPath] {
return nil, nil
}
visited[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return []IncludeWarning{{Label: label, Err: err}}, nil
}
included, err := model.ParseBytes(data)
if err != nil {
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
}
// Recursively resolve the included file's own includes first, merging
// everything into `included` before we merge it into the parent `p`.
var warnings []IncludeWarning
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included)
return warnings, extWarnings
}
// resolveProjectInclude fetches all files listed under a single project: entry
// and merges them into p.
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig) []IncludeWarning {
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
ref, _ := entry["ref"].(string)
var warnings []IncludeWarning
var extWarnings []ExtendWarning
for _, filePath := range includeFiles(entry) {
label := fmt.Sprintf("project %s:%s", project, filePath)
@@ -94,56 +162,58 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
continue
}
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included)
}
return warnings
return warnings, extWarnings
}
// resolveComponentInclude fetches a CI/CD catalog component and merges it into p.
// Returns (warning, true) if something went wrong; (zero, false) on success.
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig) (IncludeWarning, bool) {
// Returns (warning, extWarnings, true) if something went wrong; (zero, nil, false) on success.
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) (IncludeWarning, []ExtendWarning, bool) {
label := "component " + ref
// Variable interpolation (e.g. $CI_SERVER_FQDN) cannot be resolved locally.
if strings.ContainsRune(ref, '$') {
return IncludeWarning{
Label: label,
Err: fmt.Errorf("component reference contains a CI variable that cannot be resolved at lint time"),
}, true
}, nil, true
}
host, project, component, version, err := parseComponentRef(ref)
if err != nil {
return IncludeWarning{Label: label, Err: err}, true
return IncludeWarning{Label: label, Err: err}, nil, true
}
hostCfg := cfg.ForHost(host)
data, err := fetchComponentFile(hostCfg, project, component, version)
if err != nil {
return IncludeWarning{Label: label, Err: err}, true
return IncludeWarning{Label: label, Err: err}, nil, true
}
included, err := model.ParseBytes(data)
if err != nil {
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, true
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
}
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included)
return IncludeWarning{}, false
return IncludeWarning{}, extWarnings, false
}
// parseComponentRef parses a CI/CD component reference of the form:
//
// <host>/<project-path>/<component-name>@<version>
//
// The host is the GitLab instance FQDN. The last path segment before @ is the
// component name; everything between host and component name is the project path.
//
// Examples:
//
// gitlab.com/components/golang/build@v1.0.0
// gitlab.example.com/my-org/ci-templates/lint@main
// gitlab.com/components/secret-detection/secret-detection@~latest
func parseComponentRef(ref string) (host, project, component, version string, err error) {
atIdx := strings.LastIndex(ref, "@")
if atIdx < 0 || atIdx == len(ref)-1 {
@@ -154,7 +224,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
path := ref[:atIdx]
parts := strings.Split(path, "/")
// Minimum: host + at least one project segment + component = 3 parts.
if len(parts) < 3 {
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
return
@@ -167,9 +236,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
}
// fetchComponentFile fetches a component's template YAML from a GitLab project.
// GitLab supports two layouts; both are attempted:
// - templates/<component>.yml (single-file component)
// - templates/<component>/template.yml (directory component)
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
primary := "templates/" + component + ".yml"
data, err := cfg.FetchFile(project, primary, version)
@@ -180,14 +246,12 @@ func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version st
fallback := "templates/" + component + "/template.yml"
data2, err2 := cfg.FetchFile(project, fallback, version)
if err2 != nil {
// Return the primary error — it refers to the canonical path.
return nil, fmt.Errorf("%v (also tried %s: %v)", err, fallback, err2)
}
return data2, nil
}
// normaliseInclude converts a raw include: list element to a string-keyed map.
// An include: value can be a plain string (shorthand local) or a map.
func normaliseInclude(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
@@ -199,7 +263,6 @@ func normaliseInclude(raw any) (map[string]any, bool) {
}
// includeFiles returns the list of file paths from an include entry.
// The file: key may be a single string or a list of strings.
func includeFiles(entry map[string]any) []string {
raw, ok := entry["file"]
if !ok {