Canonical home for the reusable GitHub Actions workflows and composite actions shared across the kethalia, chillwhales, and phlox-labs orgs. Consumers reference workflows via absolute uses: (e.g. uses: kethalia/workflows/.github/workflows/ci-build-lint-test.yml@<version>) — no per-repo copies, one place to fix, one place to evolve.
Always pin to a specific released version (e.g.
@v1.0.0). The examples in this README use@<version>as a placeholder — substitute the tag you intend to consume. See Versioning for the available ref styles and recommendations.
See also: .github/docs/RUNNER-TIERING.md for the heavy/light runner resolution model used by resolve-runner.yml and downstream consumers.
- Consumer-side alias pattern
- Versioning
- Workflows
- Reusable — Build and push Docker image to GHCR —
build-and-push.yml - Reusable — Build stack of Docker images —
build-stack.yml - CI — Build, Lint & Test —
ci-build-lint-test.yml - CI — Changeset Check —
ci-changeset-check.yml - CI — Publish Validation —
ci-publish-validation.yml - CI — Quality Checks —
ci-quality.yml - Reusable — GHCR retention prune —
ghcr-prune.yml - CI — Helm Lint & Template —
helm-lint.yml - Publish — Docker image to GHCR —
publish-docker-ghcr.yml - Release — Changesets —
release-changesets.yml - Release — Docker stack —
release-docker-stack.yml - Reusable — Resolve runner labels —
resolve-runner.yml - Retag — Single GHCR image —
retag-image.yml - Smoke — retag-image —
internal-retag-smoke.yml - Retag — Docker stack (promote on release) —
retag-stack.yml - Reusable — Verify GHCR tags —
verify-ghcr-tags.yml - Reusable — Visual Regression Tests —
reusable-visual-tests.yml - Reusable — Update Visual Snapshots (Dispatcher) —
update-snapshots.yml
- Reusable — Build and push Docker image to GHCR —
Wrap each shared workflow you consume in a thin local alias under .github/workflows/ in the consumer repo. The wrapper centralizes the pin so the eventual version bump is one line per consumer, not N lines:
name: ci
on: [push, pull_request]
jobs:
ci:
uses: kethalia/workflows/.github/workflows/ci-build-lint-test.yml@<version>
with:
build-command: pnpm build
artifact-paths: |
distWhen you upgrade, you change the uses: line in this wrapper to the new tag and every workflow run in that repo picks it up — no edits to caller jobs, no PR sprawl.
Pin to a specific released version. Examples in this README use @<version> as a placeholder — replace it with a real tag (e.g. @v1.0.0) before committing.
Releases are cut by Changesets. On push to main, internal-release.yml opens (or updates) a chore(release): version packages PR. Merging that PR:
- Bumps
package.json, regeneratesCHANGELOG.md, and runsscripts/sync-workflow-refs.mjsso every internaluses: kethalia/workflows/...@<ref>cross-reference in this repo is rewritten to the new@vX.Y.Z. The released tag therefore references its own actions and workflows at the same version — no drift inside a release. - Creates the immutable
vX.Y.Ztag. - The
tag-majorjob force-moves the floatingvXandvX.Ytags to the same SHA so consumers can opt into non-breaking updates by pinning to a moving major or minor.
Pinning recommendations for consumers, in order of preference:
@vX.Y.Z(e.g.@v1.0.0) — recommended. Fully reproducible; CI runs are deterministic and a forced retag of a major or minor cannot silently change behavior.@vX.Y(e.g.@v1.0) — receives patch fixes automatically; no minor or major drift. Acceptable when you trust the patch promise.@vX(e.g.@v1) — receives all non-breaking changes. Convenient, but a minor release that introduces a regression hits every consumer at once.@main— not recommended. Because internaluses:refs are pinned by the version PR,mainreferences the previous release's actions until the next release PR rewrites them. Use only for ad-hoc testing of unreleased changes.
Breaking changes ship as a new major (v2, v3, ...) and are announced via the Changesets release notes before merging.
File: build-and-push.yml. Builds a Docker image with a registry-backed BuildKit cache and optionally pushes it to GHCR. When push: false, builds only and skips cache-to (read-only tokens cannot export cache).
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
image |
string | true | — | GHCR image name without owner prefix or tag (e.g. "chillpass-smoke"). Lowercased automatically. |
tags |
string | true | — | Newline-separated full tag refs to apply (e.g. "ghcr.io/chillwhales/chillpass-smoke:smoke-abc1234"). |
context |
string | false | . |
Docker build context path. |
dockerfile |
string | false | Dockerfile |
Path to the Dockerfile, relative to the build context. |
platforms |
string | false | linux/amd64 |
Comma-separated build platforms. |
build-args |
string | false | "" |
Newline-separated KEY=VALUE build args. |
push |
boolean | false | true |
Whether to push tags and export build cache. Set false for fork PR validation builds. |
jobs:
build:
permissions:
contents: read
packages: write
uses: kethalia/workflows/.github/workflows/build-and-push.yml@<version>
with:
image: chillpass-smoke
tags: |
ghcr.io/${{ github.repository_owner }}/chillpass-smoke:smoke-${{ github.sha }}File: build-stack.yml. Fans out a matrix build of multiple GHCR images from a single JSON service spec. Calls build-and-push.yml per service.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
services |
string | true | — | JSON array of services to build. Each entry supports: image (required) — GHCR image name (no owner, no tag). Lowercased downstream. context (optional) — Docker build context. Default ".". dockerfile (optional) — Dockerfile path relative to context. Default "Dockerfile". platforms (optional) — Comma-separated. Default "linux/amd64". build-args (optional) — Newline-separated KEY=VALUE. Default "". |
push |
boolean | true | — | Whether to push tags and export build cache. Set false for fork-PR validation. Typically: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository |
jobs:
build-stack:
permissions:
contents: read
packages: write
uses: kethalia/workflows/.github/workflows/build-stack.yml@<version>
with:
services: |
[
{ "image": "chillpass-api", "context": "apps/api" },
{ "image": "chillpass-web", "context": "apps/web" }
]
push: ${{ github.event_name == 'push' }}File: ci-build-lint-test.yml. Build → lint → format → typecheck → test (matrix) pipeline with optional incremental build cache and artifact upload.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node-version |
number | false | 22 |
Node.js version |
build-command |
string | true | — | Build command (e.g., "pnpm build") |
lint-command |
string | false | "" |
Lint command (e.g., "pnpm lint") |
format-command |
string | false | "" |
Format check command (e.g., "pnpm format:check") |
test-command |
string | false | "" |
Test command (e.g., "pnpm test:coverage") |
typecheck-command |
string | false | "" |
Typecheck command (e.g., "pnpm typecheck") |
pre-lint-command |
string | false | "" |
Command to run before lint (e.g., build dependency packages) |
test-node-versions |
string | false | "[20, 22]" |
JSON array of Node versions for test matrix |
artifact-paths |
string | true | — | Newline-separated paths to upload after build |
coverage-artifact-name |
string | false | coverage-reports |
Name for coverage artifact |
build-cache-paths |
string | false | "" |
Newline-separated paths to cache across builds (e.g., .next/cache). Cache is keyed on the lockfile + source file hashes so incremental builds reuse prior compilation output. Leave empty to disable. |
build-cache-key-files |
string | false | **/*.[jt]s\n**/*.[jt]sx |
Glob(s) for source files whose hash invalidates the build cache. Defaults to common JS/TS source patterns. |
jobs:
ci:
uses: kethalia/workflows/.github/workflows/ci-build-lint-test.yml@<version>
with:
build-command: pnpm build
artifact-paths: |
distFile: ci-changeset-check.yml. Verifies that a PR includes (or intentionally skips) a changeset entry against the base branch.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node-version |
number | false | 22 |
Node.js version |
base-branch |
string | false | main |
Base branch for changeset comparison |
jobs:
changeset-check:
uses: kethalia/workflows/.github/workflows/ci-changeset-check.yml@<version>File: ci-publish-validation.yml. Validates package publish readiness and (optionally) publishes pkg-pr-new previews for selected packages.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node-version |
number | false | 22 |
Node.js version |
preview-packages |
string | false | "" |
Space-separated list of package directories for pkg-pr-new preview |
jobs:
publish-validation:
uses: kethalia/workflows/.github/workflows/ci-publish-validation.yml@<version>File: ci-quality.yml. Aggregate quality gate: package verify, changeset status (PRs), audit, sherif, knip, madge — each opt-in.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node-version |
number | false | 22 |
Node.js version |
verify-command |
string | false | "" |
Package verification command (e.g., "pnpm validate:publish") |
changeset-check |
boolean | false | true |
Whether to run changeset status check on PRs |
base-branch |
string | false | main |
Base branch for changeset comparison (e.g., "main") |
audit |
boolean | false | true |
Whether to run pnpm audit |
audit-level |
string | false | critical |
pnpm audit severity threshold (low/moderate/high/critical) |
sherif-command |
string | false | "" |
Sherif monorepo consistency check command (e.g., "pnpm sherif"). Empty = skip. |
knip-command |
string | false | "" |
Knip unused-code check command (e.g., "pnpm knip"). Empty = skip. |
madge-command |
string | false | "" |
Madge circular-dependency check command (e.g., "pnpm madge --circular ."). Empty = skip. |
jobs:
quality:
uses: kethalia/workflows/.github/workflows/ci-quality.yml@<version>File: ghcr-prune.yml. Deletes aged PR-tag versions from GHCR while preserving sha-, v*, latest, edge, and buildcache tags.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
org |
string | true | — | GitHub organization that owns the container packages. Lowercased automatically. |
packages |
string | true | — | Newline- or comma-separated list of container package names under the org (e.g. "chillpass\nchillpass-auth"). |
pr-tag-pattern |
string | false | ^pr- |
ERE matching tags considered "PR build" candidates for deletion. |
preserve-patterns |
string | false | ^sha-|^v[0-9]|^latest$|^edge$|^buildcache$ |
ERE — if ANY tag on a version matches this, the version is kept regardless of age. Default protects sha-, v*, latest, edge, and buildcache. |
age-days |
number | false | 14 |
Minimum age in days before a pr-* version is eligible for deletion. |
dry-run |
boolean | false | false |
When true, log WOULD-DELETE decisions without calling DELETE. |
jobs:
prune:
permissions:
packages: write
uses: kethalia/workflows/.github/workflows/ghcr-prune.yml@<version>
with:
org: chillwhales
packages: |
chillpass
chillpass-authFile: helm-lint.yml. Runs helm lint (optionally --strict) and helm template against a matrix of charts.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
charts |
string | true | — | Newline-separated list of chart paths (relative to caller repo root) |
helm-version |
string | false | v3.16.2 |
Helm version to install via azure/setup-helm |
strict |
boolean | false | true |
Pass --strict to helm lint |
runs-on |
string | false | "" |
Runner label for the lint matrix jobs. When empty, falls back to the tiered resolver (vars.RUNNER_HEAVY → default self-hosted). Pass a value to force a specific runner. |
jobs:
helm:
uses: kethalia/workflows/.github/workflows/helm-lint.yml@<version>
with:
charts: |
charts/api
charts/webFile: publish-docker-ghcr.yml. Builds and publishes a single image with semver + :latest tags. No-ops when version is empty so callers can wire it unconditionally.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
image-name |
string | true | — | Image name under ghcr.io// (e.g. "chillpass-api"). Lowercased automatically. |
version |
string | false | "" |
Semver version for the image (e.g. "1.4.0"). When empty, the workflow no-ops so callers can wire it unconditionally. |
context |
string | false | . |
Docker build context path. |
dockerfile |
string | false | Dockerfile |
Path to the Dockerfile, relative to the repo root. |
platforms |
string | false | linux/amd64 |
Comma-separated build platforms. |
build-args |
string | false | "" |
Newline-separated KEY=VALUE build args. |
push-latest |
boolean | false | true |
Also tag and push :latest. |
jobs:
publish:
permissions:
contents: read
packages: write
uses: kethalia/workflows/.github/workflows/publish-docker-ghcr.yml@<version>
with:
image-name: chillpass-apiFile: release-changesets.yml. Runs changesets/action to open Version PRs and (when publish-command is set) publish to npm.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node-version |
number | false | 22 |
Node.js version |
publish-command |
string | false | "" |
Command Changesets runs to publish (e.g. "pnpm release"). Leave empty to only open the Version PR. |
version-command |
string | false | pnpm changeset version |
Command Changesets runs to bump versions and write changelogs. |
setup-command |
string | false | "" |
Optional command to run after install and before version/publish (e.g. build). |
pr-title |
string | false | chore(release): version packages |
Title for the Version PR opened by Changesets. |
commit-message |
string | false | chore(release): version packages |
Commit message used by Changesets when versioning. |
| Secret | Required | Description |
|---|---|---|
NPM_TOKEN |
required | npm auth token. Required when publish-command publishes to npm. |
GH_PAT |
required | PAT used by changesets/action to open the Version PR. Falls back to GITHUB_TOKEN. |
Both secrets are declared with
required: falseupstream but should be supplied (secrets: inheritor explicit pass-through) — npm publish fails withoutNPM_TOKEN, and Version PRs against branch-protectedmaintypically requireGH_PAT.
jobs:
release:
uses: kethalia/workflows/.github/workflows/release-changesets.yml@<version>
secrets: inheritFile: release-docker-stack.yml. Runs Changesets release, then for each published package whose name matches a key in images, builds and publishes the corresponding Docker image to GHCR.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
images |
string | true | — | JSON map: { "": { "image": "", "context": "", "dockerfile": "" } }. Keys must match name values in the changesets publishedPackages output; only intersecting entries are published. |
node-version |
number | false | 22 |
Node.js version |
jobs:
release:
permissions:
contents: read
packages: write
uses: kethalia/workflows/.github/workflows/release-docker-stack.yml@<version>
with:
images: |
{
"@chillwhales/api": { "image": "chillpass-api", "context": "apps/api" }
}File: resolve-runner.yml. Emits heavy and light runner labels resolved from repo/org vars, used by downstream callers to pick a runner tier. See .github/docs/RUNNER-TIERING.md.
(No inputs.)
jobs:
runners:
uses: kethalia/workflows/.github/workflows/resolve-runner.yml@<version>
build:
needs: runners
runs-on: ${{ needs.runners.outputs.heavy }}
steps:
- run: echo buildFile: retag-image.yml. Repoints destination tags at the manifest digest of an existing source tag — no rebuild, no new bytes pushed. Manifest digests are preserved across the retag.
jobs:
retag:
permissions:
packages: write
uses: kethalia/workflows/.github/workflows/retag-image.yml@<version>
with:
image: chillpass
source-tag: sha-abc1234
dest-tags: |
v1.2.3
latestFile: internal-retag-smoke.yml. Manual smoke test for retag-image.yml — exercises the retag flow end-to-end against a disposable GHCR image. Triggered via workflow_dispatch (Actions UI). This is not a reusable workflow and is not invoked via uses:.
The workflow file is published at kethalia/workflows/.github/workflows/internal-retag-smoke.yml@<version> for inspection but is not callable; trigger it from the Actions tab of this repo.
File: retag-stack.yml. On Changesets release, for each published package whose name matches a key in images, retags the existing :sha-<short> image to :v<version> and :latest (no rebuild).
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
images |
string | true | — | JSON map: { "": { "image": "" } }. Keys must match name values in the changesets publishedPackages output; only intersecting entries are retagged. context/dockerfile are NOT consumed (no rebuild) — they may be present but are ignored. |
node-version |
number | false | 22 |
Node.js version used for the changesets step. |
registry |
string | false | ghcr.io |
Container registry hostname. |
jobs:
release:
permissions:
contents: read
packages: write
uses: kethalia/workflows/.github/workflows/retag-stack.yml@<version>
with:
images: |
{
"@chillwhales/api": { "image": "chillpass-api" }
}File: verify-ghcr-tags.yml. Asserts that an expected tag (sha-derived or explicit) exists on every named GHCR package.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
org |
string | true | — | GitHub organization that owns the container packages. Lowercased automatically. |
packages |
string | true | — | JSON array of container package names under the org (e.g. '["chillpass", "chillpass-auth"]'). |
sha |
string | false | "" |
Full git sha to verify. The short form (first 7 chars) is checked as :sha-<short>. Defaults to the PR head sha on pull_request events. |
tag |
string | false | "" |
Explicit tag to verify (e.g., "v1.2.3"). Overrides sha when set. |
runner |
string | false | ubuntu-latest |
Runner label (resolved by caller via resolve-runner.yml). |
jobs:
verify:
uses: kethalia/workflows/.github/workflows/verify-ghcr-tags.yml@<version>
with:
org: chillwhales
packages: '["chillpass", "chillpass-auth"]'File: reusable-visual-tests.yml. Runs a Playwright visual regression suite inside the official mcr.microsoft.com/playwright container, with a peer label-gate job that fails closed when committed baseline screenshots change without an approval label on the PR. Designed for monorepos where the visual suite lives in a package and writes its baselines to a known path.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
playwright-image |
string | false | mcr.microsoft.com/playwright |
Container image to run the suite in. |
playwright-image-tag |
string | true | — | Image tag matching the @playwright/test version (e.g. v1.56.0-jammy). Keep in lockstep with the SDK. |
browsers-path |
string | false | /ms-playwright |
PLAYWRIGHT_BROWSERS_PATH for the image. Override only for custom images. |
pnpm-version |
string | false | 9.15.9 |
pnpm version activated via corepack. |
install-command |
string | false | pnpm install --frozen-lockfile |
Dependency install command. |
test-command |
string | true | — | Visual regression command. Must emit a Playwright HTML report at the path under report-paths so the failure-artifact upload has content to capture. |
report-paths |
string | true | — | Newline-separated actions/upload-artifact paths (HTML report + per-test results dir). Project-specific; the caller must supply them. |
screenshots-path |
string | false | packages/visual-tests/src/__screenshots__/ |
Path to the committed baseline tree, diffed by label-gate. |
approval-label |
string | false | baselines:approved |
Label required on the PR for baseline diffs to land. |
timeout-minutes |
number | false | 30 |
Per-job timeout for the visual-tests run. |
artifact-retention-days |
number | false | 14 |
Retention for the failure-artifact upload. |
update-baselines |
boolean | false | false |
When true, run update-command instead of test-command and commit any regenerated files under screenshots-path back to the source branch. The label-gate job is skipped in this mode. Wire to workflow_dispatch so maintainers can refresh baselines from the same container CI uses for verification. |
update-command |
string | false | '' |
Command that regenerates baseline screenshots (typically the test command with --update-snapshots). Required when update-baselines is true. |
update-commit-message |
string | false | chore(visual-tests): refresh baselines from CI |
Commit message used when update-baselines produces changes. |
git-user-name |
string | false | github-actions[bot] |
Committer name for the baseline-refresh commit. |
git-user-email |
string | false | 41898282+github-actions[bot]@users.noreply.github.com |
Committer email for the baseline-refresh commit. |
name: Visual Tests
on:
push:
branches: [main]
pull_request: {}
workflow_dispatch:
inputs:
update-baselines:
description: Refresh baseline screenshots from CI and commit back to this branch.
type: boolean
default: false
jobs:
visual:
uses: kethalia/workflows/.github/workflows/reusable-visual-tests.yml@<version>
with:
playwright-image-tag: v1.56.0-jammy
test-command: pnpm turbo run test:visual --filter=@top-decor/visual-tests -- --reporter=line,html
update-command: pnpm turbo run test:visual:update --filter=@top-decor/visual-tests -- --reporter=line
update-baselines: ${{ github.event_name == 'workflow_dispatch' && inputs.update-baselines || false }}
report-paths: |
packages/visual-tests/playwright-report
packages/visual-tests/test-results
!packages/visual-tests/playwright/.cacheLocal renders drift from CI renders because of font/antialiasing differences across environments. To avoid committing baselines that won't match CI, maintainers regenerate them from CI:
- Go to Actions → Visual Tests → Run workflow, pick the PR branch, tick update-baselines.
- The workflow runs
update-command, then pushes achore(visual-tests): refresh baselines from CIcommit to that branch using the defaultGITHUB_TOKEN. - The PR's "Files changed" tab now shows the baseline PNGs side-by-side. Review the visual diff like any other code change.
- The next PR-triggered run compares CI-captured baselines against CI-captured screenshots — zero-tolerance pixel matching works.
The reusable does not declare a permissions: block — GitHub Actions does not evaluate the inputs context inside jobs.<id>.permissions, and a static contents: write on the reusable would exceed the token scope of test-only callers and trigger a startup failure. Callers always supply permissions themselves: test-only callers grant contents: read on the calling job (or workflow); callers that may set update-baselines: true grant contents: write.
File: update-snapshots.yml. Human-in-the-loop dispatcher for the /update-snapshots PR-comment pattern (the same model Chromatic / Percy / Argos use). A maintainer comments /update-snapshots on a PR whose visual-tests check failed on an intentional UI change; this reusable verifies the commenter has write access, then dispatches the visual-tests workflow on the PR branch in baseline-refresh mode. The refreshed PNGs are committed back to the PR branch by the visual-tests reusable.
The issue_comment trigger is not a valid workflow_call event, so the caller workflow owns the trigger and if: gate; this reusable owns the auth check, fork-PR refusal, branch resolution, dispatch, and confirmation comment.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
target-workflow |
string | false | visual-tests.yml |
Basename of the workflow to dispatch. Must declare a workflow_dispatch trigger and accept the input named by dispatch-input-name. |
dispatch-input-name |
string | false | update-baselines |
Name of the boolean workflow_dispatch input set to true on the dispatched run. |
trigger-phrase |
string | false | /update-snapshots |
Comment prefix that authorizes a dispatch. The caller gates on this prefix; the reusable also re-checks it as a defense-in-depth guard. |
allowed-permissions |
string | false | admin,write,maintain |
Comma-separated repo permission levels allowed to trigger a dispatch. |
confirmation-message |
string | false | 🔄 Dispatched baseline refresh on \{ref}`. Watch the run at {runs_url}. New baselines will be committed back to this PR if any pixels changed.` |
Markdown body for the follow-up comment. Supports {ref} and {runs_url} placeholders. |
Caller (thin shim that consumers drop into each repo):
name: Update Visual Snapshots
on:
issue_comment:
types: [created]
jobs:
dispatch:
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/update-snapshots') }}
uses: kethalia/workflows/.github/workflows/update-snapshots.yml@<version>
permissions:
pull-requests: write # PR-context comment reactions + dispatch follow-up
# (issue_comment on PRs routes through pull-requests,
# not issues, despite the URL shape)
actions: write # dispatch the target workflow
contents: read # actions/github-script + gh CLI base scopeThe caller's if: filter must match the trigger-phrase input (or omit the input to accept the default) — the reusable re-checks the prefix and fails loudly if they diverge.
The permissions: block on the caller (workflow- or job-level) is mandatory: reusable workflows cannot elevate beyond the caller's GITHUB_TOKEN, and on repos with the default read-only token the dispatch step will silently fail without these scopes.
Trust boundary: issue_comment always loads workflow files from the default branch, never the PR HEAD copy. The Check commenter permission step in the reusable is the only auth gate — keep it intact. Fork PRs are refused by design: workflow_dispatch cannot target a branch that lives outside the target repo, so the reusable exits with an actionable error rather than silently no-opping.