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>.ymlfor 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
| Component | What it does |
|---|---|
| commitlint | Validate commit messages against the Conventional Commits spec, using git std lint. |
| release | Bump semver per the commits since the last tag, commit + tag, then push (git std bump). |
| release-notes | Publish a release page with notes from a tag (GH auto-notes; GL composes from git log). |
Presets
| Preset | What it does |
|---|---|
| standard-release | commitlint 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 latest0.x.y. New optional inputs and bug fixes land automatically; breaking changes wait forv1.@v0.1.0— exact tag, fully reproducible.@~latest(GitLab only) — equivalent of@v0semantics 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
| Name | Required | Default | Description |
|---|---|---|---|
range | yes | — | Range to validate (e.g. main..HEAD). |
git-std-version | no | 0.11.12 | git-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:
| Event | Recommended range | Notes |
|---|---|---|
pull_request | ${{ github.event.pull_request.base.sha }}..HEAD | Lints commits introduced by the PR. Requires fetch-depth: 0. |
merge_group | ${{ github.event.merge_group.base_sha }}..HEAD | Same 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.beforeis0000000000000000000000000000000000000000, sobefore..HEADis invalid. Either guard the step (if: github.event.before != '0000000000000000000000000000000000000000') or fall back to<base-branch>..HEAD. - Force-push.
github.event.beforepoints at the pre-push tip, which may no longer be reachable from the new HEAD. Lint will reportinvalid 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/mergewhose subject isMerge <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: 0on 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
| Name | Required | Default | Description |
|---|---|---|---|
remote | no | origin | Remote to push to. |
dry-run | no | false | If true, bump + tag but skip push. |
git-std-version | no | 0.11.12 | git-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: writepermission 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
| Name | Required | Default | Description |
|---|---|---|---|
tag | no | ${{ github.ref_name }} | Tag to publish a release for. Defaults to the current ref on tag-push events. |
latest | no | auto | true / false / auto. auto lets GitHub decide based on tag semver order. |
draft | no | false | Create the release as a draft. |
prerelease | no | false | Mark 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: writeto publish the release. - Idempotent: if a release for the tag already exists, the step exits successfully without modifying it.
- GitHub auto-attaches source
.tar.gzand.ziparchives 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
| Name | Required | Default | Description |
|---|---|---|---|
range | no | ${{ github.event.pull_request.base.sha }}..HEAD | Commit range commitlint validates on PRs. |
remote | no | origin | Remote the release job pushes to. |
dry-run | no | false | If 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: writeso thereleaseandrelease-notesjobs can push the tag and publish the release page. - Job gates:
commitlintruns onpull_request;releaseandrelease-notesrun onpushtomain. The reusable workflow handles all gating internally. release-notesruns afterreleaseand is skipped whendry-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
| Name | Required | Default | Description |
|---|---|---|---|
range | no | $CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD | Git range to validate. |
image | no | ghcr.io/driftsys/dock:lint-v0.2.0 | Container image with git-std. |
stage | no | test | Pipeline 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: 0is required to access full commit history.- Override
imageto 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
| Name | Required | Default | Description |
|---|---|---|---|
image | no | ghcr.io/driftsys/dock:core-v0.2.0 | Container image. |
stage | no | deploy | Pipeline stage. |
remote | no | origin | Remote to push to. |
dry-run | no | false | Skip 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
imageto 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
| Name | Required | Default | Description |
|---|---|---|---|
image | no | registry.gitlab.com/gitlab-org/release-cli:latest | Container image with release-cli + git. |
stage | no | .post | Pipeline 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_TAGis 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-cliimage rather than adriftsys/dockimage, since the dock catalogue doesn’t shiprelease-clitoday. - The pipeline must be able to fetch full history (
GIT_DEPTH: 0) sogit describecan 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
| Name | Required | Default | Description |
|---|---|---|---|
range | no | $CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD | Commit range commitlint validates on MRs. |
remote | no | origin | Remote the release job pushes to. |
dry-run | no | false | If 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, sostandard-release@v0.2.0reproducibly uses the v0.2.0 sub-components. - The
release-notesjob runs on tag pipelines (afterreleasepushes the tag), not on the default-branch pipeline that thereleasejob 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:
- One responsibility per component. commitlint validates commits; release bumps and pushes. They do not double as general-purpose shell runners.
- All inputs documented in both the YAML (
action.yml/template.yml) and the component’sREADME.md— the README doubles as the published chapter in the user guide. - Outputs are JSON when the value is structured (e.g. a list of failed commit SHAs).
- Rolling
v0branch tracks the latest release; semver tags for pinning. - GitLab variants pin
image:toghcr.io/driftsys/dock:<image>-v<ver>by default; consumers can override via theimageinput. - Components on GitHub are composite actions (not Docker-based) unless the
required tooling is missing from
:core/:lintand installing it inline is unreasonable. Presets on GitHub are reusable workflows, since they span multiple jobs with distinct triggers. - Inline shell in
action.ymlandtemplate.ymlrather than calling out to sharedscripts/*.sh. GitLab components run with the consumer’s checkout as CWD and can’t reachdriftsys/ci/scripts/; inlining keeps each artifact self-contained when published. - GitLab inputs go through
variables:.$[[ inputs.x ]]references live in thevariables:block;script:bodies use the resulting$VARonly. That keepsscript:bodies pure bash and shellcheckable (scripts/lint-gitlab-shell.shextracts and validates them). - Explicit
permissions:blocks at the job level in all provided example workflows. continue-on-erroris 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:
- Action / component glue — the
action.ymlandtemplate.ymlfiles, including the inline shell in theirrun:/script:blocks. Errors here are usually typos, missingenv:bindings, or quoting bugs in the inline shell. - 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.
Recommended strategy for driftsys/ci
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). Onpushto 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 underscripts/.actionlint— shellchecks inline shell in everyaction.ymland the reusable workflows under.github/workflows/.scripts/schema-check.sh—action.yml+template.ymlschema validation.scripts/lint-gitlab-shell.sh— extracts and shellchecks inline shell intemplate.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, usescontinue-on-error: trueand assertssteps.<id>.outcome == 'failure'.release (dry-run)— runsgit 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
commitlintjob — the only event where$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEADis meaningful. - Default-branch pipelines run the
releasejob indry-run: truemode. This proves the template parses, the inlinescript:body executes cleanly, andgit std bumpworks against real history without actually pushing. - Tag pipelines run the
release-notesjob, which both publishes the GitLab Release for the tag and registers the components in the GitLab CI Catalog (therelease: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.
actin 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:
| Layer | Location | Status |
|---|---|---|
| 1 | .github/workflows/ci.yml | Shipped |
| 2 | .github/workflows/smoke-components.yml | Shipped |
| 3 | .gitlab-ci.yml on the GL mirror | Shipped |
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.