name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Merged stable version without v (for example 0.3.0)"
required: true
type: string
sha:
description: "Optional exact commit to release (defaults to the merged release/v<version> PR)"
required: false
default: ""
type: string
publish:
description: "Publish after validation and protected-environment approval"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: release-publication
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
preflight:
name: Validate release identity
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
tag: ${{ steps.identity.outputs.tag }}
sha: ${{ steps.identity.outputs.sha }}
version: ${{ steps.identity.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
persist-credentials: false
- name: Resolve and validate the release commit
id: identity
env:
VERSION: ${{ inputs.version }}
SHA_OVERRIDE: ${{ inputs.sha }}
GH_TOKEN: ${{ github.token }}
run: |
scripts/release/check-version.sh main
scripts/release/check-version.sh stable "$VERSION"
# The dispatch tip of main; the release commit must be one of its
# ancestors, i.e. an already-merged, reviewed commit.
main_tip="$(git rev-parse HEAD)"
branch="release/v${VERSION}"
if [ -n "$SHA_OVERRIDE" ]; then
scripts/release/check-version.sh sha "$SHA_OVERRIDE"
sha="$SHA_OVERRIDE"
else
sha="$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged \
--head "$branch" --base main --json mergeCommit,mergedAt \
--jq 'sort_by(.mergedAt) | last | .mergeCommit.oid // empty')"
if [ -z "$sha" ]; then
echo "::error::no merged $branch PR found; run Prepare Release and merge it, or pass an explicit sha"
exit 1
fi
fi
if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
echo "::error::$sha is not a known commit in this repository"
exit 1
fi
if ! git merge-base --is-ancestor "$sha" "$main_tip"; then
echo "::error::$sha is not an ancestor of main; only merged commits can be released"
exit 1
fi
git checkout --quiet --detach "$sha"
scripts/release/check-version.sh manifest "$VERSION"
echo "Releasing v$VERSION from $sha"
{
echo "version=$VERSION"
echo "tag=v$VERSION"
echo "sha=$sha"
} >> "$GITHUB_OUTPUT"
rust-ci:
needs: preflight
uses: ./.github/workflows/ci-test.yml
with:
caller-run-id: ${{ github.run_id }}
ref: ${{ needs.preflight.outputs.sha }}
audit:
needs: preflight
uses: ./.github/workflows/audit.yml
with:
caller-run-id: ${{ github.run_id }}
ref: ${{ needs.preflight.outputs.sha }}
nix:
needs: preflight
uses: ./.github/workflows/nix.yml
with:
caller-run-id: ${{ github.run_id }}
ref: ${{ needs.preflight.outputs.sha }}
workflow-lint:
needs: preflight
uses: ./.github/workflows/actionlint.yml
with:
caller-run-id: ${{ github.run_id }}
ref: ${{ needs.preflight.outputs.sha }}
source-package:
name: Stage crate and SBOM
needs: [preflight, rust-ci, audit, nix, workflow-lint]
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.preflight.outputs.sha }}
persist-credentials: false
- name: Set up build environment
uses: ./.github/actions/setup
- name: Install pinned SBOM generator
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 with:
tool: cargo-cyclonedx@0.5.9
- name: Package and verify the crate
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
scripts/release/check-version.sh manifest "$VERSION"
cargo package --locked --verbose
cargo publish --dry-run --locked --verbose
cargo cyclonedx --format json --target all --all-features
# cargo-cyclonedx stamps a wall-clock timestamp and a random
# serialNumber, so two runs of an identical tree differ byte-for-byte.
# That would break a resumed publish, whose byte-identity check compares
# the regenerated SBOM against the one already uploaded. Pin the
# timestamp to the commit time and replace the random serial with a
# UUID derived deterministically from the release commit. The serial
# cannot simply be dropped: actions/attest requires a serialNumber and
# rejects a CycloneDX document without one as an unsupported format.
commit_iso="$(TZ=UTC git show -s --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ HEAD)"
serial="urn:uuid:$(python3 -c "import uuid, sys; print(uuid.uuid5(uuid.NAMESPACE_URL, 'kinjo-sbom-' + sys.argv[1]))" "$(git rev-parse HEAD)")"
jq --arg ts "$commit_iso" --arg serial "$serial" \
'.serialNumber = $serial | .metadata.timestamp = $ts' \
kinjo.cdx.json > "kinjo-${VERSION}.cdx.json"
rm -f kinjo.cdx.json
git archive --format=tar --prefix="kinjo-${VERSION}/" HEAD |
gzip -n > "kinjo-${VERSION}.tar.gz"
tar -tzf "kinjo-${VERSION}.tar.gz" | grep -Fx "kinjo-${VERSION}/Cargo.toml"
# upload-artifact roots an artifact at the least common ancestor of its
# paths, so the crate has to sit beside the other two or it would be
# stored under target/package/ and land in the wrong place on download.
mv "target/package/kinjo-${VERSION}.crate" .
- name: Upload staged source artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-source-${{ needs.preflight.outputs.version }}
path: |
kinjo-${{ needs.preflight.outputs.version }}.crate
kinjo-${{ needs.preflight.outputs.version }}.cdx.json
kinjo-${{ needs.preflight.outputs.version }}.tar.gz
if-no-files-found: error
retention-days: 7
debian:
needs: [preflight, rust-ci, audit, nix, workflow-lint]
uses: ./.github/workflows/release-deb.yml
with:
version: ${{ needs.preflight.outputs.version }}
ref: ${{ needs.preflight.outputs.sha }}
macos:
needs: [preflight, rust-ci, audit, nix, workflow-lint]
uses: ./.github/workflows/release-macos.yml
with:
version: ${{ needs.preflight.outputs.version }}
ref: ${{ needs.preflight.outputs.sha }}
stage:
name: Assemble and verify the candidate artifacts
needs: [preflight, source-package, debian, macos]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.preflight.outputs.sha }}
persist-credentials: false
- name: Download every staged artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: release-*-${{ needs.preflight.outputs.version }}
path: dist
merge-multiple: true
- name: Verify the staged artifact set
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: scripts/release/check-artifacts.sh dist "$VERSION"
dry-run-complete:
name: Release candidate is ready
if: ${{ !inputs.publish }}
needs: [preflight, stage]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- run: echo "v${{ needs.preflight.outputs.version }} passed the complete release candidate gate; nothing was published."
publish:
name: Publish approved release
if: ${{ inputs.publish }}
needs: [preflight, stage]
runs-on: ubuntu-24.04
timeout-minutes: 30
environment:
name: release
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Download the validated source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.preflight.outputs.sha }}
path: source
persist-credentials: false
- name: Download every staged artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: release-*-${{ needs.preflight.outputs.version }}
path: dist
merge-multiple: true
- name: Create checksums and validate staged names
working-directory: source
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: scripts/release/check-artifacts.sh ../dist "$VERSION"
- name: Mint release App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
- name: Create or verify the draft release
id: draft
working-directory: source
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ needs.preflight.outputs.tag }}
SHA: ${{ needs.preflight.outputs.sha }}
run: |
metadata="$RUNNER_TEMP/release.json"
# Make sure the immutable tag exists at the exact release commit before
# creating the release. On a resume it already exists and must point at
# SHA; otherwise push it from this checkout, whose HEAD is the release
# commit, using the App token via git (the releases API cannot create a
# non-HEAD tag under immutable releases).
if tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null)"; then
if [ "$tag_sha" != "$SHA" ]; then
echo "::error::$TAG already exists at $tag_sha instead of $SHA"
exit 1
fi
else
head_sha="$(git rev-parse HEAD)"
if [ "$head_sha" != "$SHA" ]; then
echo "::error::source checkout is at $head_sha, not the release commit $SHA"
exit 1
fi
gh auth setup-git
git push origin "HEAD:refs/tags/${TAG}"
fi
# Create the release from the now-existing tag (no --target, so no tag
# is created through the API). A resumed run reuses the existing draft.
if ! gh release view "$TAG" --json isDraft,isPrerelease,name > "$metadata" 2>/dev/null; then
gh release create "$TAG" --draft --generate-notes --title "$TAG"
gh release view "$TAG" --json isDraft,isPrerelease,name > "$metadata"
fi
# Read raw values only: jq -e would exit non-zero on a legitimate
# false/null (isDraft is false for a resumed published release,
# isPrerelease is false for every normal release), which under set -e
# would abort the step before the explicit checks below can run.
state="$(jq -r .isDraft "$metadata")"
name="$(jq -r .name "$metadata")"
prerelease="$(jq -r .isPrerelease "$metadata")"
if [ "$name" != "$TAG" ] || [ "$prerelease" != false ]; then
echo "::error::$TAG has conflicting release metadata"
exit 1
fi
echo "is-draft=$state" >> "$GITHUB_OUTPUT"
- name: Upload only new or byte-identical assets
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ needs.preflight.outputs.tag }}
run: |
mkdir -p existing-assets
while IFS= read -r name; do
test -f "dist/$name" || {
echo "::error::release contains unexpected asset $name"
exit 1
}
done < <(gh release view "$TAG" --json assets --jq '.assets[].name')
for file in dist/*; do
name="${file##*/}"
if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq "$name"; then
gh release download "$TAG" --pattern "$name" --dir existing-assets
cmp "$file" "existing-assets/$name" || {
echo "::error::published asset $name differs from the staged artifact"
exit 1
}
else
gh release upload "$TAG" "$file"
fi
done
- name: Attest build provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 with:
subject-checksums: dist/SHA256SUMS
- name: Attest the CycloneDX SBOM
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 with:
subject-checksums: dist/SHA256SUMS
sbom-path: dist/kinjo-${{ needs.preflight.outputs.version }}.cdx.json
- name: Check whether crates.io already has this exact crate
id: crate-state
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
response="$RUNNER_TEMP/crates-version.json"
status="$(curl --proto '=https' --proto-redir '=https' -sSLo "$response" -w '%{http_code}' \
-H 'User-Agent: kinjo-release-workflow' \
"https://crates.io/api/v1/crates/kinjo/${VERSION}")"
case "$status" in
200)
published="$(jq -er '.version.checksum' "$response")"
staged="$(sha256sum "dist/kinjo-${VERSION}.crate" | cut -d' ' -f1)"
if [ "$published" != "$staged" ]; then
echo "::error::crates.io kinjo $VERSION has checksum $published, expected $staged"
exit 1
fi
echo "publish=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "publish=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::error::crates.io returned HTTP $status"
cat "$response"
exit 1
;;
esac
- name: Reproduce the staged crate before publishing it
if: steps.crate-state.outputs.publish == 'true'
working-directory: source
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
cargo package --locked --no-verify --verbose
cmp "target/package/kinjo-${VERSION}.crate" "../dist/kinjo-${VERSION}.crate"
- name: Obtain a short-lived crates.io token
if: steps.crate-state.outputs.publish == 'true'
id: crates-auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 - name: Publish crates.io
if: steps.crate-state.outputs.publish == 'true'
working-directory: source
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
run: cargo publish --locked --no-verify --verbose
- name: Verify the immutable crates.io checksum
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
expected="$(sha256sum "dist/kinjo-${VERSION}.crate" | cut -d' ' -f1)"
for attempt in {1..12}; do
response="$RUNNER_TEMP/crates-version-${attempt}.json"
status="$(curl --proto '=https' --proto-redir '=https' -sSLo "$response" -w '%{http_code}' \
-H 'User-Agent: kinjo-release-workflow' \
"https://crates.io/api/v1/crates/kinjo/${VERSION}")"
if [ "$status" = 200 ]; then
actual="$(jq -er '.version.checksum' "$response")"
test "$actual" = "$expected" || {
echo "::error::crates.io checksum $actual does not match $expected"
exit 1
}
exit 0
fi
if [ "$status" != 404 ]; then
echo "::error::crates.io returned HTTP $status"
cat "$response"
exit 1
fi
sleep 5
done
echo "::error::kinjo $VERSION did not become visible on crates.io"
exit 1
- name: Publish the immutable GitHub release
if: steps.draft.outputs.is-draft == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ needs.preflight.outputs.tag }}
run: gh release edit "$TAG" --draft=false --latest
- name: Verify the release is public and immutably tagged
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ needs.preflight.outputs.tag }}
SHA: ${{ needs.preflight.outputs.sha }}
run: |
test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = false
# Publishing created the tag at the release target. Confirm the tag ref
# now exists and resolves to the exact validated commit. Allow a few
# retries for the ref to become readable right after publication.
for _ in 1 2 3 4 5; do
if tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null)"; then
test "$tag_sha" = "$SHA" || {
echo "::error::$TAG targets $tag_sha instead of $SHA"
exit 1
}
exit 0
fi
sleep 3
done
echo "::error::$TAG has no tag ref after publication"
exit 1
homebrew:
name: Open validated Homebrew PR
if: ${{ inputs.publish }}
needs: [preflight, publish]
uses: ./.github/workflows/update-homebrew-tap.yml
secrets: inherit
with:
tag: ${{ needs.preflight.outputs.tag }}
sha: ${{ needs.preflight.outputs.sha }}