This repo is a single-consumer project (only the optivem org workspace uses it). We do not bump the major tag on breaking input renames or removals — the @v1 tag gets moved to the new commit, and all callers inside the workspace are updated atomically in the same change.
This deliberately violates SemVer. It's safe here because:
- There are no external consumers pinned to
@v1who could silently break. - All callers live alongside the actions in this workspace and move together.
- Skipping
@v2/@v3cycles avoids churn that buys nothing when the consumer graph is{self}.
If this repo ever gains an external consumer, revisit this policy and start cutting proper major tags.
All actions in this repo run on GitHub-hosted Linux runners, so pwsh buys nothing a bash toolchain doesn't already cover. To avoid paying every cross-cutting concern twice (retry wrappers, lint rules, structured logging, auth), all production code is bash-only:
action.ymlsteps useshell: bash.- Scripts are
.sh, not.ps1. Multi-line shell logic lives in a sibling.shfile, not inline inaction.yml. Each step invokes its script asrun: bash "$GITHUB_ACTION_PATH/<name>.sh"and passes inputs via the step'senv:block. This keepsshellcheckandbash -nauthoritative on every line of shell, removes${{ }}masking noise from shellcheck output, and forces inputs through env vars (which is the recommended injection-safe pattern). Single-linerun:commands (e.g.run: echo "::notice...") may stay inline. - Use shared/retry.sh (
retry_runwrapper) for any external-service call (gh,docker,git push/fetch, sonarscanner). Transient errors (HTTP 5xx, TLS/DNS/connection blips,context deadline exceeded) are retried with 4 attempts / 5s→15s→45s backoff; hard failures (4xx,unauthorized,manifest unknown,repository not found) pass through immediately. Usejqfor JSON handling. - UTF-8 shell is assumed. Several actions emit emoji (✅ ❌ 🚀 📦) to
$GITHUB_STEP_SUMMARY. GitHub-hosted Linux runners default to UTF-8 so this is transparent; any self-hosted runner must run bash under a UTF-8 locale.
Four lint checks enforce the conventions (all run by .github/workflows/commit-stage.yml except where noted):
- shared/_lint/check-no-pwsh.sh (via
.github/workflows/lint-shell-policy.yml) fails PRs that contain anyshell: pwshor.ps1files (exceptshared/_test-*harnesses). - shared/_lint/check-no-raw-gh.sh (via
.github/workflows/lint-gh-usage.yml) fails PRs that callghwithout theretry_runwrapper. Whitelist:gh auth status,gh api rate_limit. - shared/_lint/check-no-inline-run.sh fails PRs that keep multi-line
run: |blocks in anyaction.yml. - shared/_lint/check-shell-scripts.sh runs
bash -nandshellcheck --severity=warningon every tracked*.sh.
| Action | Inputs | Outputs |
|---|---|---|
| bump-patch-versions | • version-files• repository• token |
• bumps• bumped• summary |
| check-changes-since-tag | • tag-patterns• paths |
• changed• baseline-tag• baseline-sha• changed-files |
| check-commit-status-exists | • commit-sha• status-context• head-sha• repository• token |
• exists• created-at |
| check-ghcr-packages-exist | • image-urls• tag• token |
• exist• results |
| check-sha-on-branch | • commit-sha• base-branch |
• on-branch |
| check-tag-exists | • tag• repository• token• git-host |
• exists |
| check-timestamp-newer | • latest• since |
• newer |
| cleanup-deployments | • keep-count• protected-environments• delete-delay-seconds• rate-limit-threshold• dry-run• token |
• deleted-count• dry-run-count |
| cleanup-ghcr-orphan-manifests | • retention-days• container-packages• delete-delay-seconds• rate-limit-threshold• dry-run• token |
• deleted-count• dry-run-count |
| cleanup-prereleases | • retention-days• container-packages• delete-delay-seconds• rate-limit-threshold• dry-run• token |
• deleted-count• dry-run-count |
| commit-files | • files• branch• max-retries• token |
• commits• committed |
| compose-docker-image-urls | • tag• base-image-urls |
• image-urls |
| compose-prerelease-version | • base-version• suffix• build-number• prefix |
• version |
| compose-release-version | • prerelease-version |
• version |
| compose-tags | • versions• template |
• tags |
| create-commit-status | • ref• context• state• description• target-url• token |
— |
| deploy-docker-compose | • environment• version• image-urls• service-names• compose-file• working-directory |
— |
| evaluate-run-gate | • skip-conditions |
• should-run• skip-reason |
| format-artifact-list | • artifacts |
• formatted |
| generate-release-notes | • prerelease-version• release-version• artifact-urls |
• title• notes-file |
| get-commit-status | • commit-sha• context• state• repository• token |
• description• state• target-url |
| get-last-workflow-run | • workflow-name• repository• exclude-run-id• status• conclusion• limit• token |
• timestamp |
| publish-tag | • tag• commit-sha• repository• git-host• token |
— |
| read-base-version | • file |
• base-version |
| read-base-versions | • entries• token |
• versions |
| render-stage-summary | • stage-name• stage-result• stage-content• stage-success-content• stage-skipped-content |
— |
| render-system-stage-summary | • stage-name• stage-result• environment• success-version• success-artifact-ids• skipped-reason• latest-artifact-ids• latest-updated-at• last-run-at |
— |
| resolve-commit | • repository• ref• token• git-host |
• sha• timestamp |
| resolve-docker-image-digests | • base-image-urls• tag |
• image-digest-urls• latest-updated-at |
| resolve-latest-prerelease-tag | • tag-prefix• tag-suffix• repository• token• git-host |
• tag• base-tag |
| resolve-latest-prerelease-with-status | • tag-prefix• status-context• version• repository• token |
• tag |
| resolve-latest-tag-from-sha | • repository• commit-sha• pattern• token• git-host |
• tag |
| tag-docker-images | • image-urls• tag• image-tags• registry• registry-username• token |
• tagged-image-urls |
| trigger-and-wait-for-workflow | • workflow• repository• ref• workflow-inputs• poll-interval• rate-limit-threshold• timeout-seconds• token |
• run-id |
| validate-env-vars-defined | • names |
— |
| validate-tag-exists | • tag• repository• token• git-host |
— |
| wait-for-endpoints | • endpoints• compose-file• working-directory• max-attempts• wait-seconds• timeout-seconds |
— |
| wait-for-workflow | • workflow• commit-sha• repository• poll-interval• watch-interval• max-discovery-attempts• rate-limit-threshold• timeout-seconds• token |
• run-id |
For each {path, value} entry, reads the VERSION file and — if the matching git tag already exists on the remote — computes a patch bump. Probes the tag via git ls-remote. Reads only; writes nothing to disk. Pair with commit-files to persist the bumps. A legacy signal: git-tag field is accepted and ignored for backward compatibility with already-scaffolded student repos; the previously-supported ghcr-image signal has been removed.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
version-files |
yes | — | JSON array of {"path": string, "value": string} objects. Bumps the file if tag {value}{current-version} exists on the remote (e.g. value="meta-v" + version 1.0.40 probes tag meta-v1.0.40). |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format (used for tag probes) |
token |
no | ${{ github.token }} |
Token for git remote auth |
Outputs
| Name | Description |
|---|---|
bumps |
JSON array of {path, old-version, new-version, release-signal} for files that need bumping. release-signal is the matched tag name. |
bumped |
true if at least one file needs bumping |
summary |
Human-readable summary (one line per file, bumped or skipped) |
Walks tag patterns in priority order, resolves the most recent matching tag as a baseline, and runs git diff --name-only baseline HEAD -- <paths> to detect whether the given pathspecs have changed. Fail-open: if no tag matches any pattern, reports changed=true.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag-patterns |
yes | — | Newline-separated git tag globs in priority order. The first pattern that matches at least one tag wins; the most recent match within that pattern (by git tag --sort=-version:refname) is used as the baseline. |
paths |
yes | — | Newline-separated path filters passed to git diff. Supports any pathspec syntax (directories, globs, :(exclude)...). |
Outputs
| Name | Description |
|---|---|
changed |
true if any of the specified paths changed between the baseline tag's SHA and HEAD. Also true (fail-open) if no baseline tag matched any pattern. false only when a baseline was found AND no paths changed. |
baseline-tag |
The tag used as the comparison baseline. Empty if no tag matched. |
baseline-sha |
Commit SHA of the baseline tag. Empty if no tag matched. |
changed-files |
Newline-separated list of changed files under the specified paths. Empty if no changes. |
Notes: Requires the caller to have checked out with fetch-depth: 0 (or equivalent) so tag history is available. A defensive git fetch --tags runs first.
Boolean existence check for a success commit-status on head-sha matching (context, description=commit-sha). Returns exists=true when the status is present, exists=false when the API definitively reports no matching status. API failure (after retry_run exhaustion) is indeterminate — the action exits 1 rather than coercing to a false answer (see the no-swallow rule in claude/CLAUDE.md). Caller picks the status-context label; the action only reports presence. Pairs with create-commit-status (write) and get-commit-status (read state).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
commit-sha |
yes | — | Commit SHA to look up in the description field of commit-statuses (typically an upstream-repo commit the pipeline previously processed). |
status-context |
yes | — | The commit-status context to search for (e.g. acceptance-stage). Matched against the context field. Caller-chosen label encoding the type of check; no verb suffix — the state field carries the outcome. |
head-sha |
no | ${{ github.sha }} |
Commit whose statuses are inspected |
repository |
no | ${{ github.repository }} |
Repository in owner/name form |
token |
no | ${{ github.token }} |
GitHub token used for API calls |
Outputs
| Name | Description |
|---|---|
exists |
true when a success commit-status on head-sha matches the given context + description=commit-sha. false when the API definitively reports none. API failure after retries → action exits 1 (indeterminate, not coerced to false). |
created-at |
ISO 8601 createdAt of the matching success status, if one was found. Empty otherwise. |
Probes GHCR packages to determine whether a tag exists for each, via OCI HEAD /v2/{path}/manifests/{tag}. Each input line is either a plain ghcr.io/{owner}/{repo}/{image} path (probed with the default tag input) or ghcr.io/{owner}/{repo}/{image}:{tag} (per-line tag override). Works uniformly for user- and org-owned repos and for public/private packages. Two usage shapes: preflight gate — read the any-of exist output; per-image probe — read the keyed results output. Definitive only: HTTP 200 → exists=true, HTTP 404 → exists=false. Anything else (401/403, 5xx after retries, network failure, token-exchange failure) is indeterminate and the action exits 1 with an actionable error — see the no-swallow rule in claude/CLAUDE.md.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
image-urls |
yes | — | Newline-separated list of GHCR packages to probe. Each line is either ghcr.io/{owner}/{repo}/{image} (uses the default tag input) or ghcr.io/{owner}/{repo}/{image}:{tag} (per-line tag override). OCI image paths contain no : so the tag separator is unambiguous. |
tag |
no | latest |
Default tag to probe when a line omits its own :tag suffix. latest is the reliable "any artifact has been built" signal since the commit stage always publishes it alongside versioned tags. |
token |
no | ${{ github.token }} |
Token used for GHCR authentication. |
Outputs
| Name | Description |
|---|---|
exist |
true if any probed package returned HTTP 200 for the probed tag (any-of-published signal); false if every probe returned HTTP 404. Indeterminate cases never reach this output — the action exits 1 instead. |
results |
JSON array of {"image": string, "tag": string, "exists": boolean} objects, one entry per input line. image is the URL without any :tag suffix; tag is the effective tag probed (per-line override if given, else the tag input default). Preserves input order. Only emitted when every probe was definitive. |
Fetches the base branch from origin and runs git merge-base --is-ancestor <sha> origin/<base-branch> to determine whether the SHA is in the branch's history. Writes the result to $GITHUB_STEP_SUMMARY. Exit-1 from git merge-base (commit is not an ancestor) yields on-branch=false; any other non-zero exit (e.g. exit 128 — bad SHA, missing ref) is indeterminate and the action exits 1 rather than coercing to on-branch=false (see the no-swallow rule in claude/CLAUDE.md).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
commit-sha |
no | ${{ github.sha }} |
Git SHA to check |
base-branch |
no | main |
Base branch to check ancestry against |
Outputs
| Name | Description |
|---|---|
on-branch |
true if the SHA is an ancestor of the base branch; false if git merge-base --is-ancestor definitively returned exit 1 (not an ancestor). Any other non-zero exit → action exits 1 (indeterminate, not coerced to false). |
Notes: Use to guard downstream steps against workflow_dispatch inputs pointing at commits not in the base branch.
Queries a remote git repository with git ls-remote --tags "refs/tags/<tag>" and reports whether at least one tag matches. Accepts either an exact tag or a glob pattern. Tool-agnostic — no platform API dependency.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag |
yes | — | Exact tag or glob pattern to match (e.g., monolith-java-v1.0.0 or meta-v*) |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
token |
no | ${{ github.token }} |
Token for authenticating to the remote |
git-host |
no | github.com |
Git host to query (e.g. github.com, gitlab.com, codeberg.org) |
Outputs
| Name | Description |
|---|---|
exists |
Whether at least one matching tag exists (true/false) |
Pure ISO 8601 timestamp comparator. Lexicographically compares latest against since — outputs newer=true when latest is strictly newer, OR when since is empty (fail-open). Outputs newer=false when since is set and latest is not newer. No platform dependency.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
latest |
yes | — | ISO 8601 timestamp under observation (e.g. latest docker image push time, latest artifact build time, latest commit timestamp). |
since |
no | `` | ISO 8601 timestamp to compare against (typically the last-run timestamp). When empty, the action returns newer=true (fail-open — useful for "first run" semantics). |
Outputs
| Name | Description |
|---|---|
newer |
true when latest is strictly newer than since, OR when since is empty (fail-open). false when since is set AND latest is not newer than it. |
Notes: ISO 8601 lexicographic comparison is only correct when both timestamps are UTC with the same format (both Z-suffixed). GitHub API and typical observed timestamps (docker push times, git commit times) satisfy this.
Fetches all GitHub deployments and deletes superseded ones in non-protected environments. Delegates to cleanup-deployments.sh in the action directory. Protects configured environments and RCs (via git tag lookup), and treats any SHA already deployed to a protected env as obsolete in pre-prod.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
keep-count |
no | 3 |
Per-environment count cap: keep this many newest deployments; anything beyond the cap is deleted. Applies only to non-protected environments and to SHAs not already deployed to a protected env |
protected-environments |
no | *-production,production |
Comma-separated list of environment name patterns whose deployments must never be deleted. Supports * wildcards, case-insensitive |
delete-delay-seconds |
no | 10 |
Seconds to wait between each API delete call to avoid GitHub rate limiting |
rate-limit-threshold |
no | 50 |
Pause before each API delete when remaining core-rate-limit requests fall below this number (set 0 to disable) |
dry-run |
no | false |
If true, only log what would be deleted without actually deleting anything |
token |
no | ${{ github.token }} |
GitHub token used for deployment API calls |
Outputs
| Name | Description |
|---|---|
deleted-count |
Number of deployments actually deleted (real mode only; 0 in dry-run) |
dry-run-count |
Number of deployments that would be deleted (dry-run mode only; 0 in real mode) |
Notes:
- Released-RC deployments (final tag
vX.Y.Zexists): immediately deletes any deployment whose SHA matches avX.Y.Z-rc.*tag; bypasseskeep-count. - SHA already in production: any non-protected deployment whose SHA is also present in a protected (production) deployment is deleted; the prod copy is the source of truth, so the pre-prod copy is obsolete. Bypasses
keep-count. - Superseded per environment (count cap): for what remains, keeps the newest
keep-countdeployments per environment; anything beyond the cap is deleted. - Protected environments are never touched by any scenario.
- Ordering: run this action before
cleanup-prereleasesin the same workflow — the released-RC logic relies on RC git tags being present to resolve SHAs, andcleanup-prereleasesdeletes those tags immediately for released versions.
Deletes untagged GHCR Docker manifests older than retention-days that are not referenced by any active tagged manifest list or attestation index. Delegates to cleanup-ghcr-orphan-manifests.sh in the action directory. Rate-limit-aware.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
retention-days |
no | 30 |
Number of days to retain orphan untagged manifests before deletion |
container-packages |
yes | — | Comma-separated list of container package names to scan (e.g., "myapp,myapp-worker") |
delete-delay-seconds |
no | 10 |
Seconds to wait between each API delete call to avoid GitHub rate limiting |
rate-limit-threshold |
no | 50 |
Pause before each API delete when remaining core-rate-limit requests fall below this number (set 0 to disable) |
dry-run |
no | false |
If true, only log what would be deleted without actually deleting anything |
token |
no | ${{ github.token }} |
GitHub token used for package API calls and GHCR registry-token exchange |
Outputs
| Name | Description |
|---|---|
deleted-count |
Number of orphan manifests actually deleted (real mode only; 0 in dry-run) |
dry-run-count |
Number of orphan manifests that would be deleted (dry-run mode only; 0 in real mode) |
Notes:
- Protection: before deleting, fetches each tagged version's manifest from
ghcr.io. Children of OCI/Docker manifest lists or attestation indexes (multi-arch images,provenance: mode=max,sbom: true) are collected and protected — only digests not referenced by any active tag are eligible. - Common orphan sources: re-pushed tags (the previous digest is left untagged), failed/aborted pushes, stale provenance/SBOM blobs.
- Ordering: run after
cleanup-prereleasesin the same workflow, so manifests freshly orphaned by prerelease tag deletion are caught in the same pass.
Cleans up prerelease git tags, GitHub releases, and Docker image tags that are no longer needed. Delegates to cleanup-prereleases.sh in the action directory. Rate-limit-aware.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
retention-days |
no | 30 |
Number of days to retain prerelease Docker image tags after release, and superseded RC artifacts before release |
container-packages |
no | `` | Comma-separated list of container package names for Docker image tag cleanup (e.g., "myapp,myapp-worker"). If empty, Docker cleanup is skipped. |
delete-delay-seconds |
no | 10 |
Seconds to wait between each API delete call to avoid GitHub rate limiting |
rate-limit-threshold |
no | 50 |
Pause before each API delete when remaining core-rate-limit requests fall below this number (set 0 to disable) |
dry-run |
no | false |
If true, only log what would be deleted without actually deleting anything |
token |
no | ${{ github.token }} |
GitHub token used for tag, release, and package API calls |
Outputs
| Name | Description |
|---|---|
deleted-count |
Number of items (git tags, GitHub releases, Docker image tags) actually deleted (real mode only; 0 in dry-run) |
dry-run-count |
Number of items that would be deleted (dry-run mode only; 0 in real mode) |
Notes:
- Released versions (final tag
vX.Y.Zexists): immediately deletes prerelease GitHub releases + git tags (vX.Y.Z-rc.*,vX.Y.Z-rc.*-qa-*); after the retention period, deletes prerelease Docker image tags. - Superseded prereleases (no final release yet): after the retention period, deletes older RCs + their status tags + Docker image tags; never deletes the latest RC.
- Orphan untagged manifests: handled by the dedicated cleanup-ghcr-orphan-manifests action. Run it after
cleanup-prereleasesto catch manifests freshly orphaned by tag deletion. - Ordering: run
cleanup-deploymentsfirst (see its Notes).
For each {path, content, message} entry, reads the current file SHA from the GitHub Contents API (if any), base64-encodes the new content, and PUTs it with the SHA precondition. Retries on HTTP 409/422 (SHA conflict) with exponential backoff. Race-safe alternative to git push for concurrent workflows on the same branch.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
files |
yes | — | JSON array of {path, content, message} entries. path is repo-relative, content is the full new file content (plain text, will be base64-encoded before PUT), message is the commit message for that file. |
branch |
yes | — | Branch to commit to (e.g. main). Pass explicitly — do not rely on github.ref_name: on tag pushes it is the tag name, and on workflow_call/workflow_dispatch it is the caller/dispatcher ref, neither of which is a safe commit target. |
max-retries |
no | 3 |
Maximum number of retries per file on SHA-precondition conflict (HTTP 409/422) |
token |
no | ${{ github.token }} |
GitHub token with contents:write permission on the target branch |
Outputs
| Name | Description |
|---|---|
commits |
JSON array of {path, commit-sha, content-sha, html-url} for each file that was committed |
committed |
true if at least one file was committed |
Pure string helper. Takes a list of base image URLs (JSON array or newline-separated) and a tag, and returns a JSON array of {base}:{tag} URLs. No registry lookup. Delegates to compose-docker-image-urls.sh.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag |
yes | — | Tag to append to base image URLs (e.g., v1.0.0-rc.1) |
base-image-urls |
yes | — | Base image URLs (without tag). Accepts either JSON array format or newline-separated list |
Outputs
| Name | Description |
|---|---|
image-urls |
JSON array of resolved image URLs with tags |
Pure string transform. Validates base-version matches X.Y.Z and composes v{version}-{suffix}.{build-number} (or {prefix}-v{version}-{suffix}.{build-number} when a prefix is supplied).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
base-version |
yes | — | Base semantic version the prerelease parts are appended to (e.g., 1.0.0) |
suffix |
yes | — | Prerelease suffix (e.g., rc, dev, alpha, beta) |
build-number |
yes | — | CI build counter appended after the suffix (e.g., github.run_number). Forms the second dot-separated pre-release identifier per SemVer. |
prefix |
no | `` | Optional prefix prepended to the tag. Produces {prefix}-v{version}-{suffix}.{build-number} when set, else v{version}-{suffix}.{build-number} |
Outputs
| Name | Description |
|---|---|
version |
The composed prerelease version string |
Pure string transform. Strips any SemVer prerelease identifier of the shape -<word>.<number> (e.g., v1.0.0-rc.1 → v1.0.0, v1.0.0-nightly.42 → v1.0.0). Preserves the leading v if present on input.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
prerelease-version |
yes | — | Prerelease version to convert. Accepts any SemVer prerelease identifier of the form -<word>.<number> (e.g., v1.0.0-rc.1, v1.0.0-alpha.1, v1.0.0-dev.3, v1.0.0-nightly.42). |
Outputs
| Name | Description |
|---|---|
version |
Release version with the prerelease identifier removed (e.g., v1.0.0) |
Pure string transform over a keyed list. For each {key, version} entry, applies a template (default v{version}) and emits {key, tag}. Key is opaque and preserved — the action doesn't know or care what it represents. Pairs naturally with read-base-versions upstream and tag-docker-images (map mode) downstream, but works for any caller that wants batched version → tag templating.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
versions |
yes | — | JSON array of {"key": string, "version": string} objects |
template |
no | v{version} |
Template string containing the {version} placeholder |
Outputs
| Name | Description |
|---|---|
tags |
JSON array of {"key": string, "tag": string} objects. Keys preserved from input; tags produced by substituting {version} in the template. |
Resolves ref (SHA, branch, or tag) to a commit SHA via gh api repos/{repo}/commits/{ref}, then calls gh api repos/{repo}/statuses/{sha} (via retry_run) to POST a commit status with the given context, state, description, and target URL. Defaults ref to github.sha and target-url to the current workflow run URL.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
ref |
no | `` | Ref (SHA, branch, or tag) to attach the status to. Resolved to a commit SHA before the status is written. SHAs pass through unchanged. Empty = current commit (github.sha). |
context |
yes | — | Status context (label shown on the commit). Use <env>/<verb> convention (e.g. qa/signoff, acceptance/tested). |
state |
no | success |
Status state: success, failure, pending, or error |
description |
no | `` | Short human-readable description (often the subject identifier, e.g. the verified upstream SHA) |
target-url |
no | `` | URL the status links to. Empty = link to the current workflow run. |
token |
no | ${{ github.token }} |
GitHub token used for API calls. Needs statuses:write + contents:read (for ref resolution). |
Runs docker compose up -d (optionally with -f <compose-file>) from a working directory — the local-deployment stepping stone from the Farley deploy vocabulary. The environment and version inputs are logged for operator visibility. Each (image-urls[i], service-names[i]) pair is exported as SYSTEM_IMAGE_<UPPER(name)>=<url> so compose files can SHA-pin via ${SYSTEM_IMAGE_<NAME>:-...} substitution; counts must match exactly or the action fails loud. Pair with wait-for-endpoints to verify readiness after deployment.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
environment |
yes | — | Display-only label shown in step logs (e.g., acceptance, qa, production). Required — every run must announce its target. The action does not route based on this value. |
version |
yes | — | Display-only version label shown in step logs (e.g., v1.0.0-rc.1). Required — every run must announce the version being deployed. The action does not select artifacts based on this value. |
image-urls |
yes | — | Docker image URLs being deployed (JSON array). Each entry is exported as SYSTEM_IMAGE_<UPPER(service-name)>=<url> for compose substitution; counts must match service-names exactly. |
service-names |
yes | — | Newline-separated list of compose service names corresponding to image-urls (same order). Each (url, name) pair is exported as SYSTEM_IMAGE_<UPPER(name)>=<url>; compose files consume via ${SYSTEM_IMAGE_<NAME>:-...} substitution. Counts must match image-urls exactly. |
compose-file |
no | `` | Docker Compose file to use (e.g., docker-compose.yml) |
working-directory |
yes | — | Working directory containing the Docker Compose file |
Aggregates multiple skip signals into a single go/no-go decision for a pipeline stage. Evaluates skip-conditions in priority order; the first entry whose when is true wins and yields should-run=false plus its reason. If none match, should-run=true and skip-reason is empty. Generic — use for any stage that composes multiple skip signals (release race, stale artifacts, missing inputs, etc.).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
skip-conditions |
yes | — | JSON array of {"when": boolean, "reason": string} entries, evaluated in priority order. Caller composes each when from GitHub Actions expressions (e.g. ${{ steps.x.outputs.y == 'true' }}) so the boolean is already resolved before the action runs. |
Outputs
| Name | Description |
|---|---|
should-run |
"true" when no skip-condition matched, "false" when one did. |
skip-reason |
The reason from the first matching skip-condition, or empty string when should-run=true. |
Pure string transform. Splits a newline-separated list of identifiers, trims whitespace, drops blank lines, and formats as a bulleted markdown list (• <item> per line). Returns an empty string if the input is blank.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
artifacts |
no | `` | Newline-separated artifact identifiers (e.g. a single docker.io/app:v1 or multiple entries via a multiline YAML string). Empty string returns empty output. |
Outputs
| Name | Description |
|---|---|
formatted |
Bulleted markdown list (one • <item> per line). Empty string if no artifacts provided. |
Generates a release title and a markdown notes file (written to a mktemp path on the runner filesystem) for the release being cut. Title shape is <title-prefix><release-version><title-suffix>; defaults compose 🚀 <version> PROD for production releases — pass non-default title-prefix / title-suffix for QA / signoff / acceptance variants. The optional prerelease-version input adds a Promoted From: line to the notes body; leave empty for non-promotion releases. Returns both the title and the notes-file path so the caller can pass them to softprops/action-gh-release or equivalent.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
prerelease-version |
no | '' |
The prerelease RC tag being promoted (e.g., v1.0.0-rc.1). When non-empty, adds a Promoted From: line to the notes body. Leave empty for non-promotion releases. |
release-version |
yes | — | The final SemVer release being cut (e.g., v1.0.0). Used as the release title version. |
artifact-urls |
no | [] |
JSON array of artifact URLs to include under an Artifacts section of the notes. Empty array or empty string skips the section. |
title-prefix |
no | '🚀 ' |
Prefix rendered before release-version in the title. Default emits a rocket emoji + space for the prod shape; pass an alternate string for QA / signoff variants. |
title-suffix |
no | ' PROD' |
Suffix rendered after release-version in the title. Default emits PROD for the prod shape; pass QA / SIGNOFF / etc. for non-prod variants. |
Outputs
| Name | Description |
|---|---|
title |
Composed release title (e.g., "🚀 v1.0.0 PROD" with default prefix/suffix) |
notes-file |
Absolute path to a temp file on the runner filesystem (plain markdown, UTF-8). Valid only for the duration of the current job. |
Reads commit statuses via gh api repos/{repo}/commits/{sha}/statuses and selects the first match by context (and optionally state). Returns empty outputs if no match is found (caller-side check required). Writes description/state/target-url to outputs and appends a line to $GITHUB_STEP_SUMMARY.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
commit-sha |
yes | — | Commit SHA to read the status from |
context |
yes | — | Status context to look up |
state |
no | success |
Required state filter (success, failure, pending, error). Empty = any state. |
repository |
no | ${{ github.repository }} |
Repository in owner/name form |
token |
no | ${{ github.token }} |
GitHub token used for API calls |
Outputs
| Name | Description |
|---|---|
description |
The description field of the matched status |
state |
The state of the matched status |
target-url |
The target_url of the matched status |
Returns the createdAt timestamp of the most recent run of a given workflow matching the given status and conclusion (defaults to completed + success), excluding the current run by default, queried via gh run list. Output is empty when no matching run exists. Pair timestamp with check-timestamp-newer to skip stages when nothing has changed since the last successful run — failed runs are ignored by default so the next trigger retries instead of skipping until artifacts change.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
workflow-name |
yes | — | Workflow file name or display name to query (e.g. github.workflow) |
repository |
no | ${{ github.repository }} |
Repository in owner/name form |
exclude-run-id |
no | ${{ github.run_id }} |
Run ID to exclude from results — typically the current run, so the action returns the previous run. Set to empty string to disable exclusion. |
status |
no | completed |
Run status to filter on (e.g. completed, in_progress, queued). Set to empty string to disable the status filter. The default prevents picking a still-queued or in-progress sibling whose createdAt would be a future timestamp from the freshness gate's perspective. |
conclusion |
no | success |
Run conclusion to filter on (e.g. success, failure, cancelled, skipped). Set to empty string to disable the conclusion filter. The default makes freshness gates compare against the last successful verification — failed runs do not count as "last verified". |
limit |
no | 20 |
Maximum number of recent runs to fetch from gh run list before applying filters. Must cover concurrent triggers plus any recent runs filtered out by status/conclusion. Increase if recent failures or in-progress siblings can push the last matching run beyond the default window. |
token |
no | ${{ github.token }} |
GitHub token used for API calls |
Outputs
| Name | Description |
|---|---|
timestamp |
ISO 8601 createdAt of the last matching run. Empty if no matching run exists. |
Publishes a git tag to origin at a given commit SHA (or current HEAD) using the github-actions[bot] identity. Idempotent — no-ops if the tag already exists at the same commit, tolerates concurrent creation, fails hard only if the remote tag points at a different commit.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag |
yes | — | Git tag name to create (e.g., v1.0.3-rc.1) |
commit-sha |
no | `` | Commit SHA to tag. Empty = current HEAD. |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
git-host |
no | github.com |
Git host to push to (e.g. github.com, gitlab.com, codeberg.org) |
token |
no | ${{ github.token }} |
Token used to push the tag. Pass a PAT or GitHub App token with workflows:write when the tagged commit contains workflow file changes that differ from the default branch — GITHUB_TOKEN cannot push such refs. |
Reads the first line of a VERSION file (stripping whitespace) and exposes it as an output. Fails the step if the file does not exist.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
file |
no | VERSION |
Path to the VERSION file |
Outputs
| Name | Description |
|---|---|
base-version |
The base semantic version (e.g., 1.0.0) |
Batched, keyed form of read-base-version. For each {key, file, repo?} entry, reads the first line of the VERSION file (stripping whitespace) and emits {key, version} preserving the original key. Key is opaque — use it to pair versions with whatever downstream needs them (image URLs, component names, etc.). When repo is present, fetches the file via the GitHub API instead of from the local working tree — useful for system-level workflows that need to read VERSION files from sibling repos in a multirepo split. Fails fast if any file is missing or empty.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
entries |
yes | — | JSON array of {"key": string, "file": string, "repo": string?} objects. The key is opaque (caller-supplied). The file is the path to a VERSION file — relative to the workspace when read locally, or relative to the target repo root when read via API. The optional repo (in owner/repo format) triggers cross-repo API fetch instead of local read. |
token |
no | ${{ github.token }} |
GitHub token used for cross-repo API fetches when entries contain a repo field. Must have read access to the target repos. Defaults to github.token (sufficient for same-org public repos and the calling repo). Pass a PAT or app token for cross-org access or private repos in other orgs. |
Outputs
| Name | Description |
|---|---|
versions |
JSON array of {"key": string, "version": string} objects. Keys preserved from input; versions are the trimmed first line of each file. |
Validates stage-result is one of success/failure/cancelled/skipped, then writes a markdown stage summary (with icons and per-result content blocks) to $GITHUB_STEP_SUMMARY. Used directly by commit-stage workflows and composed by render-system-stage-summary for richer system-stage rendering.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
stage-name |
yes | — | Name of the stage being summarized |
stage-result |
yes | — | Result of the stage job (success, failure, cancelled, skipped) |
stage-content |
no | `` | General content to display for any stage result (supports markdown) |
stage-success-content |
no | `` | Custom content to display on success (supports markdown) |
stage-skipped-content |
no | `` | Custom content to display on skipped (supports markdown). When provided, the stage is treated as a first-class skipped outcome rather than unknown. |
Thin composite that validates inputs, calls format-artifact-list twice (for success and latest artifact lists), delegates to render-stage-summary for the markdown body, and emits a ::notice annotation on skipped.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
stage-name |
yes | — | The name of the stage (e.g., Acceptance, QA, Production) |
stage-result |
yes | — | Overall result of the stage (success/failure/skipped) |
environment |
yes | — | The environment name for this stage |
success-version |
no | `` | The version created on success (e.g., prerelease version). Required when stage-result == success. |
success-artifact-ids |
no | `` | The artifact IDs created on success as a newline-separated list. Single artifact: a plain string. Multiple artifacts: a multiline YAML string (one per line). |
skipped-reason |
no | `` | Human-readable reason the stage was skipped (e.g., "No new artifacts since last successful run"). Only used when stage-result is skipped. |
latest-artifact-ids |
no | `` | The latest known artifact IDs as a newline-separated list (even though the stage did not run against them). Displayed on skipped. Same format as success-artifact-ids. |
latest-updated-at |
no | `` | ISO 8601 timestamp of when the latest artifacts were last updated. Displayed on skipped. |
last-run-at |
no | `` | ISO 8601 timestamp of the last successful run of this workflow. Displayed on skipped. |
Resolves a remote git ref (branch, tag, or SHA) to its 40-char commit SHA + committer timestamp. Uses a throwaway git init + shallow git fetch --depth=1 <ref> against the remote URL — works on any host that supports uploadpack.allowReachableSHA1InWant (GitHub does).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
repository |
yes | — | Repository in owner/name form (e.g. optivem/shop). Resolved against the configured git-host (defaults to github.com). |
ref |
no | main |
Git ref to resolve (branch, tag, or SHA). Empty defaults to main. |
token |
no | ${{ github.token }} |
Token for authenticating to the remote |
git-host |
no | github.com |
Git host to query (e.g. github.com, gitlab.com, codeberg.org) |
Outputs
| Name | Description |
|---|---|
sha |
40-character commit SHA |
timestamp |
Committer timestamp in ISO 8601 format |
Finds Docker images and resolves their sha256: digests from any container registry. Takes base image URLs plus a tag — appends :tag to each base URL and resolves the digest. Callers own the tag convention (e.g. latest, sha-<sha> for docker/metadata-action's type=sha,format=long convention used by the commit stage, or v1.2.3). Emits a JSON array of digest URLs (same order as input) and the most recent image creation timestamp across all processed images. Delegates to resolve-docker-image-digests.sh.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
base-image-urls |
yes | — | Base image URLs (no :tag). Combined with tag to produce the image reference. Supports newline-separated list or JSON array format. |
tag |
no | latest |
Image tag to resolve (e.g. latest, sha-<sha>, v1.2.3). Appended after : to each base URL. |
Outputs
| Name | Description |
|---|---|
image-digest-urls |
JSON array of digest URLs in the same order as input |
latest-updated-at |
ISO 8601 timestamp of the most recently created image among all processed images |
Finds the latest git tag in a repository that matches a given prefix (and optional suffix) using git ls-remote + version-aware sort. Tool-agnostic — no releases API dependency. Pair with validate-tag-exists for the "validate an explicit tag" case.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag-prefix |
yes | — | Required tag prefix (e.g. monolith-java-v). Tags matching ${tag-prefix}*${tag-suffix} are considered; the highest by version-aware sort wins. |
tag-suffix |
no | `` | Optional tag suffix (e.g. -qa-approved). When set, only tags that also end with this suffix are considered, and the base-tag output contains the matched tag with the suffix stripped. |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
token |
no | ${{ github.token }} |
Token for authenticating to the remote |
git-host |
no | github.com |
Git host to query (e.g. github.com, gitlab.com, codeberg.org) |
Outputs
| Name | Description |
|---|---|
tag |
The latest tag matching tag-prefix (and tag-suffix, if provided) |
base-tag |
The matched tag with tag-suffix stripped (equals tag when tag-suffix is empty) |
Walks rc tags matching <tag-prefix><X.Y.Z>-rc.<N> (highest rc number first), and for each tag queries repos/{r}/commits/{sha}/statuses to find the newest tag whose SHA carries a success commit-status with the given context. Returns the tag; hard-fails when none qualify. Used to find the latest "qa-approved" / "acceptance-tested" prerelease once promotion state lives in commit-statuses rather than tag suffixes. Pairs with create-commit-status (write) and get-commit-status / check-commit-status-exists (read), and parallels resolve-latest-prerelease-tag (same noun, different qualifier).
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag-prefix |
yes | — | Required tag prefix (e.g. monolith-dotnet-v). Only tags matching the strict pattern <tag-prefix><X.Y.Z>-rc.<N> are considered — legacy status-suffix tags (e.g. -qa-approved) are excluded. |
status-context |
yes | — | The commit-status context that must be present with state=success on the rc tag's SHA (e.g. qa/signoff). |
version |
no | `` | Optional. If set, restricts the search to tags matching <tag-prefix><version>-rc.<N>. |
repository |
no | ${{ github.repository }} |
Repository in owner/name form |
token |
no | ${{ github.token }} |
GitHub token used for API calls. Needs statuses:read. |
Outputs
| Name | Description |
|---|---|
tag |
The latest rc tag whose SHA has a passing commit-status. |
Notes: Caller must have run actions/checkout with fetch-depth: 0 (or otherwise fetched tags) — git tag --list and git rev-parse need the local refs.
Calls git ls-remote --tags against the remote URL, filters by the given glob pattern (default * matches any tag), matches tags (lightweight or annotated, peeled) against the target SHA, and picks the highest by sort -V (version sort). Returns empty if no matching tag points at the SHA.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
repository |
no | ${{ github.repository }} |
Repository in owner/name form (e.g. optivem/shop) |
commit-sha |
yes | — | Commit SHA to look up |
pattern |
no | * |
Tag glob pattern to filter by (e.g. monolith-typescript-v1.0.26-rc.*). Default * matches any tag. |
token |
no | ${{ github.token }} |
Token for authenticating to the remote |
git-host |
no | github.com |
Git host to query (e.g. github.com, gitlab.com, codeberg.org) |
Outputs
| Name | Description |
|---|---|
tag |
The highest tag (by version sort) matching the pattern and pointing at the SHA, or empty string if none found |
Notes: git ls-remote --tags fetches the full tag list and filters client-side. Fine at current scale (dozens of tags); for thousands of tags, a paginated gh api /repos/.../tags would be faster.
Promotes existing Docker images by issuing a server-side manifest retag (docker buildx imagetools create --tag <new> <source>) for each entry. Used for moving already-built artifacts through pipeline stages (Farley-style promotion: build-once, promote-many). No image data crosses the runner; multi-arch manifest lists are preserved. Same registry throughout — only a tag is added, no image content moves.
Two mutually exclusive input modes:
- Broadcast —
image-urls+tag: apply one uniform tag to every image in the list. Example: re-tag all system images withv1.3.2. - Map —
image-tags: apply a per-image tag. Chains directly withcompose-tagsoutput (both use thekeyfield for the source image URL). Example: re-tag each component image with its ownv{component-version}.
Exactly one mode must be used. Both modes set or neither set → fails fast with a clear error.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
image-urls |
no | `` | Broadcast mode: JSON array of source Docker image URLs with existing tags. Requires tag. Mutually exclusive with image-tags. |
tag |
no | `` | Broadcast mode: target tag applied uniformly to every image in image-urls (e.g., v1.0.5, latest, production). Mutually exclusive with image-tags. |
image-tags |
no | `` | Map mode: JSON array of {"key": string, "tag": string} objects where key is the source image URL and tag is the tag to apply to it. Mutually exclusive with image-urls + tag. |
registry |
no | ghcr.io |
Container registry URL (e.g., ghcr.io, docker.io, gcr.io) |
registry-username |
no | ${{ github.actor }} |
Username for registry authentication |
token |
no | ${{ github.token }} |
Token used to authenticate against the registry |
Outputs
| Name | Description |
|---|---|
tagged-image-urls |
JSON array of Docker image URLs with new tags applied |
Probes the GitHub rate limit (and sleeps until reset if below threshold), dispatches a workflow_dispatch workflow via gh workflow run (through retry_run), then captures the triggered run's ID by polling the workflow's runs endpoint and filtering client-side on event=workflow_dispatch, head_sha matching the resolved ref, and created_at >= dispatch time — robust against the API-indexing race and concurrent dispatches by other actors. Finally gh run watch --exit-statuses the run to fail the step if the triggered run fails.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
workflow |
yes | — | Workflow filename (e.g. monolith-java-commit-stage.yml) — matches gh workflow run --workflow |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
ref |
no | main |
Git ref to trigger the workflow on |
workflow-inputs |
no | {} |
JSON object of workflow inputs (e.g., {"version": "v1.0.0-rc.1"}). Named workflow-inputs rather than inputs to avoid shadowing the workflow inputs context at the caller site. |
poll-interval |
no | 120 |
Seconds between status polls |
rate-limit-threshold |
no | 50 |
Pause when remaining API requests fall below this number |
timeout-seconds |
no | 1800 |
Hard timeout on the watch phase. Action fails with exit code 124 if the triggered run has not terminated within this many seconds. Default is 30 minutes (fast-feedback sizing); callers that trigger longer-running workflows must override upward. |
token |
no | ${{ github.token }} |
GitHub token used to dispatch and watch the workflow run. Callers may pass a higher-scoped token (e.g. a PAT) when cross-repo workflow_dispatch is required. |
Outputs
| Name | Description |
|---|---|
run-id |
The database ID of the triggered workflow run |
Notes: The run-ID lookup uses gh run list --limit 1 against workflow + ref, with a 10s sleep before the lookup. Under heavy concurrent dispatches this could race with a sibling run — acceptable at current scale.
Iterates the newline-separated names list and uses printenv to confirm each name has a non-empty value in the step's environment. Fails the step with a ::error::Missing required config: message listing all missing names.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
names |
yes | — | Newline-separated list of environment variable names to validate. Leading/trailing whitespace on each name is trimmed; blank lines are ignored. |
Notes: List the names in names and pass the values via env: at the caller's step — the action reads them from the process environment.
Asserts that a git tag exists on a remote via git ls-remote --tags "refs/tags/<tag>". Fails the step if missing. For the inverse (assert a tag does NOT exist) or for soft-predicate usage, use check-tag-exists and gate on its exists output.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
tag |
yes | — | Git tag to require (e.g., meta-v1.0.0-rc.1, monolith-java-v1.0.0) |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
token |
no | ${{ github.token }} |
Token for authenticating to the remote |
git-host |
no | github.com |
Git host to query (e.g. github.com, gitlab.com, codeberg.org) |
For each {name, url} in the input array, polls the URL with curl -f up to max-attempts times with exponential backoff between attempts, subject to a hard total-time ceiling from timeout-seconds. Fails the step with exit code 124 if the ceiling is hit, or exit code 1 if any URL exhausts max-attempts first. On failure, if compose-file is set, dumps docker compose logs --timestamps and docker compose ps for debugging.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
endpoints |
yes | — | JSON array of endpoints to health check, each with name and url (e.g., [{"name": "API", "url": "http://localhost:8080/health"}]) |
compose-file |
no | `` | Docker Compose file for log dump on failure. If set, runs docker compose -f <file> logs/ps when any URL fails. |
working-directory |
no | . |
Working directory for the Docker Compose log dump (used only when compose-file is set) |
max-attempts |
no | 30 |
Maximum number of polling attempts per URL |
wait-seconds |
no | 10 |
Base seconds to wait between attempts (doubled each attempt, capped at wait-seconds * 16, plus small jitter) |
timeout-seconds |
no | 900 |
Hard timeout on the total polling time across all endpoints. Action fails with exit code 124 if not all endpoints become ready within this ceiling. Default is 15 min — aligned with fast-feedback sizing. |
Polls gh run list (via retry_run) for runs of a given workflow, filters by headSha == <commit-sha> until a match is found, then gh run watch --exit-statuses the run to fail the step if it fails. Sibling of trigger-and-wait-for-workflow — use that when you need to dispatch the workflow yourself; use this when a commit push has already triggered it.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
workflow |
yes | — | Workflow filename (e.g. monolith-java-commit-stage.yml) — matches gh workflow run --workflow |
commit-sha |
yes | — | The commit SHA to match against |
repository |
no | ${{ github.repository }} |
Repository in owner/repo format |
poll-interval |
no | 30 |
Seconds between discovery polls |
watch-interval |
no | 120 |
Seconds between gh run watch polls |
max-discovery-attempts |
no | 120 |
Maximum number of discovery polls before the action fails with "run never appeared" |
rate-limit-threshold |
no | 50 |
Pause when remaining API requests fall below this number |
timeout-seconds |
no | 1800 |
Hard timeout on the combined discovery + watch phases. Action fails with exit code 124 if a matching run has not been discovered AND watched to completion within this many seconds. Default is 30 minutes (fast-feedback sizing); callers waiting for longer-running workflows must override upward. |
token |
no | ${{ github.token }} |
GitHub token used for API calls |
Outputs
| Name | Description |
|---|---|
run-id |
The database ID of the matched workflow run |