name: Release # Every run on `main` (each push, a daily schedule, or a manual run) compares # `main` with the latest published release, and cuts the next one when that # range changes what ships. It builds and checks every binary first, then # uploads them to a draft release and publishes it, which creates the tag, so # no tag exists without its complete release. A failed or cancelled run needs # no recovery: the next run sees the same unreleased range and replaces any # draft it left. Pull requests that change what ships, and manual runs on other # branches, only build and check. scripts/release-plan.sh holds both decisions. on: push: branches: [main] pull_request: schedule: - cron: "17 6 * * *" workflow_dispatch: permissions: contents: read # Runs on `main` queue rather than cancel each other; a pending run that a newer # one replaces loses nothing, since the newer one sees its range too. concurrency: group: release-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: plan: runs-on: ubuntu-latest timeout-minutes: 5 outputs: build: ${{ steps.plan.outputs.build }} release: ${{ steps.plan.outputs.release }} released: ${{ steps.plan.outputs.released }} version: ${{ steps.plan.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Plan the release id: plan env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} EVENT: ${{ github.event_name }} BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} run: | # The latest published release; drafts and prereleases don't count. if ! released=$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name 2>"$RUNNER_TEMP/latest"); then grep -q 'HTTP 404' "$RUNNER_TEMP/latest" || { cat "$RUNNER_TEMP/latest" >&2; exit 1; } released= fi plan=$(bash scripts/release-plan.sh next "$released" "$GITHUB_SHA") echo "$plan" version=$(sed -n 's/^version=//p' <<<"$plan") ships=$(sed -n 's/^release=//p' <<<"$plan") release=false if [[ $EVENT == pull_request ]]; then build=false if git diff --name-only --no-renames "$BASE...$HEAD" | bash scripts/release-plan.sh ships; then build=true; fi elif [[ $GITHUB_REF == refs/heads/main ]]; then release=$ships build=$ships if [[ $EVENT == workflow_dispatch ]]; then build=true; fi else build=true fi { echo "build=$build" echo "release=$release" echo "released=$released" echo "version=$version" } >>"$GITHUB_OUTPUT" echo "build=$build release=$release version=$version released=${released:-none}" # Bun cross-compiles every target from one Linux runner, since the pinned # nixpkgs no longer evaluates on Intel macOS. build: needs: plan if: needs.plan.outputs.build == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f with: determinate: false - name: Install dependencies run: nix develop --command just install # The dev shell's Bun is patched for Nix, so compile with the official # build of the same version, which package.json's packageManager pins. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json # Each binary embeds the version it releases as, which `quest --version` prints. - name: Compile env: VERSION: ${{ needs.plan.outputs.version }} run: | declare -A bun_targets=( [aarch64-apple-darwin]=bun-darwin-arm64 [x86_64-apple-darwin]=bun-darwin-x64-baseline [aarch64-unknown-linux-gnu]=bun-linux-arm64 [x86_64-unknown-linux-gnu]=bun-linux-x64-baseline ) mkdir release for target in "${!bun_targets[@]}"; do bun run compile --target="${bun_targets[$target]}" --define "QUEST_VERSION=\"$VERSION\"" tar -czf "release/quest-$target.tar.gz" -C dist quest done for pair in x86_64-pc-windows-msvc:bun-windows-x64 aarch64-pc-windows-msvc:bun-windows-arm64; do target=${pair%%:*} bun run compile --target="${pair#*:}" --define "QUEST_VERSION=\"$VERSION\"" zip -j "release/quest-$target.zip" dist/quest.exe done cd release sha256sum quest-*.tar.gz quest-*.zip > SHA256SUMS - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release path: release if-no-files-found: error check: name: check (${{ matrix.target }}) needs: [plan, build] strategy: fail-fast: false matrix: include: - target: aarch64-unknown-linux-gnu runner: ubuntu-22.04-arm - target: x86_64-unknown-linux-gnu runner: ubuntu-22.04 runs-on: ${{ matrix.runner }} timeout-minutes: 10 steps: &check-steps - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Nix doesn't run on Intel macOS; package.json's packageManager pins the flake's Bun. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release path: release - name: Unpack env: TARGET: ${{ matrix.target }} run: tar -xzf "release/quest-$TARGET.tar.gz" -C "$RUNNER_TEMP" - name: Check the architecture shell: bash env: TARGET: ${{ matrix.target }} run: bash scripts/arch-check.sh "$RUNNER_TEMP/quest" "$TARGET" - name: Check the binary's version and lifecycle shell: bash env: VERSION: ${{ needs.plan.outputs.version }} run: bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest" "$VERSION" # The binary gates real commands. Work outside the job holds a fresh macOS # runner's load above its CPUs, which must not keep a lone command waiting. # Ubuntu 22.04's systemd 249 is too old for Quest's scopes, so it checks the # process-group fallback. - name: Check that the gate admits and owns its command's descendants shell: bash run: bash scripts/gate-check.sh "$RUNNER_TEMP/quest" # Hosted macOS runners can queue past the timeout, which then reports a job # that never ran as failed. Pull requests skip these; releases, which publish # the artifacts, and manual runs still check them. Check covers macOS on main. macos: name: check (${{ matrix.target }}) if: github.event_name != 'pull_request' needs: [plan, build] strategy: fail-fast: false matrix: include: - target: aarch64-apple-darwin runner: macos-15 - target: x86_64-apple-darwin runner: macos-15-intel runs-on: ${{ matrix.runner }} timeout-minutes: 10 steps: *check-steps # Check's suite runs on Linux and Windows only, so the gate's macOS owner is # tested natively before a release publishes. gate-macos: needs: plan if: github.event_name != 'pull_request' && needs.plan.outputs.build == 'true' uses: ./.github/workflows/gate-macos.yml windows: name: check (${{ matrix.target }}) needs: [plan, build] strategy: fail-fast: false matrix: include: - target: x86_64-pc-windows-msvc runner: windows-2022 - target: aarch64-pc-windows-msvc runner: windows-11-arm runs-on: ${{ matrix.runner }} timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Nix doesn't run here; package.json's packageManager pins the flake's Bun. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release path: release - name: Verify the archive, architecture, version, and native lifecycle shell: pwsh env: TARGET: ${{ matrix.target }} TAG: v${{ needs.plan.outputs.version }} run: ./scripts/install-check.ps1 -Method archive -Target $env:TARGET -Tag $env:TAG -ReleaseDirectory (Resolve-Path release).Path publish: needs: [plan, check, macos, gate-macos, windows] if: needs.plan.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release path: release - name: Publish the GitHub release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: v${{ needs.plan.outputs.version }} RELEASED: ${{ needs.plan.outputs.released }} run: | # A run that failed after uploading leaves a draft, which this replaces. gh api --paginate "repos/$GH_REPO/releases" --jq '.[] | select(.draft) | "\(.id) \(.tag_name)"' | while read -r id tag; do if [[ $tag == v[0-9]* ]]; then gh api -X DELETE "repos/$GH_REPO/releases/$id"; fi done sed "s/@TAG@/$TAG/" scripts/installer.sh > release/quest-installer.sh cd release sha256sum quest-*.tar.gz quest-*.zip quest-installer.sh > SHA256SUMS notes=(--generate-notes) if [[ -n $RELEASED ]]; then notes+=(--notes-start-tag "$RELEASED"); fi gh release create "$TAG" --draft --target "$GITHUB_SHA" --title "$TAG" "${notes[@]}" \ quest-*.tar.gz quest-*.zip quest-installer.sh SHA256SUMS # Publishing the draft creates its tag at this run's commit, with every asset already attached. id=$(gh api --paginate "repos/$GH_REPO/releases" --jq ".[] | select(.draft and .tag_name == \"$TAG\") | .id") gh api -X PATCH "repos/$GH_REPO/releases/$id" -F draft=false -f make_latest=true --jq .html_url # Mise and the installer need the published release, so they check it last and # fail loudly; their fix ships as the next release. install-check: needs: [plan, publish] uses: ./.github/workflows/install-check.yml with: tag: v${{ needs.plan.outputs.version }}