feat(cli): output formats, GL034-GL041 lint rules, include inputs and cache
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m9s

Bundles three patch releases (v0.2.16–v0.2.18):

v0.2.18 — output formats (--format flag on glint check):
- json: stable JSON report (schema_version: 1, findings array, summary)
- sarif: SARIF 2.1.0 for GitHub Code Scanning / GitLab SAST
- junit: JUnit XML for CI test-report artifacts (artifacts:reports:junit)
- github: GitHub Actions ::error:: / ::warning:: annotation lines
- Unknown --format value exits 2 with a helpful error message
- Summary line routed to stderr in structured formats; context suppressed

v0.2.17 — include resolution improvements:
- Recursive include depth capped at 100 (matches GitLab's own limit)
- project: and component: includes tracked in visited set (cycle detection)
- $[[ inputs.KEY ]] / $[[ inputs.KEY | default(…) ]] substituted from with:
- --cache-dir: persist fetched remote templates to disk (SHA-256 keyed)
- --offline: serve from cache only; defaults to ~/.cache/glint

v0.2.16 — new lint rules (GL034–GL041):
- GL034: services map form requires name; alias must be valid DNS label
- GL035: rules:changes / rules:exists absolute path detection
- GL036: timeout format validation (job-level + default.timeout)
- GL037: id_tokens entries must have an aud key
- GL038: secrets entries must declare a provider (vault / gcp / azure)
- GL039: pages: keyword + artifacts.paths consistency
- GL040: duplicate stage names in stages: list
- GL041: cache.key.files must be exact paths, not globs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 10:09:16 +02:00
parent 54b5850835
commit f5f8546bcf
17 changed files with 1623 additions and 66 deletions
+41
View File
@@ -0,0 +1,41 @@
package fetcher
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
)
// cacheRead returns the cached bytes for the given cache key, or (nil, false)
// on a miss (key not present, dir empty, or any read error).
func cacheRead(dir, key string) ([]byte, bool) {
if dir == "" {
return nil, false
}
data, err := os.ReadFile(cachePath(dir, key))
if err != nil {
return nil, false
}
return data, true
}
// cacheWrite stores bytes in the cache for the given key. Write errors are
// silently ignored so cache failures never block the normal fetch path.
func cacheWrite(dir, key string, data []byte) {
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(cachePath(dir, key), data, 0o644)
}
// cachePath returns the filesystem path for a cache entry.
// The filename is the SHA-256 hex digest of the key so arbitrary keys (URLs,
// "project:file@ref" strings) map to safe, stable filenames.
func cachePath(dir, key string) string {
h := sha256.Sum256([]byte(key))
return filepath.Join(dir, fmt.Sprintf("%x.yml", h))
}
+35 -10
View File
@@ -22,9 +22,11 @@ const (
// GitLabConfig holds everything needed to reach a GitLab instance.
type GitLabConfig struct {
BaseURL string
Token string
Source TokenSource
BaseURL string
Token string
Source TokenSource
CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls
}
// AutoConfig builds a GitLabConfig from environment variables.
@@ -54,8 +56,11 @@ func AutoConfig() GitLabConfig {
return cfg
}
// WithOverrides returns a copy of cfg with non-empty overrides applied.
func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
// WithOverrides returns a copy of cfg with the provided overrides applied.
// Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode).
// cacheDir: empty string disables caching.
func (cfg GitLabConfig) WithOverrides(baseURL, token, cacheDir string, offline bool) GitLabConfig {
if baseURL != "" {
cfg.BaseURL = strings.TrimRight(baseURL, "/")
}
@@ -63,6 +68,8 @@ func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
cfg.Token = token
cfg.Source = TokenPrivate
}
cfg.CacheDir = cacheDir
cfg.Offline = offline
return cfg
}
@@ -84,16 +91,24 @@ func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" }
// - filePath — repository file path, e.g. "/templates/ci.yml"
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
if ref == "" {
ref = "HEAD"
}
cKey := cfg.BaseURL + "|" + project + "|" + filePath + "|" + ref
if data, ok := cacheRead(cfg.CacheDir, cKey); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s:%s@%s is not in the local cache (run without --offline first to populate the cache)", project, filePath, ref)
}
encodedProject := url.PathEscape(project)
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
cfg.BaseURL, encodedProject, encodedFile)
if ref == "" {
ref = "HEAD"
}
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
@@ -139,12 +154,21 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
}
}
cacheWrite(cfg.CacheDir, cKey, body)
return body, nil
}
// FetchURL downloads the content at a plain HTTPS URL without authentication.
// Used for include: remote: entries which are public by definition.
func FetchURL(rawURL string) ([]byte, error) {
// Responses are read from and written to the local cache when CacheDir is set.
func (cfg GitLabConfig) FetchURL(rawURL string) ([]byte, error) {
if data, ok := cacheRead(cfg.CacheDir, rawURL); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL)
}
resp, err := http.Get(rawURL) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err)
@@ -157,6 +181,7 @@ func FetchURL(rawURL string) ([]byte, error) {
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
}
cacheWrite(cfg.CacheDir, rawURL, body)
return body, nil
}
+1 -1
View File
@@ -177,7 +177,7 @@ func (b *treeBuilder) recurseRemote(node *treeNode, rawURL string) {
}
b.visited[key] = true
data, err := fetcher.FetchURL(rawURL)
data, err := b.cfg.FetchURL(rawURL)
if err != nil {
return
}
+305 -2
View File
@@ -8,6 +8,15 @@ import (
"git.k3nny.fr/glint/internal/model"
)
// dnsLabelPattern matches a valid hostname label: starts and ends with
// alphanumeric, middle may contain hyphens and dots; max 63 chars per label.
var dnsLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,61}[a-zA-Z0-9])?$`)
// timeoutPattern matches a GitLab CI duration string: one or more pairs of
// "<number> <unit>" where unit is a recognised time word or abbreviation.
var timeoutPattern = regexp.MustCompile(
`(?i)^\s*(\d+\s*(weeks?|w|days?|d|hours?|h|minutes?|mins?|m|seconds?|secs?|s)\s*)+$`)
var validJobWhen = map[string]bool{
"on_success": true,
"on_failure": true,
@@ -98,6 +107,13 @@ func checkJobKeywords(name string, job model.Job) []Finding {
findings = append(findings, checkDeadRules(name, job)...)
findings = append(findings, checkImage(name, job)...)
findings = append(findings, checkInherit(name, job)...)
findings = append(findings, checkServices(name, job)...)
findings = append(findings, checkRulesGlobs(name, job)...)
findings = append(findings, checkTimeout(name, job)...)
findings = append(findings, checkIDTokens(name, job)...)
findings = append(findings, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...)
return findings
}
@@ -402,8 +418,9 @@ func checkArtifacts(name string, job model.Job) []Finding {
})
}
}
// Pages job should publish to public/
if name == "pages" {
// Pages job should publish to public/. Skip when pages: keyword is set;
// GL039 (checkPagesKeyword) handles that case with a more precise check.
if name == "pages" && job.Pages == nil {
if paths, ok := m["paths"].([]any); ok {
found := false
for _, p := range paths {
@@ -547,3 +564,289 @@ func checkInherit(name string, job model.Job) []Finding {
}
return findings
}
// GL034: services: map form requires 'name'; 'alias' must be a valid DNS label.
func checkServices(name string, job model.Job) []Finding {
if len(job.Services) == 0 {
return nil
}
var findings []Finding
for i, svc := range job.Services {
m, ok := svc.(map[string]any)
if !ok {
continue // string form is valid
}
n := m["name"]
if n == nil || n == "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: map form requires a 'name' key", i),
})
}
if alias, ok := m["alias"].(string); ok && alias != "" {
if !dnsLabelPattern.MatchString(alias) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: alias %q is not a valid DNS label (only letters, digits, hyphens, and dots; must start and end with alphanumeric)", i, alias),
})
}
}
}
return findings
}
// GL035: rules:changes and rules:exists paths must not be absolute.
func checkRulesGlobs(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].changes", i), rule.Changes)...)
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].exists", i), rule.Exists)...)
}
return findings
}
func checkAbsolutePaths(name, field string, val any) []Finding {
var paths []string
switch v := val.(type) {
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
case map[string]any:
if ps, ok := v["paths"].([]any); ok {
for _, item := range ps {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
}
}
var findings []Finding
for _, p := range paths {
if strings.HasPrefix(p, "/") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleAbsoluteGlobPath,
Job: name,
Message: fmt.Sprintf("%s: path %q is absolute; GitLab CI paths are relative to the repository root — absolute paths will never match", field, p),
})
}
}
return findings
}
// GL036: timeout: must be a valid GitLab CI duration string.
func checkTimeout(name string, job model.Job) []Finding {
if job.Timeout == "" {
return nil
}
if !timeoutPattern.MatchString(job.Timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
Job: name,
Message: fmt.Sprintf("'timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", job.Timeout),
}}
}
return nil
}
// CheckDefaultTimeout validates the pipeline-level default: timeout: field.
// Exported so it can be called from linter.go.
func checkDefaultTimeout(timeout, file string) []Finding {
if timeout == "" {
return nil
}
if !timeoutPattern.MatchString(timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
File: file,
Message: fmt.Sprintf("'default.timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", timeout),
}}
}
return nil
}
// GL037: id_tokens: each entry must have an 'aud' key.
func checkIDTokens(name string, job model.Job) []Finding {
if job.IDTokens == nil {
return nil
}
m, ok := job.IDTokens.(map[string]any)
if !ok {
return nil
}
var findings []Finding
for tokenName, tokenVal := range m {
tokenMap, ok := tokenVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: must be a map with an 'aud' key", tokenName),
})
continue
}
if _, hasAud := tokenMap["aud"]; !hasAud {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: missing required 'aud' key", tokenName),
})
}
}
return findings
}
// GL038: secrets: each entry must have a provider key (vault, gcp_secret_manager, azure_key_vault).
func checkSecrets(name string, job model.Job) []Finding {
if job.Secrets == nil {
return nil
}
m, ok := job.Secrets.(map[string]any)
if !ok {
return nil
}
validProviders := []string{"vault", "gcp_secret_manager", "azure_key_vault"}
var findings []Finding
for secretName, secretVal := range m {
secretMap, ok := secretVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: must be a map with a provider key (vault, gcp_secret_manager, or azure_key_vault)", secretName),
})
continue
}
hasProvider := false
for _, provider := range validProviders {
if _, ok := secretMap[provider]; ok {
hasProvider = true
break
}
}
if !hasProvider {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: missing provider key; must include one of: vault, gcp_secret_manager, azure_key_vault", secretName),
})
}
}
return findings
}
// GL039: a job with the pages: keyword must have the publish directory in artifacts.paths.
func checkPagesKeyword(name string, job model.Job) []Finding {
if job.Pages == nil {
return nil
}
publishDir := "public"
if m, ok := job.Pages.(map[string]any); ok {
if p, ok := m["publish"].(string); ok && p != "" {
publishDir = p
}
}
if job.Artifacts == nil {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but has no 'artifacts:' block — GitLab Pages will not deploy", publishDir),
}}
}
m, ok := job.Artifacts.(map[string]any)
if !ok {
return nil
}
paths, ok := m["paths"].([]any)
if !ok || len(paths) == 0 {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but 'artifacts.paths' is missing — GitLab Pages will not deploy", publishDir),
}}
}
for _, p := range paths {
if s, ok := p.(string); ok && (s == publishDir || strings.HasPrefix(s, publishDir+"/")) {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("'pages.publish' is %q but 'artifacts.paths' does not include it — GitLab Pages will not deploy", publishDir),
}}
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil {
return nil
}
var maps []map[string]any
switch v := job.Cache.(type) {
case map[string]any:
maps = []map[string]any{v}
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
maps = append(maps, m)
}
}
}
var findings []Finding
for _, m := range maps {
keyVal, ok := m["key"]
if !ok {
continue
}
keyMap, ok := keyVal.(map[string]any)
if !ok {
continue
}
filesVal, ok := keyMap["files"]
if !ok {
continue
}
filesList, ok := filesVal.([]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: "'cache.key.files' must be a list of file paths",
})
continue
}
for _, item := range filesList {
s, ok := item.(string)
if !ok {
continue
}
if strings.ContainsAny(s, "*?[") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: fmt.Sprintf("cache.key.files: %q looks like a glob pattern; 'key.files' must be exact file paths, not globs", s),
})
}
}
}
return findings
}
+28
View File
@@ -55,6 +55,8 @@ func (f Finding) String() string {
func Lint(p *model.Pipeline) []Finding {
var findings []Finding
findings = append(findings, checkStages(p)...)
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...)
@@ -85,6 +87,32 @@ func checkStages(p *model.Pipeline) []Finding {
return findings
}
// GL040: warn when a stage name appears more than once in stages:.
func checkDuplicateStages(p *model.Pipeline) []Finding {
seen := make(map[string]bool, len(p.Stages))
var findings []Finding
for _, s := range p.Stages {
if seen[s] {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleDuplicateStage,
File: p.SourceFile,
Message: fmt.Sprintf("stage %q appears more than once in 'stages'; GitLab silently merges duplicate stage entries", s),
})
}
seen[s] = true
}
return findings
}
// checkDefault validates the pipeline-level default: block.
func checkDefault(p *model.Pipeline) []Finding {
if p.Default == nil {
return nil
}
return checkDefaultTimeout(p.Default.Timeout, p.SourceFile)
}
func checkWorkflow(p *model.Pipeline) []Finding {
if p.Workflow == nil {
return nil
+25
View File
@@ -118,4 +118,29 @@ const (
// GL033: every rule in a job's rules: block has when: never, so the job
// can never be included in any pipeline run.
RuleDeadRules = "GL033"
// GL034: services: map form is missing 'name', or 'alias' is not a valid DNS label.
RuleInvalidService = "GL034"
// GL035: rules:changes or rules:exists contains an absolute path (starts with /);
// GitLab CI paths are relative to the repository root and absolute paths never match.
RuleAbsoluteGlobPath = "GL035"
// GL036: timeout: is not a valid GitLab CI duration string (e.g. '1h 30m', '90 minutes').
RuleInvalidTimeout = "GL036"
// GL037: id_tokens: entry is missing the required 'aud' key.
RuleInvalidIDToken = "GL037"
// GL038: secrets: entry is missing a provider key (vault, gcp_secret_manager, or azure_key_vault).
RuleInvalidSecret = "GL038"
// GL039: a job has the pages: keyword but artifacts.paths does not include the publish directory.
RulePagesPublish = "GL039"
// GL040: a stage name appears more than once in stages:; GitLab silently merges duplicates.
RuleDuplicateStage = "GL040"
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
)
+95 -16
View File
@@ -4,12 +4,27 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// maxIncludeDepth is the maximum nesting level for include: chains.
// This matches GitLab's own documented limit and also guards against
// include cycles that slip past the visited-key deduplication.
const maxIncludeDepth = 100
// inputPlaceholderRe matches GitLab CI component input references:
//
// $[[ inputs.KEY ]]
// $[[ inputs.KEY | default('value') ]]
// $[[ inputs.KEY | default(true) ]]
// $[[ inputs.KEY | default(123) ]]
var inputPlaceholderRe = regexp.MustCompile(
`\$\[\[\s*inputs\.(\w+)(?:\s*\|\s*default\(([^)]*)\))?\s*\]\]`)
// 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 {
@@ -53,11 +68,18 @@ type ExtendWarning struct {
// 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)
return resolveIncludes(p, p.Include, cfg, rootDir, visited, 0)
}
// 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) {
func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
if depth > maxIncludeDepth {
return []IncludeWarning{{
Label: "includes",
Err: fmt.Errorf("include nesting depth exceeded (%d levels) — possible include cycle detected", maxIncludeDepth),
}}, nil
}
var warnings []IncludeWarning
var extWarnings []ExtendWarning
@@ -68,14 +90,15 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
}
if project, _ := entry["project"].(string); project != "" {
w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited)
w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited, depth)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
}
if compRef, _ := entry["component"].(string); compRef != "" {
w, ew, hadErr := resolveComponentInclude(p, compRef, cfg, rootDir, visited)
inputs := extractInputs(entry)
w, ew, hadErr := resolveComponentInclude(p, compRef, inputs, cfg, rootDir, visited, depth)
if hadErr {
warnings = append(warnings, w)
}
@@ -84,14 +107,14 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
}
if local, _ := entry["local"].(string); local != "" {
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited)
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited, depth)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
}
if remote, _ := entry["remote"].(string); remote != "" {
w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited)
w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited, depth)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
@@ -105,7 +128,7 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
// 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) {
func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
relPath := strings.TrimPrefix(rawPath, "/")
absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath
@@ -131,7 +154,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
var warnings []IncludeWarning
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
@@ -142,7 +165,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
// resolveRemoteInclude fetches a plain HTTPS URL, parses it as CI YAML, and
// merges it into p. Sub-includes of the fetched file are resolved recursively.
func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
label := "remote " + rawURL
if visited[rawURL] {
@@ -150,7 +173,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
}
visited[rawURL] = true
data, err := fetcher.FetchURL(rawURL)
data, err := cfg.FetchURL(rawURL)
if err != nil {
return []IncludeWarning{{Label: label, Err: err}}, nil
}
@@ -164,7 +187,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
var warnings []IncludeWarning
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
@@ -175,7 +198,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
// 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, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
ref, _ := entry["ref"].(string)
var warnings []IncludeWarning
var extWarnings []ExtendWarning
@@ -186,6 +209,12 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
label += "@" + ref
}
visitKey := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref)
if visited[visitKey] {
continue
}
visited[visitKey] = true
if !cfg.HasToken() {
warnings = append(warnings, IncludeWarning{Label: label, Skipped: true})
continue
@@ -205,7 +234,7 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
included.SetJobOrigin(label)
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
@@ -215,9 +244,11 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
return warnings, extWarnings
}
// resolveComponentInclude fetches a CI/CD catalog component and merges it into p.
// resolveComponentInclude fetches a CI/CD catalog component, substitutes any
// $[[ inputs.KEY ]] placeholders with values from `inputs` (the include's
// with: block), and merges the result into p.
// 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) {
func resolveComponentInclude(p *model.Pipeline, ref string, inputs map[string]any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) (IncludeWarning, []ExtendWarning, bool) {
label := "component " + ref
if strings.ContainsRune(ref, '$') {
@@ -227,6 +258,12 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
}, nil, true
}
visitKey := "component:" + ref
if visited[visitKey] {
return IncludeWarning{}, nil, false
}
visited[visitKey] = true
host, project, component, version, err := parseComponentRef(ref)
if err != nil {
return IncludeWarning{Label: label, Err: err}, nil, true
@@ -238,6 +275,9 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
return IncludeWarning{Label: label, Err: err}, nil, true
}
// Substitute $[[ inputs.KEY ]] placeholders with the values from with:.
data = substituteInputs(data, inputs)
included, err := model.ParseBytes(data)
if err != nil {
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
@@ -246,7 +286,7 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
extWarnings = append(extWarnings, ew...)
}
@@ -254,6 +294,45 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
return IncludeWarning{}, extWarnings, false
}
// substituteInputs replaces all $[[ inputs.KEY ]] and
// $[[ inputs.KEY | default('…') ]] placeholders in data with the corresponding
// values from inputs (the with: block of the component include entry).
// Missing keys with no default become empty strings.
// Missing keys with a default use the default value (single/double quotes stripped).
func substituteInputs(data []byte, inputs map[string]any) []byte {
if len(data) == 0 {
return data
}
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
groups := inputPlaceholderRe.FindSubmatch(match)
if len(groups) < 2 {
return match
}
if val, ok := inputs[string(groups[1])]; ok {
return []byte(fmt.Sprintf("%v", val))
}
// Use the default value when the key is absent from inputs.
if len(groups) >= 3 && len(groups[2]) > 0 {
def := strings.TrimSpace(string(groups[2]))
if (strings.HasPrefix(def, "'") && strings.HasSuffix(def, "'")) ||
(strings.HasPrefix(def, `"`) && strings.HasSuffix(def, `"`)) {
def = def[1 : len(def)-1]
}
return []byte(def)
}
return []byte("")
})
}
// extractInputs returns the with: map from a component include entry, or nil.
func extractInputs(entry map[string]any) map[string]any {
with, ok := entry["with"].(map[string]any)
if !ok {
return nil
}
return with
}
// parseComponentRef parses a CI/CD component reference of the form:
//
// <host>/<project-path>/<component-name>@<version>
+90
View File
@@ -0,0 +1,90 @@
package resolver
import (
"testing"
)
func TestSubstituteInputs(t *testing.T) {
cases := []struct {
name string
data string
inputs map[string]any
want string
}{
{
name: "simple substitution",
data: `stage: $[[ inputs.STAGE ]]`,
inputs: map[string]any{"STAGE": "deploy"},
want: `stage: deploy`,
},
{
name: "default string used when key absent",
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
inputs: map[string]any{},
want: `image: ubuntu:22.04`,
},
{
name: "input overrides default",
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
inputs: map[string]any{"IMAGE": "alpine:3.18"},
want: `image: alpine:3.18`,
},
{
name: "integer input",
data: `variables:\n RETRIES: $[[ inputs.RETRY_COUNT ]]`,
inputs: map[string]any{"RETRY_COUNT": 3},
want: `variables:\n RETRIES: 3`,
},
{
name: "boolean default",
data: `variables:\n ENABLED: $[[ inputs.ENABLE | default(true) ]]`,
inputs: map[string]any{},
want: `variables:\n ENABLED: true`,
},
{
name: "numeric default",
data: `variables:\n COUNT: $[[ inputs.COUNT | default(5) ]]`,
inputs: map[string]any{},
want: `variables:\n COUNT: 5`,
},
{
name: "missing key no default becomes empty",
data: `script: $[[ inputs.CMD ]]`,
inputs: map[string]any{},
want: `script: `,
},
{
name: "no placeholders unchanged",
data: `stage: build`,
inputs: nil,
want: `stage: build`,
},
{
name: "multiple placeholders in one document",
data: "stage: $[[ inputs.STAGE ]]\nimage: $[[ inputs.IMAGE | default('alpine') ]]",
inputs: map[string]any{"STAGE": "test"},
want: "stage: test\nimage: alpine",
},
{
name: "double-quoted default",
data: `image: $[[ inputs.IMAGE | default("debian:12") ]]`,
inputs: map[string]any{},
want: `image: debian:12`,
},
{
name: "whitespace inside brackets",
data: `stage: $[[ inputs.STAGE ]]`,
inputs: map[string]any{"STAGE": "build"},
want: `stage: build`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(substituteInputs([]byte(tc.data), tc.inputs))
if got != tc.want {
t.Errorf("substituteInputs:\n got %q\n want %q", got, tc.want)
}
})
}
}