name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Stable release tag to rehearse, in vX.Y.Z form
required: true
type: string
permissions:
contents: read
checks: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
cancel-in-progress: false
jobs:
gate:
name: Release gate
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
release_tag: ${{ steps.release-metadata.outputs.release_tag }}
release_version: ${{ steps.release-metadata.outputs.release_version }}
image_repository: ${{ steps.release-metadata.outputs.image_repository }}
primary_image_ref: ${{ steps.release-metadata.outputs.primary_image_ref }}
docker_tags: ${{ steps.release-metadata.outputs.docker_tags }}
steps:
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with:
fetch-depth: 0
persist-credentials: false
- name: Resolve release metadata
id: release-metadata
env:
EVENT_NAME: ${{ github.event_name }}
PUSH_TAG: ${{ github.ref_name }}
WORKFLOW_TAG: ${{ inputs.release_tag }}
run: |
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
release_tag="$WORKFLOW_TAG"
else
release_tag="$PUSH_TAG"
fi
[[ -n "$release_tag" ]] || {
printf 'error: release tag input is required\n' >&2
exit 1
}
release_version="${release_tag#v}"
image_repository="docker.io/runewarp/runewarp"
primary_image_ref="${image_repository}:${release_version}"
minor_series="${release_version%.*}"
major_series="${release_version%%.*}"
{
printf 'RELEASE_TAG=%s\n' "$release_tag"
printf 'RELEASE_VERSION=%s\n' "$release_version"
printf 'IMAGE_REPOSITORY=%s\n' "$image_repository"
printf 'PRIMARY_IMAGE_REF=%s\n' "$primary_image_ref"
} >> "$GITHUB_ENV"
{
printf 'release_tag=%s\n' "$release_tag"
printf 'release_version=%s\n' "$release_version"
printf 'image_repository=%s\n' "$image_repository"
printf 'primary_image_ref=%s\n' "$primary_image_ref"
printf 'docker_tags<<EOF\n'
printf '%s:%s\n' "$image_repository" "$release_version"
printf '%s:%s\n' "$image_repository" "$minor_series"
printf '%s:%s\n' "$image_repository" "$major_series"
printf '%s:latest\n' "$image_repository"
printf 'EOF\n'
} >> "$GITHUB_OUTPUT"
- name: Validate rehearsal release gate
if: github.event_name == 'workflow_dispatch'
run: ./scripts/validate-release-gates.sh rehearsal --tag "$RELEASE_TAG"
- name: Validate signed release tag
if: github.event_name == 'push'
run: ./scripts/validate-release-gates.sh tag --tag "$RELEASE_TAG" --allowed-signers-file "$PWD/.github/release-allowed-signers"
- name: Verify prior green CI
if: github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
run: |
python - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
repository = os.environ["REPOSITORY"]
commit_sha = os.environ["COMMIT_SHA"]
token = os.environ["GITHUB_TOKEN"]
request = urllib.request.Request(
f"https://api.github.com/repos/{repository}/commits/{commit_sha}/check-runs?check_name=CI&filter=latest",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(request) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
print(
f"error: failed to query GitHub check runs for commit {commit_sha}: "
f"HTTP {error.code} {error.reason}",
file=sys.stderr,
)
sys.exit(1)
except urllib.error.URLError as error:
print(
f"error: failed to reach GitHub while checking CI for commit {commit_sha}: "
f"{error.reason}",
file=sys.stderr,
)
sys.exit(1)
matches = [
check_run
for check_run in payload.get("check_runs", [])
if check_run.get("name") == "CI"
]
if not matches:
print(
f"error: commit {commit_sha} does not have an aggregate CI check run",
file=sys.stderr,
)
sys.exit(1)
if not any(check_run.get("conclusion") == "success" for check_run in matches):
print(
f"error: aggregate CI check for commit {commit_sha} is not successful",
file=sys.stderr,
)
sys.exit(1)
PY
- name: Render release notes preview
run: ./scripts/render-release-notes.sh --version "${RELEASE_TAG#v}" > /tmp/release-notes.md
- name: Summarize release gate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
mode="rehearsal"
publish_note="Rehearsal skips Docker Hub pushes, Sigstore signing, crates.io publication, and GitHub Release creation."
else
mode="tag gate"
publish_note="Real release runs crates.io publication first, then Docker Hub publication, then finalizes the GitHub Release."
fi
{
printf '## Release workflow\n\n'
printf -- '- Mode: %s\n' "$mode"
printf -- "- Release tag: \`%s\`\n" "$RELEASE_TAG"
printf -- "- Release version: \`%s\`\n" "$RELEASE_VERSION"
printf -- "- Source ref: \`%s\`\n" "${GITHUB_REF}"
printf -- "- Docker tags: \`%s\`, \`%s\`, \`%s\`, \`latest\`\n" "$RELEASE_VERSION" "${RELEASE_VERSION%.*}" "${RELEASE_VERSION%%.*}"
printf -- '- Publish status: %s\n' "$publish_note"
} >> "$GITHUB_STEP_SUMMARY"
crate-release:
name: Publish crates.io release
if: github.event_name == 'push'
needs:
- gate
runs-on: ubuntu-latest
timeout-minutes: 45
environment: release
permissions:
contents: read
steps:
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with:
persist-credentials: false
- name: Install Rust toolchain
run: |
rustup set profile minimal
rustup toolchain install stable
rustup default stable
- name: Publish crate to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_RETRY: "5"
run: cargo publish --locked --manifest-path Cargo.toml
- name: Verify published crate install surface
run: |
./scripts/validate-install-surfaces.sh registry-install \
--crate-name runewarp \
--bin-name runewarp \
--expected-version "${{ needs.gate.outputs.release_version }}" \
--retry-attempts 10 \
--retry-delay-seconds 30
docker-release:
name: Publish Docker Hub release
if: github.event_name == 'push'
needs:
- gate
- crate-release
runs-on: ubuntu-latest
timeout-minutes: 60
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Enforce Docker version tag immutability
run: |
./scripts/validate-install-surfaces.sh docker-registry-tag-absent \
--image-ref "${{ needs.gate.outputs.primary_image_ref }}"
- name: Build and push multi-arch release image
id: build-image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: mode=max
tags: ${{ needs.gate.outputs.docker_tags }}
- name: Install cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac
- name: Sign released image
env:
IMAGE_REPOSITORY: ${{ needs.gate.outputs.image_repository }}
IMAGE_DIGEST: ${{ steps.build-image.outputs.digest }}
run: |
cosign sign --yes "${IMAGE_REPOSITORY}@${IMAGE_DIGEST}"
- name: Verify published Docker image
run: |
./scripts/validate-install-surfaces.sh docker-registry-image \
--image-ref "${{ needs.gate.outputs.primary_image_ref }}" \
--expected-version "${{ needs.gate.outputs.release_version }}" \
--retry-attempts 10 \
--retry-delay-seconds 15
github-release:
name: Finalize GitHub release
if: github.event_name == 'push'
needs:
- gate
- docker-release
- crate-release
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with:
persist-credentials: false
- name: Render release notes
run: ./scripts/render-release-notes.sh --version "${{ needs.gate.outputs.release_version }}" > /tmp/release-notes.md
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.gate.outputs.release_tag }}
RELEASE_VERSION: ${{ needs.gate.outputs.release_version }}
REPOSITORY: ${{ github.repository }}
run: |
gh release create "$RELEASE_TAG" \
--repo "$REPOSITORY" \
--verify-tag \
--latest \
--title "Runewarp $RELEASE_VERSION" \
--notes-file /tmp/release-notes.md