Hands-on DevOps · Part 1

Hands-on DevOps #1 — GitLab CI/CD Components & Catalog: Build, Publish, and Consume by Version


TL;DR

  • Build — Put a component under templates/ and declare its inputs with spec:inputs — types, defaults, options, even regex. Invalid values are rejected before the pipeline is even created.
  • Publish & consume — Push a semantic-version tag and the release job publishes the component to the CI/CD Catalog; other projects pull it in with include: component@version. Version ranges like @1 and @~latest let you control breaking changes.
  • How it’s verified — Every output in this article was captured by running directly against a real gitlab.com project, SEON.N/gitlab-ci-components-catalog (public), via pipelines, releases, and the CI Lint API.

Overview

When you first set up a CI/CD pipeline, most people start by copying another project’s .gitlab-ci.yml. It works for now, but as projects multiply, the same configuration gets duplicated everywhere and three problems keep recurring.

  • Discoverability — There’s no way to know whether someone has already built the same build/test/deploy job. So every team rewrites a similar pipeline from scratch.
  • Reusability — You can pull in another file with include, but there’s no versioning and no input validation. When the source file changes, the pipelines referencing it break without warning.
  • Contribution — There’s no standard path to safely distribute a well-built pipeline piece across the organization and announce “here it is, go use it.”

CI/CD components and the catalog exist to solve exactly these three. In one line: they turn pipeline configuration from “code you copy-paste” into “a versioned package.”

Just as you don’t copy a library’s source wholesale but pull it in by version with npm install or a Go module, components let you pull pipeline pieces in by version, like a dependency. And the CI/CD Catalog is the marketplace that gathers those components in one place so you can search and discover them.

A CI/CD component is what GitLab defines as a “reusable single pipeline configuration unit.” You pull it in with include just like before, but two things are fundamentally different.

  1. Typed inputs (**spec:inputs**) — A component can declare string/number/boolean/array types, defaults, options (enum), and regex validation. Invalid values are rejected before the pipeline is even created.
  2. Semantic versioning + catalog — Components are released with semantic versions and discovered in the CI/CD Catalog.

Compared with the existing include:

MethodWhat it pulls inTyped inputsVersion/Catalog
include:locala file in the same repoNoNo
include:projecta file from another project (ref)NoNo
include:remotea file at a URLNoNo
include:templatea GitLab-provided templateNoNo
**include:component**a component (spec + job)YesYes (Catalog)

In one line: where include:local/include:project/include:remote “copy a file as-is,” include:component “pulls in a dependency with an explicit version and input contract (spec).” The key difference is that you’re pulling in not a plain file but a building block with a guaranteed input spec and version.

CI/CD components and the CI/CD Catalog reached GA in GitLab 17.0 (2024-05-16); before that they were experimental/beta. This article uses GitLab.com (always the latest version).

Where it’s useful

Components shine when “you repeat the same thing across many projects.” Common real-world use cases:

In this situationSolve it with a component
Apply the same security scan or lint to every repositoryBuild a scan component once and put it in the catalog; each project gets the same checks with a few lines of include
Standardize a deploy routine (Cloud Run, Kubernetes, etc.)Take the environment name and image tag as inputs, and reuse the same component across teams by changing only the values
A platform team wants to enforce a company-standard pipelineGather components at the group level and offer them as a catalog — you get governance (enforcement) and DRY (de-duplication) at the same time
Control breaking changes, like a library upgradeConsumers pin a partial version like @1 to auto-accept only non-breaking updates, or pin an exact version

The flagship example GitLab cited at GA was a Google Cloud Run deploy component, and the most common adoption driver is turning “jobs every team repeats the same way” — security scan, build, lint, deploy — into shared, organization-wide building blocks.

Version history and direction

Components and the catalog haven’t stood still since they went GA in 17.0. GitLab keeps adding to this area with each quarterly release.

VersionWhat was added/changed
Beta (2023-12)CI/CD Catalog beta released
17.0 (2024-05)Components + catalog reach full GA
18.0The release job’s standard image moved from release-cli to glab; release-cli is deprecated, removal planned for 20.0 (until then it falls back automatically when glab is absent)
18.5Per-project component limit raised 30 → 100
18.6–18.7Component context expression — a component can access its own metadata such as name and version
18.9Catalog resource usage analytics introduced
19.0 (2026-05)Detailed component usage for maintainers — track which project uses which version

The direction is clear: the core syntax (spec:inputs, semantic versioning, release, the catalog) has stayed stable since GA, while operational conveniences (usage analytics, usage detail, context expressions) and raised limits keep being layered on top.

So it’s a stable foundation you learn once and use for a long time, and at the same time an area GitLab actively expands every quarter. This article’s hands-on was run on gitlab.com (always latest), so the captured behavior already reflects the newest version.

The availability range is broad too. Components, the catalog, and spec:inputs all work across:

  • Tier: Free · Premium · Ultimate — all tiers
  • Offering: GitLab.com (SaaS) · GitLab Self-Managed · GitLab Dedicated — all offerings

Even on the free tier or self-managed, the core actions — build, publish, and consume components — work as-is. Just remember two things. First, 19.0’s “detailed component usage” is Premium-and-up only. Second, a self-managed instance’s catalog starts with zero published components, so you fill it by publishing your own or mirroring from GitLab.com, and the instance must be on 17.0 or later.

Architecture

ecosystem

The component project (templates/*.yml + .gitlab-ci.yml + README/LICENSE) publishes versions to the catalog via release, and any consumer project pulls them in with include: component@version.

Lifecycle

lifecycle

(1) Write the component and push to main → (2) the self-test pipeline actually runs the component → (3) tag a semantic version → (4) the release job publishes → (5) the version is registered in the catalog → (6) consumers include it. Because self-test runs first even on the tag pipeline, a broken component never gets published.

include resolution and input validation

resolution

include: component@version is resolved at pipeline creation time: version resolution (tag/SHA/partial/~latest) → fetch the template → input validation (type/options/regex) → $[[ inputs.x ]] interpolation → merge the job. If it’s blocked at validation, the pipeline fails before a runner ever spins up.

Prerequisites

This hands-on uses only local glab, git, curl, and python3. Use an already-installed, authenticated glab; if you don’t have it, install it per the table below (to use it as a container, docker run the registry.gitlab.com/gitlab-org/cli image).

ToolVersionmacOSWindowsLinux
GitLab17.0+— (SaaS)
glab1.40+brew install glabwinget install glab.glabpackage manager, or docker run --rm -it registry.gitlab.com/gitlab-org/cli:latest
git2.30+preinstalledpreinstalled / winget install Git.Gitpreinstalled / distro package
curl7.x+preinstalledpreinstalledpreinstalled
Tokena PAT with api + write_repository scope, or glab auth loginsamesame
Runnerenable a shared/group runner on the project (to run pipelines)samesame

glab/git/curl use the same commands regardless of OS. The commands below are identical across all three; only differences are noted separately.

Core concepts

1) Component directory structure

A component project places components under a top-level templates/ directory.

├── templates/
│   ├── greeting.yml          # single-file component
│   └── my-other/             # directory-form component
│       └── template.yml      # only this file is published
├── LICENSE.md
├── README.md
└── .gitlab-ci.yml

A single file is templates/<name>.yml; a more complex component is templates/<name>/template.yml. In the directory form, only template.yml is published — the rest (build/test helpers) are not.

2) spec:inputs — typed inputs

A component file is split into two YAML documents. Above --- is the spec (input declarations); below it is the actual job definition.

spec:
  inputs:
    stage:
      type: string      # string (default) / number / boolean / array
      default: test      # with a default it's optional; without one it's required
    style:
      options: [plain, banner]   # allowed-value whitelist (enum)
    version:
      regex: ^v?\d+\.\d+\.\d+$    # regex validation (RE2)
---
# interpolate with $[[ inputs.NAME ]] in the job definition

The key is the “why.” Input validation happens at pipeline creation time (when the configuration is fetched). So invalid input is rejected before a runner ever spins up, saving cost and time. A pipeline can take up to 20 inputs.

3) Interpolation $[[ inputs.x ]]

Unlike the CI variable $VAR, input interpolation uses the $[[ inputs.name ]] syntax. It works in the job name, in scripts, and on array elements $[[ inputs.arr[0] ]]. Interpolation is evaluated once at config-fetch time and stays fixed for the whole pipeline.

4) Version references

Components are referenced in this priority order:

  1. Commit SHA@e3262fdd...
  2. Tag@v1.0.0 (catalog publishing requires a semantic-version tag)
  3. Branch@main
  4. Partial version / latest@1.2, @1, @~latest

~latest points to the latest released version (excluding pre-releases), so breaking changes can flow in automatically. In production, prefer a pinned version or a partial version like @1.

5) include path and release

include:
  - component: $CI_SERVER_FQDN/<project-path>/<component-name>@<version>

$CI_SERVER_FQDN is a predefined variable for the GitLab host FQDN, so the same config works across instances. And for a version to appear in the catalog, you must create the release with the **release** keyword (not the Releases API).

Hands-on steps

Step 1 — Create the project and clone

This step creates the empty project (the container) that will hold the components. To publish to the catalog, a project needs a description, a README, and components under templates/, so we prepare that frame first. There are no components yet — we just take the empty repo and set up the working directory.

We created it with a description via the API for clarity (the catalog requires a description). glab repo create works too.

# Get the token from glab config, or export a PAT directly
GITLAB_TOKEN=$(glab config get token --host gitlab.com)
REPO_NAME="gitlab-ci-components-catalog"
DESC="Reusable GitLab CI/CD components (greeting, semver-guard) published to the CI/CD Catalog. Hands-on samples."

# Create via API (or: glab repo create "SEON.N/${REPO_NAME}" --public --description "${DESC}")
curl -s --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  --header "Content-Type: application/json" \
  --data "{\"name\":\"${REPO_NAME}\",\"path\":\"${REPO_NAME}\",\"namespace_id\":<your-namespace-id>,\"visibility\":\"public\",\"description\":\"${DESC}\"}" \
  "https://gitlab.com/api/v4/projects"

git clone "https://oauth2:${GITLAB_TOKEN}@gitlab.com/SEON.N/${REPO_NAME}.git" "/tmp/${REPO_NAME}"
cd "/tmp/${REPO_NAME}"
git symbolic-ref HEAD refs/heads/main
mkdir -p templates

Output

created: SEON.N/gitlab-ci-components-catalog | id 83321559 | vis public
Cloning into '/tmp/gitlab-ci-components-catalog'...
warning: You appear to have cloned an empty repository.

Note: A service account or CI environment may not have an SSH key, so do git push in the https://oauth2:${GITLAB_TOKEN}@... form. The token needs api (create) and write_repository (push) scope.

Step 2 — The greeting component (4 inputs + interpolation)

This step writes the first component. To show the two essentials — typed inputs and interpolation — at once, we build greeting, which takes four inputs and injects them into the job name and the script. It’s the smallest example of a component whose behavior changes with its inputs.

templates/greeting.yml. It uses string/boolean/options/default, and interpolates into the job name and the script.

spec:
  inputs:
    stage:
      type: string
      default: test
    name:
      type: string
      description: "Who to greet. Required: no default."
    style:
      type: string
      default: plain
      options: [plain, banner]
    shout:
      type: boolean
      default: false
---
"greeting $[[ inputs.name ]]":          # interpolated into the job name too
  stage: $[[ inputs.stage ]]
  image: alpine:3.20
  variables:
    GREET_NAME: "$[[ inputs.name ]]"
    GREET_STYLE: "$[[ inputs.style ]]"
    GREET_SHOUT: "$[[ inputs.shout ]]"
  script:
    - |
      MSG="Hello, ${GREET_NAME}!"
      if [ "${GREET_SHOUT}" = "true" ]; then
        MSG="$(echo "${MSG}" | tr '[:lower:]' '[:upper:]')"
      fi
      if [ "${GREET_STYLE}" = "banner" ]; then
        LINE="$(echo "${MSG}" | sed 's/./=/g')"
        printf '%s\n%s\n%s\n' "${LINE}" "${MSG}" "${LINE}"
      else
        echo "${MSG}"
      fi

File: templates/greeting.yml

Note: Write multi-line scripts as a - | block scalar. If you write - echo "OK: ...", the : (colon+space) inside a YAML plain scalar is parsed as a mapping separator and causes a parse error (see Troubleshooting below).

Step 3 — The semver-guard component (regex) + .gitlab-ci.yml

This step builds a second component to show input validation (regex), and at the same time wires up .gitlab-ci.yml so the project self-tests its own components and releases from a tag. You assemble the component and the “publishing pipeline skeleton” together.

templates/semver-guard.yml validates its input with a regex.

spec:
  inputs:
    stage:
      type: string
      default: test
    version:
      type: string
      regex: ^v?\d+\.\d+\.\d+$
---
semver-guard:
  stage: $[[ inputs.stage ]]
  image: alpine:3.20
  variables:
    INPUT_VERSION: "$[[ inputs.version ]]"
  script:
    - |
      set -e
      echo "Validating version '${INPUT_VERSION}'"
      echo "${INPUT_VERSION}" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+$'
      echo "OK - '${INPUT_VERSION}' is a valid semantic version"

The project’s own .gitlab-ci.yml (a) self-tests the components at the current SHA, and (b) creates a release from a tag.

stages: [test, release]

include:
  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/greeting@$CI_COMMIT_SHA
    inputs:
      name: GitLab
      style: banner
      shout: true
  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/semver-guard@$CI_COMMIT_SHA
    inputs:
      version: v1.0.0

create-release:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - echo "Creating release for tag $CI_COMMIT_TAG"
  release:
    tag_name: $CI_COMMIT_TAG
    description: "Release $CI_COMMIT_TAG of the components."

File: .gitlab-ci.yml

Note: Using @$CI_COMMIT_SHA in the self-test pulls in exactly the components in the commit you just pushed, at that point in time — the “test yourself in your own pipeline” pattern. Before pushing, we validated the YAML syntax locally.

python3 - <<'PY'
import yaml
for f in ["templates/greeting.yml","templates/semver-guard.yml",".gitlab-ci.yml"]:
    print("OK", f, len(list(yaml.safe_load_all(open(f)))), "doc")
PY
# OK templates/greeting.yml 2 doc
# OK templates/semver-guard.yml 2 doc
# OK .gitlab-ci.yml 1 doc

Step 4 — Push and run the self-test pipeline (real output)

This step checks the component you wrote actually runs. Pushing to main triggers the self-test pipeline, which runs the component you just made exactly as it is in that commit. It’s the gate that filters out broken components before publishing.

git add .
git commit -m "Add greeting and semver-guard reusable CI/CD components"
git push -u origin main
# pushing to main triggers the self-test pipeline

Check the pipeline and job status via the API.

PID=83321559
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.com/api/v4/projects/${PID}/pipelines?per_page=1"

Real output (job list)

14845634397 | semver-guard     | test | success | 8.4 s
14845634396 | greeting GitLab  | test | success | 10.3 s

Notice the job is named greeting GitLab — the interpolation of "greeting $[[ inputs.name ]]" worked. The job logs (trace) look like this.

Real output (greeting job trace)

$ MSG="Hello, ${GREET_NAME}!" # collapsed multi-line command
==============
HELLO, GITLAB!
==============

Real output (semver-guard job trace)

$ set -e # collapsed multi-line command
Validating version 'v1.0.0'
OK - 'v1.0.0' is a valid semantic version

Note: Both style: banner (banner output) and shout: true (uppercasing) took effect, so HELLO, GITLAB! printed in a box. You can also see the boolean input interpolated into the script as the string "true".

Step 5 — Register the catalog resource + publish the version (real output)

This step puts the verified component in the catalog so it can be “consumed by version.” First mark the project as a catalog resource, then push a semantic-version tag so the release job publishes that version to the catalog. From here, other projects can search for and use it.

Mark the project as a “catalog resource.” In the UI it’s the “CI/CD Catalog project” toggle under Settings > General > Visibility, but for automation use the GraphQL catalogResourcesCreate (no REST yet, issue 463043).

curl -s --request POST "https://gitlab.com/api/graphql" \
  --header "Authorization: Bearer ${GITLAB_TOKEN}" \
  --header "Content-Type: application/json" \
  --data '{"query":"mutation { catalogResourcesCreate(input: { projectPath: \"SEON.N/gitlab-ci-components-catalog\" }) { errors } }"}'
# => {"data":{"catalogResourcesCreate":{"errors":[]}}}

Now pushing a semantic-version tag runs the tag pipeline: self-test, then the release job.

git tag v1.0.0
git push origin v1.0.0

Real output (tag pipeline jobs)

14845637416 | create-release   | release | success
14845637415 | semver-guard     | test    | success
14845637414 | greeting GitLab  | test    | success

Real output (create-release job trace)

$ echo "Creating release for tag $CI_COMMIT_TAG"
Creating release for tag v1.0.0
• Creating or updating release  repo=SEON.N/gitlab-ci-components-catalog tag=v1.0.0
✓ Release created:	url=https://gitlab.com/SEON.N/gitlab-ci-components-catalog/-/releases/v1.0.0
✓ Release succeeded after 0.77 seconds.

create-release job log — release created with the registry.gitlab.com/gitlab-org/cli (glab) image

Check that the version was registered in the catalog via GraphQL.

curl -s --request POST "https://gitlab.com/api/graphql" \
  --header "Authorization: Bearer ${GITLAB_TOKEN}" --header "Content-Type: application/json" \
  --data '{"query":"{ ciCatalogResource(fullPath: \"SEON.N/gitlab-ci-components-catalog\") { name webPath versions { count nodes { name } } } }"}'

Output

{"data":{"ciCatalogResource":{"name":"gitlab-ci-components-catalog",
  "webPath":"/SEON.N/gitlab-ci-components-catalog",
  "versions":{"count":1,"nodes":[{"name":"v1.0.0"}]}}}}

v1.0.0 published in the CI/CD Catalog (Components: greeting, semver-guard)

Note: The image the release job uses is, per the current official docs, registry.gitlab.com/gitlab-org/cli (glab) — changed from the former release-cli. Pushing only a tag without the release keyword does not create a version in the catalog.

Step 6 — Verify consumer include (CI Lint API, real output)

This step checks whether another project can actually consume the published version. Without spinning up a runner, the CI Lint API alone confirms that version references and input validation behave as intended.

We verify with the CI Lint API (POST /projects/:id/ci/lint) whether another project can pull in the published version. include resolution and input validation are confirmed without a runner.

PID=83321559
curl -s --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  --header "Content-Type: application/json" \
  --data '{"include_jobs":true,"content":"stages: [test]\ninclude:\n  - component: gitlab.com/SEON.N/gitlab-ci-components-catalog/greeting@v1.0.0\n    inputs:\n      name: World\n      style: plain"}' \
  "https://gitlab.com/api/v4/projects/${PID}/ci/lint"

Output (three version references)

ref @v1.0.0     -> valid=True  jobs=['greeting World']  errors=[]
ref @~latest    -> valid=True  jobs=['greeting World']  errors=[]
ref @1.0.0      -> valid=False errors=["Component '.../greeting@1.0.0' - content not found"]

Since the tag is v1.0.0, the include must also be @v1.0.0 (or @~latest). @1.0.0 (no v prefix) differs from the tag name, so it returns “content not found.” Next, validate semver-guard’s regex input.

Real output (regex input pass/reject)

=== valid version v2.3.4 (regex pass) ===
valid: True | jobs: ['semver-guard'] | errors: []

=== invalid version 'not-a-version' (regex reject) ===
valid: False | errors: ['`.../semver-guard@v1.0.0`: `version` input: provided value does not match required RegEx pattern']

semver-guard in the catalog UI — version is a regex-validated input

Note: An invalid version string is blocked by regex validation at the pipeline-creation stage, before the job ever runs. This is the core value of a component’s typed/regex inputs.

Advanced hands-on

The basic loop — build the components (Steps 1–3), verify with self-test (Step 4), publish to the catalog (Step 5), confirm a consumer can pull them in (Step 6) — ends here. Below are advanced patterns common in practice: real consumption from another project, controlling breaking changes with version ranges, and composing multiple components.

Step 7 — Actually consume from a consumer project (usage)

The point of the catalog is consuming a component with include from another project, not the one that built it. So we created a separate consumer project (public) and actually pulled it in.

The consumer project’s .gitlab-ci.yml only needs to pull the components in by version.

stages: [test]

include:
  - component: $CI_SERVER_FQDN/<your-namespace>/gitlab-ci-components-catalog/greeting@v1.0.0
    inputs:
      name: Consumer
      style: banner
  - component: $CI_SERVER_FQDN/<your-namespace>/gitlab-ci-components-catalog/semver-guard@v1.0.0
    inputs:
      version: v3.1.4

Real output (consumer pipeline jobs)

greeting Consumer | test | success
semver-guard      | test | success

Consumer project pipeline — running greeting and semver-guard included from the catalog

The job named greeting Consumer confirms the interpolation worked with the consumer’s input (name: Consumer). In other words, once a component is published, any project pulls it in with its own inputs.

Note: The catalog aggregates a per-component usage count. Query it via GraphQL.

glab api graphql -f query='{ ciCatalogResource(fullPath:"<group>/<project>"){ versions{ nodes{ name components{ nodes{ name last30DayUsageCount } } } } } }'

However, last30DayUsageCount is not reflected immediately after consumption (in our test it was still 0 on a same-day re-query). Check usage after the aggregation has refreshed.

Step 8 — v2.0.0 release and version ranges (controlling breaking changes)

Signal a breaking change with a MAJOR bump. We changed greeting’s default style from plain to banner (a change that alters existing consumers’ default output) and released v2.0.0.

# templates/greeting.yml: change style default from plain to banner (breaking)
git commit -am "greeting v2.0.0: change default style to banner (breaking change)"
git tag v2.0.0
git push origin main v2.0.0

Once the tag pipeline passes self-test and then release, two versions coexist in the catalog.

Real output (catalog versions)

{"versions":{"count":2,"nodes":[{"name":"v2.0.0"},{"name":"v1.0.0"}]}}

Now the version a consumer pulls in depends on which reference it uses. We verified with CI Lint.

Real output (version reference resolution)

@v1.0.0  -> valid     exact tag
@v2.0.0  -> valid     exact tag
@1       -> valid     latest in the 1.x range = v1.0.0
@2       -> valid     latest in the 2.x range = v2.0.0
@~latest -> valid     latest released (pre-releases excluded) = v2.0.0
@1.0.0   -> invalid   content not found (the tag is v1.0.0)

What matters is the operational strategy. Even when the breaking v2.0.0 ships, a consumer pinned to @1 keeps receiving 1.x and stays safe. A consumer on @~latest, however, automatically gets v2.0.0 and its behavior changes (here, the default output becomes banner). So production pipelines should use a partial version like @1 or a pinned version, and avoid @~latest.

Step 9 — Composing components (multiple components in one pipeline)

The real power of components is “assembly”: bundling several single-purpose components as stages into a standard pipeline. Here we add one more component, lint, and compose the self-test as a lint → test flow.

templates/lint.yml:

spec:
  inputs:
    stage:
      type: string
      default: lint
---
component-lint:
  stage: $[[ inputs.stage ]]
  image: alpine:3.20
  script:
    - |
      echo "Linting component templates..."
      for f in templates/*.yml; do echo "checking ${f}"; done
      echo "OK - lint passed"

The .gitlab-ci.yml sets stages to [lint, test, release] and adds lint (the lint stage) before the existing greeting/semver-guard (test).

stages: [lint, test, release]

include:
  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/lint@$CI_COMMIT_SHA
    inputs:
      stage: lint
  # greeting and semver-guard are the same as Step 3 (test stage)

Real output (pipeline graph)

self-test pipeline composed as lint → test in two stages

The lint stage’s component-lint must pass first, then the test stage’s greeting/semver-guard run. Each component is versioned and published independently, but the consumer composes them as stages into a single standard pipeline — this is the heart of an “organization-standard pipeline catalog.”

Verification

CheckCommand/methodExpected
Component worksself-test pipelinegreeting/semver-guard jobs success
Interpolationjob namegreeting GitLab
Catalog publishGraphQL ciCatalogResourceversions.count == 1, v1.0.0
ReleaseGET /projects/:id/releasestag_name: v1.0.0
Consumer resolutionCI Lint @v1.0.0valid=true, job merged
Input validationCI Lint, invalid valuevalid=false, RegEx error

On failure: if the pipeline doesn’t start, check the project’s runner setup (shared/group runner) and the .gitlab-ci.yml syntax (glab ci lint); if the version doesn’t appear in the catalog, check (1) catalog-resource registration, (2) use of the release keyword, (3) presence of a description/README.

Production

  • Permission model & token rotation: Publishing requires the Owner role and api+write_repository scope. In CI, prefer CI_JOB_TOKEN or a group access token where possible, and set an expiry on PATs and rotate them regularly. A token with no expiry is especially risky, so always have an expiry/rotation policy.
  • Governance: Gather component projects at the group level to enforce a standard pipeline. Signal breaking changes with semantic versions (MAJOR.MINOR.PATCH); consumers pin @1 (a partial version) to auto-accept only non-breaking updates, or pin an exact version.
  • **\~latest** caution: Convenient, but breaking changes flow in automatically. Production pipelines should prefer a pinned version.
  • Security hardening: Put regex/options on the inputs a component takes to shrink the arbitrary-command-injection surface. When taking an image tag as input, restrict it with a regex too.
  • Monitoring & observability: Wire release/pipeline failures to alerts — e.g., a webhook to a notification channel on pipeline failure. (Integrate with your environment’s observability stack, e.g. Prometheus/Loki/Tempo.) Expose the pipeline status badge and the catalog version count as dashboard metrics.
  • Failure-recovery runbook: If you published a bad version: (1) bump a patch version and republish, (2) consumers pin to the last good version, (3) if a ~latest consumer broke, switch it to a pinned version immediately.

Common mistakes & troubleshooting

SymptomCauseFix
content not found on includetag is v1.0.0 but referenced as @1.0.0match the include version to the actual tag name (@v1.0.0), or use @~latest/@1
YAML parse error expected <block end>a : (colon+space) in a plain-scalar script (echo "OK: ...")write multi-line scripts as a block scalar so colon+space isn’t in a plain scalar
version doesn’t appear in the catalogproject isn’t registered as a catalog resourceenable via catalogResourcesCreate GraphQL or the UI toggle
registered but zero versionspushed only a tag without the release keywordadd a release: job to the pipeline and re-push the tag
pipeline doesn’t start at allno active runner on the project (gitlab.com free tier may require account verification)enable a shared/group runner; check the group’s shared_runners_setting
401 with a bad tokenusing a self-managed or expired tokenuse the correct host’s token via glab config get token --host gitlab.com

Going further

  • **array** inputs and indexing: use structured inputs like $[[ inputs.servers[0].host ]] (max 5 indices per segment).
  • Strengthen component tests: add a negative test to the self-test that asserts invalid input is actually rejected, so a broken component never gets published.
  • Search the CI/CD Catalog: explore public components at https://gitlab.com/explore/catalog to learn reuse patterns.

Cleanup

This hands-on uses only git push and release, and stands up no extra infrastructure. gitlab.com pipelines consume a small amount of the project’s CI minutes.

# After the hands-on, clean up (optional):
# - Delete the whole project only after your own approval. To keep it, just revoke the token.
# - Manage the temporary token used for git push per your expiry/rotation policy.
# - Clean the local working directory: rm -rf /tmp/gitlab-ci-components-catalog

Cost/billing note: Publishing to the catalog itself costs nothing extra. But running pipelines consumes CI minutes. Use rules to avoid repeatedly triggering large self-tests.

References