name: Release

# The sixth workflow, and the one that decides whether the other three
# run at all.  It reads the Conventional Commits since the last tag, and
# if any of them is releasable it writes the changelog, pushes the tag,
# and creates the Gitea release whose body is that changelog section.
# The publishing workflows are keyed on `v*`, so the tag push is what
# starts them.
#
# **It is triggered by hand, and there is deliberately no `push`
# trigger.**  There was one, on `main`, which made the trigger "a PR was
# merged" and nothing else: eight releases in twenty-two hours
# (v0.0.1 -> v0.3.1) for one session's work, each fanning out to four
# publishers on a runner with capacity 1, so ~40 packaging jobs shipped
# three issues and ordinary PR CI queued behind them.  A version per
# merged PR is a version per unit of *work*, not per *shipment*, and
# pacman, Homebrew and Obtainium see every one.
#
# Nothing else had to change to batch them: semantic-release already
# reads every commit since the last tag, so five fixes and two feats
# become one minor release with all seven in the notes.  Release
# frequency was only ever how often this file fired.
#
# This is the rule `index-artifact.yml` states and is the other instance
# of: **a job that mutates state which cannot be rebuilt in ten minutes
# is triggered deliberately, not by a push.**  A release here is a tag,
# a Gitea release, an Arch package, a Homebrew formula, a signed APK and
# desktop assets — and an Android version going backwards costs the user
# their library (docs/android-release.md).
#
# A schedule was considered and rejected: a cron batches without anyone
# having to remember, but it puts the decision back on a timer, which is
# the thing being removed.
#
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
# workflow's own token (go-gitea#33123).  The token is what decides this,
# not the workflow — so semantic-release is handed a repositoryUrl
# carrying a *user* PAT, and the resulting push is attributed to a person
# and triggers the `v*` workflows normally.
#
# That limitation is used deliberately in the bootstrap step below, where
# a tag that must *not* trigger anything is pushed with the Actions token
# instead.

on:
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Report what would be released and stop"
        required: false
        default: "false"

# Cutting a tag is not a thing to cancel halfway: a superseded run must
# finish, not be killed between `git push --tags` and the release POST.
concurrency:
  group: release-main
  cancel-in-progress: false

jobs:
  release:
    runs-on: ubuntu-latest
    container:
      image: ubuntu:24.04
    env:
      SERVER_URL: ${{ github.server_url }}
      OWNER: ${{ github.repository_owner }}
      REPO: ${{ github.repository }}
      PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
      DEBIAN_FRONTEND: noninteractive
    steps:
      - name: System packages
        run: |
          set -eu
          apt-get update -qq
          apt-get install -y -qq --no-install-recommends ca-certificates curl git jq

      - name: Node toolchain
        run: |
          set -eu
          curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
          apt-get install -y -qq --no-install-recommends nodejs
          node --version

      # By hand rather than actions/checkout, like the other five: that is
      # a JS action and needs node inside the container before any step has
      # installed it.  The full history is required — semantic-release
      # reads tags and walks commits, and a shallow clone silently makes
      # every release look like the first one.
      - name: Clone repo at this commit
        run: |
          set -eu
          git clone --quiet \
            "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
          # -B main rather than --detach, which the other five workflows
          # use: semantic-release resolves the release branch and then
          # pushes a commit and a tag to it, and a detached HEAD is a
          # worse starting point for both than a local branch named after
          # the one being released.  Pinned to this commit, not to
          # whatever main points at by the time the container started.
          git -C /src checkout --quiet -B main "${{ github.sha }}"
          git config --global --add safe.directory /src
          git -C /src log --oneline -1

      # Nothing currently pushes a `chore(release):` commit — main is a
      # protected branch, so .releaserc.yml carries no @semantic-release/git
      # and the release page is the changelog.  This guard is kept for the
      # day someone adds that plugin back: without it the commit-back is a
      # push to the branch this workflow runs on, and the loop is a release
      # per release.  Six lines against that is cheap.
      - name: Skip a changelog commit, if one ever exists
        id: guard
        working-directory: /src
        run: |
          set -eu
          subject=$(git log -1 --format='%s')
          case "$subject" in
            "chore(release):"*)
              echo "this is the release commit itself; nothing to do"
              echo "skip=true" >> "$GITHUB_OUTPUT"
              ;;
            *)
              echo "skip=false" >> "$GITHUB_OUTPUT"
              ;;
          esac

      # semantic-release calls the first release of a repo with no tags
      # 1.0.0, and offers no option to say otherwise.  A floor tag is the
      # only way to start at 0.0.1, so this creates one — once, ever.
      #
      # **It is pushed with the Actions token on purpose.**  v0.0.0 is a
      # floor, not a shipment: pushing it with a user PAT would start the
      # Arch, Homebrew and Android workflows for a version that does not
      # exist.  The very limitation the header describes is what makes
      # this inert.
      - name: Seed the version floor
        if: steps.guard.outputs.skip == 'false'
        working-directory: /src
        env:
          ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
        run: |
          set -eu
          git fetch --quiet --tags origin

          if [ -n "$(git tag --list 'v[0-9]*')" ]; then
            echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
            exit 0
          fi

          # Prefer the Actions token because a ref it pushes starts no
          # workflow, which is the whole point for a tag that is a floor
          # rather than a shipment.  Falling back to the PAT is safe
          # rather than merely convenient: all four publishing workflows
          # skip v0.0.0 explicitly, so the worst case is four jobs that
          # start and immediately say there is nothing to build.
          token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
          [ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"

          # **On the parent, not on HEAD.**  The floor marks what has
          # already been released, so tagging the commit being pushed
          # leaves nothing between the floor and HEAD — semantic-release
          # then correctly reports there is nothing to release, which is
          # exactly what the first run of this workflow did.  HEAD^ is the
          # first parent, so on the merge commit this fires for it is main
          # as it was before the merge, and everything the merge brought
          # in is releasable.
          floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
          if [ -z "$floor" ]; then
            echo "HEAD has no parent, so no commit can precede the floor" >&2
            exit 1
          fi

          echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
          git tag v0.0.0 "$floor"
          git push --quiet \
            "https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
            refs/tags/v0.0.0
          echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"

      # Pinned rather than installed into the repo: this is a Go project
      # and a package.json at its root invites the npm plugin and every
      # tool that looks for one.  conventional-changelog-conventionalcommits
      # is in the list because both the analyzer and the notes generator
      # name that preset and neither depends on it.
      #
      # **That preset is held at 9 and the reason is worth keeping.**  At
      # 10 it is silently incompatible with the writer that
      # release-notes-generator@14 pulls in (^8): every release note comes
      # out as a bare `## 0.0.1 (date)` heading with **no sections and no
      # commits under it**, and nothing errors.  The version would have
      # been right, the tag would have been right, every job would have
      # been green, and the release body would have been empty.  Check the
      # notes, not the exit code, before moving any of these.
      # The point of a manual trigger is deliberateness, and deliberate
      # means being able to look before pulling the lever.  `--dry-run`
      # reports the version and the notes and writes nothing: no tag, no
      # release, no publishers.  `make release-dry` is the same answer
      # locally; this is it from the runner, against the same commit and
      # the same tag history, which is what actually decides.
      - name: Run semantic-release
        if: steps.guard.outputs.skip == 'false'
        working-directory: /src
        env:
          DRY_RUN: ${{ inputs.dry_run }}
        run: |
          set -eu
          git config user.name  "yellowjacket-ci"
          git config user.email "yj@yellowjacket.app"

          # Anything but a literal "true" releases for real.  A typo in a
          # dispatch box must not silently turn a shipment into a no-op
          # that reports success — the failure worth avoiding is the one
          # where nothing happens and the run is green.
          dry=""
          if [ "${DRY_RUN:-false}" = "true" ]; then
            echo "DRY RUN — no tag will be pushed and no release created"
            dry="--dry-run"
          fi

          npx --yes \
            -p semantic-release@25 \
            -p @semantic-release/commit-analyzer@13 \
            -p @semantic-release/release-notes-generator@14 \
            -p @semantic-release/changelog@7 \
            -p @semantic-release/exec@7 \
            -p conventional-changelog-conventionalcommits@9 \
            semantic-release $dry \
              --repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
