Skip to content

Nightly Release

Nightly Release #22

name: Nightly Release
on:
schedule:
- cron: "17 9 * * *"
workflow_dispatch:
concurrency:
group: nightly-release
cancel-in-progress: false
permissions:
contents: write
actions: read
jobs:
cut-nightly-release:
name: Cut Nightly Release
# Forks inherit this workflow's schedule but not upstream secrets
# (RELEASE_BOT_TOKEN), so a scheduled run in a fork can only fail noisily.
# Restrict the whole release graph to the canonical repository.
if: github.repository == 'phase-rs/phase'
runs-on: ubuntu-latest
timeout-minutes: 90
outputs:
dispatch_recovery: ${{ steps.release-run.outputs.dispatch_recovery }}
release_tag: ${{ steps.version.outputs.tag }}
steps:
- name: Verify release bot token
env:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
run: |
set -euo pipefail
if [ -z "$RELEASE_BOT_TOKEN" ]; then
echo "::error::RELEASE_BOT_TOKEN is required so commit/tag pushes trigger downstream workflows"
exit 1
fi
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.RELEASE_BOT_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-shared-key: rust-nightly-release
- name: Configure git
run: |
set -euo pipefail
git config user.name "phase-rs release bot"
git config user.email "phase-rs-release-bot@users.noreply.github.com"
- name: Compute release version
id: version
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
run: |
set -euo pipefail
current_version=$(sed -nE 's/^version = "([^"]+)"/\1/p' Cargo.toml | head -1)
if [[ ! "$current_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Cargo.toml version $current_version is not plain SemVer"
exit 1
fi
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
# Each nightly bumps the MINOR version (patch always resets to 0). Find
# the highest minor already released for this major and take the next
# one; never go backwards relative to Cargo.toml's own minor.
latest_minor=$(gh release list --limit 100 --json tagName \
| jq -r --arg major "$major" '
[
.[].tagName
| capture("^v(?<major>[0-9]+)\\.(?<minor>[0-9]+)\\.(?<patch>[0-9]+)$")?
| select(.major == $major)
| .minor | tonumber
]
| max // empty
')
next_minor=0
if [ -n "$latest_minor" ]; then
next_minor=$((latest_minor + 1))
fi
if [ "$minor" -lt "$next_minor" ]; then
minor="$next_minor"
fi
patch=0
if [ "$major" -gt 255 ] || [ "$minor" -gt 255 ] || [ "$patch" -gt 65535 ]; then
echo "::error::Version $major.$minor.$patch is not valid for the Windows MSI ProductVersion"
exit 1
fi
VERSION="$major.$minor.$patch"
TAG="v$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify main and tags
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
git fetch --force origin main
git fetch --force --tags origin
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "::error::Checked-out HEAD is not origin/main"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists; recovery checks will run without creating a new release commit."
fi
- name: Verify existing tag version
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
exit 0
fi
check_toml_version() {
local path="$1"
local actual
actual=$(git show "$TAG:$path" | sed -nE 's/^version = "([^"]+)"/\1/p' | head -1)
if [ "$actual" != "$VERSION" ]; then
echo "::error::$path version $actual does not match $TAG"
exit 1
fi
}
check_json_version() {
local path="$1"
local actual
actual=$(git show "$TAG:$path" | jq -r '.version')
if [ "$actual" != "$VERSION" ]; then
echo "::error::$path version $actual does not match $TAG"
exit 1
fi
}
check_toml_version Cargo.toml
check_json_version client/package.json
check_json_version client/src-tauri/tauri.conf.json
check_toml_version client/src-tauri/Cargo.toml
- name: Check existing release workflow run
id: release-run
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
echo "dispatch_recovery=false" >> "$GITHUB_OUTPUT"
echo "skip_creation=false" >> "$GITHUB_OUTPUT"
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
exit 0
fi
echo "skip_creation=true" >> "$GITHUB_OUTPUT"
TAG_SHA=$(git rev-list -n 1 "$TAG")
RUNS=$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs" \
-f per_page=50)
MATCH=$(jq -r --arg sha "$TAG_SHA" '
[
.workflow_runs[]
| select(.head_sha == $sha)
| {status, conclusion, html_url, created_at}
]
| sort_by(.created_at)
| reverse
| .[0] // empty
| @base64
' <<< "$RUNS")
if [ -z "$MATCH" ]; then
echo "No Release workflow run exists for $TAG; dispatching recovery in the follow-up job."
echo "dispatch_recovery=true" >> "$GITHUB_OUTPUT"
exit 0
fi
run_field() {
echo "$MATCH" | base64 -d | jq -r "$1"
}
STATUS=$(run_field '.status')
CONCLUSION=$(run_field '.conclusion')
URL=$(run_field '.html_url')
if [ "$STATUS" != "completed" ]; then
echo "Release workflow for $TAG is already $STATUS: $URL"
exit 0
fi
if [ "$CONCLUSION" = "success" ]; then
echo "Release workflow for $TAG already succeeded: $URL"
exit 0
fi
echo "::error::Release workflow for $TAG completed with $CONCLUSION. Rerun it here: $URL"
exit 1
- name: Stop after existing tag handling
if: ${{ steps.release-run.outputs.skip_creation == 'true' }}
run: echo "Existing tag handled; no new release commit or tag will be created."
- name: Install pinned cargo-release
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
run: cargo install cargo-release --version 1.1.2 --locked
- name: Create release commit and local tag
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
current_version=$(sed -nE 's/^version = "([^"]+)"/\1/p' Cargo.toml | head -1)
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists; skipping release creation."
exit 0
fi
if [ "$current_version" = "$VERSION" ]; then
echo "origin/main is already at $VERSION without $TAG; creating the missing local tag."
git tag -a "$TAG" -m "$TAG"
exit 0
fi
cargo release "$VERSION" --workspace --execute --no-confirm --no-publish --no-push --allow-branch main
# cargo-release bumps the main workspace but not nested lockfiles whose
# path-dependencies it just revved. Resync them so the committed locks
# match the new version, then fold them into the release commit + tag.
cargo update --manifest-path client/src-tauri/Cargo.toml --package phase-tauri
cargo update --manifest-path lobby-worker/broker-wasm/Cargo.toml --package engine --package lobby-broker
if ! git diff --quiet -- client/src-tauri/Cargo.lock lobby-worker/broker-wasm/Cargo.lock; then
git add client/src-tauri/Cargo.lock lobby-worker/broker-wasm/Cargo.lock
git commit --amend --no-edit
git tag -f -a "$TAG" -m "$TAG"
fi
- name: Verify release commit and tag
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
check_toml_version() {
local path="$1"
local actual
actual=$(sed -nE 's/^version = "([^"]+)"/\1/p' "$path" | head -1)
if [ "$actual" != "$VERSION" ]; then
echo "::error::$path version $actual does not match $VERSION"
exit 1
fi
}
check_json_version() {
local path="$1"
local actual
actual=$(jq -r '.version' "$path")
if [ "$actual" != "$VERSION" ]; then
echo "::error::$path version $actual does not match $VERSION"
exit 1
fi
}
check_toml_version Cargo.toml
check_json_version client/package.json
check_json_version client/src-tauri/tauri.conf.json
check_toml_version client/src-tauri/Cargo.toml
if [ -n "$(git status --porcelain)" ]; then
git status --short
echo "::error::cargo-release left the working tree dirty"
exit 1
fi
if [ "$(git rev-list -n 1 "$TAG")" != "$(git rev-parse HEAD)" ]; then
echo "::error::$TAG does not point at HEAD"
exit 1
fi
current_version=$(git show HEAD:Cargo.toml | sed -nE 's/^version = "([^"]+)"/\1/p' | head -1)
if [ "$current_version" != "$VERSION" ]; then
echo "::error::HEAD version $current_version does not match $VERSION"
exit 1
fi
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
changed=$(git diff-tree --no-commit-id --name-only -r HEAD | sort)
allowed=$(printf '%s\n' \
Cargo.lock \
Cargo.toml \
client/package.json \
client/src-tauri/Cargo.lock \
client/src-tauri/Cargo.toml \
client/src-tauri/tauri.conf.json \
lobby-worker/broker-wasm/Cargo.lock \
| sort)
extra=$(comm -23 <(printf '%s\n' "$changed") <(printf '%s\n' "$allowed"))
if [ -n "$extra" ]; then
echo "::error::Release commit changed unexpected files:"
printf '%s\n' "$extra"
exit 1
fi
fi
- name: Push release commit
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
env:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
run: |
set -euo pipefail
git fetch --force origin main
if [ "$(git rev-parse origin/main)" != "$(git rev-parse HEAD)" ]; then
base=$(git merge-base HEAD origin/main)
if [ "$base" != "$(git rev-parse origin/main)" ]; then
echo "::error::origin/main advanced while preparing the release"
exit 1
fi
git push "https://x-access-token:${RELEASE_BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main
else
echo "Release commit is already on origin/main."
fi
- name: Wait for CI on release commit
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
deadline=$((SECONDS + 3600))
while [ "$SECONDS" -lt "$deadline" ]; do
RUNS=$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs" \
-f branch=main \
-f event=push \
-f per_page=20)
MATCH=$(jq -r --arg sha "$RELEASE_SHA" '
[
.workflow_runs[]
| select(.head_sha == $sha)
| {status, conclusion, html_url, created_at}
]
| sort_by(.created_at)
| reverse
| .[0] // empty
| @base64
' <<< "$RUNS")
if [ -n "$MATCH" ]; then
decoded=$(echo "$MATCH" | base64 -d)
status=$(jq -r '.status' <<< "$decoded")
conclusion=$(jq -r '.conclusion' <<< "$decoded")
url=$(jq -r '.html_url' <<< "$decoded")
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "CI succeeded for $RELEASE_SHA: $url"
exit 0
fi
echo "::error::CI completed with $conclusion for $RELEASE_SHA: $url"
exit 1
fi
echo "CI for $RELEASE_SHA is $status: $url"
else
echo "Waiting for CI run for $RELEASE_SHA..."
fi
sleep 30
done
echo "::error::Timed out waiting for CI on $RELEASE_SHA"
exit 1
- name: Push release tag
if: ${{ steps.release-run.outputs.skip_creation != 'true' }}
env:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
git fetch --force origin main
if [ "$(git rev-parse origin/main)" != "$RELEASE_SHA" ]; then
echo "::error::origin/main no longer points at release commit $RELEASE_SHA"
exit 1
fi
git push "https://x-access-token:${RELEASE_BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG"
dispatch-recovery:
name: Dispatch Existing Tag Recovery
needs: [cut-nightly-release]
if: ${{ needs.cut-nightly-release.outputs.dispatch_recovery == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: write
steps:
- name: Dispatch release workflow for existing tag
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
TAG: ${{ needs.cut-nightly-release.outputs.release_tag }}
run: |
set -euo pipefail
gh workflow run release.yml --ref "$TAG" -f tag="$TAG"