# Cut a release: stamp the version across every package.json, commit, tag, push.
#
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
# that build to go green and then dispatches deploy-galactus.yml with
# `tag=X.Y.Z scope=app` (no leading v — the git tag carries the `v`, the image
# tag does not). A tag is the only ref that starts either chain; pushing to
# master builds images and stops there.
#
# So cutting a release DOES reach prod. To cut a version without deploying it,
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml then
# prints the manual command instead of running it.
#
# Why a workflow instead of three local commands: the release commit is the one
# thing that must be identical every time, and cutting it from a laptop is how
# a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the
# only input is the number.
#
# Prereqs (once):
#  - Repo secret RELEASE_TOKEN: a Gitea personal access token with
#    write:repository on this repo. The built-in Actions token is deliberately
#    NOT used — whether a push made with it re-triggers build.yml depends on the
#    Gitea version, and a release that silently publishes no images is worse
#    than one that fails. A PAT push is an ordinary push and always triggers.
#    If build.yml somehow does not start, it has workflow_dispatch: run it
#    against the new tag by hand.

name: Cut release

on:
  workflow_dispatch:
    inputs:
      bump:
        description: "Which part to bump (choose 'explicit' to type the number)"
        type: choice
        required: true
        default: "minor"
        options:
          - patch
          - minor
          - major
          - explicit
      version:
        description: "Exact version when bump=explicit (x.y.z, no leading v)"
        required: false
        default: ""

jobs:
  release:
    name: Release
    runs-on: docker
    container:
      image: node:20-alpine
    steps:
      - name: Install tools
        run: apk add --no-cache git

      - name: Preflight — RELEASE_TOKEN
        env:
          RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -eu
          if [ -z "${RELEASE_TOKEN:-}" ]; then
            echo "::error::Secret RELEASE_TOKEN is not set. Create a Gitea PAT with"
            echo "::error::write:repository and add it as a repo secret named RELEASE_TOKEN."
            exit 1
          fi

      # Full history + tags: the duplicate-tag check below is meaningless
      # against a shallow clone, which has none of them.
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          ref: master
          token: ${{ secrets.RELEASE_TOKEN }}

      - name: Resolve the new version
        id: ver
        env:
          BUMP: ${{ github.event.inputs.bump }}
          EXPLICIT: ${{ github.event.inputs.version }}
        run: |
          set -eu
          CURRENT=$(node -p "require('./package.json').version")
          echo "current: $CURRENT"

          if [ "$BUMP" = "explicit" ]; then
            NEXT="$EXPLICIT"
            if [ -z "$NEXT" ]; then
              echo "::error::bump=explicit requires the version input."
              exit 1
            fi
          else
            NEXT=$(node -e '
              const [cur, part] = process.argv.slice(1);
              const m = /^(\d+)\.(\d+)\.(\d+)/.exec(cur);
              if (!m) { console.error(`unparseable current version: ${cur}`); process.exit(1); }
              let [maj, min, pat] = m.slice(1).map(Number);
              if (part === "major") { maj += 1; min = 0; pat = 0; }
              else if (part === "minor") { min += 1; pat = 0; }
              else { pat += 1; }
              process.stdout.write(`${maj}.${min}.${pat}`);
            ' "$CURRENT" "$BUMP")
          fi

          # set-version.mjs validates the shape too, but failing here keeps the
          # working tree clean when the input is a typo.
          case "$NEXT" in
            v*) echo "::error::Version must not carry a leading 'v' (got $NEXT)."; exit 1 ;;
          esac
          if ! printf '%s' "$NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
            echo "::error::Invalid version: $NEXT (expected x.y.z)."
            exit 1
          fi
          if [ "$NEXT" = "$CURRENT" ]; then
            echo "::error::$NEXT is already the current version."
            exit 1
          fi
          if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
            echo "::error::Tag v$NEXT already exists. Releases are immutable — pick a new number."
            exit 1
          fi

          echo "next: $NEXT"
          echo "version=$NEXT" >> "$GITHUB_OUTPUT"

      - name: Stamp the version across every manifest
        run: node scripts/set-version.mjs "${{ steps.ver.outputs.version }}"

      # A release whose only content is the version bump means the dispatch was
      # a mistake — set-version.mjs already refused a no-op above, so an empty
      # diff here means the manifests were somehow already at this number.
      - name: Commit, tag, push
        env:
          RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          VERSION: ${{ steps.ver.outputs.version }}
          ACTOR: ${{ github.actor }}
        run: |
          set -eu
          if git diff --quiet; then
            echo "::error::No manifest changed. Nothing to release."
            exit 1
          fi

          git config user.name "gitea-actions"
          git config user.email "actions@git.mancinas.io"

          git commit -a \
            -m "chore(release): v${VERSION}" \
            -m "Cut by ${ACTOR} via the \"Cut release\" workflow. Pushing the tag triggers build.yml; deploy separately with tag=${VERSION}."
          git tag -a "v${VERSION}" -m "v${VERSION}"

          # Re-point at an authenticated remote. The token is a secret, so Gitea
          # masks it in the log; nothing here echoes the URL regardless.
          git remote set-url origin \
            "$(printf '%s' "${GITHUB_SERVER_URL}" | sed "s#://#://x-access-token:${RELEASE_TOKEN}@#")/${GITHUB_REPOSITORY}.git"

          # One push for both refs: a commit that lands without its tag builds
          # nothing and looks like a successful release.
          #
          # The output is captured because a failing *post-receive* hook does not
          # fail the push: git prints `remote: error: ...`, updates both refs and
          # exits 0. That is how v1.0.3 was cut — the hook 500'd, so Gitea never
          # created the build run, and this step went green anyway.
          if ! git push origin "HEAD:master" "refs/tags/v${VERSION}" 2>push.log; then
            cat push.log
            echo "::error::Push failed. Nothing was released."
            exit 1
          fi
          cat push.log

          if grep -q '^remote: error' push.log; then
            echo "::warning::The remote's post-receive hook errored. Both refs landed,"
            echo "::warning::but Gitea most likely created no workflow run for them."
            echo "::warning::The next step checks and dispatches build.yml if needed."
          fi

          echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
        id: push

      # Gitea creates workflow runs from the post-receive hook, so a hook error
      # silently costs you the build: the tag exists, no image is ever published,
      # and the failure only surfaces later as a 404 when deploy pulls the image.
      # Confirm the run exists; dispatch it if it does not; fail loudly if that
      # does not work either.
      - name: Verify build.yml started
        env:
          RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          VERSION: ${{ steps.ver.outputs.version }}
          SHA: ${{ steps.push.outputs.sha }}
        run: |
          node -e '
            const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
            const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
            const sha = process.env.SHA;
            const tag = `v${process.env.VERSION}`;
            const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

            // The master push and the tag push carry the SAME commit, so a sha
            // match alone is not enough: build.yml skips the master run by
            // design, and that skipped run would satisfy a sha-only check even
            // if the tag run were never created. When the API reports a ref for
            // the run, require it to be the tag; when it reports none, fall back
            // to the sha match rather than failing a release over a field name.
            const isTagRun = (r) => {
              const ref = r.head_branch || r.ref || "";
              return !ref || ref === tag || ref === `refs/tags/${tag}`;
            };

            const started = async () => {
              const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
              if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
              const body = await res.json();
              return (body.workflow_runs || []).some(
                (r) =>
                  r.head_sha === sha &&
                  String(r.path || "").includes("build.yml") &&
                  isTagRun(r),
              );
            };

            // The hook fires synchronously with the push, so a run that is coming
            // is usually already there; the retries cover a busy instance.
            const poll = async (attempts) => {
              for (let i = 0; i < attempts; i++) {
                if (await started()) return true;
                await sleep(10_000);
              }
              return started();
            };

            (async () => {
              if (await poll(3)) {
                console.log(`build.yml is running for ${sha}.`);
                return;
              }

              console.log(`No build.yml run for ${sha}. Dispatching against ${tag}.`);
              const res = await fetch(
                `${base}/actions/workflows/build.yml/dispatches`,
                {
                  method: "POST",
                  headers: { ...headers, "Content-Type": "application/json" },
                  // Must be the tag, not master: metadata-action only emits the
                  // X.Y.Z and X.Y image tags when the ref is a semver tag. And
                  // it must be the fully qualified ref — Gitea 404s on `v1.0.3`.
                  body: JSON.stringify({ ref: `refs/tags/${tag}` }),
                },
              );
              if (!res.ok) console.log(`Dispatch returned HTTP ${res.status}.`);

              if (await poll(3)) {
                console.log(`build.yml is running for ${sha}.`);
                return;
              }

              console.log(`::error::${tag} is pushed but nothing is building it, and`);
              console.log(`::error::the dispatch did not take. Run "Build and Push Images"`);
              console.log(`::error::by hand with ref=${tag} (the tag, not master), then`);
              console.log(`::error::deploy. Check the Gitea server log for the`);
              console.log(`::error::post-receive error while you are at it.`);
              process.exit(1);
            })();
          '

      - name: Summary
        env:
          VERSION: ${{ steps.ver.outputs.version }}
        run: |
          set -eu
          echo "Released v${VERSION}."
          echo ""
          echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
          echo "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
          echo "'Deploy to galactus' with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
          echo ""
          echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false),"
          echo "dispatch 'Deploy to galactus' by hand with the same inputs."
          echo "Rollback = re-dispatch it with an older tag."
