Release #55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "Release" | |
| permissions: | |
| contents: write | |
| actions: write | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| create_release: | |
| description: "Bump version, create tag, and upload built assets" | |
| required: false | |
| default: false | |
| type: boolean | |
| version: | |
| description: "Version to release when enabled (example: 0.6.0 or v0.6.0)" | |
| required: false | |
| type: string | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| jobs: | |
| resolve-tag: | |
| name: Resolve release tag | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release_tag: ${{ steps.resolve.outputs.release_tag }} | |
| checkout_ref: ${{ steps.resolve.outputs.checkout_ref }} | |
| publish_release: ${{ steps.resolve.outputs.publish_release }} | |
| steps: | |
| - name: Determine release mode | |
| id: mode | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| if [ "${{ github.event.inputs.create_release }}" = "true" ]; then | |
| RAW_INPUT="${{ github.event.inputs.version }}" | |
| if [ -z "$RAW_INPUT" ]; then | |
| echo "version is required when create_release is enabled." | |
| exit 1 | |
| fi | |
| NORMALIZED_VERSION="${RAW_INPUT#v}" | |
| if [[ ! "$NORMALIZED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Invalid version: '$RAW_INPUT'. Use SemVer like 7.3.0 or v7.3.0." | |
| exit 1 | |
| fi | |
| RELEASE_TAG="v${NORMALIZED_VERSION}" | |
| MODE="manual_release" | |
| else | |
| RELEASE_TAG="" | |
| MODE="build_only" | |
| fi | |
| else | |
| RELEASE_TAG="${{ github.ref_name }}" | |
| MODE="tag_push" | |
| fi | |
| echo "release_tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "mode=${MODE}" >> "$GITHUB_OUTPUT" | |
| - name: Checkout repository | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' }} | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| submodules: true | |
| - name: Detect existing tag and release | |
| id: existing | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.mode.outputs.release_tag }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| TAG_EXISTS=false | |
| if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then | |
| TAG_EXISTS=true | |
| fi | |
| echo "tag_exists=${TAG_EXISTS}" >> "$GITHUB_OUTPUT" | |
| if [ "${TAG_EXISTS}" = "true" ]; then | |
| echo "Tag '${RELEASE_TAG}' already exists on origin; refreshing the release from the existing tag." | |
| else | |
| if gh release view "${RELEASE_TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| echo "Release '${RELEASE_TAG}' exists without a tag, which is unexpected." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| - name: Configure git identity | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' && steps.existing.outputs.tag_exists != 'true' }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - name: Update codebase version tags | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' && steps.existing.outputs.tag_exists != 'true' }} | |
| env: | |
| VERSION: ${{ steps.mode.outputs.release_tag }} | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import pathlib | |
| import re | |
| repo_root = pathlib.Path(".") | |
| version = os.environ["VERSION"].removeprefix("v") | |
| flake_path = repo_root / "flake.nix" | |
| flake_content = flake_path.read_text(encoding="utf-8") | |
| updated_flake_content, replacements = re.subn( | |
| r'packageVersion = "[0-9]+\.[0-9]+\.[0-9]+";', | |
| f'packageVersion = "{version}";', | |
| flake_content, | |
| count=1, | |
| ) | |
| if replacements != 1: | |
| raise SystemExit("Failed to update packageVersion in flake.nix") | |
| flake_path.write_text(updated_flake_content, encoding="utf-8") | |
| vcpkg_path = repo_root / "vcpkg.json" | |
| vcpkg_data = json.loads(vcpkg_path.read_text(encoding="utf-8")) | |
| vcpkg_data["version"] = version | |
| vcpkg_path.write_text( | |
| json.dumps(vcpkg_data, indent=2) + "\n", | |
| encoding="utf-8", | |
| ) | |
| PY | |
| - name: Commit version updates | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' && steps.existing.outputs.tag_exists != 'true' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.mode.outputs.release_tag }} | |
| run: | | |
| git add flake.nix vcpkg.json | |
| if git diff --cached --quiet; then | |
| echo "Version files were already up to date." | |
| else | |
| git commit -m "chore(release): prepare ${RELEASE_TAG}" | |
| git push origin "HEAD:${{ github.ref_name }}" | |
| fi | |
| - name: Create and push release tag | |
| if: ${{ steps.mode.outputs.mode == 'manual_release' && steps.existing.outputs.tag_exists != 'true' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.mode.outputs.release_tag }} | |
| run: | | |
| git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" | |
| git push origin "${RELEASE_TAG}" | |
| - name: Resolve and validate release tag | |
| id: resolve | |
| env: | |
| MODE: ${{ steps.mode.outputs.mode }} | |
| MODE_RELEASE_TAG: ${{ steps.mode.outputs.release_tag }} | |
| run: | | |
| PUBLISH_RELEASE="true" | |
| if [ "$MODE" = "build_only" ]; then | |
| RELEASE_TAG="" | |
| CHECKOUT_REF="${{ github.ref }}" | |
| PUBLISH_RELEASE="false" | |
| else | |
| RELEASE_TAG="${MODE_RELEASE_TAG#refs/tags/}" | |
| if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Invalid tag '$RELEASE_TAG'. Expected vMAJOR.MINOR.PATCH" | |
| exit 1 | |
| fi | |
| TAG_READY="false" | |
| for attempt in $(seq 1 12); do | |
| if git ls-remote --exit-code --tags "https://github.com/${{ github.repository }}.git" "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then | |
| TAG_READY="true" | |
| break | |
| fi | |
| echo "Tag refs/tags/${RELEASE_TAG} not visible on origin yet (attempt ${attempt}/12); waiting 5s..." | |
| sleep 5 | |
| done | |
| if [ "$TAG_READY" != "true" ]; then | |
| echo "Tag refs/tags/${RELEASE_TAG} does not exist on origin." | |
| exit 1 | |
| fi | |
| CHECKOUT_REF="refs/tags/${RELEASE_TAG}" | |
| fi | |
| echo "release_tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT" | |
| echo "publish_release=${PUBLISH_RELEASE}" >> "$GITHUB_OUTPUT" | |
| build-linux: | |
| name: Build Linux binaries | |
| uses: ./.github/workflows/build_linux_tools.yml | |
| needs: resolve-tag | |
| with: | |
| checkout_ref: ${{ needs.resolve-tag.outputs.checkout_ref }} | |
| secrets: inherit | |
| build-windows: | |
| name: Build Windows binaries | |
| uses: ./.github/workflows/build_windows_tools.yml | |
| needs: resolve-tag | |
| with: | |
| checkout_ref: ${{ needs.resolve-tag.outputs.checkout_ref }} | |
| secrets: inherit | |
| build-macos: | |
| name: Build macOS binaries | |
| uses: ./.github/workflows/build_macos_tools.yml | |
| needs: resolve-tag | |
| with: | |
| checkout_ref: ${{ needs.resolve-tag.outputs.checkout_ref }} | |
| release_tag: ${{ needs.resolve-tag.outputs.release_tag }} | |
| secrets: inherit | |
| upload-release-assets: | |
| name: Upload release assets | |
| needs: [resolve-tag, build-linux, build-windows, build-macos] | |
| if: ${{ needs.resolve-tag.outputs.publish_release == 'true' }} | |
| runs-on: ubuntu-latest | |
| env: | |
| RELEASE_TAG: ${{ needs.resolve-tag.outputs.release_tag }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - name: Checkout release tag history | |
| uses: actions/checkout@v5 | |
| with: | |
| ref: ${{ needs.resolve-tag.outputs.checkout_ref }} | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - name: Download Linux artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_linux_x86_64 | |
| path: ./artifacts/ | |
| - name: Download Linux ARM64 artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_linux_arm64 | |
| path: ./artifacts/ | |
| - name: Download Windows artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_windows_x86_64 | |
| path: ./artifacts/windows/ | |
| - name: Download Windows ARM64 artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_windows_arm64 | |
| path: ./artifacts/windows-arm/ | |
| - name: Download macOS x86_64 artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_macos_x86_64 | |
| path: ./artifacts/ | |
| - name: Download macOS arm64 artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_macos_arm64 | |
| path: ./artifacts/ | |
| - name: Download macOS universal artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: tbc-tools_macos_universal | |
| path: ./artifacts/ | |
| - name: Build versioned release assets | |
| run: | | |
| mkdir -p artifacts/release | |
| mv ./artifacts/tbc-tools_linux_x86_64.tar.xz "./artifacts/release/tbc-tools_${RELEASE_TAG}_linux_x86_64.tar.xz" | |
| mv ./artifacts/tbc-tools_linux_arm64.tar.xz "./artifacts/release/tbc-tools_${RELEASE_TAG}_linux_arm64.tar.xz" | |
| cd artifacts/windows | |
| zip -r "../release/tbc-tools_${RELEASE_TAG}_windows_x86_64.zip" . | |
| cd ../windows-arm | |
| zip -r "../release/tbc-tools_${RELEASE_TAG}_windows_arm64.zip" . | |
| cd ../.. | |
| mv ./artifacts/tbc-tools_x86_64.dmg "./artifacts/release/tbc-tools_${RELEASE_TAG}_macos_x86_64.dmg" | |
| mv ./artifacts/tbc-tools_arm64.dmg "./artifacts/release/tbc-tools_${RELEASE_TAG}_macos_arm64.dmg" | |
| mv ./artifacts/tbc-tools_universal.dmg "./artifacts/release/tbc-tools_${RELEASE_TAG}_macos_universal.dmg" | |
| - name: Determine previous release tag | |
| id: previous-tag | |
| run: | | |
| PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match "v*" "${RELEASE_TAG}^" 2>/dev/null || true)" | |
| if [ -n "$PREVIOUS_TAG" ]; then | |
| echo "Using release notes range: ${PREVIOUS_TAG}..${RELEASE_TAG}" | |
| else | |
| echo "No previous release tag found before ${RELEASE_TAG}; generating notes from full history." | |
| fi | |
| echo "previous_tag=${PREVIOUS_TAG}" >> "$GITHUB_OUTPUT" | |
| - name: Generate release notes | |
| id: generate-notes | |
| env: | |
| PREVIOUS_TAG: ${{ steps.previous-tag.outputs.previous_tag }} | |
| run: | | |
| API_NOTES_FILE="$(mktemp)" | |
| if [ -n "$PREVIOUS_TAG" ]; then | |
| gh api \ | |
| --method POST \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${{ github.repository }}/releases/generate-notes" \ | |
| -f tag_name="$RELEASE_TAG" \ | |
| -f previous_tag_name="$PREVIOUS_TAG" \ | |
| --jq '.body' > "$API_NOTES_FILE" | |
| else | |
| gh api \ | |
| --method POST \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${{ github.repository }}/releases/generate-notes" \ | |
| -f tag_name="$RELEASE_TAG" \ | |
| --jq '.body' > "$API_NOTES_FILE" | |
| fi | |
| # GitHub's generate-notes API only lists merged pull requests and | |
| # "New Contributors", which omits direct commits pushed to the | |
| # default branch. Build a granular, commit-based changelog from git | |
| # history (grouped by component, each change citing its hash) and | |
| # preserve only the "New Contributors" section from the API output. | |
| NOTES_FILE="$(mktemp)" | |
| python3 scripts/release/generate_release_notes.py \ | |
| --repo "${{ github.repository }}" \ | |
| --tag "$RELEASE_TAG" \ | |
| --previous-tag "$PREVIOUS_TAG" \ | |
| --api-notes-file "$API_NOTES_FILE" \ | |
| --output "$NOTES_FILE" | |
| echo "Final release notes:" | |
| cat "$NOTES_FILE" | |
| echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT" | |
| - name: Ensure published GitHub release exists | |
| env: | |
| NOTES_FILE: ${{ steps.generate-notes.outputs.notes_file }} | |
| run: | | |
| if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| IS_DRAFT="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft')" | |
| if [ "$IS_DRAFT" = "true" ]; then | |
| echo "Release $RELEASE_TAG exists as draft; publishing it." | |
| gh release edit "$RELEASE_TAG" --repo "${{ github.repository }}" --draft=false --notes-file "$NOTES_FILE" | |
| else | |
| echo "Release $RELEASE_TAG already exists; refreshing release notes." | |
| gh release edit "$RELEASE_TAG" --repo "${{ github.repository }}" --notes-file "$NOTES_FILE" | |
| fi | |
| else | |
| gh release create "$RELEASE_TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --title "$RELEASE_TAG" \ | |
| --notes-file "$NOTES_FILE" | |
| fi | |
| - name: Upload release assets | |
| run: | | |
| gh release upload "$RELEASE_TAG" ./artifacts/release/* --repo "${{ github.repository }}" --clobber |