name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to validate (example: v0.3.1)"
required: false
type: string
publish:
description: "Publish to crates.io and create GitHub release"
required: false
default: false
type: boolean
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
CRATE_NAME: motto
jobs:
verify:
name: Verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Run quality gates
run: |
cargo check --all-features
cargo fmt --all --check
cargo clippy --all-features -- -D warnings
cargo test --all-features
cargo test --all-features -- --ignored
validate_version:
name: Validate Tag Version
runs-on: ubuntu-latest
needs: verify
outputs:
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.compare.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Resolve tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
else
tag="${{ github.event.inputs.tag }}"
fi
if [ -z "${tag}" ]; then
echo "No release tag available. For manual runs, provide the 'tag' input."
exit 1
fi
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
- name: Compare tag and Cargo.toml version
id: compare
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
if [[ ! "${TAG}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "Tag must follow vMAJOR.MINOR.PATCH format. Got: ${TAG}"
exit 1
fi
tag_version="${BASH_REMATCH[1]}"
cargo_version="$(python -c 'import tomllib;print(tomllib.load(open("Cargo.toml","rb"))["package"]["version"])')"
if [ "${tag_version}" != "${cargo_version}" ]; then
echo "Tag version (${tag_version}) does not match Cargo.toml version (${cargo_version})."
exit 1
fi
echo "version=${cargo_version}" >> "${GITHUB_OUTPUT}"
ci_gate:
name: Gate On CI
runs-on: ubuntu-latest
needs: validate_version
permissions:
actions: read
contents: read
env:
RELEASE_TAG: ${{ needs.validate_version.outputs.tag }}
steps:
- name: Resolve release commit from tag
id: release_sha
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
python - <<'PY'
import json
import os
repo = os.environ["REPO"]
tag = os.environ["RELEASE_TAG"]
def gh_api(path: str):
import urllib.request
req = urllib.request.Request(
f"https://api.github.com{path}",
headers={
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
ref = gh_api(f"/repos/{repo}/git/ref/tags/{tag}")
obj = ref["object"]
sha = obj["sha"]
# Annotated tags point to a tag object; peel to commit.
if obj["type"] == "tag":
tag_obj = gh_api(f"/repos/{repo}/git/tags/{sha}")
sha = tag_obj["object"]["sha"]
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"sha={sha}\n")
print(f"Resolved {tag} -> {sha}")
PY
- name: Wait for CI workflow success on release commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RELEASE_SHA: ${{ steps.release_sha.outputs.sha }}
run: |
python - <<'PY'
import json
import os
import sys
import time
repo = os.environ["REPO"]
sha = os.environ["RELEASE_SHA"]
deadline = time.time() + 3600 # 60 minutes
def gh_api(path: str):
import urllib.request
req = urllib.request.Request(
f"https://api.github.com{path}",
headers={
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
while True:
runs = gh_api(f"/repos/{repo}/actions/runs?head_sha={sha}&per_page=100")["workflow_runs"]
ci_runs = [r for r in runs if r.get("name") == "CI"]
if ci_runs:
run = max(ci_runs, key=lambda r: r["run_number"])
status = run.get("status")
conclusion = run.get("conclusion")
url = run.get("html_url")
print(f"CI run: status={status} conclusion={conclusion} url={url}")
if status == "completed":
if conclusion == "success":
print("CI gate passed.")
break
print("CI gate failed: CI workflow did not succeed.")
sys.exit(1)
else:
print(f"No CI workflow run found yet for {sha}.")
if time.time() > deadline:
print("Timed out waiting for CI workflow completion.")
sys.exit(1)
time.sleep(20)
PY
publish:
name: Publish Crate
runs-on: ubuntu-latest
needs: [validate_version, ci_gate]
if: github.event_name == 'push' || github.event.inputs.publish == 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Ensure crates.io token exists
run: |
if [ -z "${CARGO_REGISTRY_TOKEN}" ]; then
echo "CARGO_REGISTRY_TOKEN is not set"
exit 1
fi
- name: Check if version already exists
id: crate_check
env:
VERSION: ${{ needs.validate_version.outputs.version }}
run: |
status="$(curl -s -o /tmp/crate.json -w "%{http_code}" "https://crates.io/api/v1/crates/${CRATE_NAME}/${VERSION}")"
if [ "${status}" = "200" ]; then
echo "Version ${VERSION} is already published."
echo "already_published=true" >> "${GITHUB_OUTPUT}"
else
echo "Version ${VERSION} is not published yet."
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: Publish to crates.io
if: steps.crate_check.outputs.already_published != 'true'
run: cargo publish --locked
github_release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [validate_version, publish]
if: github.event_name == 'push' || github.event.inputs.publish == 'true'
steps:
- name: Create release with auto notes
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.validate_version.outputs.tag }}
generate_release_notes: true