name: Release

# Manually triggered ("Run workflow"). On trigger it:
#   1. reads the version from package.json,
#   2. promotes `## [Unreleased]` content into `## [<version>]` in
#      CHANGELOG.md (and commits + pushes that change back to main), so
#      the published release notes are never sparse just because the
#      maintainer didn't pre-stage the [<version>] block by hand,
#   3. builds a self-contained bundle for every platform (one runner — there's no
#      native compilation, so cross-packaging is fine),
#   4. creates the GitHub Release (tag v<version>) with all archives, using the
#      release notes from CHANGELOG.md,
#   5. publishes the npm thin-installer (shim + per-platform packages).
#
# Before triggering: bump package.json. CHANGELOG.md entries can live under
# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret.
on:
  workflow_dispatch: {}

permissions:
  contents: write   # create the GitHub Release + tag, push the CHANGELOG promote

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          # Default checkout is detached at a SHA; we need an actual branch
          # so the CHANGELOG-promote commit knows where to push.
          ref: ${{ github.ref }}
          # Authenticate as the maintainer (admin), not as github-actions[bot].
          # The "Require PR approval for main branch" ruleset only lets the
          # Admin repo role bypass — and GitHub blocks adding the GitHub
          # Actions integration to bypass_actors on user-owned (non-org)
          # repos with "Actor GitHub Actions integration must be part of
          # the ruleset source or owner organization." So the auto-promote
          # and auto-sync `git push origin HEAD:main` steps below both fail
          # under the default GITHUB_TOKEN. Using a fine-grained PAT owned
          # by the admin makes the push go through cleanly. Set the
          # RELEASE_PAT secret with: contents:write on this repo, no other
          # scopes. Rotate per your token policy; the workflow only runs
          # on manual dispatch so the blast radius is small.
          token: ${{ secrets.RELEASE_PAT }}
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org

      - name: Sync package-lock.json if version drifted
        # When the maintainer bumps the version on package.json only — for
        # example via a GitHub web-UI edit — `npm ci` would refuse to run
        # with `EUSAGE: npm ci can only install packages when your
        # package.json and package-lock.json … are in sync`. This step
        # rewrites just the lock-file's version fields (top-level + the
        # `packages.""` entry) to match package.json, then auto-commits
        # and pushes the result so on-disk truth on `main` stays
        # consistent. Idempotent: if the lock file already matches, no
        # commit is made.
        run: |
          set -euo pipefail
          PKG_V=$(node -p "require('./package.json').version")
          LOCK_V=$(node -p "require('./package-lock.json').version")
          if [ "$PKG_V" = "$LOCK_V" ]; then
            echo "package-lock.json already at $PKG_V — nothing to sync."
            exit 0
          fi
          echo "Lock-file version drift: lock=$LOCK_V, package=$PKG_V. Syncing."
          # `--package-lock-only` rewrites only the lock file, doesn't
          # touch node_modules or actually install anything. Cheap.
          npm install --package-lock-only --ignore-scripts
          # Sanity: lockfile should now report the package version.
          NEW_LOCK_V=$(node -p "require('./package-lock.json').version")
          if [ "$NEW_LOCK_V" != "$PKG_V" ]; then
            echo "::error::lock-file still at $NEW_LOCK_V after sync attempt; expected $PKG_V"; exit 1
          fi
          if git diff --quiet -- package-lock.json; then
            echo "lock file unchanged after sync? bailing"; exit 1
          fi
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add package-lock.json
          git commit -m "release: sync package-lock.json to ${PKG_V}" -m "[skip ci] Auto-generated by Release workflow."
          git push origin "HEAD:${GITHUB_REF#refs/heads/}"

      - run: npm ci
      - name: Ensure zip/unzip
        run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip

      - name: Resolve version
        id: ver
        run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

      - name: Promote [Unreleased] → [<version>] in CHANGELOG.md
        # Idempotent: a no-op if [Unreleased] is empty OR if the previous
        # run already moved everything. Auto-commit + push the change back
        # so the version block on main is the source of truth going
        # forward (and so subsequent extract-release-notes.mjs calls
        # surface the full content even if this run is re-triggered).
        run: |
          set -euo pipefail
          V="${{ steps.ver.outputs.version }}"
          before=$(git rev-parse HEAD)
          node scripts/prepare-release.mjs "$V"
          if git diff --quiet -- CHANGELOG.md; then
            echo "CHANGELOG.md unchanged — nothing to commit."
          else
            git config user.name  "github-actions[bot]"
            git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
            git add CHANGELOG.md
            git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow."
            # Push to the branch the workflow was triggered on (main).
            git push origin "HEAD:${GITHUB_REF#refs/heads/}"
          fi

      - name: Build all platform bundles
        run: |
          for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do
            bash scripts/build-bundle.sh "$t"
          done
          ls -lh release

      - name: Generate SHA256SUMS
        # Published as a release asset; the npm launcher verifies downloaded
        # bundles against it (basenames only, so its path.basename match works).
        run: |
          ( cd release && sha256sum codegraph-* > SHA256SUMS )
          cat release/SHA256SUMS

      - name: Release notes from CHANGELOG.md
        # The [<version>] block was guaranteed-populated by the
        # "Promote" step above, so the [Unreleased] fallback should
        # never be needed in practice. Kept for defense-in-depth.
        run: |
          V="${{ steps.ver.outputs.version }}"
          node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \
            || node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true
          if [ ! -s notes.md ]; then
            echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]."
            exit 1
          fi
          echo "----- release notes -----"; cat notes.md

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="v${{ steps.ver.outputs.version }}"
          # Idempotent: create the release once, otherwise (re-run) refresh assets.
          if gh release view "$TAG" >/dev/null 2>&1; then
            gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber
          else
            gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md
          fi

      - name: Publish to npm
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          V="${{ steps.ver.outputs.version }}"
          bash scripts/pack-npm.sh "$V"
          # Platform packages first, then the main shim (which depends on them).
          # Skip any already on the registry so a re-run only fills in gaps.
          for dir in release/npm/codegraph-* release/npm/main; do
            name=$(node -p "require('./$dir/package.json').name")
            if npm view "$name@$V" version >/dev/null 2>&1; then
              echo "skip $name@$V (already published)"
            else
              echo "publishing $name@$V"
              ( cd "$dir" && npm publish --access public )
            fi
          done

      - name: Verify every package is actually on the registry
        run: |
          V="${{ steps.ver.outputs.version }}"
          # npm publish can print success without persisting; confirm against the
          # registry (with retries for propagation) so green means really shipped.
          for dir in release/npm/codegraph-* release/npm/main; do
            name=$(node -p "require('./$dir/package.json').name")
            ok=
            for i in 1 2 3 4 5 6; do
              if npm view "$name@$V" version >/dev/null 2>&1; then ok=1; break; fi
              echo "waiting for $name@$V to appear ($i)…"; sleep 10
            done
            [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; }
            echo "verified $name@$V"
          done

      - name: Sync packages to npmmirror
        # npmmirror/cnpm mirror lazily and frequently never pull the per-platform
        # optionalDependencies on their own, so `npm i` there fails with
        # "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get
        # the bundle without waiting. Best-effort — the launcher also self-heals
        # from GitHub Releases — so a mirror hiccup never fails the release.
        continue-on-error: true
        run: |
          for dir in release/npm/codegraph-* release/npm/main; do
            name=$(node -p "require('./$dir/package.json').name")
            enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)")
            echo "sync $name"
            curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true
            echo
          done
