fix(model): handle YAML map forms that caused unmarshall errors #3

Merged
k3nny merged 1 commits from fix/unmarshall_errors into main 2026-06-11 20:31:31 +02:00
5 changed files with 111 additions and 14 deletions
Showing only changes of commit c4ab64391d - Show all commits
+7
View File
@@ -7,6 +7,13 @@ This project uses [Semantic Versioning](https://semver.org).
## [Unreleased] ## [Unreleased]
### Fixed
- **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations.
- **`default.image` map form now parses correctly** — `default: image: {name: ..., pull_policy: ...}` used to cause `yaml: cannot unmarshal !!map into string`; `DefaultConfig.Image` is now typed as `any` to match `Job.Image`.
- **`default.before_script` / `default.after_script` now accept both list and scalar forms** — previously `DefaultConfig.BeforeScript` and `DefaultConfig.AfterScript` were `[]string`, causing a parse error when the field was written as a block scalar string. They are now typed as `any` to match the corresponding `Job` fields.
- **`rules.changes` / `rules.exists` map form now parses correctly** — extended `changes: {paths: [...], compare_to: "..."}` syntax (GitLab CI 15.3+) used to cause `yaml: cannot unmarshal !!map into []string`.
## [0.2.0] - 2026-06-11 ## [0.2.0] - 2026-06-11
### Added ### Added
+1
View File
@@ -19,6 +19,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token - **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules` - **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated - **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG - **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters - **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
+2
View File
@@ -45,6 +45,8 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/includes_component.yml - cmd: ./{{.BINARY}} check testdata/includes_component.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/script_multiline.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/context_rules.yml - cmd: ./{{.BINARY}} check testdata/context_rules.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml - cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml
+14 -14
View File
@@ -3,20 +3,20 @@ package model
// Pipeline represents the top-level structure of a .gitlab-ci.yml file. // Pipeline represents the top-level structure of a .gitlab-ci.yml file.
// Unknown top-level keys are collected into Jobs. // Unknown top-level keys are collected into Jobs.
type Pipeline struct { type Pipeline struct {
Stages []string `yaml:"stages"` Stages []string `yaml:"stages"`
Variables map[string]string `yaml:"variables"` Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Default *DefaultConfig `yaml:"default"` Default *DefaultConfig `yaml:"default"`
Include []any `yaml:"include"` Include []any `yaml:"include"`
Workflow *Workflow `yaml:"workflow"` Workflow *Workflow `yaml:"workflow"`
// Jobs holds every non-reserved top-level key (i.e. job definitions). // Jobs holds every non-reserved top-level key (i.e. job definitions).
Jobs map[string]Job `yaml:"-"` Jobs map[string]Job `yaml:"-"`
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
} }
type DefaultConfig struct { type DefaultConfig struct {
Image string `yaml:"image"` Image any `yaml:"image"` // string or {name,pull_policy,...} map
BeforeScript []string `yaml:"before_script"` BeforeScript any `yaml:"before_script"` // []string or string (block scalar)
AfterScript []string `yaml:"after_script"` AfterScript any `yaml:"after_script"` // []string or string
Cache any `yaml:"cache"` Cache any `yaml:"cache"`
Artifacts any `yaml:"artifacts"` Artifacts any `yaml:"artifacts"`
Retry any `yaml:"retry"` Retry any `yaml:"retry"`
@@ -37,8 +37,8 @@ type Job struct {
AfterScript any `yaml:"after_script"` // []string or string AfterScript any `yaml:"after_script"` // []string or string
Image any `yaml:"image"` Image any `yaml:"image"`
Services []any `yaml:"services"` Services []any `yaml:"services"`
Variables map[string]string `yaml:"variables"` Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Rules []Rule `yaml:"rules"` Rules []Rule `yaml:"rules"`
Only any `yaml:"only"` Only any `yaml:"only"`
Except any `yaml:"except"` Except any `yaml:"except"`
Needs []any `yaml:"needs"` Needs []any `yaml:"needs"`
@@ -68,8 +68,8 @@ type Job struct {
type Rule struct { type Rule struct {
If string `yaml:"if"` If string `yaml:"if"`
When string `yaml:"when"` When string `yaml:"when"`
Changes []string `yaml:"changes"` Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists []string `yaml:"exists"` Exists any `yaml:"exists"` // []string or map form
} }
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+87
View File
@@ -0,0 +1,87 @@
---
# Exercises multi-line script patterns and extended variable declarations.
# Ref: https://docs.gitlab.com/ci/yaml/script/#split-long-commands
# Ref: https://docs.gitlab.com/ee/ci/yaml/#variablesdescription
# All patterns here must parse cleanly (exit 0).
stages:
- build
- test
- deploy
# Pipeline-level variables: plain strings and extended {value, description} map form.
variables:
PLAIN_VAR: "hello"
DEPLOY_ENV:
value: "staging"
description: "The deployment target. Set to staging or production."
RETRIES:
value: "3"
description: "Number of retry attempts."
options:
- "1"
- "3"
- "5"
default:
# image in map form (name + pull_policy)
image:
name: alpine:latest
pull_policy: if-not-present
# before_script as a block scalar (not a list)
before_script:
- apk add --no-cache curl git
build-literal-block:
stage: build
# script items using literal block scalar (|)
script:
- |
if [[ "$DEPLOY_ENV" == "production" ]]; then
echo "Production build"
else
echo "Non-production build"
fi
- echo "Build step done"
build-folded-block:
stage: build
# script items using folded block scalar (>)
script:
- >
apt-get update -qq &&
apt-get install -y curl wget
- echo "Packages installed"
before_script:
- |
echo "Job-level before_script"
echo "Using literal block scalar"
test-job:
stage: test
script:
- echo "Running tests"
- |
set -e
go test ./...
echo "Tests passed"
# Job-level variable with extended form
variables:
TEST_FLAG:
value: "true"
description: "Enable verbose test output"
# rules.changes in map form (GitLab 15.3+)
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
paths:
- "**/*.go"
compare_to: "main"
when: on_success
- when: on_success
deploy-job:
stage: deploy
script:
- echo "Deploying to $DEPLOY_ENV"
when: manual