waterui-cli 0.3.2

Cross-platform tooling for WaterUI applications
Documentation
name: Release

# Two entry points:
#
# * `workflow_run` on CI: a green `dev` run refreshes the release-plz pull
#   request; a green `main` run (a merged dev→main release PR) tags the
#   released version `v<version>` and creates the GitHub release.
#   release-plz only computes versions, updates the changelog and pushes
#   the tag — `publish = false` in release-plz.toml; crates.io publishing
#   is the `publish` job below.
# * `workflow_dispatch` with a `tag` input: runs the publish, dist and
#   asset jobs for an existing release tag. This is the release's second
#   half, not just recovery: crates.io trusted publishing refuses OIDC
#   tokens minted under `workflow_run`, so crates.io publishing can only
#   ever happen on `workflow_dispatch`/`push`/`release` events. The flow
#   for a release is therefore: merge the release PR → the tag and GitHub
#   release appear automatically → dispatch this workflow with the tag to
#   publish, build binaries, and hand the Homebrew formula to the tap.
#
# release-plz runs under the workflow's own GITHUB_TOKEN, and nothing here
# depends on a tag push firing another workflow: the tag release-plz just
# created is read out of its JSON report into a job output, and the
# publish/dist/asset jobs chain off it with `needs:` in this same run —
# the shape the framework's release pipeline uses, and the only one that
# works, because a tag pushed under GITHUB_TOKEN triggers no workflow.
on:
  workflow_run:
    workflows: ["CI"]
    branches: [dev, main]
    types: [completed]
  workflow_dispatch:
    inputs:
      tag:
        description: "Existing release tag to re-run, e.g. v0.2.2"
        required: true
        type: string

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  DIST_VERSION: 0.30.2

jobs:
  release:
    name: Tag the released version
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'workflow_run' &&
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.head_branch == 'main'
    concurrency:
      group: release-main
      cancel-in-progress: false
    permissions:
      contents: write
    outputs:
      # The `v<version>` tag release-plz created this run, empty when it
      # released nothing.
      tag: ${{ steps.cli_tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
          fetch-depth: 0
      - uses: ./.github/actions/setup-rust
        with:
          cache: "false"
      # `release-plz release` verify-builds the manifest it tags.
      - uses: ./.github/actions/setup-linux-deps
      - uses: ./.github/actions/release-plz
        id: release_plz
        with:
          command: release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      # Read the tag out of release-plz's own report rather than looking
      # for one on this job's HEAD — the tag lands on the release branch's
      # tip, not on the merge commit this job checked out.
      - name: Detect CLI release tag
        id: cli_tag
        shell: bash
        env:
          REPORT: ${{ steps.release_plz.outputs.report }}
        run: |
          set -euo pipefail

          TAG="$(jq -r '[.releases[]? | select(.package_name == "waterui-cli") | .tag] | last // ""' <<< "$REPORT")"

          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
          if [[ -n "${TAG}" ]]; then
            echo "release-plz released waterui-cli as ${TAG}."
          else
            echo "release-plz released no waterui-cli this run."
          fi

  release-pr:
    name: Prepare the next release
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'workflow_run' &&
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.head_branch == 'dev'
    concurrency:
      group: release-plz-dev
      cancel-in-progress: false
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: dev
          fetch-depth: 0
      - uses: ./.github/actions/setup-rust
        with:
          cache: "false"
      - uses: ./.github/actions/setup-linux-deps
      - uses: ./.github/actions/release-plz
        with:
          command: release-pr
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  publish:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    needs: release
    # crates.io trusted publishing rejects OIDC tokens issued under
    # `workflow_run`, so the publish leg only ever runs on dispatch.
    # `always()` because a skipped `release` (every dispatch) would
    # otherwise cascade-skip this job too.
    if: always() && github.event_name == 'workflow_dispatch'
    permissions:
      contents: read
      id-token: write
    env:
      TAG: ${{ needs.release.outputs.tag || inputs.tag }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}
      - uses: ./.github/actions/setup-rust
        with:
          cache: "false"
      # `cargo publish` verify-builds the package.
      - uses: ./.github/actions/setup-linux-deps
      # Mint a short-lived crates.io token from the GitHub OIDC identity
      # instead of a stored CARGO_REGISTRY_TOKEN (trusted publishing is
      # configured per crate on crates.io).
      - uses: rust-lang/crates-io-auth-action@v1
        id: crates-io-auth
      # Idempotent so `workflow_dispatch` can re-run a release whose publish
      # leg already landed: only the version on crates.io decides.
      - name: Publish the crate
        shell: bash
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
        run: |
          set -euo pipefail
          version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
          if curl -sf -o /dev/null "https://crates.io/api/v1/crates/waterui-cli/${version}"; then
            echo "waterui-cli ${version} is already published — nothing to do"
          else
            cargo publish --locked
          fi

  dist:
    name: dist / ${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    needs: release
    # Binaries belong to the same dispatch as the publish leg: building
    # them on `workflow_run` would only produce artifacts that
    # `release-assets` cannot attach while publish is skipped. `always()`
    # because a skipped `release` would otherwise cascade-skip this job.
    if: always() && github.event_name == 'workflow_dispatch'
    permissions:
      contents: read
    env:
      TAG: ${{ needs.release.outputs.tag || inputs.tag }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: aarch64-unknown-linux-gnu
            os: ubuntu-24.04-arm
          - target: x86_64-apple-darwin
            os: macos-latest
          - target: aarch64-apple-darwin
            os: macos-latest
          - target: x86_64-pc-windows-msvc
            os: windows-latest
          - target: aarch64-pc-windows-msvc
            os: windows-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}

      - uses: ./.github/actions/setup-rust
        with:
          shared-key: dist-${{ matrix.target }}

      - uses: ./.github/actions/setup-linux-deps
        if: runner.os == 'Linux'

      - uses: ./.github/actions/install-dxc
        if: runner.os == 'Windows'

      - name: Install cargo-dist
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-dist@${{ env.DIST_VERSION }}

      - name: Build artifacts
        shell: bash
        run: |
          dist build \
            --tag "${{ env.TAG }}" \
            --artifacts=local \
            --target "${{ matrix.target }}"

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: artifacts-${{ matrix.target }}
          if-no-files-found: error
          path: |
            target/distrib/*${{ matrix.target }}*.tar.*
            target/distrib/*${{ matrix.target }}*.zip*
            target/distrib/*${{ matrix.target }}*-dist-manifest.json

  release-assets:
    name: Upload release assets
    needs: [release, publish, dist]
    # `always()` so a skipped `release` on workflow_dispatch still resolves;
    # the publish/dist results are what actually gate this job.
    if: always() && needs.publish.result == 'success' && needs.dist.result == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    env:
      TAG: ${{ needs.release.outputs.tag || inputs.tag }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}

      - name: Install cargo-dist
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-dist@${{ env.DIST_VERSION }}

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          pattern: artifacts-*
          path: target/distrib
          merge-multiple: true

      - name: Build global release artifacts
        run: dist build --tag "${{ env.TAG }}" --artifacts=global

      # The patterns must not overlap. `*.tar.*` also matched every
      # `…tar.xz.sha256`, which `*.sha256` matched again, so each checksum
      # beside a tarball was listed twice; the action then issued two deletes
      # for the one existing asset of that name and the second answered
      # `Not Found`, failing the step. Invisible on a first release, where
      # there is nothing to delete — and fatal on the re-run that recovers
      # one. `.tar.xz`, `.tar.gz`, `.zip`, `.sha256` and `sha256.sum` are
      # disjoint suffixes, so each file is named once.
      - name: Upload assets to release
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ env.TAG }}
          files: |
            target/distrib/*.tar.xz
            target/distrib/*.tar.gz
            target/distrib/*.zip
            target/distrib/*.sha256
            target/distrib/sha256.sum

      - name: Checkout Homebrew tap
        uses: actions/checkout@v4
        with:
          repository: water-rs/homebrew-water
          token: ${{ secrets.HOMEBREW_PAT }}
          path: homebrew-water

      - name: Stage dist-generated Homebrew formula
        run: cp target/distrib/water.rb homebrew-water/Formula/water.rb

      # The tap's `dev` is ruled like every other branch in the organisation:
      # changes arrive through a pull request and commits carry a verified
      # signature. This job can satisfy neither directly — a commit made on a
      # runner is unsigned, and `HOMEBREW_PAT` may write contents there but
      # not pull requests (`Resource not accessible by personal access
      # token`). So it pushes the branch and stops. water-rs/homebrew-water's
      # own `publish-formula.yml` picks it up, opens the pull request and
      # merges it under that repository's `GITHUB_TOKEN`, and the squash
      # commit GitHub writes is signed.
      #
      # `--force` because a re-run of a release pushes the same branch again.
      - name: Push the Homebrew formula branch
        shell: bash
        run: |
          set -euo pipefail
          version="${TAG#v}"
          cd homebrew-water
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git config user.name "github-actions[bot]"
          git add Formula/water.rb
          if git diff --cached --quiet; then
            echo "No Homebrew changes to publish."
            exit 0
          fi
          git commit -m "water ${version}"
          git push --force origin "HEAD:refs/heads/water-${version}"