name: Auto Release
concurrency:
group: auto-release-${{ github.event.workflow_run.head_branch || github.ref_name || 'main' }}
cancel-in-progress: true
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.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 gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${version}" >/dev/null 2>&1; then
detected=bump
else
detected=tag
fi
phase="${REQUESTED_PHASE}"
if [[ "${phase}" == "auto" ]]; then
phase="${detected}"
fi
echo "head=${sha} manifest=${version} detected=${detected} phase=${phase}"
{
echo "sha=${sha}"
echo "version=${version}"
echo "phase=${phase}"
} >> "$GITHUB_OUTPUT"
- name: Check CI status
id: ci_status
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_SHA: ${{ steps.target.outputs.sha }}
run: |
set -euo pipefail
# Docker is required alongside CI and Security: phase 1 pins the image
# published for this commit so it runs a current binary, and the tag
# phase has nothing to pin if that publish never happened.
for workflow in ci security docker; do
# --repo is required: this job has no checkout, so gh cannot infer
# the repository from a git remote.
status="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow="${workflow}.yml" --branch=main --commit="${TARGET_SHA}" --event=push --limit=1 --json conclusion --jq '.[0].conclusion // ""')"
echo "${workflow}=${status}"
if [[ "${status}" != "success" ]]; then
echo "all_passed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "all_passed=true" >> "$GITHUB_OUTPUT"
- name: Decide whether to attempt a release
id: decide
env:
ALL_PASSED: ${{ steps.ci_status.outputs.all_passed }}
PHASE: ${{ steps.target.outputs.phase }}
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.
#
# The tag phase is exempt: its target is the version commit this
# automation pushed, and a push by GITHUB_TOKEN starts no workflows, so
# there are no runs to check. Its gate is the registry instead — the
# image must exist and must report the version being tagged.
if [[ "${PHASE}" == "tag" || "${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: 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: Tag the verified image with the release version
if: needs.check.outputs.phase == 'tag'
env:
IMAGE: ${{ steps.runtime.outputs.image }}
DIGEST: ${{ steps.runtime.outputs.digest }}
VERSION: ${{ needs.check.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
major="${VERSION%%.*}"
minor="${VERSION#*.}"
minor="${minor%%.*}"
docker buildx imagetools create \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:${major}.${minor}" \
--tag "${IMAGE}:${major}" \
"${IMAGE}@${DIGEST}"
- 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 }}
bump: ${{ github.event.inputs.version_bump || 'auto' }}
phase: bump
- name: Publish a runtime image for 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, so ask docker.yml to build it. release-prep makes
# docker.yml dispatch the tag phase once the image is published.
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 }}
update-major-alias: "true"
phase: tag
extra-files: ${{ steps.runtime_pin.outputs.files }}
- 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"