forgectl 0.1.0

Keep a GitHub account's repositories in line with one manifest
name: Rust release

# The release pipeline shared by every Rust repo forge manages. Each repo's
# release.yml calls it on a vX.Y.Z tag push:
#
#   on:
#     push:
#       tags: ["v[0-9]+.[0-9]+.[0-9]+"]
#   jobs:
#     release:
#       uses: lkurcak/forge/.github/workflows/rust-release.yml@v1
#       permissions:
#         contents: write
#         id-token: write
#       secrets: inherit
#       with:
#         crates-io: true
#         binaries: true
#
# The inputs are where a repo says what it ships; forge reads them to set up
# what each one needs (secrets, the release environment) and to check the
# result. Binaries are built before anything is published, so a broken target
# stops the release before crates.io has it. The GitHub release is created last with every
# archive attached, so a release that exists is complete.
#
# crates.io trusted publishing matches the caller's workflow file, so it stays
# configured as release.yml with the `release` environment.

on:
  workflow_call:
    inputs:
      crates-io:
        description: >-
          Publish every crate in the workspace that can be published to
          crates.io, through trusted publishing.
        type: boolean
        default: false
      binaries:
        description: >-
          Attach {repo}-vX.Y.Z-{target} archives (.tar.gz, or .zip on Windows)
          for each of BINARIES_TARGETS to the GitHub release. They hold the
          binary named like the repo, or else the workspace's only binary.
        type: boolean
        default: false
      homebrew:
        description: >-
          Open a PR bumping Formula/{repo}.rb in {owner}/homebrew-tap,
          through a GitHub App installed there (the TAP_APP_ID variable and
          TAP_APP_PRIVATE_KEY secret). From a
          public repo the formula builds the tag's source. Homebrew can't
          download a private repo's source or releases, so from one it installs
          the binaries archives instead (set binaries too), which go to a
          {repo}-dist-vX.Y.Z release on the tap. The formula's first version is
          written by hand; releases only update its url and sha256 lines.
        type: boolean
        default: false
      winget:
        description: >-
          Build WINGET_TARGETS and submit {owner}.{repo} to winget-pkgs from
          them, with a classic token in the WINGET_PAT secret. The first
          version has to be submitted with forge's scripts/winget-new.sh,
          since the action only updates existing packages.
        type: boolean
        default: false

env:
  CARGO_TERM_COLOR: always
  REPO: ${{ github.event.repository.name }}
  # What each input builds; forge reads {INPUT}_TARGETS to check releases
  # have every archive.
  BINARIES_TARGETS: >-
    aarch64-apple-darwin
    aarch64-unknown-linux-gnu
    x86_64-unknown-linux-gnu
    x86_64-pc-windows-msvc
  WINGET_TARGETS: >-
    x86_64-pc-windows-msvc
    aarch64-pc-windows-msvc

jobs:
  verify:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      version: ${{ steps.version.outputs.version }}
      matrix: ${{ steps.matrix.outputs.matrix }}
      binaries: ${{ steps.matrix.outputs.any }}
      bin: ${{ steps.bin.outputs.bin }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      # A tag pushed from a PR branch before its squash merge points at a
      # commit main never gets, even when the files are the same.
      - name: Check the tag is on the default branch
        env:
          DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
        run: |
          if ! git merge-base --is-ancestor HEAD "origin/$DEFAULT_BRANCH"; then
            echo "::error::Tag $GITHUB_REF_NAME points at a commit that isn't on $DEFAULT_BRANCH: tag the merged commit instead"
            exit 1
          fi
      - uses: dtolnay/rust-toolchain@stable
      # Every crate that gets published shares the version (all of them, if
      # none is published).
      - name: Check tag matches Cargo.toml version
        id: version
        run: |
          version=${GITHUB_REF_NAME#v}
          mismatched=$(cargo metadata --no-deps --format-version 1 \
            | jq -r --arg v "$version" '.packages as $all
                | [$all[] | select(.publish != [])] | if length > 0 then . else $all end
                | .[] | select(.version != $v) | "\(.name) \(.version)"')
          if [ -n "$mismatched" ]; then
            echo "::error::Tag $GITHUB_REF_NAME does not match Cargo.toml: $mismatched"
            exit 1
          fi
          echo "version=$version" >> "$GITHUB_OUTPUT"
      # The archives are named after the repo, but the executable in them
      # keeps its own name: the binary named like the repo, or else the
      # workspace's only binary.
      - if: inputs.binaries || inputs.winget
        name: Find the binary
        id: bin
        run: |
          bins=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[].targets[] | select(.kind | index("bin")) | .name')
          if grep -qxF "$REPO" <<<"$bins"; then
            bin=$REPO
          elif [ "$(grep -c . <<<"$bins")" -eq 1 ]; then
            bin=$bins
          else
            echo "::error::No binary named $REPO, and not exactly one other to ship instead: $(tr '\n' ' ' <<<"$bins")"
            exit 1
          fi
          echo "bin=$bin" >> "$GITHUB_OUTPUT"
      # winget's validation runs the executable without arguments and holds
      # the PR for a moderator unless it exits 0, on every update too. clap
      # exits 2 when a required subcommand is missing, on any platform.
      - if: inputs.winget
        name: Check the binary exits 0 without arguments
        env:
          BIN: ${{ steps.bin.outputs.bin }}
        run: |
          cargo build --locked --quiet --bin "$BIN"
          status=0
          "target/debug/$BIN" </dev/null >/dev/null 2>&1 || status=$?
          if [ "$status" -ne 0 ]; then
            echo "::error::$BIN exits $status without arguments, and winget's validation flags anything but 0: print help instead (with clap, make the subcommand an Option)"
            exit 1
          fi
      - name: Pick a runner per target
        id: matrix
        env:
          BINARIES: ${{ inputs.binaries }}
          WINGET: ${{ inputs.winget }}
        run: |
          targets=
          [ "$BINARIES" = true ] && targets="$targets $BINARIES_TARGETS"
          [ "$WINGET" = true ] && targets="$targets $WINGET_TARGETS"
          include='[]'
          for target in $(tr ' ' '\n' <<<"$targets" | awk 'NF && !seen[$0]++'); do
            case "$target" in
              *-apple-darwin)              os=macos-latest ;;
              aarch64-unknown-linux-*)     os=ubuntu-24.04-arm ;;
              x86_64-unknown-linux-*)      os=ubuntu-latest ;;
              aarch64-pc-windows-msvc)     os=windows-11-arm ;;
              x86_64-pc-windows-msvc)      os=windows-latest ;;
              *) echo "::error::No runner known for target $target"; exit 1 ;;
            esac
            include=$(jq -c --arg t "$target" --arg os "$os" '. + [{target: $t, os: $os}]' <<<"$include")
          done
          echo "matrix=$(jq -c '{include: .}' <<<"$include")" >> "$GITHUB_OUTPUT"
          echo "any=$(jq 'length > 0' <<<"$include")" >> "$GITHUB_OUTPUT"
      - if: inputs.crates-io
        run: cargo publish --workspace --dry-run --locked
      - if: inputs.homebrew && github.event.repository.private && !inputs.binaries
        run: |
          echo "::error::Homebrew installs a private repo's binaries archives: set binaries: true"
          exit 1

  build:
    needs: verify
    if: needs.verify.outputs.binaries == 'true'
    strategy:
      fail-fast: true
      matrix: ${{ fromJSON(needs.verify.outputs.matrix) }}
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
    env:
      TARGET: ${{ matrix.target }}
      ARCHIVE: ${{ github.event.repository.name }}-v${{ needs.verify.outputs.version }}-${{ matrix.target }}
      BIN: ${{ needs.verify.outputs.bin }}
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - if: contains(matrix.target, '-musl')
        run: sudo apt-get update && sudo apt-get install -y musl-tools
      - name: Build
        shell: bash
        env:
          # No VC++ runtime dependency on Windows.
          RUSTFLAGS: ${{ contains(matrix.target, 'windows-msvc') && '-C target-feature=+crt-static' || '' }}
        run: cargo build --release --locked --target "$TARGET"
      - name: Archive
        shell: bash
        run: |
          case "$TARGET" in *windows*) exe=.exe ;; *) exe= ;; esac
          mkdir "$ARCHIVE"
          cp "target/$TARGET/release/$BIN$exe" "$ARCHIVE/"
          cp README* LICENSE* UNLICENSE* COPYRIGHT* NOTICE* "$ARCHIVE/" 2>/dev/null || true
          if [ -n "$exe" ]; then
            # PowerShell is on every Windows image, ARM included; 7-Zip may not be.
            pwsh -NoProfile -Command "Compress-Archive -Path '$ARCHIVE/*' -DestinationPath '$ARCHIVE.zip'"
          else
            tar czf "$ARCHIVE.tar.gz" "$ARCHIVE"
          fi
      - uses: actions/upload-artifact@v7
        with:
          name: ${{ env.ARCHIVE }}
          path: ${{ env.ARCHIVE }}.*
          if-no-files-found: error

  crates-io:
    needs: [verify, build]
    if: ${{ inputs.crates-io && !failure() && !cancelled() }}
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: rust-lang/crates-io-auth-action@v1
        id: auth
      - run: cargo publish --workspace --locked
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

  github-release:
    needs: [verify, build, crates-io]
    if: ${{ !failure() && !cancelled() }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - if: needs.verify.outputs.binaries == 'true'
        uses: actions/download-artifact@v8
        with:
          path: dist
          merge-multiple: true
      - name: Create GitHub release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          shopt -s nullglob
          gh release create "$GITHUB_REF_NAME" dist/* \
            --repo "$GITHUB_REPOSITORY" \
            --title "$GITHUB_REF_NAME" \
            --generate-notes \
            --verify-tag

  homebrew:
    needs: [verify, github-release]
    if: ${{ inputs.homebrew && !failure() && !cancelled() }}
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read
    env:
      TAP: ${{ github.repository_owner }}/homebrew-tap
      VERSION: ${{ needs.verify.outputs.version }}
      PRIVATE: ${{ github.event.repository.private }}
    steps:
      - uses: actions/create-github-app-token@v3
        id: app-token
        with:
          app-id: ${{ vars.TAP_APP_ID }}
          private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
          repositories: homebrew-tap
      - uses: actions/checkout@v7
        with:
          repository: ${{ env.TAP }}
          token: ${{ steps.app-token.outputs.token }}
      - if: env.PRIVATE == 'true'
        uses: actions/download-artifact@v8
        with:
          path: ${{ runner.temp }}/dist
          merge-multiple: true
      - if: env.PRIVATE == 'true'
        name: Publish the archives on the tap
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          DIST: ${{ runner.temp }}/dist
        run: |
          tag="${REPO}-dist-v${VERSION}"
          # A rerun of this job finds the release already there.
          if gh release view "$tag" --repo "$TAP" >/dev/null 2>&1; then
            gh release upload "$tag" "$DIST"/*.tar.gz --repo "$TAP" --clobber
          else
            gh release create "$tag" "$DIST"/*.tar.gz --repo "$TAP" \
              --title "${REPO} v${VERSION}" \
              --notes "Archives of ${REPO} v${VERSION}, whose source repository is private."
          fi
      - if: env.PRIVATE != 'true'
        name: Point the formula at the source
        run: |
          url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${GITHUB_REF_NAME}.tar.gz"
          sha256=$(curl -fsSL "$url" | sha256sum | cut -d' ' -f1)
          sed -i -E \
            -e "s|^  url \".*\"$|  url \"${url}\"|" \
            -e "s|^  sha256 \"[0-9a-f]+\"$|  sha256 \"${sha256}\"|" \
            "Formula/${REPO}.rb"
      # Each url names one archive, by target; the sha256 after it is that
      # archive's.
      - if: env.PRIVATE == 'true'
        name: Point the formula at the archives
        env:
          DIST: ${{ runner.temp }}/dist
        shell: python
        run: |
          import hashlib, os, pathlib, re, sys

          repo, version, dist = os.environ["REPO"], os.environ["VERSION"], os.environ["DIST"]
          root = f"https://github.com/{os.environ['TAP']}/releases/download/{repo}-dist-v{version}"
          archive = re.compile(rf"/{re.escape(repo)}-v\d+\.\d+\.\d+-(.+)\.tar\.gz$")
          formula = pathlib.Path(f"Formula/{repo}.rb")
          lines, sha = [], None
          for line in formula.read_text().splitlines(keepends=True):
              if url := re.match(r'(\s*)url "(.*)"\s*$', line):
                  target = archive.search(url[2])
                  if not target:
                      sys.exit(f"::error::{formula}: {url[2]} isn't one of {repo}'s archives")
                  name = f"{repo}-v{version}-{target[1]}.tar.gz"
                  path = pathlib.Path(dist, name)
                  if not path.exists():
                      sys.exit(f"::error::{formula} wants {name}, which this release doesn't build")
                  sha = hashlib.sha256(path.read_bytes()).hexdigest()
                  line = f'{url[1]}url "{root}/{name}"\n'
              elif sha and (old := re.match(r'(\s*)sha256 "[0-9a-f]+"\s*$', line)):
                  line = f'{old[1]}sha256 "{sha}"\n'
                  sha = None
              lines.append(line)
          formula.write_text("".join(lines))
      - name: Open formula bump PR
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
        run: |
          formula="Formula/${REPO}.rb"
          git diff --exit-code --quiet "$formula" && { echo "::error::$formula unchanged"; exit 1; }

          branch="bump-${REPO}-${VERSION}"
          git config user.name "${APP_SLUG}[bot]"
          git config user.email "$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)+${APP_SLUG}[bot]@users.noreply.github.com"
          git switch -c "$branch"
          git commit -am "chore(${REPO}): update to ${VERSION}"
          git push -u origin "$branch"
          gh pr create --title "chore(${REPO}): update to ${VERSION}" --body "Automated bump from ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"

  winget:
    needs: [verify, github-release]
    if: ${{ inputs.winget && !failure() && !cancelled() }}
    runs-on: ubuntu-latest
    permissions: {}
    steps:
      - name: Check the package exists in winget-pkgs
        id: exists
        env:
          GH_TOKEN: ${{ github.token }}
          OWNER: ${{ github.repository_owner }}
        run: |
          initial=${OWNER:0:1}
          if gh api "repos/microsoft/winget-pkgs/contents/manifests/${initial,,}/${OWNER}/${REPO}" --silent; then
            echo "exists=true" >> "$GITHUB_OUTPUT"
          else
            echo "::warning::${OWNER}.${REPO} isn't in winget-pkgs yet; submit the first version with forge's scripts/winget-new.sh"
          fi
      - if: steps.exists.outputs.exists == 'true'
        uses: vedantmgoyal9/winget-releaser@v2
        with:
          identifier: ${{ github.repository_owner }}.${{ github.event.repository.name }}
          version: ${{ needs.verify.outputs.version }}
          installers-regex: -pc-windows-msvc\.zip$
          release-tag: ${{ github.ref_name }}
          token: ${{ secrets.WINGET_PAT }}