Self-Service Config on GitLab CI: From 30 Minutes to Under 30 Seconds
- Published on
- Reading time
- 6 mins read
On this page
In the coupon domain at Trendyol, a client team's configuration updates used to go through a manual, code-change-driven process, and each one took 30 minutes.
I replaced it with a validated pipeline the team runs itself. An update now takes under 30 seconds.
Self-service only works if the pipeline is stricter than the process it replaces. Below is the shape of such a pipeline on GitLab CI, using campaign rules as the example config. File names, fields and limits are illustrative.
Config gets its own repository
The rules live in a repository of their own: one JSON file per rule, a schema, two small scripts and a pipeline. Every change is a merge request, and nobody needs a local setup: GitLab's web editor can edit a file and open the merge request. The repository has its own permissions, history and pipeline, so a rule change no longer needs a code change.
The schema is the contract
A trimmed-down schema, in JSON Schema draft 2020-12:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Campaign rule",
"type": "object",
"additionalProperties": false,
"required": ["id", "discountType", "discountValue", "startsAt", "endsAt"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
"discountType": { "enum": ["PERCENTAGE", "FIXED_AMOUNT"] },
"discountValue": { "type": "number", "exclusiveMinimum": 0 },
"maxUsagePerCustomer": { "type": "integer", "minimum": 1 },
"startsAt": { "$ref": "#/$defs/utcTimestamp" },
"endsAt": { "$ref": "#/$defs/utcTimestamp" }
},
"if": { "properties": { "discountType": { "const": "PERCENTAGE" } } },
"then": { "properties": { "discountValue": { "maximum": 100 } } },
"$defs": {
"utcTimestamp": {
"type": "string",
"format": "date-time",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
}
}
}
additionalProperties: falseturns a typo into an error. Without it, a misspelledmaxUsagePerCustmerpasses validation, and the limit you think you set may not exist.if/thencaps percentage discounts at 100 while leaving fixed amounts alone.- Timestamps have one format: UTC, ending in
Z. In draft 2020-12,formatis only an annotation unless the validator is told to check it. check-jsonschema, used in the pipeline below, checks it by default and rejects February 30. Thepatternpins the shape in any validator and keeps the date math trivial.
Sanity assertions
A schema checks the shape of one document. It can't compare the values of two fields or look at other files, so a short Bash and jq script covers the rest:
#!/usr/bin/env bash
# Rules a schema can't express: relations between fields, and the file layout.
MAX_DAYS=90
status=0
fail() { echo "FAIL $1: $2"; status=1; }
for f in rules/*.json; do
[ "$(jq -r .id "$f")" = "$(basename "$f" .json)" ] \
|| fail "$f" "id must match the file name"
jq -e '(.endsAt | fromdateiso8601) > (.startsAt | fromdateiso8601)' "$f" > /dev/null \
|| fail "$f" "endsAt must be after startsAt"
jq -e --argjson max "$MAX_DAYS" \
'(.endsAt | fromdateiso8601) - (.startsAt | fromdateiso8601) <= $max * 86400' "$f" > /dev/null \
|| fail "$f" "a campaign can run for at most $MAX_DAYS days"
done
exit "$status"
jq's fromdateiso8601 parses exactly the shape the schema enforces. Matching ids to file names also makes them unique. The messages are for the people who will read them: a file name and a sentence. check-jsonschema likewise reports the file and the JSON path, as in Additional properties are not allowed ('maxUsagePerCustmer' was unexpected).
The pipeline
stages:
- validate
- apply
default:
image: registry.example.com/platform/config-tools:1.0 # bash, jq, check-jsonschema
validate:
stage: validate
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- check-jsonschema --schemafile schema/campaign-rule.schema.json rules/*.json
- ./scripts/sanity-check.sh
apply:
stage: apply
needs: [validate]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
resource_group: production-config
environment: production
script:
- ./scripts/apply.sh rules/
validateruns in every merge request pipeline and again on the default branch after the merge. The sanity checks only run once the schema passes.applyruns only on the default branch, aftervalidatesucceeds.apply.shpublishes the complete rule set, not a diff. Applying the same commit twice changes nothing, so a failed apply can be retried, and the default branch is the source of truth for what's live.resource_grouplets only one apply run at a time. Its default mode doesn't order waiting jobs, though, so enable "Prevent outdated deployment jobs" (or switch the group tooldest_firstthrough the API). Otherwise an older pipeline can overwrite a newer change.environment: productionrecords every apply as a deployment, with a full history per environment.- The credentials
apply.shneeds live in a protected CI/CD variable, which pipelines on unprotected branches never receive. A merge request from a feature branch can't apply anything, even if it edits.gitlab-ci.yml.
Guardrails on merge
- Protect the default branch with nobody allowed to push, so changes only arrive through merge requests.
- Enable "Pipelines must succeed", so a merge request that fails validation can't be merged. The same setting lets a fail-only rerun stage block merges on real regressions only.
- Decide who approves. On GitLab Premium and Ultimate, approval rules or Code Owners can require it. On Free, approvals are optional, so the protected branch's "Allowed to merge" setting is the lever.
Audit trail and rollback
Every change leaves a commit with an author, a merge request with its discussion, a pipeline log and a deployment entry. Who changed a rule, when, and who approved it is answered by tools the team already uses.
Rolling back is a revert. GitLab's Revert button on a merged merge request creates a revert commit, optionally through a new merge request, which goes through the same validation and apply as any other change. Because apply.sh publishes full state, what's live after the revert is exactly what the repository says.
The environment page also offers "Rollback environment", which runs the deployment job again for an older commit. Prefer the revert: the rollback leaves production out of sync with the default branch until someone notices.
Takeaways
- Config in its own repository, changed only through merge requests.
- A schema for shape, a script for relations between fields, and errors a non-developer can act on.
- Apply full state, idempotently, one at a time, with the newest change winning.
- Roll back with a revert, through the same pipeline.