Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

driftsys/ci is a catalogue of small CI building blocks for the driftsys org.

There are two kinds of artifact:

  • Components — single-purpose, one job each. Use them when you need fine-grained control over which steps run when.
  • Presets — opinionated combinations of components plus the orchestration (event gates, permissions, job ordering) for a common scenario. Use them when you want the canonical pipeline in one line.

Every artifact ships in two parallel forms:

  • a composite GitHub Action under actions/<name>/, or a reusable workflow under .github/workflows/<name>.yml for presets
  • a GitLab CI component at templates/<name>/

Pick the form that matches your platform — the inputs, defaults, and behaviour are aligned across both.

Components

ComponentWhat it does
commitlintValidate commit messages against the Conventional Commits spec, using git std lint.
releaseBump semver per the commits since the last tag, commit + tag, then push (git std bump).
release-notesPublish a release page with notes from a tag (GH auto-notes; GL composes from git log).

Presets

PresetWhat it does
standard-releasecommitlint on PRs / MRs + release on the default branch. The driftsys default.

How to use this guide

Each artifact has two chapters in the sidebar — one per platform. Start with the chapter for the CI you actually run; the example at the top is the smallest working invocation. The “Inputs” table covers every knob.

For end-to-end pipelines that combine multiple components, see Recipes.

For why each design decision was made (input naming, output shapes, versioning, testing strategy), see Research.

Versioning

Components and presets follow semver. Pin to @v0, @v0.1.0, or @~latest depending on how strict you need to be:

  • @v0 — rolling pointer to the latest 0.x.y. New optional inputs and bug fixes land automatically; breaking changes wait for v1.
  • @v0.1.0 — exact tag, fully reproducible.
  • @~latest (GitLab only) — equivalent of @v0 semantics on GitLab.

Contributing

Source lives at https://github.com/driftsys/ci. The repo’s README.md covers local development and contribution guidance.

commitlint

Composite GitHub Action that validates Conventional Commits in a git range using git-std.

Inputs

NameRequiredDefaultDescription
rangeyesRange to validate (e.g. main..HEAD).
git-std-versionno0.11.12git-std release to install.

Example

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- uses: driftsys/ci/actions/commitlint@v0
  with:
    range: ${{ github.event.pull_request.base.sha }}..HEAD

Picking a range for each event

GitHub does not expose a single “compare-against” variable, so the right value for range depends on the workflow trigger. Use this table:

EventRecommended rangeNotes
pull_request${{ github.event.pull_request.base.sha }}..HEADLints commits introduced by the PR. Requires fetch-depth: 0.
merge_group${{ github.event.merge_group.base_sha }}..HEADSame idea for GitHub merge queues.
push to a branch${{ github.event.before }}..${{ github.sha }}Lints commits in this push. Fails on the first push to a new branch (see below).
workflow_dispatch / schedule<last-tag>..HEAD (e.g. $(git describe --tags --abbrev=0)..HEAD)No event SHA is available; pass an explicit anchor.

Edge cases on push

  • First push to a new branch. github.event.before is 0000000000000000000000000000000000000000, so before..HEAD is invalid. Either guard the step (if: github.event.before != '0000000000000000000000000000000000000000') or fall back to <base-branch>..HEAD.
  • Force-push. github.event.before points at the pre-push tip, which may no longer be reachable from the new HEAD. Lint will report invalid range. Either skip the step on force-push or always lint against the base branch.
  • Merge commits in the range. GitHub generates a synthetic merge commit at refs/pull/N/merge whose subject is Merge <head_sha> into <base_sha>. git-std doesn’t recognise this as a process commit. Avoid it by checking out the PR head ref directly: with: { ref: ${{ github.head_ref }} } so HEAD is the PR tip, not the synthetic merge.

Notes

  • Requires fetch-depth: 0 on the checkout step so the full commit history is available.
  • Validation uses git-std’s Conventional Commits rules. See git-std docs for the rule set.

release

Composite GitHub Action that bumps the semver version via git-std, commits + tags the release, then pushes.

Inputs

NameRequiredDefaultDescription
remotenooriginRemote to push to.
dry-runnofalseIf true, bump + tag but skip push.
git-std-versionno0.11.12git-std release to install.

Example

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- uses: driftsys/ci/actions/release@v0

Notes

  • Requires a git identity (user.email, user.name) to be configured before this step runs.
  • The calling workflow needs contents: write permission to push the tag.

release-notes

Composite GitHub Action that publishes a GitHub Release with auto-generated notes for a tag.

It’s a thin wrapper around gh release create --generate-notes. Useful to run on tag push so a git push --follow-tags (or the release action) automatically gets a Release page with changelog notes.

Inputs

NameRequiredDefaultDescription
tagno${{ github.ref_name }}Tag to publish a release for. Defaults to the current ref on tag-push events.
latestnoautotrue / false / auto. auto lets GitHub decide based on tag semver order.
draftnofalseCreate the release as a draft.
prereleasenofalseMark the release as a prerelease.

Example

# .github/workflows/release-notes.yml
on:
  push:
    tags: ["v*.*.*"]

jobs:
  notes:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - uses: driftsys/ci/actions/release-notes@v0

Notes

  • The caller’s job needs contents: write to publish the release.
  • Idempotent: if a release for the tag already exists, the step exits successfully without modifying it.
  • GitHub auto-attaches source .tar.gz and .zip archives to every release; this action does not upload anything additional.

standard-release (GitHub reusable workflow)

The driftsys default release pipeline as a GitHub Actions reusable workflow. One adoption line gets you commit-message validation on every PR plus a semver bump-and-tag plus a published release page with auto-generated notes on every push to the default branch.

It’s a thin preset over the commitlint, release, and release-notes actions — same defaults, fewer lines of YAML in your repo.

Inputs

NameRequiredDefaultDescription
rangeno${{ github.event.pull_request.base.sha }}..HEADCommit range commitlint validates on PRs.
remotenooriginRemote the release job pushes to.
dry-runnofalseIf true, the release job bumps + tags but skips push.

Example

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push:
    branches: [main]

jobs:
  release:
    permissions:
      contents: write
    uses: driftsys/ci/.github/workflows/standard-release.yml@v0

Notes

  • The caller’s job needs contents: write so the release and release-notes jobs can push the tag and publish the release page.
  • Job gates: commitlint runs on pull_request; release and release-notes run on push to main. The reusable workflow handles all gating internally.
  • release-notes runs after release and is skipped when dry-run: true.
  • The actual workflow file lives at .github/workflows/standard-release.yml; this directory only holds the docs chapter.

commitlint (GitLab CI component)

Validates Conventional Commits in a merge request using git-std.

This component consumes a driftsys/dock image which ships git-std preinstalled.

Inputs

NameRequiredDefaultDescription
rangeno$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEADGit range to validate.
imagenoghcr.io/driftsys/dock:lint-v0.2.0Container image with git-std.
stagenotestPipeline stage.

Example

include:
  - component: gitlab.com/driftsys/ci/commitlint@~latest

Notes

  • The job only runs on merge request pipelines (CI_PIPELINE_SOURCE == "merge_request_event").
  • GIT_DEPTH: 0 is required to access full commit history.
  • Override image to pin to a specific dock release for reproducibility.

release (GitLab CI component)

Bumps the semver version via git-std, commits + tags, then pushes.

This component consumes a driftsys/dock core image.

Inputs

NameRequiredDefaultDescription
imagenoghcr.io/driftsys/dock:core-v0.2.0Container image.
stagenodeployPipeline stage.
remotenooriginRemote to push to.
dry-runnofalseSkip push if true.

Example

include:
  - component: gitlab.com/driftsys/ci/release@~latest

Notes

  • The job only runs on the default branch (CI_COMMIT_BRANCH == CI_DEFAULT_BRANCH).
  • The pipeline runner must have push access to the repository.
  • Override image to pin to a specific dock release.

release-notes (GitLab CI component)

Publishes a GitLab Release for a tag, with notes composed from commits since the previous tag.

The component uses release-cli under the hood. Unlike GitHub’s --generate-notes, GitLab has no native auto-notes feature, so this component composes a simple bullet list of commit subjects between the previous tag and the current tag.

Inputs

NameRequiredDefaultDescription
imagenoregistry.gitlab.com/gitlab-org/release-cli:latestContainer image with release-cli + git.
stageno.postPipeline stage for the release-notes job.

Example

# .gitlab-ci.yml
include:
  - component: gitlab.com/driftsys/ci/release-notes@~latest

Notes

  • The job only runs on tag pipelines ($CI_COMMIT_TAG is set).
  • Notes are composed from git log --pretty='- %s' PREV..TAG. If no previous tag exists, the full history up to the tag is used.
  • This component intentionally uses the upstream release-cli image rather than a driftsys/dock image, since the dock catalogue doesn’t ship release-cli today.
  • The pipeline must be able to fetch full history (GIT_DEPTH: 0) so git describe can find the previous tag.

standard-release (GitLab CI component)

The driftsys default release pipeline as a GitLab CI component. One include line gets you commit-message validation on every MR plus a semver bump-and-tag plus a published release page with notes on every push to the default branch.

It’s a thin preset over the commitlint, release, and release-notes components — same defaults, fewer lines of YAML in your repo.

Inputs

NameRequiredDefaultDescription
rangeno$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEADCommit range commitlint validates on MRs.
remotenooriginRemote the release job pushes to.
dry-runnofalseIf true, the release job bumps + tags but skips push.

Example

# .gitlab-ci.yml
include:
  - component: gitlab.com/driftsys/ci/standard-release@~latest

Notes

  • The component pins its sub-components (commitlint, release, release-notes) at $CI_COMPONENT_REF, so standard-release@v0.2.0 reproducibly uses the v0.2.0 sub-components.
  • The release-notes job runs on tag pipelines (after release pushes the tag), not on the default-branch pipeline that the release job belongs to.
  • For finer control over a sub-component, include it directly instead of (or in addition to) standard-release; the preset only exposes the most common subset of inputs.

PR validation recipe

A complete PR validation pipeline combining commitlint with your existing test steps.

GitHub Actions

# .github/workflows/pr.yml
name: PR validation
on: pull_request

jobs:
  commitlint:
    name: Lint commits
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: driftsys/ci/actions/commitlint@v0
        with:
          range: ${{ github.event.pull_request.base.sha }}..HEAD

  test:
    name: Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: just test

Release workflow

Pair release with a release job that runs only on the default branch:

# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: driftsys/ci/actions/release@v0

GitLab CI

# .gitlab-ci.yml
include:
  - component: gitlab.com/driftsys/ci/commitlint@~latest
  - component: gitlab.com/driftsys/ci/release@~latest

DevEx survey: reusable GH Actions and GitLab CI components

This note surveys public and corporate patterns for reusable CI building blocks. The goal is to extract principles that make components easy to adopt, trust, and maintain.

Why this matters

A reusable action or component is a public API. Once consumers pin to a version, any breaking change is a support burden. Small ergonomic decisions (unclear input names, swallowed error codes, opaque failure messages) compound across dozens of pipelines. Getting this right upfront is cheaper than retrofitting it.

Inputs

Make required inputs explicit. Both GH Actions (required: true) and GitLab components (spec.inputs.<name> without a default:) support this. Surface missing required inputs as an error at configuration time, not as a cryptic runtime failure.

Use sensible defaults for optional inputs. A consumer who only reads the example should get a working pipeline. Defaults should reflect the most common use case, not the safest-for-the-maintainer one.

Document inputs with description:. Both platforms render descriptions in their UIs. A one-sentence description prevents the most common support questions. Compare actions/cache (clear descriptions per input) with older internal actions that have undocumented SOME_FLAG env vars.

Single-purpose inputs beat kitchen-sink configs. actions/setup-node exposes node-version and cache; it does not expose every npm config flag. The more surface area, the harder it is to keep stable. Prefer composing small actions over building configurable monoliths.

Outputs

Use stable, lowercase, hyphenated output names. Outputs are referenced in downstream steps.<id>.outputs.<name> expressions. A rename is a breaking change. Follow actions/checkout’s precedent: ref, sha, token.

Expose structured JSON when the output is structured. softprops/action-gh-release outputs assets as a JSON array string. Consumers parse it with fromJSON(...). This is more stable than trying to output separate asset-1-url, asset-2-url keys.

Avoid implicit side-effects as outputs. If a step writes to $GITHUB_ENV or $GITHUB_PATH as a side effect, document it. Consumers who read only the inputs/outputs table will be surprised.

Versioning

Offer a rolling major-version branch. actions/checkout@v4 tracks the latest v4.x.y release. This lets security-conscious consumers stay current without constant pin updates. GitLab’s ~latest serves the same purpose for components.

Tag every release with a full semver tag. Rolling branches are convenient; full tags (v0.1.3) are essential for reproducibility and security audits. Orgs that mandate SHA pinning need the tag to resolve the SHA.

Treat breaking changes as major bumps. A new required input, a renamed output, or a changed exit-code convention is a breaking change. Following semver strictly builds trust.

For pre-1.0 components, use v0.x and communicate that the API may change. Consumers who need stability should wait for v1.

Presets and bundles

Single-purpose components are easy to maintain and easy to compose, but consumers don’t want to rediscover the canonical composition every time. Mature catalogues offer presets — opinionated combinations with the event gates, permissions, and orchestration baked in.

On GitHub, presets must be reusable workflows, not composite actions. A composite action lives inside one job and inherits its triggers from the caller; a preset that runs commitlint on PRs and release on push-to-main needs two separate jobs with different if: event gates and different permissions. That’s only expressible as a reusable workflow (on: workflow_call).

On GitLab, a preset is just another component whose template body is an include: list of sub-components. The trick is propagating the consumer’s pinned ref to the sub-components: $CI_COMPONENT_REF resolves to whatever the consumer pinned (@v0.1.0 → exact tag, @~latest → rolling), so writing commitlint@$CI_COMPONENT_REF inside the preset gives byte-reproducibility for free.

Pin sub-components to the rolling-major on GitHub. GH lacks $CI_COMPONENT_REF’s equivalent — there’s no built-in way for a reusable workflow to forward its own caller’s ref. Pin to @v0 (rolling) and rely on the major-stability promise. Consumers who need byte-reproducibility SHA-pin the preset itself, which transitively freezes everything inside.

Discoverability

README at the action root. driftsys/ci/actions/commitlint/README.md is what GitHub renders when a user browses to the action. Keep it short: what it does, inputs table, minimal example.

branding: in action.yml. GitHub’s marketplace and search use the icon and colour for visual identification. Even if you never publish to the marketplace, consistent branding distinguishes your actions at a glance in third-party catalogs.

A central index. driftsys.github.io/ci lists all components with one- line descriptions. Developers browsing for a solution see the full menu; those who find a specific component via search can navigate to siblings.

Consistent naming. Use a single verb or <verb>-<noun> (e.g. commitlint, release). Avoid abbreviations (cmt for commitlint) and avoid over-generic names (run, execute, helper). Name by intent, not implementation (release over bump-push).

Failure modes

Always exit non-zero on failure. Composite actions can accidentally swallow a failure if a sub-step uses continue-on-error: true without checking steps.<id>.outcome afterwards. Test this explicitly: a smoke test that provides bad input should make the whole job fail.

Emit structured errors. GitHub Actions supports ::error::message and ::error file=...,line=...,col=...:message. GitLab surfaces job output directly. Either way, tell the user which commit failed and why, not just that something went wrong.

Never silently skip. If a component has no work to do (e.g. commitlint with an empty range), exit 0 with a notice, not silently. Silent success on an empty range hides misconfiguration.

Document continue-on-error use cases. Some callers want lint failures to be advisory (post a comment but not block the merge). Expose soft-fail as an explicit input rather than assuming.

Patterns from public OSS actions

actions/checkout — the gold standard for composite input design. Small surface area, every input documented, no surprising side effects. The fetch-depth default of 1 is a common gotcha (commitlint needs full history), but the input is clearly documented and the workaround is one line.

actions/cache — demonstrates key-based invalidation as a first-class concept. The key / restore-keys design makes cache busting explicit rather than magic. The cache-hit output lets callers conditionally skip work.

softprops/action-gh-release — good structured output (assets JSON array), but the files input accepts a glob that silently does nothing if no files match. A strict mode would be safer for most consumers.

dorny/paths-filter — excellent discoverability (README with interactive examples), but grew a very large input surface over time. The more inputs, the harder semver discipline becomes.

Patterns from corporate environments

Internal action catalogs. Large engineering orgs maintain an internal catalog (e.g. a Confluence/Notion page, or a GitHub org-level topic filter) of blessed actions. Discoverability within the org matters as much as on the marketplace.

SHA pinning. Security-sensitive orgs require SHA-pinned references (uses: driftsys/ci/actions/commitlint@<sha>) rather than floating tags. This is compatible with rolling major branches: the org’s Renovate/Dependabot configuration keeps SHAs current automatically.

OIDC over PATs. Actions that push or publish should use OIDC tokens (permissions: id-token: write) rather than long-lived PATs. This limits the blast radius of a compromised workflow.

Least-privilege permissions: blocks. Define permissions: at the job level, not the workflow level. Grant only what the action explicitly needs.

CA certificate bootstrap. Corporate environments often run self-signed or custom CA certs. driftsys/dock’s dock-bootstrap utility handles this transparently. GitLab components that use dock images should call dock-bootstrap in before_script.

Implications for driftsys/ci

Based on the above, we adopt the following concrete rules:

  1. One responsibility per component. commitlint validates commits; release bumps and pushes. They do not double as general-purpose shell runners.
  2. All inputs documented in both the YAML (action.yml / template.yml) and the component’s README.md — the README doubles as the published chapter in the user guide.
  3. Outputs are JSON when the value is structured (e.g. a list of failed commit SHAs).
  4. Rolling v0 branch tracks the latest release; semver tags for pinning.
  5. GitLab variants pin image: to ghcr.io/driftsys/dock:<image>-v<ver> by default; consumers can override via the image input.
  6. Components on GitHub are composite actions (not Docker-based) unless the required tooling is missing from :core/:lint and installing it inline is unreasonable. Presets on GitHub are reusable workflows, since they span multiple jobs with distinct triggers.
  7. Inline shell in action.yml and template.yml rather than calling out to shared scripts/*.sh. GitLab components run with the consumer’s checkout as CWD and can’t reach driftsys/ci/scripts/; inlining keeps each artifact self-contained when published.
  8. GitLab inputs go through variables:. $[[ inputs.x ]] references live in the variables: block; script: bodies use the resulting $VAR only. That keeps script: bodies pure bash and shellcheckable (scripts/lint-gitlab-shell.sh extracts and validates them).
  9. Explicit permissions: blocks at the job level in all provided example workflows.
  10. continue-on-error is never set by default; it is an explicit opt-in input if we add it.

Testing CI/CD frameworks

This note surveys how projects test their own reusable actions and components, evaluates each approach for driftsys/ci, and closes with the strategy we adopt.

What we are testing

Two distinct layers need coverage:

  1. Action / component glue — the action.yml and template.yml files, including the inline shell in their run: / script: blocks. Errors here are usually typos, missing env: bindings, or quoting bugs in the inline shell.
  2. End-to-end behaviour — does the action do the right thing when a real runner executes it against a real repository?

We deliberately inline the shell into the YAML rather than calling out to shared scripts/*.sh files. The reason is portability: a GitLab component running in a consumer’s pipeline has the consumer’s checkout as CWD and cannot reach driftsys/ci/scripts/. Inlining keeps each component self-contained when published.

Approach A: Static checks

What it covers: YAML schema validation of action.yml and template.yml, plus shellcheck on the inline shell in GH Actions via actionlint, plus a yq + shellcheck extractor for GitLab script: lines.

Tooling: check-jsonschema, actionlint, shellcheck, shfmt, dprint, markdownlint-cli2, yq.

Strengths: Fast (seconds), no runner required, catches the most common class of glue errors (wrong field names, invalid YAML structure, shell syntax errors and unquoted-variable bugs in inline run: / script: blocks).

Weaknesses: Doesn’t catch missing env: bindings, wrong uses: path references, or runner-environment mismatches.

Verdict: Necessary and sufficient for layer 1.

Convention enabling the GitLab script: lint

GitLab templates must pipe $[[ inputs.x ]] through variables: and reference the resulting $VAR from script: lines. That keeps script: bodies pure bash, which scripts/lint-gitlab-shell.sh extracts via yq and feeds to shellcheck. Inline $[[ inputs.x ]] inside script: would parse as a bash arithmetic expansion and confuse shellcheck.

Approach B: bash_unit for shared scripts

Verdict for driftsys/ci: Skipped. We inline shell into the YAML rather than share scripts/*.sh between components, so there’s nothing to unit-test in isolation. If a future component needs more than ~10 lines of branching shell, re-introduce scripts/<name>.sh for that one and add a sibling tests/<name>_test.sh.

Approach C: act (local GH Actions emulator)

What it covers: Runs action.yml composite steps locally in a docker container that approximates the GitHub-hosted runner environment.

Tooling: nektos/act.

Weaknesses: Image-fidelity gaps, unreliable composite-action path resolution, Docker-in-Docker maintenance overhead, and mocked context variables that diverge from production.

Verdict for driftsys/ci: Skip in CI. The real runner (Approach D) covers the same ground with higher fidelity. act remains useful as an optional developer tool but is not part of the required test suite.

Approach D: Live GH Actions smoke tests

What it covers: A workflow in this repo’s own CI that invokes each action against a synthesized fixture, on real GitHub-hosted runners.

Tooling: smoke-components.yml workflow in .github/workflows/.

How it works:

# Synthesize a known-good fixture commit, then run the action against it.
- name: Create fixture commit
  run: |
    git checkout -b smoke-fixture
    echo x > .smoke
    git add .smoke
    git commit -m "feat(repo): smoke fixture"
- uses: ./actions/commitlint
  with:
    range: HEAD~1..HEAD

Strengths: Exact same environment as production. Tests the inline shell, action.yml path resolution, and all runner context variables.

Verdict: Essential for every action. Run on every PR and on push to main.

Approach E: GitLab mirror smoke pipeline

What it covers: A GitLab CI pipeline on the GL mirror that includes each component and runs it against a fixture merge request.

Tooling: A .gitlab-ci.yml in the mirror that uses include: component: to pull in the components from the same repo. Triggered on tag push or manually via glab pipeline run.

Strengths: Tests the actual GitLab component YAML syntax, the dock image integration, and $CI_* variable bindings. This is the only way to catch GitLab-specific issues, and the only way to actually execute the inline script: lines.

Weaknesses: Requires a GitLab mirror to be set up first. Slower feedback loop. Limited to what free GitLab CI minutes allow.

Verdict: Required for GitLab components, but blocked on the mirror setup. First green run lands once the mirror is wired.

Approach F: Static GitLab CI lint

What it covers: glab ci lint (or the GitLab API POST /projects/:id/ci/lint) validates template.yml syntax and component spec structure without running a pipeline.

Verdict: Already covered by scripts/schema-check.sh (Approach A). No additional step required.

We adopt a two-layer strategy plus a deferred third (the unit-test layer was dropped when we inlined the scripts).

Layer 1 — Static checks (every PR, < 1 min)

.github/workflows/ci.yml runs each tool as a separate, named step so the GitHub UI surfaces granular pass/fail. The same set of checks is also driven locally by just verify (which runs git std lint --range main..HEAD plus the just lint + just assemble recipes — just is a local-developer convenience and is not used in CI).

Per-PR, in order:

  • ./actions/commitlint — dogfooded against the PR’s own commit range (main..HEAD). On push to main this step is skipped (the commits were already validated by their PR).
  • dprint check — formatting.
  • npx markdownlint-cli2 — markdown structure.
  • shellcheck + shfmt -d — quality of helper scripts under scripts/.
  • actionlint — shellchecks inline shell in every action.yml and the reusable workflows under .github/workflows/.
  • scripts/schema-check.shaction.yml + template.yml schema validation.
  • scripts/lint-gitlab-shell.sh — extracts and shellchecks inline shell in template.yml.
  • mdbook build — book builds without broken refs.

Layer 2 — Live GH Actions smoke (every PR, ~ 1 min)

.github/workflows/smoke-components.yml runs each component against a synthesized fixture on a real GitHub-hosted runner:

  • commitlint (good commit) — happy path, asserts success.
  • commitlint (bad commit — expect failure) — error path, uses continue-on-error: true and asserts steps.<id>.outcome == 'failure'.
  • release (dry-run) — runs git std bump + skips push.

Presets aren’t separately smoked — they compose components that are already covered, and the orchestration (event gates, permissions) is structurally validated by actionlint + schema-check. Add a dedicated preset smoke only if a preset grows non-trivial logic of its own.

Layer 3 — GitLab mirror smoke

The repo’s root .gitlab-ci.yml includes each component at the current SHA and lets GitLab’s normal pipeline rules fire the relevant jobs:

  • Merge-request pipelines run the commitlint job — the only event where $CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD is meaningful.
  • Default-branch pipelines run the release job in dry-run: true mode. This proves the template parses, the inline script: body executes cleanly, and git std bump works against real history without actually pushing.
  • Tag pipelines run the release-notes job, which both publishes the GitLab Release for the tag and registers the components in the GitLab CI Catalog (the release: keyword is what GitLab keys catalog publication off).

Each include: component: uses $CI_SERVER_FQDN/$CI_PROJECT_PATH/<name>@$CI_COMMIT_SHA, so a pipeline always tests the exact revision it was triggered from — no skew between catalog publication and Layer 3 verification.

GitHub stays the source of truth. Sync direction is push-mirror from this repo via .github/workflows/mirror-gitlab.yml, which fires on v* tag push and git push --mirrors every ref in one shot — main, v<MAJOR>, and the freshly-pushed tag all land together. Syncing only at release moments keeps the surface area small.

What we explicitly skip

  • bash_unit for shared scripts: components inline their shell, so there are no shared scripts to unit-test.
  • act in CI: too much maintenance overhead vs. the real runner.
  • gitlab-runner exec: deprecated and not supported on current versions.
  • Contract / schema mutation testing: out of scope for v0.

Applying the strategy

The layers are implemented as follows in this repo:

LayerLocationStatus
1.github/workflows/ci.ymlShipped
2.github/workflows/smoke-components.ymlShipped
3.gitlab-ci.yml on the GL mirrorShipped

Every new component added to this repo must include Layer 2 coverage (a smoke step in smoke-components.yml, happy path + error path). This is enforced by the AGENTS.md checklist for adding a component.