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.
Links sorenlouv-created backport PRs to the matching Linear sub-issue ([X.Y] Copy of ...) by adding Fixes <id> to the backport PR body, so the per-release-line issue closes when the backport merges. Wired into the backport.yaml reusable workflow and runs automatically after backports are created; advisory and skipped when no linear-token is configured.
Location: .github/actions/link-backport-prs
Usage:
- uses: loft-sh/github-actions/.github/actions/link-backport-prs@link-backport-prs/v1
with:
source-pr: ${{ github.event.pull_request.number }}
repo-owner: ${{ github.repository_owner }}
repo-name: ${{ github.event.repository.name }}
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
linear-token: ${{ secrets.LINEAR_API_TOKEN }}See link-backport-prs 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.
Parses the ```label-filter``` fenced block from a PR description, resolves
the Ginkgo label filter, and decides whether a pull_request edited event
can be skipped. E2E workflows trigger on edited so editing the label-filter
re-targets suites without a new commit, but that also re-runs the suite when a
bot (e.g. cursor[bot]) edits the PR description. The action returns
skip-edited=true when the label-filter block is unchanged across an edit, so
the caller can skip the suite. Side-effect free.
Location: .github/actions/parse-label-filter
Usage:
jobs:
parse-label-filter:
runs-on: ubuntu-22.04
outputs:
label-filter: ${{ steps.parse.outputs.label-filter }}
skip-edited: ${{ steps.parse.outputs.skip-edited }}
steps:
- name: Parse label filter
id: parse
uses: loft-sh/github-actions/.github/actions/parse-label-filter@parse-label-filter/v1
with:
pr-body: ${{ github.event.pull_request.body }}
previous-pr-body: ${{ github.event.changes.body.from }}
event-name: ${{ github.event_name }}
event-action: ${{ github.event.action }}
label-filter-input: ${{ inputs.ginkgo-label }}
e2e-tests:
needs: [parse-label-filter]
if: needs.parse-label-filter.outputs.skip-edited != 'true'
runs-on: large-8_32
steps:
- run: echo "filter ${{ needs.parse-label-filter.outputs.label-filter }}"Inputs:
pr-body(optional): current PR description (${{ github.event.pull_request.body }})previous-pr-body(optional): PR description before an edit (${{ github.event.changes.body.from }})event-name(optional):${{ github.event_name }}event-action(optional):${{ github.event.action }}label-filter-input(optional): manual-dispatch fallback (${{ inputs.ginkgo-label }})
Outputs:
label-filter: resolved filter (parsed block, else dispatch input, elsepr)skip-edited:trueonly for aneditedevent whose label-filter is unchanged
Pair with a concurrency group that splits edited from code events
(...-${{ github.event.action == 'edited' && 'edited' || 'code' }}) so a bot
edit cannot cancel a still-running code run and then skip. See the action
README for full details.
Sends a repository_dispatch event to a target repository so any source repo
can trigger any event type with one mechanical step. Domain-agnostic — the
caller chooses the event-type and payload schema, the receiver routes on
them. Foundation for cross-repo triggers (release fan-out, downstream test
runs).
Location: .github/actions/repository-dispatch
Usage:
- name: Notify vcluster-docs of release
uses: loft-sh/github-actions/.github/actions/repository-dispatch@repository-dispatch/v1
with:
target-repo: loft-sh/vcluster-docs
event-type: vcluster-released
payload: |
{
"version": "${{ github.ref_name }}",
"sha": "${{ github.sha }}"
}
env:
GH_TOKEN: ${{ secrets.CROSS_REPO_DISPATCH_TOKEN }}Inputs:
target-repo(required):<owner>/<repo>of the receiverevent-type(required): matched against the receiver'son.repository_dispatch.typespayload(optional, default{}): JSON object sent asclient_payload
GH_TOKEN is read from the step's environment, not from inputs — it must be
a PAT or GitHub App token with repo scope on the target. See the action
README for full details.
Applies or lifts a temporary code freeze on a release branch by managing a repository ruleset with the "Restrict updates" rule. During the freeze only a bypass team can merge into the branch; unfreeze disables the ruleset so the branch returns to its standing rules. One reusable ruleset per repo is re-pointed at the branch being released, so only that branch is frozen.
Location: .github/actions/release-branch-freeze
Usage:
- name: Freeze the release branch
uses: loft-sh/github-actions/.github/actions/release-branch-freeze@release-branch-freeze/v1
with:
operation: freeze
repository: ${{ github.repository }}
branch: ${{ github.event.ref }}
bypass-team-id: "16898535" # loft-sh/Eng-Tech-Leads
env:
GH_TOKEN: ${{ secrets.CODE_FREEZE_TOKEN }}Inputs:
operation(required):freezeorunfreezerepository(required):<owner>/<repo>to managebranch(freeze only): release branch to freeze, e.g.v0.36bypass-team-id(freeze only): numeric team id allowed to merge during the freezeenforcement(optional, defaultactive):active,evaluate(dry run), ordisabled
GH_TOKEN is read from the step's environment, not from inputs. It must be a
PAT or GitHub App token with Administration read and write on the target repo.
See the action README for full details.
Single entry point for cutting a vCluster release on any supported line. The
version string decides the routing (legacy < v0.36 fans out to both
loft-sh/vcluster and loft-sh/vcluster-pro; monorepo >= v0.36 dispatches
loft-sh/vcluster-pro only). Creates the tag(s) and dispatches each line's own
release.yaml; the GitHub Release is a pipeline output, not a trigger.
Location: .github/actions/vcluster-release
Usage:
- uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v1
with:
version: ${{ inputs.version }}
dry-run: ${{ inputs.dry_run }}
github-token: ${{ secrets.GH_ACCESS_TOKEN }}Inputs:
version(required): release version, e.g.v0.35.4orv0.36.2dry-run(optional, defaulttrue): run read-only routing checks and print the tag + dispatch calls without firing themgithub-token(required): PAT/App token withrepo+workflowscope on bothloft-sh/vclusterandloft-sh/vcluster-pro
See the action README for routing and guard details.
Mirrors a monorepo subtree to a downstream OSS repository. Fast-forward-only for release lines; marker-guarded force push for the mirror branch so contributions merged directly on the OSS repo are never silently destroyed. On divergence it fails closed, sets diverged=true, and leaves the OSS branch untouched.
Location: .github/actions/subtree-mirror
Usage:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- id: mirror
uses: loft-sh/github-actions/.github/actions/subtree-mirror@subtree-mirror/v1
with:
subtree-prefix: staging/github.com/loft-sh/vcluster
oss-repo: loft-sh/vcluster
branch: ${{ github.ref_name }}
force: ${{ github.ref_name == 'main' }}
github-token: ${{ secrets.GH_ACCESS_TOKEN }}Inputs:
subtree-prefix(required): Subtree path within this repo. Requiresfetch-depth: 0.oss-repo(required): Downstream repo as<owner>/<repo>.branch(required): Target branch on the OSS repo (usuallygithub.ref_name).github-token(required): Token with write access to the OSS repo.force(optional, defaultfalse):true= marker-guarded force push;false= fast-forward-only.marker-ref(optional, defaultrefs/sync/mirror-head): Ref tracking the last mirrored SHA. Force mode only.allow-divergent-force(optional, defaultfalse): Bypass the divergence guard. Force mode only.
Outputs:
diverged:truewhen the OSS branch had unmirrored commits and the force push was refused.pushed:truewhen a push was performed.split-sha: The subtree split SHA that was (or would have been) pushed.
See subtree-mirror README for detailed documentation.
Successor to Subtree Mirror. Bidirectional per-commit sync between a monorepo subtree and a downstream OSS repository: replays each commit's diff (3-way, re-rooted) preserving author, date, and message, and links the two histories with Monorepo-Commit / Oss-Commit trailers as the only sync state. Incremental (O(new commits), no git subtree split), append-only (never force-pushes), fails closed on divergence and conflicts.
Location: .github/actions/oss-commit-sync
Usage:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
# monorepo -> OSS (on push touching the subtree)
- id: sync
uses: loft-sh/github-actions/.github/actions/oss-commit-sync@oss-commit-sync/v1
with:
direction: export
subtree-prefix: staging/github.com/loft-sh/vcluster
oss-repo: loft-sh/vcluster
branch: ${{ github.ref_name }}
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
# OSS -> monorepo PR branch (on cron / divergence dispatch)
- id: import
uses: loft-sh/github-actions/.github/actions/oss-commit-sync@oss-commit-sync/v1
with:
direction: import
subtree-prefix: staging/github.com/loft-sh/vcluster
oss-repo: loft-sh/vcluster
branch: main
github-token: ${{ secrets.GH_ACCESS_TOKEN }}Key inputs: direction (export/import), subtree-prefix, oss-repo, branch, github-token; align-tree (export escape hatch: append one snapshot alignment commit on tree drift), exclude-paths (import: OSS-only paths dropped during replay), seed-monorepo-commit/seed-oss-commit (first run only).
Key outputs: export: pushed, diverged, exported-count, oss-tip; import: has-changes, replayed-count, skipped-count, conflict-sha, pr-branch.
See oss-commit-sync README for the full contract, safety mechanisms, and migration steps.
Blocks until a GitHub Release for a version exists in another repository, for pipelines where one repo's build uploads assets into a release that another repo's build creates. Presence polling alone cannot tell "the producer is still building" apart from "the producer already failed", so the optional workflow input makes the wait status-aware: a producer run that has already concluded unsuccessfully fails the wait immediately, with that run's URL and the recovery order, instead of spending the whole timeout on a precondition that can never be met.
Location: .github/actions/wait-for-release
Usage:
- uses: loft-sh/github-actions/.github/actions/wait-for-release@wait-for-release/v1
with:
repo: loft-sh/vcluster
version: ${{ steps.get_version.outputs.release_version }}
workflow: release.yaml
github-token: ${{ secrets.GH_ACCESS_TOKEN }}Inputs:
repo(required): Repository publishing the release, as<owner>/<repo>.version(required): Release tag to wait for, e.g.v0.36.1-rc.2.workflow(optional): Workflow file inrepothat produces the release. Enables fail-fast on an already-failed producer run. Omit for a plain presence poll.max-attempts(optional, default120): Polls before giving up. Wall-clock ceiling ismax-attempts x interval-seconds.interval-seconds(optional, default15): Seconds between polls.max-api-failures(optional, default5): Consecutive API failures tolerated, so one blip cannot fail a release.github-token(required): Token withcontents:readonrepo, plusactions:readwhenworkflowis set.
Outputs:
waited-seconds: Approximate seconds spent waiting before the release appeared.release-url: URL of the release that was found.
See wait-for-release README for the full behaviour table.
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 merges) 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.
With auto-merge: true the merge is performed directly, with GitHub's
auto-merge queue only as a fallback — so gh-access-token needs a merge path on
the base branch, not just the repository's auto-merge toggle. See the action's
README.
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
checks: read # required — CI poll reads /commits/:sha/check-runs
statuses: read # required — CI poll reads /commits/:sha/status
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.
Unless you pass the ci-read-token secret, checks: read and statuses: read
are not optional, and the called workflow cannot supply them for you — GitHub only lets a reusable
workflow downgrade the caller's GITHUB_TOKEN permissions, never elevate them,
and anything you omit defaults to none. Omit them and the CI poll 403s, the
action default-denies, and the PR is never approved while both the check and the
job still report success. See
auto-approve-bot-prs/README.md
for why the approving PAT cannot be used for these reads (fine-grained PATs have
no Checks permission at all).
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.
Retags moving Docker tags onto an already-published version with
digest-preserving crane tag, and can promote the caller's own GitHub Release,
a paired public release, and a Homebrew formula. It is stable-version-only and
guards every moving pointer against backport regressions.
Location: .github/actions/promote-release
See promote-release README for
the supported workflow_dispatch wiring, inputs, token scopes, and full safety
contract. Keeping the copy-paste example there avoids two documentation sources
drifting apart.
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).
Runs Checkov against infrastructure as code, open
source packages, container images, and CI/CD configurations. This is a local
copy of bridgecrewio/checkov-action
(Apache-2.0) vendored verbatim — same inputs, outputs, and behavior. We host it
here because the upstream action publishes too many git tags for Renovate to
enumerate releases; the vendored copy pins the checkov Docker image
(docker://ghcr.io/bridgecrewio/checkov:<tag>) directly, which Renovate's
built-in github-actions manager keeps up to date via the runs.image
reference.
Location: .github/actions/checkov (see LICENSE and NOTICE there)
Usage:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/checkov@checkov/v1
with:
directory: .
framework: terraform
soft_fail: "true"
output_format: cliInputs/outputs: identical to the upstream action — see its
input table. Common
inputs: directory (default .), file, framework, skip_check, check,
soft_fail, quiet, compact, config_file, output_format (default
sarif), output_file_path. The single output is results.
Notes:
- This is a Docker action; it does not need a
checkout-installed toolchain, but the caller must still check out the code it wants scanned. - To bump checkov, let Renovate update the image tag in
action.yml— do not re-point callers at the upstream action.
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-link-backport-prs.yaml- triggers on.github/actions/link-backport-prs/**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