From c4ab64391d5884168e754d07c050687b1eafb978 Mon Sep 17 00:00:00 2001 From: k3nny Date: Thu, 11 Jun 2026 20:25:53 +0200 Subject: [PATCH] fix(model): handle YAML map forms that caused unmarshall errors Variables with value/description/options sub-keys, default.image in map form, default.before_script / default.after_script as block scalars, and rules.changes / rules.exists in {paths, compare_to} map form all caused "yaml: cannot unmarshal !!map into string" because the struct fields were typed too narrowly. Changed types in model.Pipeline, model.DefaultConfig, and model.Rule to accept any to match GitLab CI spec flexibility (13.7+ variable declarations, 15.3+ rules.changes map form, image map form in default block). Adds testdata/script_multiline.yml covering all these patterns. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 +++ README.md | 1 + Taskfile.yml | 2 + internal/model/pipeline.go | 28 +++++------ testdata/script_multiline.yml | 87 +++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 testdata/script_multiline.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 336ca49..cfa0aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ This project uses [Semantic Versioning](https://semver.org). ## [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 ### Added diff --git a/README.md b/README.md index c7e877a..5ab4840 100644 --- a/README.md +++ b/README.md @@ -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 - **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 +- **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 - **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 diff --git a/Taskfile.yml b/Taskfile.yml index 7e87efc..b92307c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -45,6 +45,8 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check testdata/includes_component.yml ignore_error: false + - cmd: ./{{.BINARY}} check testdata/script_multiline.yml + ignore_error: false - cmd: ./{{.BINARY}} check testdata/context_rules.yml ignore_error: false - cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml diff --git a/internal/model/pipeline.go b/internal/model/pipeline.go index 0aeb4a3..e7c971c 100644 --- a/internal/model/pipeline.go +++ b/internal/model/pipeline.go @@ -3,20 +3,20 @@ package model // Pipeline represents the top-level structure of a .gitlab-ci.yml file. // Unknown top-level keys are collected into Jobs. type Pipeline struct { - Stages []string `yaml:"stages"` - Variables map[string]string `yaml:"variables"` - Default *DefaultConfig `yaml:"default"` - Include []any `yaml:"include"` - Workflow *Workflow `yaml:"workflow"` + Stages []string `yaml:"stages"` + Variables map[string]any `yaml:"variables"` // string or {value,description,options} map + Default *DefaultConfig `yaml:"default"` + Include []any `yaml:"include"` + Workflow *Workflow `yaml:"workflow"` // Jobs holds every non-reserved top-level key (i.e. job definitions). - Jobs map[string]Job `yaml:"-"` - RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver + Jobs map[string]Job `yaml:"-"` + RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver } type DefaultConfig struct { - Image string `yaml:"image"` - BeforeScript []string `yaml:"before_script"` - AfterScript []string `yaml:"after_script"` + Image any `yaml:"image"` // string or {name,pull_policy,...} map + BeforeScript any `yaml:"before_script"` // []string or string (block scalar) + AfterScript any `yaml:"after_script"` // []string or string Cache any `yaml:"cache"` Artifacts any `yaml:"artifacts"` Retry any `yaml:"retry"` @@ -37,8 +37,8 @@ type Job struct { AfterScript any `yaml:"after_script"` // []string or string Image any `yaml:"image"` Services []any `yaml:"services"` - Variables map[string]string `yaml:"variables"` - Rules []Rule `yaml:"rules"` + Variables map[string]any `yaml:"variables"` // string or {value,description,options} map + Rules []Rule `yaml:"rules"` Only any `yaml:"only"` Except any `yaml:"except"` Needs []any `yaml:"needs"` @@ -68,8 +68,8 @@ type Job struct { type Rule struct { If string `yaml:"if"` When string `yaml:"when"` - Changes []string `yaml:"changes"` - Exists []string `yaml:"exists"` + Changes any `yaml:"changes"` // []string or {paths,compare_to} map + Exists any `yaml:"exists"` // []string or map form } // ReservedKeys are top-level GitLab CI keys that are NOT job definitions. diff --git a/testdata/script_multiline.yml b/testdata/script_multiline.yml new file mode 100644 index 0000000..5ef2616 --- /dev/null +++ b/testdata/script_multiline.yml @@ -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