Reusable GitHub Actions
See Pipeline Conventions for constraints on how actions are written, tested, and structured.
Validates whether a given version string follows semantic versioning (semver) format.
Location: .github/actions/semver-validation
Usage:
- name: Validate version
id: semver
uses: loft-sh/github-actions/.github/actions/semver-validation@semver-validation/v1
with:
version: '1.2.3'
- name: Check if valid
run: echo "Valid: ${{ steps.semver.outputs.is_valid }}"Inputs:
version(required): Version string to validate
Outputs:
is_valid: Whether the version is valid semver (true/false)parsed_version: JSON object with parsed version componentserror_message: Error message if validation fails
See semver-validation README for detailed documentation.
Syncs Linear issues to the "Released" state when a GitHub release is published. Finds PRs between releases, extracts Linear issue IDs, and moves matching issues from "Ready for Release" to "Released".
Location: .github/actions/linear-release-sync
Usage:
- name: Sync Linear issues
uses: loft-sh/github-actions/.github/actions/linear-release-sync@linear-release-sync/v1
with:
release-tag: ${{ needs.publish.outputs.release_version }}
repo-name: my-repo
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
linear-token: ${{ secrets.LINEAR_TOKEN }}See linear-release-sync README for detailed documentation.
Runs Ginkgo tests with directory or label-based filtering and generates a JSON failure summary. Runtime-agnostic — callers handle their own cluster and image setup (vind, Kind, bare Docker).
Location: .github/actions/run-ginkgo
Usage:
- name: Run E2E tests
id: e2e
uses: loft-sh/github-actions/.github/actions/run-ginkgo@run-ginkgo/v1
with:
ginkgo-label: "my-suite && !non-default"
test-image: ghcr.io/loft-sh/vcluster:dev
# test-image-flag: "--platform-image" # default: --vcluster-image
# additional-ginkgo-flags: "-v --skip-package=linters"
# additional-args: "--use-license-server=false"
- name: Notify on failure
if: failure()
uses: loft-sh/github-actions/.github/actions/ci-test-notify@ci-test-notify/v1
with:
test-name: "E2E Tests"
status: failure
details: ${{ steps.e2e.outputs.failure-summary }}
webhook-url: ${{ secrets.SLACK_WEBHOOK }}Inputs:
| Input | Required | Default | Description |
|---|---|---|---|
test-image |
yes | Image passed to the test binary | |
test-image-flag |
no | --vcluster-image |
CLI flag name for the image |
timeout |
no | 60m |
Ginkgo test timeout |
procs |
no | 8 |
Parallel Ginkgo processes |
test-dir |
no | Directory-based test selection (mutually exclusive with ginkgo-label) |
|
ginkgo-label |
no | Label-based test selection (mutually exclusive with test-dir) |
|
append-pr-label |
no | true |
Append || pr to the label filter |
e2e-dir |
no | e2e-next |
Root test directory |
additional-args |
no | Extra args for the test binary (after --) |
|
additional-ginkgo-flags |
no | Extra ginkgo CLI flags |
Outputs:
failure-summary: Markdown-formatted test results summary
Upserts a sticky comment on a pull request, identified by a stable HTML marker. If a comment with the marker already exists it is updated in place, otherwise a new comment is created. Domain-agnostic — the caller composes the body. Useful for surfacing the last real run of a CI signal that the caller skips on some events (e.g. e2e tests skipped when PR description is unchanged), so reviewers always see the most recent meaningful result.
Location: .github/actions/sticky-pr-comment
Usage:
jobs:
e2e:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Run tests
id: tests
run: ./run-tests.sh
- name: Upsert sticky status comment
if: always() && github.event_name == 'pull_request'
uses: loft-sh/github-actions/.github/actions/sticky-pr-comment@sticky-pr-comment/v1
with:
marker: '<!-- e2e-status -->'
body: |
### E2E Tests
Status: ${{ steps.tests.outcome }}
Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}Inputs:
marker(required): HTML comment uniquely identifying this comment stream (form<!-- some-id -->)body(required): markdown body (the marker is auto-prepended when missing)pr-number(optional, default: current PR)repo(optional, default: current repo)github-token(required): token withpull-requests: write
Outputs:
comment-id: numeric ID of the upserted commentaction-taken:createdorupdated
The action is intended to be invoked from inside the job whose status it
reports — when that job is skipped via if:, the upsert never runs and the
previous comment stays in place, which is the desired "preserve last real
result" behavior. See the action README for full details.
Validates Renovate configuration files when they change in a pull request.
Location: .github/workflows/validate-renovate.yaml
Usage:
name: Validate Renovate Config
on:
pull_request:
jobs:
validate-renovate:
uses: loft-sh/github-actions/.github/workflows/validate-renovate.yaml@mainDetected config files: renovate.json, renovate.json5, .renovaterc, .renovaterc.json, .github/renovate.json, .github/renovate.json5.
Approves (and optionally enables auto-merge on) PRs from trusted bot accounts
whose title or branch matches a known safe pattern (chore: / fix(deps): /
backport/ / renovate/ / update-platform-version-). Hardened to never
block caller CI: continue-on-error: true on the job, every shell step
catches its own errors and exits 0, self-approval is pre-empted before calling
the external approve action.
Location: .github/workflows/auto-approve-bot-prs.yaml
Usage:
name: Auto-approve bot PRs
on:
pull_request:
types: [opened, synchronize]
jobs:
auto-approve:
permissions:
pull-requests: write
contents: read
uses: loft-sh/github-actions/.github/workflows/auto-approve-bot-prs.yaml@main
with:
trusted-authors: 'renovate[bot],loft-bot,github-actions[bot],dependabot[bot]'
auto-merge: false
secrets:
gh-access-token: ${{ secrets.GH_ACCESS_TOKEN }}gh-access-token must be a PAT whose identity differs from PR authors you want
to auto-approve (GitHub forbids self-review). When identity matches, the job
skips gracefully instead of failing.
End-to-end coverage: scenario-level e2e lives in vClusterLabs-Experiments/auto-approve-e2e. Runs weekly and on demand. Creates real PRs exercising every decision-table branch (chore/fix(deps) titles, backport/renovate/update-platform-version branches, ineligible titles) and asserts the never-hard-fail invariant.
Small reusable building block: run an AI call with a caller-supplied prompt
and input, bind the output to a JSON Schema, expose the schema-conforming
JSON as a step output. Downstream steps parse with fromJSON(...) and
branch on typed fields.
Structured output is the contract. Whatever the model returns is exposed
on result and conclusion=success. The action never emits failed — the
caller knows what empty output means for their pipeline.
Location: .github/actions/ai-step
Usage:
- uses: actions/checkout@v4
with:
repository: loft-sh/github-actions
ref: ai-step/v1
sparse-checkout: .github/actions/ai-step
- id: classify
uses: ./.github/actions/ai-step
with:
provider: anthropic
effort: low
prompt: 'Classify this diff. Return JSON matching the schema.'
input: ${{ steps.diff.outputs.text }}
output-schema: |
{
"type": "object",
"required": ["severity", "areas"],
"properties": {
"severity": { "type": "string", "enum": ["low","medium","high"] },
"areas": { "type": "array", "items": { "type": "string" } }
}
}
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
- if: fromJSON(steps.classify.outputs.result).severity == 'high'
run: echo "needs human review"See ai-step README for inputs, outputs, and provider asymmetries.
Lints GitHub Actions workflow files using actionlint with reviewdog integration.
Location: .github/workflows/actionlint.yaml
Usage:
name: Actionlint
on:
pull_request:
jobs:
actionlint:
uses: loft-sh/github-actions/.github/workflows/actionlint.yaml@mainInputs:
reporter(optional, default:github-pr-review): reviewdog reporter type
Packages a Helm chart and pushes one tarball per version to ChartMuseum.
Handles release pushes (single semver, optional --app-version) and head
pushes (multiple 0.0.0-* versions) under the same contract. Optionally
re-pushes the repo's highest semver afterwards so it stays first in the
upload-ordered ChartMuseum index.
Location: .github/actions/publish-helm-chart
Usage (release push):
jobs:
publish-chart:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v1.2.3
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2
with:
chart-name: vcluster
app-version: 1.2.3
chart-versions: '["1.2.3"]'
chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }}
chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }}Usage (head/dev push):
jobs:
push-head-chart:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2
with:
chart-name: vcluster-head
chart-description: "vCluster HEAD - Development builds from main branch"
app-version: head-${{ github.sha }}
chart-versions: '["0.0.0-latest","0.0.0-${{ github.sha }}"]'
chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }}
chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }}Inputs:
chart-name(required): chart name written toChart.yamland used in the tarball filenamechart-description(optional): value written to.descriptioninChart.yamlapp-version(optional): passed as--app-versiontohelm packagechart-versions(required): JSON array of versions, e.g.'["1.2.3"]'chart-directory(optional, default:chart): chart source pathvalues-edits(optional): newline-separatedjsonpath=valuepairs applied via yq to<chart-directory>/values.yamlhelm-version(optional, default:v4.1.4)republish-latest(optional, default:"false"): re-push highest semver to keep it first in the ChartMuseum indexchart-museum-url(optional, default:https://charts.loft.sh/)chart-museum-user(required)chart-museum-password(required)
Note: The ref input was removed — the caller owns actions/checkout and checks out the desired ref directly.
Runs govulncheck
against a Go module and, on scheduled runs, posts a Slack notification
(via ci-test-notify) when vulnerabilities are found. The scan always
marks the job failed on vulnerabilities — notification is the side
channel, not the gate.
Location: .github/actions/govulncheck
Usage (public repo, weekly schedule):
name: govulncheck
on:
schedule:
- cron: "0 12 * * 1" # Mon 12:00 UTC
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/govulncheck.yaml"
jobs:
scan:
runs-on: ubuntu-latest
if: github.repository_owner == 'loft-sh'
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/govulncheck@govulncheck/v1
with:
slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_CI_TESTS_ALERTS }}Usage (private repo that depends on github.com/loft-sh/*):
jobs:
scan:
runs-on: ubuntu-latest
if: github.repository_owner == 'loft-sh'
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/govulncheck@govulncheck/v1
with:
scan-paths: "./... ./cmd/..."
private-repo: "true"
gh-access-token: ${{ secrets.GH_ACCESS_TOKEN }}
slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_CI_TESTS_ALERTS }}Inputs:
scan-paths(optional, default:./...): space-separated Go package patternstest-flag(optional, default:true): pass-testto govulncheckgo-version-file(optional, default:go.mod): passed toactions/setup-goprivate-repo(optional, default:false): enable git url rewrite +GOPRIVATEgoprivate(optional, default:github.com/loft-sh/*)govulncheck-version(optional, default:latest)test-name(optional, default:govulncheck): Slack headernotify(optional, default:true): send Slack on vulnerabilities; fires onscheduleevents onlygh-access-token(required whenprivate-repo: true)slack-webhook-url(required whennotify: trueand the run is onschedule)
Notes:
- The caller checks out its own source and controls
runs-on/timeout-minutes/fork guarding at the job level. - A composite action cannot declare
timeout-minuteson its steps; settimeout-minuteson the caller job (default ~10m is reasonable for most modules).
Run all action tests locally:
make testRun tests for a specific action:
make test-semver-validation
make test-linear-pr-commenter
make test-linear-release-syncRun linters (actionlint + zizmor):
make lintSee all available targets:
make helpEach testable action has a dedicated workflow that runs its tests on PRs when the action's files change:
test-semver-validation.yaml- triggers on.github/actions/semver-validation/**test-linear-pr-commenter.yaml- triggers on.github/actions/linear-pr-commenter/**test-linear-release-sync.yaml- triggers on.github/actions/linear-release-sync/**test-sticky-pr-comment.yaml- triggers on.github/actions/sticky-pr-comment/**release-linear-release-sync.yaml- builds and publishes the binary on tag push orworkflow_dispatch
Each reusable workflow (workflow_call) also has a smoke/integration test
workflow that triggers on PRs when the workflow file changes:
test-validate-renovate.yaml- callsvalidate-renovate.yamlwith local ref. Note: When triggered by workflow YAML changes alone, the innerpaths-filterwon't match any renovate config files sonpx renovate-config-validatornever runs. The validator only exercises its full path whenrenovate.jsonis also changed.test-detect-changes.yaml- callsdetect-changes.yamland asserts outputs (true/false)test-actionlint-workflow.yaml- callsactionlint.yamlwithgithub-pr-checkreporter (PR-only). Note:actionlint.yamlskips fork PRs silently; the verify job emits a warning when this happens.test-backport.yaml- callsbackport.yamland asserts the result isskippedtest-clean-github-cache.yaml- callsclean-github-cache.yaml(PR-only, since the underlying workflow needsgithub.event.pull_request.number)test-cleanup-backport-branches.yaml- callscleanup-backport-branches.yamlwithdry-run: truetest-conflict-check.yaml- callsconflict-check.yamland asserts success or skippedtest-claude-code-review.yaml- callsclaude-code-review.yamlto validate workflow is callabletest-claude.yaml- callsclaude.yamland assertsskipped(no@claudecomment event)test-notify-release.yaml- callsnotify-release.yamlwith dummy inputs to validate the contract
Post-merge, dispatch-integration-tests.yaml triggers full E2E tests in
vClusterLabs-Experiments/github-actions-test.
-
Node.js actions - add a
test/directory with Jest tests. Seesemver-validation/test/index.test.jsfor the pattern: spawn the action'sindex.jswithINPUT_*env vars and a tempGITHUB_OUTPUTfile, then assert on the parsed outputs. -
Go actions - add
*_test.gofiles next to the source. Seelinear-pr-commenter/src/main_test.go. Use standardgo test. -
Composite actions (YAML-only like
release-notification) - these delegate to third-party actions and have no local business logic to unit test. Validate their YAML structure through actionlint instead. -
Add a Makefile target for the new action following the existing pattern.
-
Add a CI workflow at
.github/workflows/test-<action-name>.yamlwith apathsfilter scoped to the action's directory. -
Add
AUTO-DOC-INPUT/AUTO-DOC-OUTPUTmarkers to the action'sREADME.mdand runmake generate-docs(see Documentation).
Action and reusable workflow documentation is auto-generated from
action.yml / workflow YAML using tj-actions/auto-doc.
Each action README and each workflow doc in docs/workflows/ contains
AUTO-DOC-INPUT, AUTO-DOC-OUTPUT, and AUTO-DOC-SECRETS marker comments
that are filled in by the tool.
Regenerate all docs locally:
make generate-docsVerify docs are up to date (CI runs this on every PR):
make check-docsInstall the auto-doc binary only (downloaded to .bin/):
make install-auto-docReusable workflow documentation lives in docs/workflows/<workflow-name>.md.
Each file maps 1:1 to a workflow_call workflow in .github/workflows/.
-
Action -- add
## Inputsand## Outputssections with marker comments to the action'sREADME.md:## Inputs <!-- AUTO-DOC-INPUT:START - Do not remove or modify this section --> <!-- AUTO-DOC-INPUT:END --> ## Outputs <!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section --> <!-- AUTO-DOC-OUTPUT:END -->
-
Reusable workflow -- create
docs/workflows/<name>.mdwith## Inputs,## Outputs(if applicable), and## Secretsmarker sections. -
Run
make generate-docsand commit the result.
The existing release-notification action uses a repository-wide tag:
git tag -f v1
git push origin v1 --forceReferenced as:
uses: loft-sh/github-actions/release-notification@v1For all new actions, we use action-specific tags for independent versioning:
# For the ci-notify-nightly-tests action
git tag -f ci-notify-nightly-tests/v1
git push origin ci-notify-nightly-tests/v1 --force
# For the semver-validation action
git tag -f semver-validation/v1
git push origin semver-validation/v1 --force
# For other actions, follow the same pattern
git tag -f action-name/v1
git push origin action-name/v1 --force# Reference actions using their specific tag
uses: loft-sh/github-actions/.github/actions/ci-notify-nightly-tests@ci-notify-nightly-tests/v1
uses: loft-sh/github-actions/.github/actions/semver-validation@semver-validation/v1