CI as a Calm Contract: One GitLab Pipeline for Libraries, Docker Deploys, AWS, and AI Agents
A good CI pipeline should not feel like a pile of YAML.
It should feel like a contract.
A clear, boring, predictable contract between the repository, the team, the deployment flow, and now — increasingly — AI coding agents.
Recently, while working on Vyriy, I moved my GitLab CI setup into a shared CI repository and turned it into a reusable pipeline system. The result is simple from the consumer project side:
include:
- project: 'vyriy/ci'
ref: main
file: '/ci.yml'
variables:
JOB_E2E_DISABLED: '1'
JOB_AWS_DISABLED: '1'
TEST_RELEASE_COMMAND: 'yarn pack && yarn consumer'
RELEASE_COMMAND: 'yarn build:dist && sh .bin/github.sh'
That is almost the whole local CI configuration.
The same shared pipeline can work for a library, a Storybook-only project, a Docker-deployed application, or an AWS-deployed service. Each project does not need to copy and maintain its own version of the same CI logic. It only needs to declare what is different.
That small difference matters a lot.
The Problem with Project-Local CI
Most repositories start with a simple .gitlab-ci.yml.
Then they grow.
First there is install. Then lint. Then tests. Then build. Then Docker. Then release. Then deployment. Then E2E. Then security scans. Then notifications. Then Renovate. Then some custom branch rules. Then a manual release step. Then a special case for one project. Then another special case for another project.
At some point every repository has its own slightly different CI dialect.
The differences are rarely intentional architecture. Usually they are just history.
One project has an older Docker job. Another one has a slightly different release script. Another one forgot to add a security scan. Another one copied a job but missed one variable. Another one still uses an old image.
This is not calm infrastructure.
This is drift.
The Shared Pipeline Idea
The idea I wanted was simple:
The CI logic should live in one place.
Consumer repositories should include the shared entrypoint and customize behavior through variables.
The shared pipeline owns the structure:
include:
- project: 'vyriy/ci'
ref: main
file: '/ci.yml'
The consumer project owns intent:
variables:
JOB_DOCKER_DISABLED: '1'
JOB_E2E_DISABLED: '1'
or:
variables:
JOB_RELEASE_DISABLED: '1'
or:
variables:
JOB_AWS_DISABLED: '1'
TEST_RELEASE_COMMAND: 'yarn pack && yarn consumer'
RELEASE_COMMAND: 'yarn build:dist && sh .bin/github.sh'
This keeps the local .gitlab-ci.yml small, readable, and project-specific.
The shared CI repository contains the actual reusable system: workflow rules, defaults, job templates, stages, Docker logic, release logic, AWS deploy logic, E2E, security, quality gates, and notifications.
One Pipeline, Different Project Types
The important part is that the pipeline is not hardcoded for only one type of project.
A library does not need Docker deployment. An application may not need CI-driven package releases. A Storybook-only project may not need E2E. An AWS service may need build artifacts and a manual deploy step. A public package may need a GitLab release step and then GitHub/npm publishing as a follow-up.
Instead of creating separate pipelines for every case, the shared pipeline exposes switches.
For a library:
variables:
JOB_DOCKER_DISABLED: '1'
JOB_E2E_DISABLED: '1'
JOB_AWS_DISABLED: '1'
For an application:
variables:
JOB_RELEASE_DISABLED: '1'
For a Storybook-only project:
variables:
JOB_RELEASE_DISABLED: '1'
JOB_E2E_DISABLED: '1'
JOB_AWS_DISABLED: '1'
The pipeline stays the same.
The intent changes.
That is the key design point.
Pipeline as a Product
Once CI becomes shared, it should be treated like a product.
That means it needs a public entrypoint, internal structure, defaults, documentation, and a stable customization API.
In my case, the shared /ci.yml entrypoint includes separate blocks for:
workflow
default config
renovate
security
install
check
quality
docker
release
e2e
aws
notifications
The stages are explicit:
renovate
security
install
check
quality
docker
release
e2e
aws
failure
success
This makes the pipeline readable as a story:
First update dependencies. Then scan. Then install. Then check. Then analyze quality. Then build/deploy/release. Then test end to end. Then notify.
A good deployment flow should be readable almost like a story.
Defaults Should Be Boring
Shared CI works best when defaults are boring and predictable.
For Vyriy, the default image comes from the internal registry:
default:
image: $CI_REGISTRY/vyriy/ci/node:latest
Global defaults include things like:
interruptible: true
HUSKY=0
SONAR_SCANNER_IMAGE=$CI_REGISTRY/vyriy/ci/sonar:latest
This means every project starts from the same runtime assumptions.
If the Node image changes, it changes in one place. If the Sonar scanner image changes, it changes in one place. If the default CI behavior improves, projects receive that improvement by using the shared entrypoint.
This is exactly the kind of boring infrastructure I like.
Not magical. Not clever. Just centralized, explicit, and repeatable.
Release as a Manual, Safe Step
One of the most important parts of the pipeline is release.
For libraries, release jobs are manual jobs on the main branch:
patch
minor
major
They bump package versions, commit the version change, create an annotated tag, and push the branch and tag.
They also support two useful hooks:
TEST_RELEASE_COMMAND
RELEASE_COMMAND
For example:
variables:
TEST_RELEASE_COMMAND: 'yarn pack && yarn consumer'
RELEASE_COMMAND: 'yarn build:dist && sh .bin/github.sh'
This lets a project run a package-level safety check before version changes happen.
Then, after the GitLab release step prepares the version and tag, another side effect can happen. In my case, GitLab is the source of truth for day-to-day development and CI, while GitHub is used as a public mirror. Publishing to npm can run from GitHub Actions after the manual GitLab release prepares the release state.
This split feels clean:
GitLab owns development CI. GitLab owns the release decision. GitHub can own public mirror publishing. npm publishing happens after the version is already prepared.
Docker and AWS Are Just Capabilities
Another useful part of this approach is that Docker and AWS are not separate pipelines.
They are capabilities inside the same shared pipeline.
The Docker job can build and push an image using the project registry. The AWS job can run a deploy script like:
npx tsx .bin/deploy.ts
If a project does not need Docker:
variables:
JOB_DOCKER_DISABLED: '1'
If a project does not need AWS:
variables:
JOB_AWS_DISABLED: '1'
This sounds small, but it changes how repositories feel.
A repository no longer has to answer:
“Which CI pipeline should I copy?”
It only answers:
“Which shared capabilities do I need?”
CI for AI Agents
The most interesting part came later.
I asked Codex to create a dedicated CI_AGENTS.md file. This file explains how the shared CI works, how consumer projects should include it, which variables control which jobs, and what rules agents should follow when modifying CI.
Then I added a rule to project agents:
When CI behavior changes, keep CI_AGENTS.md and the local agent instructions in sync.
Now every repository can carry a small CI knowledge file near its AGENTS.md.
That means future Codex sessions do not need to guess how the pipeline works.
They can read the local project instructions and understand the shared contract:
- use variables instead of copying shared jobs;
- disable irrelevant jobs with
JOB_*_DISABLED; - document project-specific overrides;
- keep CI docs in sync with
.gitlab-ci.yml; - change shared logic in the shared CI repository, not randomly in consumers.
This is a very important pattern.
AI agents are powerful, but they need local context. Without context, they guess. With a clear contract, they can work inside the architecture instead of fighting it.
Documentation Is Part of the Pipeline
For me, the documentation is not separate from the CI system.
The shared pipeline has a contract.
The contract is documented.
The consumer project links to the contract.
The agent instructions link to the contract.
The local .gitlab-ci.yml expresses only project-specific intent.
That creates a loop:
shared CI implementation
→ shared CI documentation
→ consumer CI variables
→ consumer agent instructions
→ future changes remain aligned
This is especially useful when the same CI system is used across many repositories.
The pipeline becomes easier to reuse not because it is hidden, but because it is explained.
Why This Feels Calm
I keep coming back to the idea of calm architecture.
For me, calm architecture is not about using fewer tools. It is about making the flow understandable.
This CI setup feels calm because:
The entrypoint is small. The shared structure is explicit. The consumer configuration is declarative. Jobs can be enabled or disabled with clear variables. Release is manual and intentional. Docker and AWS are capabilities, not separate worlds. Agents have documentation and do not need to guess. The pipeline reads like a story.
That is the real win.
Not just “less YAML”.
Less uncertainty.
Final Thought
A shared CI pipeline is not just a convenience.
Done well, it becomes a contract.
A contract between projects. A contract between local development and deployment. A contract between GitLab and GitHub. A contract between humans and AI agents.
And when that contract is small, readable, documented, and predictable, CI stops being a fragile pile of scripts.
It becomes part of the architecture.
Calm, boring, and reliable.