Repository navigation
Release #53
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 | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_type: | |
| description: Which version component to bump. | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| default: patch | |
| # Editing this workflow triggers a no-side-effect dry run | |
| push: | |
| paths: | |
| - .github/workflows/release.yml | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| jobs: | |
| prepare: | |
| runs-on: ${{ github.event_name == 'push' && 'ubuntu-latest' || 'macos-26' }} | |
| outputs: | |
| tag: ${{ steps.compute.outputs.tag }} | |
| version: ${{ steps.compute.outputs.version }} | |
| steps: | |
| - name: Enforce release from main | |
| if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' | |
| run: | | |
| echo "::error::Releases must be cut from main (got $GITHUB_REF)." | |
| exit 1 | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Fetch tags | |
| run: git fetch --tags --force | |
| # Real dispatch: only minor/major build fresh and so need a version | |
| # computed up front; patch promotes an existing build and reads its version | |
| # back later. Fails if the target tag already exists; does NOT create it. | |
| - name: Compute next version | |
| id: compute | |
| if: github.event_name == 'workflow_dispatch' && inputs.release_type != 'patch' | |
| env: | |
| RELEASE_TYPE: ${{ inputs.release_type }} | |
| run: | | |
| # Single source of truth for the "last release tag + bump" math, | |
| # shared with the in-development version derive-version computes for | |
| # CI/local builds. | |
| VERSION=$(scripts/derive-version --next "$RELEASE_TYPE") | |
| TAG="v$VERSION" | |
| if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then | |
| echo "Tag $TAG already exists on origin." | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "Releasing $VERSION ($RELEASE_TYPE) from $GITHUB_SHA" | |
| # Dry run (push): exercise the exact same script for every bump type. Under | |
| # `set -e` a broken derive-version or an unparsable tag fails the run, so a | |
| # regression surfaces on the PR. | |
| - name: Dry-run version computation | |
| if: github.event_name == 'push' | |
| run: | | |
| echo "Next version per release_type (no tags are created):" | |
| for type in patch minor major; do | |
| version=$(scripts/derive-version --next "$type") | |
| tag="v$version" | |
| if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then | |
| echo " $type -> $version (tag $tag already exists; a real run would fail)" | |
| else | |
| echo " $type -> $version (tag $tag is free)" | |
| fi | |
| done | |
| build: | |
| needs: prepare | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.release_type != 'patch' | |
| uses: ./.github/workflows/build.yml | |
| with: | |
| ref: ${{ github.sha }} | |
| version: ${{ needs.prepare.outputs.version }} | |
| notarize: true | |
| secrets: | |
| APP_APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_BASE64 }} | |
| APP_APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_PASSWORD }} | |
| PKG_APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_BASE64 }} | |
| PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD }} | |
| PKG_APPLE_DEVELOPER_TEAM_ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }} | |
| PKG_APPLE_ID_EMAIL: ${{ secrets.PKG_APPLE_ID_EMAIL }} | |
| PKG_APPLE_ID_APP_SPECIFIC_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }} | |
| release: | |
| needs: [prepare, build] | |
| if: ${{ !cancelled() && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # create the tag and release | |
| actions: read # download build artifacts (this run, or the promoted main run) | |
| id-token: write # attest provenance for the released artifacts | |
| attestations: write | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| RELEASE_TYPE: ${{ inputs.release_type }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Resolve artifacts and tag target | |
| run: | | |
| dry_run=false | |
| [ "$GITHUB_EVENT_NAME" = "push" ] && dry_run=true | |
| if [ "$dry_run" = true ] || [ "$RELEASE_TYPE" = "patch" ]; then | |
| promote=$(scripts/last-main-build) | |
| RUN_ID=${promote%% *} | |
| TARGET=${promote##* } | |
| if [ -z "$RUN_ID" ]; then | |
| if [ "$dry_run" = true ]; then | |
| echo "No successful main build to promote yet; nothing more to exercise." | |
| exit 0 | |
| fi | |
| echo "::error::No successful main build to promote." | |
| exit 1 | |
| fi | |
| echo "Promoting main build run $RUN_ID (commit $TARGET)." | |
| else | |
| RUN_ID="$GITHUB_RUN_ID" | |
| TARGET="$GITHUB_SHA" | |
| fi | |
| gh run download "$RUN_ID" --name Homebrew-pkg --dir dist | |
| gh run download "$RUN_ID" --name Homebrew-app --dir dist | |
| # The dSYMs artifact only exists on builds cut after it was introduced, | |
| # so a dry run promoting an older main build won't have one. Tolerate | |
| # that here; a real release still requires it (checked below). | |
| gh run download "$RUN_ID" --name Homebrew-dsyms --dir dsyms || true | |
| shopt -s nullglob | |
| pkgs=(dist/Homebrew-*.pkg) | |
| zips=(dist/Homebrew-*.zip) | |
| dsyms=(dsyms/Homebrew-*.dSYMs.zip) | |
| if [ ${#pkgs[@]} -eq 0 ] || [ ${#zips[@]} -eq 0 ]; then | |
| echo "::error::Downloaded artifacts are missing a Homebrew-*.pkg or Homebrew-*.zip." | |
| exit 1 | |
| fi | |
| PKG_PATH="${pkgs[0]}" | |
| APP_ZIP="${zips[0]}" | |
| if [ ${#dsyms[@]} -gt 0 ]; then | |
| DSYM_ZIP="${dsyms[0]}" | |
| elif [ "$dry_run" = true ]; then | |
| DSYM_ZIP="" | |
| echo "::warning::No Homebrew-*.dSYMs.zip on run $RUN_ID; tolerated for this dry run (real releases require it)." | |
| else | |
| echo "::error::Downloaded artifacts are missing a Homebrew-*.dSYMs.zip." | |
| exit 1 | |
| fi | |
| base=$(basename "$PKG_PATH") | |
| VERSION="${base#Homebrew-}"; VERSION="${VERSION%.pkg}" | |
| if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| echo "::error::Could not parse a semver from artifact name '$base'." | |
| exit 1 | |
| fi | |
| TAG="v$VERSION" | |
| if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then | |
| if [ "$dry_run" = true ]; then | |
| echo "Tag $TAG already exists — a real patch run would stop here (nothing new to ship)." | |
| exit 0 | |
| fi | |
| echo "::error::Tag $TAG already exists — these bytes are already released; nothing to promote." | |
| exit 1 | |
| fi | |
| { | |
| echo "PKG_PATH=$PKG_PATH" | |
| echo "APP_ZIP=$APP_ZIP" | |
| echo "DSYM_ZIP=$DSYM_ZIP" | |
| echo "VERSION=$VERSION" | |
| echo "TAG=$TAG" | |
| echo "TARGET=$TARGET" | |
| } >> "$GITHUB_ENV" | |
| echo "Releasing $TAG from $TARGET" | |
| - name: Attest build provenance | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-path: | | |
| ${{ env.PKG_PATH }} | |
| ${{ env.APP_ZIP }} | |
| ${{ env.DSYM_ZIP }} | |
| - name: Create tag and release | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| run: | | |
| gh release create "$TAG" "$PKG_PATH" "$APP_ZIP" "$DSYM_ZIP" \ | |
| --target "$TARGET" --generate-notes |