Skip to content

Release

Release #400

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Version bump type"
required: true
type: choice
options:
- patch
- minor
- major
concurrency:
group: dispatch-release
cancel-in-progress: false
jobs:
prepare-release:
runs-on:
- self-hosted
- Linux
permissions:
contents: write
outputs:
tag: ${{ steps.prepare.outputs.tag }}
release_commit: ${{ steps.prepare.outputs.release_commit }}
artifact_name: ${{ steps.prepare.outputs.artifact_name }}
steps:
- uses: actions/checkout@v6
with:
token: ${{ secrets.RELEASE_PAT }}
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Prepare release commit
id: prepare
run: |
set -euo pipefail
git fetch origin main --tags --prune
git checkout main
git reset --hard origin/main
mkdir -p release-notes
PREV_TAG=$(git tag --sort=-version:refname | head -n 1)
NEW_VERSION=$(npm version ${{ inputs.version }} --no-git-tag-version)
pnpm -r exec npm version ${{ inputs.version }} --no-git-tag-version
if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then
echo "error: remote tag ${NEW_VERSION} already exists" >&2
exit 1
fi
if [ -n "$PREV_TAG" ]; then
gh api "repos/${{ github.repository }}/releases/generate-notes" \
-X POST \
-f tag_name="$NEW_VERSION" \
-f target_commitish="main" \
-f previous_tag_name="$PREV_TAG" \
--jq '.body' > release-notes/current.md
else
gh api "repos/${{ github.repository }}/releases/generate-notes" \
-X POST \
-f tag_name="$NEW_VERSION" \
-f target_commitish="main" \
--jq '.body' > release-notes/current.md
fi
git add package.json apps/*/package.json pnpm-lock.yaml release-notes/current.md
git commit -m "Release ${NEW_VERSION}"
META="release-notes/next-assisted-update.json"
if [ -f "$META" ]; then
bun bin/embed-assisted-update.ts \
--metadata "$META" \
--notes release-notes/current.md
git rm "$META"
git add release-notes/current.md
git commit --amend --no-edit
fi
git pull --rebase origin main
git push origin HEAD:main
RELEASE_COMMIT=$(git rev-parse HEAD)
ARTIFACT_NAME="release-bundle-${NEW_VERSION}"
{
echo "tag=${NEW_VERSION}"
echo "release_commit=${RELEASE_COMMIT}"
echo "artifact_name=${ARTIFACT_NAME}"
} >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
verify-release:
needs: prepare-release
runs-on:
- self-hosted
- Linux
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-release.outputs.release_commit }}
fetch-depth: 1
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Start Postgres
id: postgres
run: |
CONTAINER="dispatch-release-verify-pg-$GITHUB_RUN_ID"
echo "container=$CONTAINER" >> "$GITHUB_OUTPUT"
PORT=$((RANDOM % 10000 + 10000))
echo "port=$PORT" >> "$GITHUB_OUTPUT"
docker run --rm -d \
--name "$CONTAINER" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=dispatch \
-e POSTGRES_DB=postgres \
-p "$PORT":5432 \
postgres:17-alpine
for i in $(seq 1 15); do
docker exec "$CONTAINER" pg_isready -U dispatch && break
sleep 1
done
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm run check
- name: Lint
run: pnpm run lint:web
- name: Test
run: pnpm run test
env:
TEST_DATABASE_URL: postgres://dispatch:[email protected]:${{ steps.postgres.outputs.port }}/postgres
- name: Stop Postgres
if: always()
run: docker stop ${{ steps.postgres.outputs.container }} 2>/dev/null || true
build-binaries:
needs:
- prepare-release
- verify-release
runs-on:
- self-hosted
- macOS
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-release.outputs.release_commit }}
fetch-depth: 1
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Create temporary keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }}
run: |
set -euo pipefail
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -lut 21600 build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security list-keychains -d user -s build.keychain login.keychain-db
security default-keychain -d user -s build.keychain
- name: Import Developer ID certificate
env:
CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }}
run: |
set -euo pipefail
CERT_PATH="$RUNNER_TEMP/developer-id.p12"
printf '%s' "$CERT_BASE64" | openssl base64 -d -A -out "$CERT_PATH"
security import "$CERT_PATH" \
-k build.keychain \
-P "$CERT_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcrun
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$CERT_PASSWORD" \
build.keychain
- name: Store notarization credentials
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$HOME/Library/Keychains/build.keychain-db"
xcrun notarytool store-credentials dispatch-release-notary \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_NOTARY_PASSWORD" \
--keychain "$KEYCHAIN_PATH"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Bun binaries
run: |
export DISPATCH_NOTARY_KEYCHAIN="$HOME/Library/Keychains/build.keychain-db"
pnpm run build:bun
env:
DISPATCH_CODESIGN_IDENTITY: "Developer ID Application: Brad Harris (ML8BQ6D727)"
DISPATCH_MACOS_BUNDLE_ID: dev.bradharris.dispatch
DISPATCH_NOTARIZE_MACOS_BINARIES: "1"
DISPATCH_NOTARY_KEYCHAIN_PROFILE: dispatch-release-notary
- name: Pack release artifact
run: bin/pack-release --output dispatch-release.tar.gz
- name: Upload release bundle
uses: actions/upload-artifact@v7
with:
name: ${{ needs.prepare-release.outputs.artifact_name }}
if-no-files-found: error
path: |
dist/bun/
dispatch-release.tar.gz
release-notes/current.md
retention-days: 7
- name: Delete temporary keychain
if: always()
run: security delete-keychain build.keychain || true
smoke-linux:
needs:
- prepare-release
- build-binaries
runs-on:
- self-hosted
- Linux
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-release.outputs.release_commit }}
fetch-depth: 1
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download release bundle
uses: actions/download-artifact@v8
with:
name: ${{ needs.prepare-release.outputs.artifact_name }}
- name: Restore binary execute bits
run: chmod +x dist/bun/dispatch-*
- name: Start Postgres
id: postgres
run: |
CONTAINER="dispatch-release-linux-pg-$GITHUB_RUN_ID"
echo "container=$CONTAINER" >> "$GITHUB_OUTPUT"
PORT=$((RANDOM % 10000 + 10000))
echo "port=$PORT" >> "$GITHUB_OUTPUT"
docker run --rm -d \
--name "$CONTAINER" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=dispatch \
-e POSTGRES_DB=postgres \
-p "$PORT":5432 \
postgres:17-alpine
for i in $(seq 1 15); do
docker exec "$CONTAINER" pg_isready -U dispatch && break
sleep 1
done
- name: Smoke test Linux binary
run: |
case "$(uname -m)" in
aarch64|arm64) ARCH="arm64" ;;
x86_64) ARCH="x64" ;;
*) echo "unsupported Linux architecture: $(uname -m)" >&2; exit 1 ;;
esac
BINARY=$(find dist/bun -maxdepth 1 -type f -name "dispatch-*-bun-linux-${ARCH}" | head -n 1)
if [[ -z "$BINARY" ]]; then
echo "error: no Linux Bun binary found for architecture ${ARCH}" >&2
exit 1
fi
bun scripts/smoke-bun-binary.mjs "$BINARY"
env:
TEST_DATABASE_URL: postgres://dispatch:[email protected]:${{ steps.postgres.outputs.port }}/postgres
- name: Stop Postgres
if: always()
run: docker stop ${{ steps.postgres.outputs.container }} 2>/dev/null || true
smoke-macos:
needs:
- prepare-release
- build-binaries
runs-on:
- self-hosted
- macOS
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-release.outputs.release_commit }}
fetch-depth: 1
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download release bundle
uses: actions/download-artifact@v8
with:
name: ${{ needs.prepare-release.outputs.artifact_name }}
- name: Restore binary execute bits
run: chmod +x dist/bun/dispatch-*
- name: Start Postgres
id: postgres
run: |
CONTAINER="dispatch-release-macos-pg-$GITHUB_RUN_ID"
echo "container=$CONTAINER" >> "$GITHUB_OUTPUT"
PORT=$((RANDOM % 10000 + 10000))
echo "port=$PORT" >> "$GITHUB_OUTPUT"
docker run --rm -d \
--name "$CONTAINER" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=dispatch \
-e POSTGRES_DB=postgres \
-p "$PORT":5432 \
postgres:17-alpine
for i in $(seq 1 15); do
docker exec "$CONTAINER" pg_isready -U dispatch && break
sleep 1
done
- name: Smoke test macOS binary
run: |
case "$(uname -m)" in
arm64|aarch64) ARCH="arm64" ;;
x86_64) ARCH="x64" ;;
*) echo "unsupported macOS architecture: $(uname -m)" >&2; exit 1 ;;
esac
BINARY=$(find dist/bun -maxdepth 1 -type f -name "dispatch-*-bun-darwin-${ARCH}" | head -n 1)
if [[ -z "$BINARY" ]]; then
echo "error: no macOS Bun binary found for architecture ${ARCH}" >&2
exit 1
fi
bun scripts/smoke-bun-binary.mjs "$BINARY"
codesign -dvv "$BINARY"
spctl -a -vv -t install "$BINARY"
env:
TEST_DATABASE_URL: postgres://dispatch:[email protected]:${{ steps.postgres.outputs.port }}/postgres
- name: Stop Postgres
if: always()
run: docker stop ${{ steps.postgres.outputs.container }} 2>/dev/null || true
publish-release:
needs:
- prepare-release
- build-binaries
- smoke-linux
- smoke-macos
runs-on:
- self-hosted
- Linux
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Download release bundle
uses: actions/download-artifact@v8
with:
name: ${{ needs.prepare-release.outputs.artifact_name }}
- name: Verify release still publishable
run: |
set -euo pipefail
TAG="${{ needs.prepare-release.outputs.tag }}"
RELEASE_COMMIT="${{ needs.prepare-release.outputs.release_commit }}"
git fetch origin main --tags --prune
REMOTE_MAIN=$(git rev-parse origin/main)
if [[ "$REMOTE_MAIN" != "$RELEASE_COMMIT" ]]; then
echo "error: origin/main advanced to $REMOTE_MAIN after smoke tests; expected $RELEASE_COMMIT" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "error: remote tag ${TAG} already exists" >&2
exit 1
fi
- name: Create GitHub release from tested artifact
run: |
gh release create "${{ needs.prepare-release.outputs.tag }}" \
--target "${{ needs.prepare-release.outputs.release_commit }}" \
--title "${{ needs.prepare-release.outputs.tag }}" \
--notes-file release-notes/current.md \
--prerelease \
dispatch-release.tar.gz
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}