name: Auto Release
concurrency:
group: auto-release-${{ github.event.workflow_run.head_branch || github.ref_name || 'main' }}
cancel-in-progress: false
queue: max
on:
workflow_run:
workflows: ["CI", "Security", "Docker"]
types: [completed]
branches: [main]
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
inputs:
version_bump:
description: Version bump type
required: true
default: auto
type: choice
options:
- auto
- patch
- minor
- major
phase:
description: Release phase; auto detects a committed but untagged version
required: true
default: auto
type: choice
options:
- auto
- bump
- tag
permissions:
contents: read
jobs:
check:
name: Check for Release
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
if: >-
(github.event_name == 'workflow_run' &&
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_dispatch') &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository) ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
permissions:
actions: read
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
target_sha: ${{ steps.target.outputs.sha }}
phase: ${{ steps.target.outputs.phase }}
version: ${{ steps.target.outputs.version }}
steps:
- name: Determine target SHA and phase
id: target
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REQUESTED_PHASE: ${{ github.event.inputs.phase || 'auto' }}
run: |
set -euo pipefail
sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "invalid head SHA '${sha}'" >&2
exit 1
fi
version="$(
gh api "repos/${GITHUB_REPOSITORY}/contents/Cargo.toml?ref=${sha}" --jq .content \
| base64 -d | grep -m1 '^version = ' | sed -E 's/^version = "(.*)"$/\1/'
)"
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "invalid manifest version '${version}' at ${sha}" >&2
exit 1
fi
# A manifest version with no tag means phase 1 already committed the
# bump, so this run finishes that release instead of starting another.
if response="$(gh api --include --silent "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${version}")"; then
detected=bump
else
status="$(awk '/^HTTP\// { status=$2 } END { print status }' <<< "${response}")"
if [[ "${status}" != 404 ]]; then
echo "release tag lookup failed with HTTP ${status:-unavailable}; refusing to infer absence" >&2
exit 1
fi
detected=tag
fi
phase="${REQUESTED_PHASE}"
if [[ "${phase}" == "auto" ]]; then
phase="${detected}"
fi
skip=false
if [[ "${phase}" == tag && "${detected}" == bump ]]; then
echo "v${version} already exists; the requested tag phase is a no-op"
skip=true
fi
echo "head=${sha} manifest=${version} detected=${detected} phase=${phase}"
{
echo "skip=${skip}"
echo "sha=${sha}"
echo "version=${version}"
echo "phase=${phase}"
} >> "$GITHUB_OUTPUT"
- name: Check CI status
if: steps.target.outputs.skip != 'true'
id: ci_status
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_SHA: ${{ steps.target.outputs.sha }}
PHASE: ${{ steps.target.outputs.phase }}
run: |
set -euo pipefail
# The Docker handoff can dispatch us just before its own run finishes.
# Wait briefly for that completion, without exempting the tag phase
# from any gate. A newer failed rerun supersedes an earlier success.
attempts=1
if [[ "${PHASE}" == tag ]]; then attempts=21; fi
for ((attempt=1; attempt<=attempts; attempt++)); do
main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
if [[ "${main_sha}" != "${TARGET_SHA}" ]]; then
echo "main advanced; a new run must validate its new head"
break
fi
all_passed=true
terminal_failure=false
for workflow in ci security docker; do
# GITHUB_TOKEN version commits need explicitly dispatched checks.
# PR and scheduled runs cannot substitute for these main checks.
status="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow="${workflow}.yml" \
--branch=main --commit="${TARGET_SHA}" --limit=100 \
--json event,status,conclusion,createdAt,updatedAt,databaseId --jq \
'[.[] | select(.event == "push" or .event == "workflow_dispatch")] | sort_by(.updatedAt, .createdAt, .databaseId) | last | if . == null then "missing" else (.status + ":" + (.conclusion // "")) end')"
echo "${workflow}=${status}"
if [[ "${status}" != completed:success ]]; then
all_passed=false
if [[ "${status}" == completed:* ]]; then terminal_failure=true; fi
fi
done
if [[ "${all_passed}" == true ]]; then
echo "all_passed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${terminal_failure}" == true || "${attempt}" == "${attempts}" ]]; then break; fi
sleep 30
done
echo "all_passed=false" >> "$GITHUB_OUTPUT"
- name: Decide whether to attempt a release
id: decide
env:
ALL_PASSED: ${{ steps.ci_status.outputs.all_passed }}
run: |
# The release CLI itself no-ops when no conventional commit warrants
# a release, so this gate only enforces green CI, Security, and Docker
# runs — including for manual dispatches.
if [[ "${ALL_PASSED}" == "true" ]]; then
echo "should_release=true" >> "$GITHUB_OUTPUT"
else
echo "should_release=false" >> "$GITHUB_OUTPUT"
fi
release:
name: Create Release
needs: check
if: needs.check.outputs.should_release == 'true'
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
permissions:
contents: write
actions: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
ref: ${{ needs.check.outputs.target_sha }}
- name: Select stable Buildx for digest promotion
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 with:
version: v0.39.0
driver: docker
- name: Resolve the runtime image for this commit
id: runtime
env:
TARGET_SHA: ${{ needs.check.outputs.target_sha }}
run: |
set -euo pipefail
repo_path="$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
short="${TARGET_SHA:0:7}"
token="$(curl -fsSL "https://ghcr.io/token?scope=repository:${repo_path}:pull&service=ghcr.io" | jq -r .token)"
# The short-SHA tag is mutable — scheduled rebuilds re-push it — so
# resolve it to a digest, which is immutable once committed.
digest="$(
curl -fsSL -o /dev/null -D - \
-H "Authorization: Bearer ${token}" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
"https://ghcr.io/v2/${repo_path}/manifests/${short}" \
| tr -d '\r' | awk 'tolower($1) == "docker-content-digest:" { print $2 }'
)"
if [[ -z "${digest}" ]]; then
echo "no published ghcr.io/${repo_path} image tagged ${short}; refusing to release a mismatched runtime" >&2
exit 1
fi
{
echo "image=ghcr.io/${repo_path}"
echo "digest=${digest}"
echo "short=${short}"
} >> "$GITHUB_OUTPUT"
- name: Verify the image reports the version being tagged
if: needs.check.outputs.phase == 'tag'
env:
IMAGE: ${{ steps.runtime.outputs.image }}
DIGEST: ${{ steps.runtime.outputs.digest }}
VERSION: ${{ needs.check.outputs.version }}
run: |
set -euo pipefail
reported="$(docker run --rm "${IMAGE}@${DIGEST}" --version | awk '{ print $NF }')"
echo "image reports '${reported}', manifest expects '${VERSION}'"
if [[ "${reported}" != "${VERSION}" ]]; then
echo "runtime image ${DIGEST} reports ${reported}, not ${VERSION}; refusing to tag a mismatched runtime" >&2
exit 1
fi
- name: Pin runtime images to the resolved digest
id: runtime_pin
env:
IMAGE: ${{ steps.runtime.outputs.image }}
DIGEST: ${{ steps.runtime.outputs.digest }}
REF_TAG: ${{ needs.check.outputs.phase == 'tag' && needs.check.outputs.version || steps.runtime.outputs.short }}
PIN_FILES: runtime/Dockerfile release/Dockerfile
run: |
set -euo pipefail
for file in ${PIN_FILES}; do
[[ -f "${file}" ]] || { echo "missing ${file}" >&2; exit 1; }
sed -i -E "s|^FROM [^[:space:]]+|FROM ${IMAGE}:${REF_TAG}@${DIGEST}|" "${file}"
grep -m1 '^FROM ' "${file}"
done
echo "files=${PIN_FILES// /,}" >> "$GITHUB_OUTPUT"
- name: Commit the version bump
id: bump
if: needs.check.outputs.phase == 'bump'
uses: ./
with:
command: release
token: ${{ secrets.GITHUB_TOKEN }}
expected-head: ${{ needs.check.outputs.target_sha }}
bump: ${{ github.event.inputs.version_bump || 'auto' }}
phase: bump
- name: Validate and publish the committed version
if: needs.check.outputs.phase == 'bump' && steps.bump.outputs.commit != '' && steps.bump.outputs.released != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.bump.outputs.version }}
COMMIT: ${{ steps.bump.outputs.commit }}
run: |
set -euo pipefail
echo "committed ${VERSION} as ${COMMIT}; building its runtime image"
# main now holds the version commit, and a push by GITHUB_TOKEN starts
# no workflows. Run the full CI and Security suites as well as Docker;
# the tag phase requires all three on the exact current main commit.
# release-prep hands back after image scan, signing, and SBOM finish.
main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
if [[ "${main_sha}" != "${COMMIT}" ]]; then
echo "main advanced after the bump; its own checks must finish before release"
exit 0
fi
gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref main
gh workflow run security.yml --repo "$GITHUB_REPOSITORY" --ref main
gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref main -f release-prep=true
- name: Tag and publish the committed version
id: release
if: needs.check.outputs.phase == 'tag'
uses: ./
with:
command: release
token: ${{ secrets.GITHUB_TOKEN }}
expected-head: ${{ needs.check.outputs.target_sha }}
update-major-alias: "true"
phase: tag
extra-files: ${{ steps.runtime_pin.outputs.files }}
- name: Tag the verified image with the release version
if: steps.release.outputs.released == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EXPECTED_IMAGE: ${{ steps.runtime.outputs.image }}
EXPECTED_DIGEST: ${{ steps.runtime.outputs.digest }}
VERSION: ${{ steps.release.outputs.version }}
run: bash scripts/promote_release_image.sh
- name: Trigger tag pipelines
if: steps.release.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.release.outputs.version }}
TAG: ${{ steps.release.outputs.tag }}
run: |
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"
- name: Trigger tag pipelines for an unphased runtime
if: needs.check.outputs.phase == 'bump' && steps.bump.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.bump.outputs.version }}
TAG: ${{ steps.bump.outputs.tag }}
run: |
echo "::warning::the pinned runtime ignored the bump phase and released ${TAG} in one pass"
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"
gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG"