Skip to content

Release

Release #74

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
channel:
description: Release channel to publish (nightly or stable)
required: true
type: choice
default: stable
options:
- stable
- nightly
version:
description: Stable MAJOR.MINOR.PATCH override; blank selects the next patch version
required: false
type: string
workflow_run:
workflows: [CI]
types: [completed]
permissions:
actions: read
contents: write
concurrency:
group: wright-${{ inputs.channel || 'nightly' }}
cancel-in-progress: ${{ inputs.channel != 'stable' }}
jobs:
resolve-trigger:
name: Resolve release trigger
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.trigger.outputs.channel }}
commit: ${{ steps.trigger.outputs.commit }}
version: ${{ steps.trigger.outputs.version }}
steps:
- name: Resolve channel and revision
id: trigger
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_CHANNEL: ${{ inputs.channel }}
REQUESTED_VERSION: ${{ inputs.version }}
WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_RUN_BRANCH: ${{ github.event.workflow_run.head_branch }}
WORKFLOW_RUN_COMMIT: ${{ github.event.workflow_run.head_sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [[ "$EVENT_NAME" == workflow_run ]]; then
if [[ "$WORKFLOW_RUN_CONCLUSION" != success || "$WORKFLOW_RUN_BRANCH" != "$DEFAULT_BRANCH" ]]; then
echo "nightly publication requires successful CI on $DEFAULT_BRANCH" >&2
exit 1
fi
current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq '.sha')"
if [[ "$current_sha" != "$WORKFLOW_RUN_COMMIT" ]]; then
echo "CI completed for $WORKFLOW_RUN_COMMIT, but $DEFAULT_BRANCH is now $current_sha" >&2
exit 1
fi
channel=nightly
commit="$WORKFLOW_RUN_COMMIT"
version=
elif [[ "$EVENT_NAME" == workflow_dispatch ]]; then
if [[ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]]; then
echo "manual releases must be dispatched from $DEFAULT_BRANCH" >&2
exit 1
fi
channel="$REQUESTED_CHANNEL"
commit="$GITHUB_SHA"
version="$REQUESTED_VERSION"
else
echo "unsupported release event: $EVENT_NAME" >&2
exit 1
fi
case "$channel" in
nightly|stable) ;;
*) echo "unsupported release channel: $channel" >&2; exit 1 ;;
esac
{
echo "channel=$channel"
echo "commit=$commit"
echo "version=$version"
} >> "$GITHUB_OUTPUT"
release-identity:
name: Prepare ${{ needs.resolve-trigger.outputs.channel }} release
needs: resolve-trigger
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.identity.outputs.channel }}
commit: ${{ steps.identity.outputs.commit }}
version: ${{ steps.identity.outputs.version }}
tag: ${{ steps.identity.outputs.tag }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-trigger.outputs.commit }}
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
persist-credentials: true
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Verify stable dispatch head
if: needs.resolve-trigger.outputs.channel == 'stable'
env:
RELEASE_COMMIT: ${{ needs.resolve-trigger.outputs.commit }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
set -euo pipefail
current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq '.sha')"
test "$current_sha" = "$RELEASE_COMMIT"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
- name: Verify nightly release state
if: needs.resolve-trigger.outputs.channel == 'nightly'
run: python3 scripts/verify-dist.py
- name: Bump and commit stable version
id: bump
if: needs.resolve-trigger.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
REQUESTED_VERSION: ${{ needs.resolve-trigger.outputs.version }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
shell: bash
run: |
set -euo pipefail
export LC_ALL=C
stable_version_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
current_version="$(cargo metadata --locked --no-deps --format-version 1 |
jq -r '.packages[] | select(.name == "wright-cli") | .version')"
if [[ ! "$current_version" =~ $stable_version_pattern ]]; then
echo "workspace version must be MAJOR.MINOR.PATCH: $current_version" >&2
exit 1
fi
test "$(tr -d '\r\n' < version.txt)" = "$current_version"
version_is_newer() {
local current="$1"
local candidate="$2"
local -a current_parts candidate_parts
local current_part candidate_part index
IFS=. read -r -a current_parts <<< "$current"
IFS=. read -r -a candidate_parts <<< "$candidate"
for index in 0 1 2; do
current_part="${current_parts[$index]}"
candidate_part="${candidate_parts[$index]}"
if (( ${#candidate_part} != ${#current_part} )); then
(( ${#candidate_part} > ${#current_part} ))
return
fi
if [[ "$candidate_part" != "$current_part" ]]; then
[[ "$candidate_part" > "$current_part" ]]
return
fi
done
return 1
}
increment_decimal() {
local value="$1"
local carry=1
local result=""
local digit
while [[ -n "$value" ]]; do
digit="${value: -1}"
value="${value:0:${#value}-1}"
if (( carry )); then
if [[ "$digit" == 9 ]]; then
digit=0
else
digit=$((digit + 1))
carry=0
fi
fi
result="$digit$result"
done
if (( carry )); then
result="1$result"
fi
printf '%s' "$result"
}
if [[ -n "$REQUESTED_VERSION" ]]; then
if [[ ! "$REQUESTED_VERSION" =~ $stable_version_pattern ]]; then
echo "stable version must be MAJOR.MINOR.PATCH without prerelease or build metadata: $REQUESTED_VERSION" >&2
exit 1
fi
if ! version_is_newer "$current_version" "$REQUESTED_VERSION"; then
echo "stable version must be newer than $current_version: $REQUESTED_VERSION" >&2
exit 1
fi
version="$REQUESTED_VERSION"
else
IFS=. read -r major minor patch <<< "$current_version"
version="$major.$minor.$(increment_decimal "$patch")"
fi
cargo_tmp="Cargo.toml.release-version.tmp"
awk -v version="$version" '
/^\[workspace\.package\]$/ { in_workspace_package=1 }
in_workspace_package && /^version = / {
print "version = \"" version "\""
in_workspace_package=0
next
}
{ print }
' Cargo.toml > "$cargo_tmp"
mv "$cargo_tmp" Cargo.toml
printf '%s\n' "$version" > version.txt
python3 scripts/update-dist-manifests.py --version "$version"
cargo check --workspace --all-targets --all-features
python3 scripts/verify-dist.py
git diff --check
test "$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "wright-cli") | .version')" = "$version"
git add Cargo.toml Cargo.lock version.txt dist
for path in Cargo.toml Cargo.lock version.txt dist; do
git diff --cached --quiet -- "$path" && {
echo "stable version preparation did not update $path" >&2
exit 1
}
done
unexpected="$(git diff --cached --name-only | grep -Ev '^(Cargo.toml|Cargo.lock|version.txt|dist/)' || true)"
test -z "$unexpected"
git diff --cached --quiet && {
echo "stable version preparation produced no changes" >&2
exit 1
}
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "chore(main): release $version"
git push origin "HEAD:$DEFAULT_BRANCH"
echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Wait for post-bump CI
if: needs.resolve-trigger.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
POST_BUMP_COMMIT: ${{ steps.bump.outputs.commit }}
run: |
set -euo pipefail
ci_run_id=
for _ in {1..120}; do
ci_run_id="$(gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow ci.yml \
--commit "$POST_BUMP_COMMIT" \
--event push \
--json databaseId \
--jq '.[0].databaseId // empty' \
--limit 1)"
if [[ -n "$ci_run_id" ]]; then
break
fi
sleep 15
done
if [[ -z "$ci_run_id" ]]; then
echo "timed out waiting for CI on $POST_BUMP_COMMIT" >&2
exit 1
fi
gh run watch "$ci_run_id" --interval 15 --exit-status
- name: Verify prepared release identity
id: identity
shell: bash
env:
RELEASE_CHANNEL: ${{ needs.resolve-trigger.outputs.channel }}
RELEASE_COMMIT: ${{ needs.resolve-trigger.outputs.commit }}
REQUESTED_VERSION: ${{ needs.resolve-trigger.outputs.version }}
run: |
set -euo pipefail
version="$(cargo metadata --locked --no-deps --format-version 1 |
jq -r '.packages[] | select(.name == "wright-cli") | .version')"
if [[ -z "$version" || "$version" == null ]]; then
echo "wright-cli workspace version is unavailable" >&2
exit 1
fi
checked_in_version="$(tr -d '\r\n' < version.txt)"
if [[ "$checked_in_version" != "$version" ]]; then
echo "version.txt '$checked_in_version' does not match workspace version '$version'" >&2
exit 1
fi
if [[ "$RELEASE_CHANNEL" == stable ]]; then
if [[ -n "$REQUESTED_VERSION" ]]; then
if [[ "$REQUESTED_VERSION" != "$version" ]]; then
echo "stable input version '$REQUESTED_VERSION' does not match prepared workspace version '$version'" >&2
exit 1
fi
fi
if [[ "$(git rev-parse HEAD)" == "$RELEASE_COMMIT" ]]; then
echo "stable release did not create a post-bump commit" >&2
exit 1
fi
tag="v$version"
else
if [[ -n "$REQUESTED_VERSION" ]]; then
echo "nightly releases must not accept a stable version input" >&2
exit 1
fi
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
tag="nightly-${RELEASE_COMMIT:0:12}"
fi
{
echo "channel=$RELEASE_CHANNEL"
echo "commit=$(git rev-parse HEAD)"
echo "version=$version"
echo "tag=$tag"
} >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
needs: release-identity
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
ext: tar.gz
- os: macos-15-intel
target: x86_64-apple-darwin
ext: tar.gz
- os: macos-15
target: aarch64-apple-darwin
ext: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
ext: zip
defaults:
run:
shell: bash
env:
RELEASE_CHANNEL: ${{ needs.release-identity.outputs.channel }}
RELEASE_VERSION: ${{ needs.release-identity.outputs.version }}
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
RELEASE_COMMIT: ${{ needs.release-identity.outputs.commit }}
TARGET: ${{ matrix.target }}
EXT: ${{ matrix.ext }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ needs.release-identity.outputs.commit }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: release-${{ matrix.target }}-stable-release
cache-targets: false
cache-all-crates: false
cache-workspace-crates: false
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: false
- name: Build release binaries
run: cargo build --locked --release -p wright-cli -p wright-lsp --target "$TARGET"
- name: Package release archive
run: >-
python scripts/package-release.py
--version "$RELEASE_VERSION"
--tag "$RELEASE_TAG"
--target "$TARGET"
--extension "$EXT"
--commit "$RELEASE_COMMIT"
--runner-os "${{ runner.os }}"
- name: Smoke test release archive
run: >-
python scripts/smoke-release-archive.py
--archive "dist/wright-${RELEASE_VERSION}-${TARGET}.${EXT}"
--version "$RELEASE_VERSION"
--target "$TARGET"
--extension "$EXT"
- name: Upload target artifacts
uses: actions/upload-artifact@v7
with:
name: wright-${{ matrix.target }}
path: |
dist/wright-*.tar.gz
dist/wright-*.tar.gz.sha256
dist/wright-*.zip
dist/wright-*.zip.sha256
dist/wright-*.build.json
release-assets:
name: Verify native release artifacts
needs:
- release-identity
- build
runs-on: ubuntu-latest
env:
RELEASE_VERSION: ${{ needs.release-identity.outputs.version }}
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
RELEASE_COMMIT: ${{ needs.release-identity.outputs.commit }}
steps:
- name: Download target artifacts
uses: actions/download-artifact@v8
with:
pattern: wright-*
path: release
merge-multiple: true
- name: Verify archives and identities
shell: bash
run: |
set -euo pipefail
mkdir verified
for spec in \
x86_64-unknown-linux-gnu:tar.gz \
x86_64-apple-darwin:tar.gz \
aarch64-apple-darwin:tar.gz \
x86_64-pc-windows-msvc:zip; do
IFS=: read -r target extension <<< "$spec"
archive="release/wright-$RELEASE_VERSION-$target.$extension"
checksum="$archive.sha256"
identity="$archive.build.json"
test -f "$archive"
test -f "$checksum"
test -f "$identity"
expected_hash="$(awk 'NR == 1 { print $1 }' "$checksum")"
actual_hash="$(sha256sum "$archive" | awk 'NR == 1 { print $1 }')"
test "$actual_hash" = "$expected_hash"
jq -e \
--arg revision "$RELEASE_COMMIT" \
--arg tag "$RELEASE_TAG" \
--arg target "$target" \
'.revision == $revision and
.tag == $tag and
.target == $target and
.toolchain == "stable" and
.profile == "release" and
.packages == ["wright-cli", "wright-lsp"] and
.features == []' "$identity" >/dev/null
cp "$archive" "$checksum" verified/
done
(cd verified && sha256sum wright-*.tar.gz wright-*.zip > SHA256SUMS)
- name: Upload verified release assets
uses: actions/upload-artifact@v7
with:
name: verified-release-assets
path: verified/*
if-no-files-found: error
package-manifests:
name: Generate package-manager manifests
needs:
- release-identity
- release-assets
if: needs.release-identity.outputs.channel == 'stable'
runs-on: ubuntu-latest
env:
RELEASE_VERSION: ${{ needs.release-identity.outputs.version }}
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ needs.release-identity.outputs.commit }}
fetch-depth: 0
- name: Download verified release assets
uses: actions/download-artifact@v8
with:
name: verified-release-assets
path: artifacts
- name: Generate package manifests
run: >-
python3 scripts/generate-release-manifests.py
--version "$RELEASE_VERSION"
--artifacts-dir artifacts
--output-dir manifests
--attach-dir attach
- name: Upload package manifests
uses: actions/upload-artifact@v7
with:
name: release-manifests
path: attach/*
if-no-files-found: error
publish-tap:
name: Publish formula to homebrew-tap
needs:
- release-identity
- package-manifests
- publish-release
if: needs.release-identity.outputs.channel == 'stable'
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ needs.release-identity.outputs.commit }}
- name: Download Homebrew formula
uses: actions/download-artifact@v8
with:
name: release-manifests
path: formula
- name: Check out homebrew-tap
uses: actions/checkout@v7
with:
repository: wrightkit/homebrew-tap
ref: main
# Only this cross-repository tap operation uses secrets.GH_TOKEN.
token: ${{ secrets.GH_TOKEN }}
path: tap
- name: Publish Homebrew formula
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: ./scripts/publish-homebrew-tap.sh
publish-release:
name: Publish completed GitHub Release
needs:
- release-identity
- release-assets
- package-manifests
if: needs.release-identity.outputs.channel == 'stable'
runs-on: ubuntu-latest
environment: release
steps:
- name: Download verified release assets
uses: actions/download-artifact@v8
with:
name: verified-release-assets
path: release
- name: Download package manifests
uses: actions/download-artifact@v8
with:
name: release-manifests
path: attach
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
RELEASE_COMMIT: ${{ needs.release-identity.outputs.commit }}
run: |
if existing_tag_commit="$(gh api \
"repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" \
--jq '.sha' 2>/dev/null)"; then
if [[ "$existing_tag_commit" != "$RELEASE_COMMIT" ]]; then
echo "tag $RELEASE_TAG already points to $existing_tag_commit" >&2
exit 1
fi
else
gh api --method POST \
"repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$RELEASE_TAG" \
-f sha="$RELEASE_COMMIT" >/dev/null
fi
draft="$(gh release view "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--json isDraft \
--jq '.isDraft' 2>/dev/null || true)"
if [[ -z "$draft" ]]; then
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--target "$RELEASE_COMMIT" \
--title "$RELEASE_TAG" \
--generate-notes \
--draft
draft=true
else
echo "found existing $([[ "$draft" == true ]] && echo draft || echo public) release $RELEASE_TAG"
fi
if [[ "$draft" == true ]]; then
gh release upload "$RELEASE_TAG" release/* attach/* \
--repo "$GITHUB_REPOSITORY" \
--clobber
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft=false \
--latest
fi
publish-r2:
name: Publish ${{ needs.release-identity.outputs.channel }} archives to R2
needs:
- release-identity
- release-assets
- publish-release
runs-on: ubuntu-latest
if: >-
always() &&
((needs.release-identity.outputs.channel == 'nightly' && needs.release-assets.result == 'success') ||
(needs.release-identity.outputs.channel == 'stable' && needs.publish-release.result == 'success'))
env:
RELEASE_CHANNEL: ${{ needs.release-identity.outputs.channel }}
RELEASE_VERSION: ${{ needs.release-identity.outputs.version }}
RELEASE_TAG: ${{ needs.release-identity.outputs.tag }}
RELEASE_COMMIT: ${{ needs.release-identity.outputs.commit }}
R2_BUCKET: wrightkit-release
R2_PUBLIC_BASE_URL: https://releases.wrightkit.dev
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ needs.release-identity.outputs.commit }}
- name: Verify AWS CLI config
run: aws --version
- name: Download verified release assets
uses: actions/download-artifact@v8
with:
name: verified-release-assets
path: artifacts
- name: Publish archives to R2
env:
ARTIFACTS_DIR: ${{ github.workspace }}/artifacts
run: ./scripts/publish-r2.sh