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.