Compare commits

..
9 Commits
Author SHA1 Message Date
k3nnyandClaude Sonnet 4.6 fba98acb86 fix(cicontext): cascade AND-group variables to later members' if: conditions
ci / vet, staticcheck, test, build (push) Successful in 5m43s
release / Build and publish release (push) Successful in 6m20s
Variables set by an earlier AND-group member's variables: block are now
injected into the evaluation context before testing later members' if:
expressions. This matches GitLab CI behaviour where e.g. element 0 sets
DEPLOY=true and element 1 can then check $DEPLOY without requiring the
caller to inject that variable manually.

Previously evalRuleGroup passed the same base vars closure to every
member, so cascaded variables were never visible during if: evaluation
(only in the merged output returned after the group fired).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:14:17 +02:00
k3nnyandClaude Sonnet 4.6 986162d270 feat(linter): add GL046-GL049 rules, AND-group support, and GL032 fix
ci / vet, staticcheck, test, build (push) Successful in 8m44s
release / Build and publish release (push) Successful in 9m52s
- GL046: validate image/service pull_policy values (always, if-not-present, never)
- GL047: error when a variables.options default value is not in the options list
- GL048: error on unrecognised trigger.forward keys
- GL049: validate rules[n].allow_failure (bool or {exit_codes:} map)
- Parse and evaluate workflow.rules/job.rules nested-array AND-groups; crash
  on !!seq nodes is fixed; all members of a group must match for it to fire
- Add workflow.name and workflow.auto_cancel fields to Workflow struct
- Fix GL032 false positive: variables declared in any workflow rule's variables:
  block no longer trigger an undeclared-variable warning in sibling workflow
  rule if: expressions
- Add Windows ARM64 release build target (task build-windows-arm64)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:44:22 +02:00
k3nny 3eea496b9f feat(release): 🚀 ajout arm64 2026-07-30 22:03:56 +02:00
k3nnyandClaude Sonnet 4.6 ef0d7b118a docs(docs): update CHANGELOG and README badge for v0.4.1
ci / vet, staticcheck, test, build (push) Successful in 3m4s
release / Build and publish release (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:58:14 +02:00
k3nnyandClaude Sonnet 4.6 b222105e1f build(build): add Linux arm64, macOS amd64/arm64 to release CI
ci / vet, staticcheck, test, build (push) Successful in 2m10s
- Add build steps for linux/arm64, darwin/amd64, darwin/arm64
- Rename Windows output to glint-<tag>-windows-amd64.exe (consistent
  with Taskfile and INSTALL.md)
- Add -X main.version=<tag> to all ldflags (was missing, causing
  release binaries to report "dev" as version)
- Use $TAG variable in upload loop instead of repeating the expression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:56:42 +02:00
k3nnyandClaude Sonnet 4.6 a8fadd4dd4 docs(docs): update CHANGELOG, README, ROADMAP, Formula, and INSTALL for v0.4.0
ci / vet, staticcheck, test, build (push) Successful in 2m2s
release / Build and publish release (push) Successful in 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:49:23 +02:00
k3nnyandClaude Sonnet 4.6 2b32267015 feat(build): add multi-platform release targets, Homebrew formula, and INSTALL.md
ci / vet, staticcheck, test, build (push) Successful in 2m13s
Taskfile:
- Add build-linux-arm64, build-darwin-amd64, build-darwin-arm64 targets
- Rename build-linux to build-linux-amd64 (keep build-linux alias)
- Rename Windows output to glint-<tag>-windows-amd64.exe for consistency
- Add build-release task that builds all five platforms in one shot

Formula/glint.rb:
- Homebrew source-build formula; depends_on "go" => :build
- tap: brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
- Includes basic test block (--version + lint a trivial pipeline)

INSTALL.md:
- Pre-built binary download instructions for all five platforms
- Homebrew tap setup and formula update procedure
- go install one-liner
- Link to README integrations section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:45:20 +02:00
k3nnyandClaude Sonnet 4.6 4f6855b9ab feat(cli): inject GitLab predefined variables into the simulation context
ci / vet, staticcheck, test, build (push) Successful in 2m5s
Two categories of predefined variables are now injected automatically:

1. Always-available (CI=true, GITLAB_CI=true): set at lowest priority for
   every non-empty context so that rules:if: expressions like '$CI == "true"'
   evaluate correctly without requiring --var.

2. MR-specific (CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME,
   CI_MERGE_REQUEST_TARGET_BRANCH_NAME, …): injected as placeholder values
   when --source merge_request_event is given, so MR-gated jobs evaluate
   as active rather than silently skipped. CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
   is derived from --branch when provided.

All injected defaults are non-pinned: --var overrides them, and pipeline
variables: blocks can also override via Inject(). The empty context (no
flags at all) is unchanged — no predefined vars are injected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:30:25 +02:00
k3nnyandClaude Sonnet 4.6 3b4f49bbe5 feat(cli): auto-detect git branch as default context
ci / vet, staticcheck, test, build (push) Successful in 2m28s
When no --branch, --tag, --source, or --var flags are given, glint now
runs "git rev-parse --abbrev-ref HEAD" in the pipeline file's directory
to determine the current branch. Falls back to "main" when the directory
is not inside a git repository or the repo is in detached-HEAD state.

This makes implicit context simulation accurate without requiring users
to pass --branch on every invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:18:51 +02:00
23 changed files with 1087 additions and 81 deletions
+56 -4
View File
@@ -32,18 +32,64 @@ jobs:
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | run: |
go build -trimpath -ldflags="-s -w" \ go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-amd64 \ -o glint-${{ github.ref_name }}-linux-amd64 \
./cmd/glint/... ./cmd/glint/...
- name: Build Linux (arm64)
env:
GOOS: linux
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-arm64 \
./cmd/glint/...
- name: Build macOS (amd64)
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-amd64 \
./cmd/glint/...
- name: Build macOS (arm64)
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-arm64 \
./cmd/glint/...
- name: Build Windows (amd64) - name: Build Windows (amd64)
env: env:
GOOS: windows GOOS: windows
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | run: |
go build -trimpath -ldflags="-s -w" \ go build -trimpath \
-o glint-${{ github.ref_name }}.exe \ -ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-windows-amd64.exe \
./cmd/glint/...
- name: Build Windows (arm64)
env:
GOOS: windows
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-windows-arm64.exe \
./cmd/glint/... ./cmd/glint/...
- name: Create release and upload assets - name: Create release and upload assets
@@ -60,7 +106,13 @@ jobs:
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id) | jq -r .id)
for file in glint-${{ github.ref_name }}-linux-amd64 glint-${{ github.ref_name }}.exe; do for file in \
glint-${TAG}-linux-amd64 \
glint-${TAG}-linux-arm64 \
glint-${TAG}-darwin-amd64 \
glint-${TAG}-darwin-arm64 \
glint-${TAG}-windows-amd64.exe \
glint-${TAG}-windows-arm64.exe; do
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
+3
View File
@@ -16,6 +16,9 @@ coverage.txt
# Task runner cache # Task runner cache
.task/ .task/
# Tmp
.tmp/
# Claude Code project memory # Claude Code project memory
.claude/ .claude/
+44
View File
@@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [0.5.1] - 2026-07-30
### Fixed
- **AND-group variable cascading** — variables set by an earlier AND-group member's `variables:` block are now visible when evaluating later members' `if:` conditions. Previously all members were evaluated with the same base context, so a group like `[{if: "$CI_COMMIT_TAG", variables: {DEPLOY: "true"}}, {if: "$DEPLOY"}]` would never fire because `$DEPLOY` was not yet injected when the second element was tested. This matches GitLab CI's actual behaviour.
## [0.5.0] - 2026-07-30
### Added
- **Windows ARM64 build target** — `task build-windows-arm64` cross-compiles `glint-<tag>-windows-arm64.exe`; the Gitea release CI uploads it alongside the other five platform binaries.
- **`workflow.rules` AND-group support** — the `- -` nested-array rule form (AND-groups) is now parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; `when:` and `variables:` from all matching members are merged. Previously this form caused a YAML unmarshal crash.
- **`workflow.name` and `workflow.auto_cancel` fields** — no longer cause a parse error when present; both are decoded into the `Workflow` struct.
- **GL046 — `pull_policy` validation** — errors when `image.pull_policy` or `services[n].pull_policy` uses a value other than `always`, `if-not-present`, or `never`; both the string scalar and list-of-strings forms are checked.
- **GL047 — `variables.options` default validation** — errors when a pipeline-level or job-level variable declares an `options:` list and its `value:` (default) is absent from that list; GitLab rejects such pipelines at creation time.
- **GL048 — `trigger.forward` key validation** — errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid.
- **GL049 — `rules[n].allow_failure` validation** — validates rule-level `allow_failure:` (GitLab CI 15.0+): must be a boolean or a map with an `exit_codes:` key; applies the same checks as the job-level GL014.
### Fixed
- **GL032 false positive in workflow rules** — variables set by any workflow rule's `variables:` block are now excluded from GL032 undeclared-variable warnings when referenced in sibling workflow rule `if:` expressions; previously only pipeline-level `variables:` entries were exempt.
## [0.4.1] - 2026-06-26
### Fixed
- **Release CI** — added Linux arm64, macOS Intel, and macOS Apple Silicon build steps; renamed Windows output to `glint-<tag>-windows-amd64.exe` for consistency; added missing `-X main.version=<tag>` ldflags so release binaries report the correct version instead of `"dev"`.
## [0.4.0] - 2026-06-26
### Added
- **Git branch auto-detection** — `glint check` and `glint graph` now run `git rev-parse --abbrev-ref HEAD` in the pipeline file's directory to determine the current branch when no `--branch`/`--tag`/`--source`/`--var` flags are given. Falls back to `main` when not inside a git repository or in detached-HEAD state (e.g. CI runners).
- **GitLab predefined variable injection** — two categories of predefined variables are now present in the simulation context automatically:
- `CI=true` and `GITLAB_CI=true` are injected for every non-empty context, so expressions like `$CI == "true"` evaluate correctly without `--var`.
- MR-specific variables (`CI_MERGE_REQUEST_IID`, `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, and four others) are injected as placeholders when `--source merge_request_event` is given. `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` is derived from `--branch` when provided. All injected defaults can be overridden with `--var`.
- **Multi-platform release binaries** — `task build-release` now builds for all five platforms at once: Linux amd64, Linux arm64, macOS Intel, macOS Apple Silicon, Windows x86-64. Individual targets: `task build-linux-amd64`, `task build-linux-arm64`, `task build-darwin-amd64`, `task build-darwin-arm64`, `task build-windows`.
- **Homebrew formula** — `Formula/glint.rb` is a source-build Homebrew formula. Set up a tap at `k3nny/homebrew-glint` on GitHub, then install with `brew tap k3nny/glint https://github.com/k3nny/homebrew-glint && brew install glint`.
- **`INSTALL.md`** — installation guide covering pre-built binary download for all platforms, Homebrew tap, `go install`, and build-from-source with `/usr/local/bin` placement.
## [0.3.1] - 2026-06-26 ## [0.3.1] - 2026-06-26
### Changed ### Changed
+29
View File
@@ -0,0 +1,29 @@
class Glint < Formula
desc "Local linter and validator for .gitlab-ci.yml pipelines"
homepage "https://git.k3nny.fr/k3nny/glint"
url "https://git.k3nny.fr/k3nny/glint/archive/v0.4.0.tar.gz"
# Update sha256 on each release: sha256sum glint-vX.Y.Z.tar.gz
sha256 ""
license "Apache-2.0"
head "https://git.k3nny.fr/k3nny/glint.git", branch: "main"
depends_on "go" => :build
def install
system "go", "build",
"-ldflags", "-X main.version=#{version}",
"-o", bin/"glint",
"./cmd/glint/..."
end
test do
assert_match version.to_s, shell_output("#{bin}/glint --version")
(testpath/".gitlab-ci.yml").write <<~YAML
stages: [build]
build-job:
stage: build
script: echo ok
YAML
system bin/"glint", "check", ".gitlab-ci.yml"
end
end
+140
View File
@@ -0,0 +1,140 @@
# Installing glint
## Requirements
- Go 1.21 or later (for building from source)
- Any 64-bit Linux, macOS, or Windows system (for pre-built binaries)
---
## Option 1 — Build from source
```bash
git clone https://git.k3nny.fr/k3nny/glint
cd glint
go build -o glint ./cmd/glint/...
sudo mv glint /usr/local/bin/
```
Or with [Task](https://taskfile.dev):
```bash
task build
sudo mv glint /usr/local/bin/
```
---
## Option 2 — Download a pre-built binary
Pre-built binaries are attached to each [release](https://git.k3nny.fr/k3nny/glint/releases).
| Platform | File |
|----------|------|
| Linux x86-64 | `glint-vX.Y.Z-linux-amd64` |
| Linux ARM64 | `glint-vX.Y.Z-linux-arm64` |
| macOS Intel | `glint-vX.Y.Z-darwin-amd64` |
| macOS Apple Silicon | `glint-vX.Y.Z-darwin-arm64` |
| Windows x86-64 | `glint-vX.Y.Z-windows-amd64.exe` |
| Windows ARM64 | `glint-vX.Y.Z-windows-arm64.exe` |
### Linux (amd64)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-linux-amd64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### Linux (ARM64 — Raspberry Pi 4, AWS Graviton, …)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-linux-arm64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### macOS (Apple Silicon — M1/M2/M3)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-darwin-arm64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### macOS (Intel)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-darwin-amd64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### Verify the installation
```bash
glint --version
```
---
## Option 3 — Homebrew (macOS and Linux)
A Homebrew tap is available at `k3nny/glint`.
> **First-time setup:** create a GitHub repository named `homebrew-glint`
> under your account and copy [`Formula/glint.rb`](Formula/glint.rb) into it.
> Users then install via the tap as shown below.
```bash
brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
brew install glint
```
To upgrade:
```bash
brew upgrade glint
```
The formula builds glint from source using Go, which Homebrew provides
automatically as a build dependency. No pre-built binary download is needed.
### Updating the formula on a new release
After tagging a new release, update `Formula/glint.rb`:
1. Compute the tarball checksum:
```bash
curl -sL https://git.k3nny.fr/k3nny/glint/archive/vX.Y.Z.tar.gz | sha256sum
```
2. Update `url` and `sha256` in the formula.
3. Commit and push to `homebrew-glint`.
---
## Option 4 — Go install
If you already have Go 1.21+:
```bash
go install git.k3nny.fr/k3nny/glint/cmd/glint@latest
```
The binary is placed in `$(go env GOPATH)/bin/`. Add that directory to your
`PATH` if it is not already there:
```bash
export PATH="$PATH:$(go env GOPATH)/bin"
```
---
## Integrations
For editor and CI integrations (pre-commit hook, GitLab CI component, GitHub
Actions, VS Code extension) see [README.md](README.md#integrations).
+21 -11
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.3.1-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.5.1-blue.svg" alt="Release"></a>
</p> </p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -15,7 +15,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
## What it does ## What it does
- **Lints** — 45 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL045); run `glint explain <ID>` for any rule - **Lints** — 49 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL049); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`) - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`)
- **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes - **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
@@ -27,25 +27,30 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements. See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
## Requirements
- Go 1.21 or later
- [Task](https://taskfile.dev) (optional, for development tasks)
## Installation ## Installation
See [INSTALL.md](INSTALL.md) for all options: pre-built binaries (Linux amd64/arm64, macOS Intel/Apple Silicon, Windows), Homebrew tap, and building from source.
Quick start (Linux/macOS, building from source):
```bash ```bash
git clone https://git.k3nny.fr/k3nny/glint git clone https://git.k3nny.fr/k3nny/glint
cd glint cd glint
go build -o glint ./cmd/glint/... go build -o glint ./cmd/glint/...
sudo mv glint /usr/local/bin/
``` ```
Or with Task: Homebrew:
```bash ```bash
task build brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
brew install glint
``` ```
## Requirements
Go 1.21 or later (when building from source). Pre-built binaries have no runtime dependencies.
## Usage ## Usage
``` ```
@@ -148,8 +153,13 @@ task changelog-next # preview unreleased section (dry-run, no file written)
task ext-install # install VS Code extension npm dependencies task ext-install # install VS Code extension npm dependencies
task ext-compile # compile the VS Code extension TypeScript source task ext-compile # compile the VS Code extension TypeScript source
task ext-package # package the VS Code extension as a .vsix task ext-package # package the VS Code extension as a .vsix
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe) task build-linux-amd64 # cross-compile for Linux x86-64 (requires a tagged commit)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64) task build-linux-arm64 # cross-compile for Linux ARM64 (requires a tagged commit)
task build-darwin-amd64 # cross-compile for macOS Intel (requires a tagged commit)
task build-darwin-arm64 # cross-compile for macOS Apple Silicon (requires a tagged commit)
task build-windows # cross-compile for Windows x86-64 (requires a tagged commit)
task build-windows-arm64 # cross-compile for Windows ARM64 (requires a tagged commit)
task build-release # build all platform binaries at once (requires a tagged commit)
task clean # remove build artifacts task clean # remove build artifacts
``` ```
+10
View File
@@ -8,6 +8,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event. Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
- [x] **`workflow.rules` AND-group semantics** — shipped v0.5.0; the `- -` nested-array rule form (AND-groups) is parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; variables and `when:` from all matching group members are merged
- [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped - [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- [x] **`workflow:rules:variables:` propagation** — shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated - [x] **`workflow:rules:variables:` propagation** — shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
- [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace - [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
@@ -32,6 +33,10 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- [x] **`image.pull_policy` / `services[n].pull_policy` validation (GL046)** — shipped v0.5.0; validates that `pull_policy` values are one of `always`, `if-not-present`, `never`; applies to both `image:` map form and service entries; list form also checked element-by-element
- [x] **`variables.options` default value validation (GL047)** — shipped v0.5.0; errors when a variable declares an `options:` list and its `value:` (default) is not listed; checked at pipeline level and per-job; GitLab rejects such pipelines at creation time
- [x] **`trigger.forward` key validation (GL048)** — shipped v0.5.0; errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid
- [x] **`rules[n].allow_failure` validation (GL049)** — shipped v0.5.0; validates rule-level `allow_failure:` (GitLab CI 15.0+) using the same rules as job-level GL014: must be a boolean or a `{exit_codes:}` map
- [x] **Variable reference validation (GL032)** — shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered - [x] **Variable reference validation (GL032)** — shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- [x] **`rules:if:` static reachability (GL033)** — shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required - [x] **`rules:if:` static reachability (GL033)** — shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- [x] **`services:` validation (GL034)** — shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label - [x] **`services:` validation (GL034)** — shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
@@ -135,3 +140,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- [x] **Fuzz testing** — shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s) - [x] **Fuzz testing** — shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s)
- [x] **`glint check --no-warn`** — shipped v0.3.0; discards all warning findings before output and exit-code calculation; mixed pipelines (errors + warnings) still exit 2 but only errors are printed; warnings-only pipelines exit 0 - [x] **`glint check --no-warn`** — shipped v0.3.0; discards all warning findings before output and exit-code calculation; mixed pipelines (errors + warnings) still exit 2 but only errors are printed; warnings-only pipelines exit 0
- [x] **Exit codes 2 / 10** *(breaking)* — shipped v0.3.0; `glint check` exits `2` when errors are present (previously `1`) and `10` when findings contain only warnings; exit `0` for clean - [x] **Exit codes 2 / 10** *(breaking)* — shipped v0.3.0; `glint check` exits `2` when errors are present (previously `1`) and `10` when findings contain only warnings; exit `0` for clean
- [x] **Git branch auto-detection** — shipped v0.4.0; when no context flags are given, `glint check` and `glint graph` run `git rev-parse --abbrev-ref HEAD` in the pipeline file's directory and use the result as the default branch (falls back to `main`)
- [x] **GitLab predefined variable injection** — shipped v0.4.0; `CI=true` and `GITLAB_CI=true` always present in a non-empty context; MR-specific variables injected as placeholders when `--source merge_request_event`; all overridable via `--var`
- [x] **Multi-platform release binaries** — shipped v0.4.0; `task build-release` builds Linux amd64/arm64, macOS Intel/Apple Silicon, and Windows amd64 in one shot
- [x] **Homebrew formula** — shipped v0.4.0; `Formula/glint.rb` source-build formula; tap at `k3nny/homebrew-glint`
- [x] **`INSTALL.md`** — shipped v0.4.0; installation guide for all platforms and methods
+97 -20
View File
@@ -117,6 +117,8 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml - cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} check testdata/nested_rules.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007 - cmd: ./{{.BINARY}} explain GL007
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain gl042 - cmd: ./{{.BINARY}} explain gl042
@@ -145,30 +147,15 @@ tasks:
- task: build - task: build
- task: validate - task: validate
build-windows: build-linux-amd64:
desc: Build the glint binary for Windows x64 (requires a tagged commit) desc: Build the glint binary for Linux x86-64 (requires a tagged commit)
aliases: [build-linux]
vars: vars:
TAG: TAG:
sh: git describe --tags --exact-match sh: git describe --tags --exact-match
preconditions: preconditions:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag" msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}.exe"
build-linux:
desc: Build the glint binary for Linux x64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag"
cmds: cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..." - "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources: sources:
@@ -177,6 +164,96 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64" - "{{.BINARY}}-{{.TAG}}-linux-amd64"
build-linux-arm64:
desc: Build the glint binary for Linux ARM64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=linux GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-arm64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-linux-arm64"
build-darwin-amd64:
desc: Build the glint binary for macOS Intel (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=darwin GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-darwin-amd64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-darwin-amd64"
build-darwin-arm64:
desc: Build the glint binary for macOS Apple Silicon (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=darwin GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-darwin-arm64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-darwin-arm64"
build-windows:
desc: Build the glint binary for Windows x86-64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-windows-amd64.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-windows-amd64.exe"
build-windows-arm64:
desc: Build the glint binary for Windows ARM64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-windows-arm64.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-windows-arm64.exe"
build-release:
desc: Build release binaries for all supported platforms (requires a tagged commit)
cmds:
- task: build-linux-amd64
- task: build-linux-arm64
- task: build-darwin-amd64
- task: build-darwin-arm64
- task: build-windows
- task: build-windows-arm64
fuzz: fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)" desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
cmds: cmds:
@@ -212,4 +289,4 @@ tasks:
clean: clean:
desc: Remove build artifacts desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 {{.BINARY}}-*-linux-arm64 {{.BINARY}}-*-darwin-amd64 {{.BINARY}}-*-darwin-arm64
+61 -16
View File
@@ -44,6 +44,29 @@ var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output() return exec.Command(name, args...).Output()
} }
// gitBranchInDir is a variable so tests can mock branch detection.
var gitBranchInDir = func(dir string) ([]byte, error) {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
return cmd.Output()
}
// detectGitBranch returns the current git branch name by running
// "git rev-parse --abbrev-ref HEAD" in dir. Returns "" when dir is not
// inside a git repository, when the repo is in detached-HEAD state, or
// when git is not available.
func detectGitBranch(dir string) string {
out, err := gitBranchInDir(dir)
if err != nil {
return ""
}
b := strings.TrimSpace(string(out))
if b == "" || b == "HEAD" {
return ""
}
return b
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed // gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo // file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist). // or the ref doesn't exist).
@@ -232,8 +255,13 @@ Options:
Print help Print help
Note: when none of --branch, --tag, --source, or --var are given, glint Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are detects the current git branch automatically and uses it as the default
always evaluated. (falling back to 'main' when not inside a git repository or in detached-HEAD
state). --source defaults to 'push' so that rules:if: expressions are always
evaluated. GitLab's always-available predefined variables (CI=true,
GITLAB_CI=true) are injected automatically; MR-specific variables
(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected
when --source merge_request_event is given. Override any with --var.
Examples: Examples:
glint check .gitlab-ci.yml glint check .gitlab-ci.yml
@@ -244,6 +272,8 @@ Examples:
glint check --branch develop .gitlab-ci.yml glint check --branch develop .gitlab-ci.yml
glint check --tag v1.0.0 .gitlab-ci.yml glint check --tag v1.0.0 .gitlab-ci.yml
glint check --source merge_request_event .gitlab-ci.yml glint check --source merge_request_event .gitlab-ci.yml
glint check --source merge_request_event --branch feature/my-branch .gitlab-ci.yml
glint check --source merge_request_event --var CI_MERGE_REQUEST_IID=42 .gitlab-ci.yml
glint check --list-vars .gitlab-ci.yml glint check --list-vars .gitlab-ci.yml
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
@@ -260,12 +290,6 @@ Examples:
} }
_ = fs.Parse(args) _ = fs.Parse(args)
// Apply implicit defaults only in single-context mode when no flags are given.
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
validFormats := map[string]bool{ validFormats := map[string]bool{
"text": true, "json": true, "sarif": true, "junit": true, "github": true, "text": true, "json": true, "sarif": true, "junit": true, "github": true,
} }
@@ -283,6 +307,17 @@ Examples:
path := fs.Arg(0) path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path)) rootDir := filepath.Dir(filepath.Clean(path))
// Apply implicit defaults only in single-context mode when no flags are given.
// Prefer the actual git branch of the repository; fall back to "main".
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
if detected := detectGitBranch(rootDir); detected != "" {
*branch = detected
} else {
*branch = "main"
}
*source = "push"
}
// Load project config (.glint.yml), searching from the pipeline directory // Load project config (.glint.yml), searching from the pipeline directory
// up to the git root. // up to the git root.
glintCfg, cfgErr := config.Load(rootDir) glintCfg, cfgErr := config.Load(rootDir)
@@ -586,8 +621,13 @@ Options:
Print help Print help
Note: when none of --branch, --tag, --source, or --var are given, glint Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are detects the current git branch automatically and uses it as the default
always evaluated. (falling back to 'main' when not inside a git repository or in detached-HEAD
state). --source defaults to 'push' so that rules:if: expressions are always
evaluated. GitLab's always-available predefined variables (CI=true,
GITLAB_CI=true) are injected automatically; MR-specific variables
(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected
when --source merge_request_event is given. Override any with --var.
Examples: Examples:
glint graph .gitlab-ci.yml glint graph .gitlab-ci.yml
@@ -618,12 +658,6 @@ Examples:
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)") changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = fs.Parse(args) _ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
if fs.NArg() != 1 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
exit(2) exit(2)
@@ -632,6 +666,17 @@ Examples:
path := fs.Arg(0) path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path)) rootDir := filepath.Dir(filepath.Clean(path))
// Apply implicit defaults when no context flag is given at all.
// Prefer the actual git branch of the repository; fall back to "main".
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
if detected := detectGitBranch(rootDir); detected != "" {
*branch = detected
} else {
*branch = "main"
}
*source = "push"
}
glintCfg, cfgErr := config.Load(rootDir) glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil { if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr) fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
+28
View File
@@ -1250,3 +1250,31 @@ func TestIsSuppressed(t *testing.T) {
if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") } if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") }
if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") } if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") }
} }
func TestDetectGitBranch(t *testing.T) {
orig := gitBranchInDir
t.Cleanup(func() { gitBranchInDir = orig })
tests := []struct {
name string
output string
err error
want string
}{
{"normal branch", "main\n", nil, "main"},
{"branch with trailing newline", "feature/my-branch\n", nil, "feature/my-branch"},
{"detached HEAD", "HEAD\n", nil, ""},
{"git error (not a repo)", "", errors.New("exit 128"), ""},
{"empty output", "\n", nil, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gitBranchInDir = func(_ string) ([]byte, error) {
return []byte(tt.output), tt.err
}
if got := detectGitBranch("."); got != tt.want {
t.Errorf("detectGitBranch() = %q, want %q", got, tt.want)
}
})
}
}
+49 -4
View File
@@ -14,6 +14,32 @@ type Context struct {
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
} }
// gitlabAlwaysVars are GitLab predefined variables that are constant across
// every pipeline, instance, and project. Injected at the lowest priority so
// that pipeline variables: blocks and --var overrides can still win.
var gitlabAlwaysVars = map[string]string{
"CI": "true",
"GITLAB_CI": "true",
}
// gitlabMRVars are predefined variables GitLab injects only for
// CI_PIPELINE_SOURCE=merge_request_event pipelines. Placeholder values are
// used so that rules:if: expressions that gate on MR context evaluate
// correctly. All can be overridden with --var.
//
// CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is left empty here and filled from
// CI_COMMIT_BRANCH (the --branch flag) when available.
var gitlabMRVars = map[string]string{
"CI_MERGE_REQUEST_ID": "1",
"CI_MERGE_REQUEST_IID": "1",
"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "",
"CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main",
"CI_MERGE_REQUEST_PROJECT_PATH": "namespace/project",
"CI_MERGE_REQUEST_TITLE": "Draft: placeholder",
"CI_MERGE_REQUEST_LABELS": "",
"CI_OPEN_MERGE_REQUESTS": "namespace/project!1",
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE // New builds a Context from high-level shortcut values and optional KEY=VALUE
// overrides. Predefined CI variables are derived from the shortcuts so callers // overrides. Predefined CI variables are derived from the shortcuts so callers
// do not need to know their exact names. // do not need to know their exact names.
@@ -21,15 +47,18 @@ type Context struct {
// Returns an empty Context (IsEmpty() == true) when all inputs are zero values, // Returns an empty Context (IsEmpty() == true) when all inputs are zero values,
// preserving the existing linting behaviour when no context flags are given. // preserving the existing linting behaviour when no context flags are given.
// //
// Override priority (highest wins): extraVars > branch/tag/source shortcuts. // Override priority (highest wins): extraVars > branch/tag/source shortcuts >
// Both shortcut-derived and extraVar variables are pinned — they will not be // pipeline variables (via Inject) > GitLab predefined defaults.
// overwritten by Inject (used for pipeline-level and workflow-rule variables).
func New(branch, tag, source string, extraVars []string) *Context { func New(branch, tag, source string, extraVars []string) *Context {
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 { if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
return &Context{} return &Context{}
} }
vars := make(map[string]string) // Seed with always-available GitLab predefined variables at lowest priority.
vars := make(map[string]string, len(gitlabAlwaysVars)+8)
for k, v := range gitlabAlwaysVars {
vars[k] = v
}
pinned := make(map[string]bool) pinned := make(map[string]bool)
pin := func(k, v string) { pin := func(k, v string) {
@@ -62,6 +91,22 @@ func New(branch, tag, source string, extraVars []string) *Context {
vars["CI_DEFAULT_BRANCH"] = "main" vars["CI_DEFAULT_BRANCH"] = "main"
} }
// For MR pipelines, inject placeholder values for the predefined MR
// variables so that rules:if: expressions like '$CI_MERGE_REQUEST_IID'
// evaluate as non-empty (truthy). These are non-pinned so --var overrides
// them and pipeline variables: can also override via Inject.
if source == "merge_request_event" {
for k, v := range gitlabMRVars {
if !pinned[k] {
vars[k] = v
}
}
// Derive source branch from --branch when available.
if branch != "" && vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] == "" {
vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] = branch
}
}
// KEY=VALUE overrides win over shortcuts and everything else. // KEY=VALUE overrides win over shortcuts and everything else.
for _, kv := range extraVars { for _, kv := range extraVars {
k, v, ok := strings.Cut(kv, "=") k, v, ok := strings.Cut(kv, "=")
+60
View File
@@ -78,6 +78,66 @@ func TestNew_DefaultBranch(t *testing.T) {
} }
} }
func TestNew_PredefinedAlwaysVars(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.Get("CI") != "true" {
t.Errorf("CI should be 'true', got %q", ctx.Get("CI"))
}
if ctx.Get("GITLAB_CI") != "true" {
t.Errorf("GITLAB_CI should be 'true', got %q", ctx.Get("GITLAB_CI"))
}
}
func TestNew_PredefinedAlwaysVars_EmptyContext(t *testing.T) {
// Always-vars must NOT be injected when the context is empty (no flags).
ctx := New("", "", "", nil)
if ctx.Get("CI") != "" {
t.Errorf("CI should be empty in empty context, got %q", ctx.Get("CI"))
}
}
func TestNew_PredefinedAlwaysVars_OverridableByVar(t *testing.T) {
ctx := New("main", "", "", []string{"CI=false"})
if ctx.Get("CI") != "false" {
t.Errorf("--var should override CI, got %q", ctx.Get("CI"))
}
}
func TestNew_PredefinedAlwaysVars_OverridableByInject(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("CI", "custom")
if ctx.Get("CI") != "custom" {
t.Errorf("Inject should override non-pinned CI, got %q", ctx.Get("CI"))
}
}
func TestNew_MRVars(t *testing.T) {
ctx := New("feature/my-branch", "", "merge_request_event", nil)
if ctx.Get("CI_MERGE_REQUEST_IID") != "1" {
t.Errorf("CI_MERGE_REQUEST_IID should be '1', got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
if ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") != "feature/my-branch" {
t.Errorf("source branch should match --branch, got %q", ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"))
}
if ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") != "main" {
t.Errorf("target branch should be 'main', got %q", ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"))
}
}
func TestNew_MRVars_NotInjectedForNonMR(t *testing.T) {
ctx := New("main", "", "push", nil)
if ctx.Get("CI_MERGE_REQUEST_IID") != "" {
t.Errorf("CI_MERGE_REQUEST_IID should be empty for push pipeline, got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
}
func TestNew_MRVars_OverridableByVar(t *testing.T) {
ctx := New("", "", "merge_request_event", []string{"CI_MERGE_REQUEST_IID=42"})
if ctx.Get("CI_MERGE_REQUEST_IID") != "42" {
t.Errorf("--var should override CI_MERGE_REQUEST_IID, got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
}
func TestInject(t *testing.T) { func TestInject(t *testing.T) {
ctx := New("main", "", "", nil) ctx := New("main", "", "", nil)
// pinned var should not be overwritten // pinned var should not be overwritten
+50 -17
View File
@@ -42,22 +42,18 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
return true, nil return true, nil
} }
vars := ctx.Get vars := ctx.Get
for _, rule := range p.Workflow.Rules { // Workflow rules use strict evaluation: an unparseable condition is treated
// Workflow rules use strict evaluation: an unparseable condition is // as no-match so later rules are reached. Permissive-true would cause an
// treated as no-match so later rules (with valid conditions or a // early rule with a complex condition to block all subsequent rules.
// bare when:) are reached. Permissive-true would cause an early rule for _, group := range p.Workflow.Rules.Groups() {
// with a complex/invalid condition to block all subsequent rules. matched, when, groupVars := evalRuleGroup(group, vars, ctx, true)
if !ruleIfMatchesStrict(rule.If, vars) { if !matched {
continue continue
} }
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" { if when == "" {
when = "always" when = "always"
} }
return when != "never", ExtractStringVars(rule.Variables) return when != "never", groupVars
} }
return false, nil // no rule matched → pipeline does not run return false, nil // no rule matched → pipeline does not run
} }
@@ -74,14 +70,12 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// rules: takes priority over only/except. // rules: takes priority over only/except.
if len(job.Rules) > 0 { if len(job.Rules) > 0 {
for _, rule := range job.Rules { for _, group := range job.Rules.Groups() {
if !ruleIfMatches(rule.If, vars) { matched, when, _ := evalRuleGroup(group, vars, ctx, false)
if !matched {
continue continue
} }
if !changesMatch(rule.Changes, ctx) { return whenToState(when)
continue
}
return whenToState(rule.When)
} }
return JobSkipped // no rule matched → job is excluded return JobSkipped // no rule matched → job is excluded
} }
@@ -98,6 +92,45 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
// evalRuleGroup evaluates one rule group (one or more rules forming an AND-group).
// All conditions in the group must match for the group to fire.
// Returns (matched, effectiveWhen, mergedVars). When strict=true, unparseable
// if: expressions count as no-match; when false, they count as match (permissive).
//
// Variables defined on an earlier member of the AND-group are cascaded to later
// members when evaluating their if: conditions. This matches GitLab CI behaviour
// where e.g. element 0 sets DEPLOY=true and element 1 can then test $DEPLOY.
func evalRuleGroup(group []model.Rule, vars func(string) string, ctx *Context, strict bool) (bool, string, map[string]string) {
merged := make(map[string]string)
effectiveWhen := ""
for _, rule := range group {
// Cascade: variables accumulated from previous group members are visible
// to this member's if: condition, shadowing the base context.
cascadeVars := func(key string) string {
if v, ok := merged[key]; ok {
return v
}
return vars(key)
}
var ifOk bool
if strict {
ifOk = ruleIfMatchesStrict(rule.If, cascadeVars)
} else {
ifOk = ruleIfMatches(rule.If, cascadeVars)
}
if !ifOk || !changesMatch(rule.Changes, ctx) {
return false, "", nil
}
for k, v := range ExtractStringVars(rule.Variables) {
merged[k] = v
}
if rule.When != "" {
effectiveWhen = rule.When
}
}
return true, effectiveWhen, merged
}
func ruleIfMatches(ifExpr string, vars func(string) string) bool { func ruleIfMatches(ifExpr string, vars func(string) string) bool {
if ifExpr == "" { if ifExpr == "" {
return true // no if: condition → rule always matches return true // no if: condition → rule always matches
+82
View File
@@ -867,4 +867,86 @@ test:
Fix: `include: Fix: `include:
- remote: https://ci-templates.example.com/build.yml`, - remote: https://ci-templates.example.com/build.yml`,
}, },
RuleInvalidPullPolicy: {
Title: "'pull_policy:' has invalid value",
Severity: Error,
Description: "'image.pull_policy' and 'services[n].pull_policy' must be one of " +
"'always', 'if-not-present', or 'never', or a list of those values. " +
"Any other string is rejected by GitLab at pipeline creation time.",
Example: `my-job:
image:
name: alpine
pull_policy: on-demand # not a valid value
script: echo hi`,
Fix: `my-job:
image:
name: alpine
pull_policy: if-not-present
script: echo hi`,
},
RuleVariableValueNotInOptions: {
Title: "variable default value not listed in 'options'",
Severity: Error,
Description: "A pipeline variable declares an 'options' list that constrains what " +
"values can be chosen when triggering the pipeline manually. If the 'value' " +
"(the default) is not in that list, GitLab rejects the pipeline at creation " +
"time with a validation error.",
Example: `variables:
DEPLOY_ENV:
value: staging
options:
- production
- review # 'staging' is missing from the list`,
Fix: `variables:
DEPLOY_ENV:
value: staging
options:
- staging
- production
- review`,
},
RuleInvalidTriggerForward: {
Title: "'trigger.forward:' has unrecognised key",
Severity: Error,
Description: "'trigger.forward' controls which variables are forwarded to the " +
"downstream pipeline. Only 'pipeline_variables' and 'yaml_variables' are " +
"valid keys. Any other key is silently ignored by some GitLab versions and " +
"rejected by others.",
Example: `deploy:
trigger:
include:
- artifact: pipeline.yml
job: build
forward:
all_variables: true # not a valid key`,
Fix: `deploy:
trigger:
include:
- artifact: pipeline.yml
job: build
forward:
pipeline_variables: true
yaml_variables: true`,
},
RuleInvalidRulesAllowFailure: {
Title: "'rules[n].allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure' inside a 'rules:' entry (GitLab CI 15.0+) must be " +
"a boolean (true/false) or a map with an 'exit_codes' key. This overrides " +
"the job-level 'allow_failure' when the rule matches.",
Example: `my-job:
script: ./test.sh
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: maybe # must be true/false or a map`,
Fix: `my-job:
script: ./test.sh
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: true`,
},
} }
+95
View File
@@ -114,6 +114,8 @@ func checkJobKeywords(name string, job model.Job) []Finding {
findings = append(findings, checkSecrets(name, job)...) findings = append(findings, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...) findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...) findings = append(findings, checkCacheKeyFiles(name, job)...)
findings = append(findings, checkPullPolicy(name, job)...)
findings = append(findings, checkRulesAllowFailure(name, job)...)
return findings return findings
} }
@@ -313,6 +315,20 @@ func checkTrigger(name string, job model.Job) []Finding {
Message: "'trigger' map must specify 'project' or 'include'", Message: "'trigger' map must specify 'project' or 'include'",
}) })
} }
// GL048: validate trigger.forward keys.
if fwd, ok := m["forward"].(map[string]any); ok {
validForwardKeys := map[string]bool{"pipeline_variables": true, "yaml_variables": true}
for k := range fwd {
if !validForwardKeys[k] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidTriggerForward,
Job: name,
Message: fmt.Sprintf("'trigger.forward' has unrecognised key %q; valid keys: pipeline_variables, yaml_variables", k),
})
}
}
}
} }
return findings return findings
} }
@@ -793,6 +809,85 @@ func checkPagesKeyword(name string, job model.Job) []Finding {
}} }}
} }
// GL046: image.pull_policy and services[n].pull_policy must use recognised values.
var validPullPolicy = map[string]bool{
"always": true, "if-not-present": true, "never": true,
}
func checkPullPolicy(name string, job model.Job) []Finding {
var findings []Finding
findings = append(findings, checkPullPolicyValue(name, "image", job.Image)...)
for i, svc := range job.Services {
findings = append(findings, checkPullPolicyValue(name, fmt.Sprintf("services[%d]", i), svc)...)
}
return findings
}
func checkPullPolicyValue(jobName, field string, v any) []Finding {
m, ok := v.(map[string]any)
if !ok {
return nil
}
pp, exists := m["pull_policy"]
if !exists || pp == nil {
return nil
}
var policies []string
switch x := pp.(type) {
case string:
policies = []string{x}
case []any:
for _, item := range x {
if s, ok := item.(string); ok {
policies = append(policies, s)
}
}
}
var findings []Finding
for _, p := range policies {
if !validPullPolicy[p] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidPullPolicy,
Job: jobName,
Message: fmt.Sprintf("%s.pull_policy has unrecognised value %q; valid: always, if-not-present, never", field, p),
})
}
}
return findings
}
// GL049: rules[n].allow_failure must be a boolean or a map with exit_codes:.
func checkRulesAllowFailure(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
if rule.AllowFailure == nil {
continue
}
switch v := rule.AllowFailure.(type) {
case bool:
// valid
case map[string]any:
if _, ok := v["exit_codes"]; !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure map form must contain 'exit_codes'", i),
})
}
default:
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure must be a boolean or a map with 'exit_codes'", i),
})
}
}
return findings
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns. // GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding { func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil { if job.Cache == nil {
+51
View File
@@ -64,6 +64,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
findings = append(findings, checkDuplicateStages(p)...) findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...) findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkPipelineVariableOptions(p)...)
findings = append(findings, checkJobs(p)...) findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p, skipped)...) findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(p, skipped)...) findings = append(findings, checkRulesNeeds(p, skipped)...)
@@ -223,6 +224,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
} }
findings = append(findings, checkJobKeywords(name, job)...) findings = append(findings, checkJobKeywords(name, job)...)
findings = append(findings, checkVariableOptionsForJob(name, job)...)
// Attach source location to every job-scoped finding collected above. // Attach source location to every job-scoped finding collected above.
for i := range findings { for i := range findings {
@@ -235,6 +237,55 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
return findings return findings
} }
// GL047: variable declared with options: must have its default value in the options list.
func checkPipelineVariableOptions(p *model.Pipeline) []Finding {
return checkVariableOptionsMap(p.Variables, "", p.SourceFile, 0, 0)
}
func checkVariableOptionsForJob(name string, job model.Job) []Finding {
return checkVariableOptionsMap(job.Variables, name, job.File, job.Line, job.Column)
}
func checkVariableOptionsMap(vars map[string]any, jobName, file string, line, col int) []Finding {
var findings []Finding
for varName, v := range vars {
m, ok := v.(map[string]any)
if !ok {
continue
}
rawOpts, hasOpts := m["options"]
rawVal, hasVal := m["value"]
if !hasOpts || !hasVal || rawVal == nil {
continue
}
opts, ok := rawOpts.([]any)
if !ok || len(opts) == 0 {
continue
}
val := fmt.Sprint(rawVal)
inOptions := false
for _, opt := range opts {
if fmt.Sprint(opt) == val {
inOptions = true
break
}
}
if !inOptions {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleVariableValueNotInOptions,
Job: jobName,
File: file,
Line: line,
Column: col,
Message: fmt.Sprintf("variable %q: default value %q is not listed in 'options'", varName, val),
})
}
}
return findings
}
// scriptNonEmpty reports whether a script/before_script/after_script field // scriptNonEmpty reports whether a script/before_script/after_script field
// (which may be a []any list or a plain string) is non-empty. // (which may be a []any list or a plain string) is non-empty.
func scriptNonEmpty(v any) bool { func scriptNonEmpty(v any) bool {
+15
View File
@@ -163,4 +163,19 @@ const (
// CI templates fetched over HTTP are transmitted in cleartext and can be // CI templates fetched over HTTP are transmitted in cleartext and can be
// intercepted or modified in transit. // intercepted or modified in transit.
RuleInsecureRemoteInclude = "GL045" RuleInsecureRemoteInclude = "GL045"
// GL046: image: or services[n]: pull_policy: contains an unrecognised value.
// Valid values: always, if-not-present, never (or a list of those values).
RuleInvalidPullPolicy = "GL046"
// GL047: a variable declared with options: has a default value: that is not
// listed in the options list. GitLab rejects the pipeline at creation time.
RuleVariableValueNotInOptions = "GL047"
// GL048: trigger.forward: contains an unrecognised key. Only
// pipeline_variables and yaml_variables are valid.
RuleInvalidTriggerForward = "GL048"
// GL049: rules[n].allow_failure: is not a boolean or a map with exit_codes:.
RuleInvalidRulesAllowFailure = "GL049"
) )
+1 -1
View File
@@ -110,7 +110,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding {
continue continue
} }
for _, varName := range extractIfVars(rule.If) { for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] { if isPredefinedVar(varName) || pipelineVars[varName] || workflowRuleVars[varName] || seen[varName] {
continue continue
} }
seen[varName] = true seen[varName] = true
+65 -2
View File
@@ -1,5 +1,7 @@
package model package model
import "gopkg.in/yaml.v3"
// 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 {
@@ -41,7 +43,9 @@ type DefaultConfig struct {
} }
type Workflow struct { type Workflow struct {
Rules []Rule `yaml:"rules"` Name string `yaml:"name"`
AutoCancel any `yaml:"auto_cancel"`
Rules Rules `yaml:"rules"`
} }
type Job struct { type Job struct {
@@ -57,7 +61,7 @@ type Job struct {
Image any `yaml:"image"` Image any `yaml:"image"`
Services []any `yaml:"services"` Services []any `yaml:"services"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Rules []Rule `yaml:"rules"` Rules Rules `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"`
@@ -91,6 +95,65 @@ type Rule struct {
Exists any `yaml:"exists"` // []string or map form Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+) Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
AllowFailure any `yaml:"allow_failure"` // bool or {exit_codes:} map (GitLab CI 15.0+)
GroupID int `yaml:"-"` // >0 means AND-group; set by Rules.UnmarshalYAML
}
// Rules is a list of Rule entries that also supports GitLab CI's nested-array
// form where each outer entry can itself be a sequence of rules (AND-group).
// Rules with the same non-zero GroupID form an AND-group: all conditions must
// match for the group to fire. GroupID == 0 means standalone rule.
type Rules []Rule
func (r *Rules) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.SequenceNode {
return nil
}
nextGroup := 1
for _, item := range value.Content {
switch item.Kind {
case yaml.MappingNode:
var rule Rule // GroupID stays 0 (standalone)
if err := item.Decode(&rule); err != nil {
return err
}
*r = append(*r, rule)
case yaml.SequenceNode:
// AND-group: assign a shared GroupID so the evaluator can apply AND logic.
var group []Rule
if err := item.Decode(&group); err != nil {
return err
}
for i := range group {
group[i].GroupID = nextGroup
}
*r = append(*r, group...)
nextGroup++
}
}
return nil
}
// Groups returns the rules partitioned into AND-groups. Standalone rules
// (GroupID == 0) form single-element groups; rules with a shared GroupID form
// one group where all conditions must match.
func (r Rules) Groups() [][]Rule {
var groups [][]Rule
seen := map[int]int{} // groupID → index in groups
for _, rule := range r {
if rule.GroupID == 0 {
groups = append(groups, []Rule{rule})
} else {
idx, ok := seen[rule.GroupID]
if !ok {
idx = len(groups)
seen[rule.GroupID] = idx
groups = append(groups, nil)
}
groups[idx] = append(groups[idx], rule)
}
}
return groups
} }
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+54
View File
@@ -2,6 +2,13 @@ stages:
- build - build
- test - test
variables:
DEPLOY_ENV:
value: canary # ERROR: not listed in options
options:
- staging
- production
workflow: workflow:
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push"' - if: '$CI_PIPELINE_SOURCE == "push"'
@@ -126,3 +133,50 @@ bad-rule-when-job:
rules: rules:
- if: '$CI_MERGE_REQUEST_ID' - if: '$CI_MERGE_REQUEST_ID'
when: sometimes # ERROR: invalid rules[0].when when: sometimes # ERROR: invalid rules[0].when
bad-pull-policy-job:
stage: build
script:
- echo hello
image:
name: alpine
pull_policy: on-demand # ERROR: invalid pull_policy value
bad-pull-policy-service-job:
stage: build
script:
- echo hello
services:
- name: postgres:15
pull_policy: lazy # ERROR: invalid service pull_policy value
bad-trigger-forward-job:
trigger:
project: mygroup/myproject
forward:
all_variables: true # ERROR: unrecognised trigger.forward key
bad-trigger-forward-key2-job:
trigger:
include:
- artifact: child.yml
job: build
forward:
inherit: false # ERROR: unrecognised trigger.forward key
bad-rules-allow-failure-job:
stage: build
script:
- echo hello
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: maybe # ERROR: invalid rules[0].allow_failure value
bad-rules-allow-failure-map-job:
stage: build
script:
- echo hello
rules:
- if: $CI_COMMIT_BRANCH
allow_failure:
codes: [1] # ERROR: map missing exit_codes key
+25
View File
@@ -15,12 +15,19 @@ default:
variables: variables:
GO_VERSION: "1.26" GO_VERSION: "1.26"
DEPLOY_ENV:
value: staging
options:
- staging
- production
- review
build-job: build-job:
stage: build stage: build
image: image:
name: golang:1.26 name: golang:1.26
entrypoint: [""] entrypoint: [""]
pull_policy: if-not-present
script: script:
- go build ./... - go build ./...
artifacts: artifacts:
@@ -72,6 +79,24 @@ release-job:
action: start action: start
when: on_success when: on_success
.trigger-valid:
trigger:
include:
- artifact: child.yml
job: build
forward:
pipeline_variables: true
yaml_variables: false
.rules-allow-failure-valid:
script: echo ok
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: true
- if: $CI_COMMIT_TAG
allow_failure:
exit_codes: [1, 2]
.template: .template:
before_script: before_script:
- echo "before" - echo "before"
+41
View File
@@ -0,0 +1,41 @@
---
# Tests that workflow.rules and job.rules accept the nested-array (AND-group)
# form where each outer entry can itself be a sequence of rule conditions.
workflow:
name: "${PIPELINE_NAME}"
auto_cancel:
on_new_commit: interruptible
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
variables:
PIPELINE_NAME: "main"
- - if: "$CI_PIPELINE_SOURCE == 'push' && $CI_COMMIT_BRANCH =~ /^feat\\///"
variables:
PIPELINE_NAME: "feature"
- - if: "$CI_COMMIT_TAG"
- if: "$DEPLOY"
variables:
PIPELINE_NAME: "deploy"
# AND-group where element 0 sets PHASE=build; element 1 checks $PHASE (cascade).
# Without cascading, element 1 would never match because PHASE is not in
# pipeline variables. With cascading, element 1 sees PHASE from element 0.
- - if: "$CI_COMMIT_BRANCH"
variables:
PHASE: build
- if: "$PHASE"
variables:
PIPELINE_NAME: "branch-build"
variables:
DEPLOY: "false"
build:
stage: build
script: echo building
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
- - if: "$CI_PIPELINE_SOURCE == 'push'"
allow_failure: true
- if: "$DEPLOY"
when: manual
+4
View File
@@ -17,6 +17,10 @@ workflow:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == "main"'
variables: variables:
DEPLOY_TARGET: "production" DEPLOY_TARGET: "production"
WORKFLOW_FLAG: "true"
# workflow rule referencing a variable set by a sibling rule's variables: — GL032 must not fire
- if: '$WORKFLOW_FLAG == "true"'
when: always
- when: always - when: always
build: build: