A single stage-name typo silently removed the deploy job
Goal
You wrap a tool that validates against GitLab's configuration schema in a gate to add team policy, hook it into a pre-commit hook, the first job of the pipeline, and whole-repository validation with include resolved, and extract the change in the job list between two commits for merge request review.
Why it matters
Errors in pipeline configuration are usually quiet. A typo in a stage name or one space of indentation deletes a job or blocks the creation of the pipeline, and that fact comes to light only after you push. You leave validation to a tool instead of human eyes, but you must know what the tool catches and what it misses in order to fill that gap with policy. The reason to place the same check in two places, before the commit and in the first job of the pipeline, is that a local hook can be skipped, and in review, "which jobs are created, disappear, or become automatic" is more important information than a text diff.
Steps
- Make
/root/glci-gatea git repository (with.gitlab-ci-local/in .gitignore) and create/root/glci-gate/ci-validate.sh <설정파일>(the placeholder stands for the configuration file). It puts that one file into a temporary git repository as.gitlab-ci.ymland validates it withgitlab-ci-local --list; if it is rejected, it ends withINVALID <이유>(the reason; the first meaningful line of the tool output) and 1, and if it passes, withVALIDand 0. In.gitlab-ci.yml, put the normal configuration from the files below and commit. The grader checks with samples of a missing stage, a when outside the allowed values, a needs target that does not exist, and a YAML syntax error. - Make ci-validate.sh apply two more team policies to a file that passed the tool's validation. If a job has both
rulesandonly/except, it printsPOLICY <잡> rules-with-only-except(job), and if artifacts has paths but noexpire_in, it printsPOLICY <잡> artifacts-without-expire_in, one line each in job-name order, and ends with 2. If all policies are kept, it isVALIDand 0. Hidden jobs (starting with a dot) and reserved keys (stages, variables, default, include, workflow, and so on) are not jobs. - Create
/root/glci-gate/hooks/pre-commitand commit it, then copy the same file to.git/hooks/pre-commitand give it execute permission. Only when there is a staged.gitlab-ci.yml, the hook validates that staged content (git show :.gitlab-ci.yml) with ci-validate.sh, and if it does not pass, it prints the reason to standard error and blocks the commit. The grader tries committing a wrong configuration and a policy-violating configuration in a copy, and also checks that committing files unrelated to the configuration is not blocked. - In
/root/glci-gate/broken.yml, leave the wrong configuration from the files below as it is, fix the problems that come out one at a time with ci-validate.sh, and make/root/glci-gate/fixed.yml. Do not change the job names (build, unit, deploy) or each job's script. fixed.yml must be VALID, unit must wait for build, and deploy must be created on main with manual approval (allow_failure false). Commit both files (the hook looks only at .gitlab-ci.yml). - To
.gitlab-ci.yml, add a jobci-lint(stage.pre,bash ci-validate.sh .gitlab-ci.yml) and commit. When you run it, ci-lint must pass as VALID and the other jobs must run. The grader runs a configuration with a policy violation (an artifact with no expiry) put in a copy and checks that ci-lint fails and build does not start. - Create
/root/glci-gate/ci-validate-repo.sh <저장소>(the placeholder stands for the repository). It makes a temporary copy of the repository (without the hook), commits, and gets the merged configuration with include resolved fromgitlab-ci-local --preview; if that fails, it ends withINVALID <이유>and 1, and if it succeeds, it passes the merged configuration to ci-validate.sh and outputs that result (VALID 0, POLICY 2) as it is. When you run it on this repository, it must be VALID. The grader checks with a copy in which an error and a policy violation were put on the side of an included file. - Create
/root/glci-gate/ci-diff.sh <저장소> <옛커밋> <새커밋> [브랜치](repository, old commit, new commit, and optionally a branch). It checks out the two commits into separate temporary work trees, and, to calculate as the pipeline of the branch (default main), gives--variable CI_COMMIT_BRANCH=<브랜치>(the branch) and gets job names and when withgitlab-ci-local --list-csv-all, and prints, in job-name order,ADDED <잡>,REMOVED <잡>, andCHANGED <잡> <옛when>-><새when>(job, then the old when and the new when). It does not touch the original repository's work tree or branches and deletes the temporary work trees. The grader checks by making, in a copy, commits that add and remove jobs and change when.
Notes
- This VM has no GitLab server or runner, and gitlab-ci-local 4.75.1 interprets .gitlab-ci.yml by the same rules as GitLab and runs jobs with the shell. If you write
image:, it tries to run with Docker, so do not use it. Protected variables, masking, CI_JOB_TOKEN, runner tags, and merge request pipeline creation are server features and are not reproduced here. - Run: at the repository root,
gitlab-ci-local --shell-isolation --no-artifacts-to-source(a separate working directory per job, artifacts not written back to the repository), job list:gitlab-ci-local --list-csv-all, interpreted configuration:gitlab-ci-local --preview. gitlab-ci-local passes only files tracked by git to jobs, sogit adda file after creating it. The grader copies the repository, commits all files, and runs it again with the same tool. - Validation of gitlab-ci-local 4.75.1 (measured): it rejects allowed values of when, a missing stage, a missing needs, a missing extends, YAML syntax, and no script, and lets through mixing rules with only and an artifact with no expiry. GitLab's CI Lint (the project's pipeline editor and the Lint API) is a server feature and cannot be called from here.
- When you test whether the hook blocks a commit, do it in a copy of the repository. A commit that was blocked in the original repository leaves only the staging behind.
- Validate GitLab CI/CD configuration (CI Lint) · CI Lint API · CI/CD YAML syntax reference · GitLab configuration validation source (processable.rb) · gitlab-ci-local
Have a tool validate the schema and references
Make /root/glci-gate a git repository (with .gitlab-ci-local/ in .gitignore) and create /root/glci-gate/ci-validate.sh <설정파일> (the placeholder stands for the configuration file). It puts that one file into a temporary git repository as .gitlab-ci.yml and validates it with gitlab-ci-local --list; if it is rejected, it ends with INVALID <이유> (the reason; the first meaningful line of the tool output) and 1, and if it passes, with VALID and 0. In .gitlab-ci.yml, put the normal configuration from the files below and commit. The grader checks with samples of a missing stage, a when outside the allowed values, a needs target that does not exist, and a YAML syntax error.
gitlab-ci-local first validates with GitLab's configuration schema and also looks at whether the references of stages, needs, and extends actually exist. If there is a problem, the exit code is non-zero. The output has noise mixed in, such as remote repository notices, so filter it and keep only the first reason.
Catch with team policy what the tool lets through
Make ci-validate.sh apply two more team policies to a file that passed the tool's validation. If a job has both rules and only/except, it prints POLICY <잡> rules-with-only-except (job), and if artifacts has paths but no expire_in, it prints POLICY <잡> artifacts-without-expire_in, one line each in job-name order, and ends with 2. If all policies are kept, it is VALID and 0. Hidden jobs (starting with a dot) and reserved keys (stages, variables, default, include, workflow, and so on) are not jobs.
This tool lets through a job that mixes rules and only and an artifact with no expiry (measured). The GitLab server rejects the former with key may not be used with rules (from GitLab's configuration validation in its source). Do not trust one tool's verdict as the whole of the gate, and once you know the difference, fill that much with policy.
Block a wrong configuration from the commit
Create /root/glci-gate/hooks/pre-commit and commit it, then copy the same file to .git/hooks/pre-commit and give it execute permission. Only when there is a staged .gitlab-ci.yml, the hook validates that staged content (git show :.gitlab-ci.yml) with ci-validate.sh, and if it does not pass, it prints the reason to standard error and blocks the commit. The grader tries committing a wrong configuration and a policy-violating configuration in a copy, and also checks that committing files unrelated to the configuration is not blocked.
A hook must look not at the working-tree file but at the content that will be committed (the index) — if you commit without adding after a fix, the working tree is fine but what gets committed is the old content. .git/hooks is not pushed to the repository, so to share it with the team, put it in a tracked location and explain how to install it.
Fix the configuration wrong in four places
In /root/glci-gate/broken.yml, leave the wrong configuration from the files below as it is, fix the problems that come out one at a time with ci-validate.sh, and make /root/glci-gate/fixed.yml. Do not change the job names (build, unit, deploy) or each job's script. fixed.yml must be VALID, unit must wait for build, and deploy must be created on main with manual approval (allow_failure false). Commit both files (the hook looks only at .gitlab-ci.yml).
The tool stops at the first error, so run it again after each fix to see the next error. Mixed in are a typo in a stage name, a needs that points to a job that does not exist, a when value that is not allowed, rules mixed with only, and an artifact with no expiry.
The first job of the pipeline validates its own configuration
To .gitlab-ci.yml, add a job ci-lint (stage .pre, bash ci-validate.sh .gitlab-ci.yml) and commit. When you run it, ci-lint must pass as VALID and the other jobs must run. The grader runs a configuration with a policy violation (an artifact with no expiry) put in a copy and checks that ci-lint fails and build does not start.
A hook can be skipped locally (--no-verify), so the same check must exist on the server side too. If you put it in the .pre stage, it runs before all the other jobs and stops before runner time is spent on a wrong configuration. The check script is in the repository, so the job can call it as it is.
Validate merged, including the included files
Create /root/glci-gate/ci-validate-repo.sh <저장소> (the placeholder stands for the repository). It makes a temporary copy of the repository (without the hook), commits, and gets the merged configuration with include resolved from gitlab-ci-local --preview; if that fails, it ends with INVALID <이유> and 1, and if it succeeds, it passes the merged configuration to ci-validate.sh and outputs that result (VALID 0, POLICY 2) as it is. When you run it on this repository, it must be VALID. The grader checks with a copy in which an error and a policy violation were put on the side of an included file.
Validation of a single file cannot see an error in an included file — because that file is not in the copy. --preview gives the result with include, extends, and anchors all resolved, so if you apply the policy to that result, configuration scattered across several files is checked in one go.
Show the reviewer how the pipeline changes
Create /root/glci-gate/ci-diff.sh <저장소> <옛커밋> <새커밋> [브랜치] (repository, old commit, new commit, and optionally a branch). It checks out the two commits into separate temporary work trees, and, to calculate as the pipeline of the branch (default main), gives --variable CI_COMMIT_BRANCH=<브랜치> (the branch) and gets job names and when with gitlab-ci-local --list-csv-all, and prints, in job-name order, ADDED <잡>, REMOVED <잡>, and CHANGED <잡> <옛when>-><새when> (job, then the old when and the new when). It does not touch the original repository's work tree or branches and deletes the temporary work trees. The grader checks by making, in a copy, commits that add and remove jobs and change when.
When include, extends, and rules are mixed, a diff of the configuration file does not tell you what actually changes. If you compare the lists of jobs the pipeline will create, a change such as "this merge makes the production deployment automatic" shows up in one line. You can check a commit out into another directory with git worktree add --detach, but that state has no branch, so all the branch-condition rules drop out; therefore pass the branch as a variable. You turn off that warning with --ignore-predefined-vars.