Skip to content

Self-Service Config on GitLab CI: From 30 Minutes to Under 30 Seconds

Published on
Reading time
6 mins read

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/campaign-rule.schema.json
{
  "$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: false turns a typo into an error. Without it, a misspelled maxUsagePerCustmer passes validation, and the limit you think you set may not exist.
  • if/then caps percentage discounts at 100 while leaving fixed amounts alone.
  • Timestamps have one format: UTC, ending in Z. In draft 2020-12, format is 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. The pattern pins 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:

scripts/sanity-check.sh
#!/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

.gitlab-ci.yml
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/
  • validate runs in every merge request pipeline and again on the default branch after the merge. The sanity checks only run once the schema passes.
  • apply runs only on the default branch, after validate succeeds.
  • apply.sh publishes 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_group lets 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 to oldest_first through the API). Otherwise an older pipeline can overwrite a newer change.
  • environment: production records every apply as a deployment, with a full history per environment.
  • The credentials apply.sh needs 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.