# Publishes every non-private workspace package whose *content* changed, without
# anyone having to remember to bump a version.
#
# The rules this encodes were all learned the hard way:
#
#   - Compare npm pack integrity against the registry, not version numbers. A
#     version bump is not evidence of a change and an unchanged version is not
#     evidence of no change.
#   - Check the publish credential before building anything. npm answers an
#     unauthorized PUT with E404 ("could not be found"), which reads like a
#     missing package — after every package has already been built, and after
#     each failed attempt has signed a provenance statement into the public
#     sigstore transparency log.
#   - Walk the workspace in dependency order, not alphabetical order. A sibling
#     that has not been built yet has no dist/, and its package.json `types`
#     entries point at files that do not exist.
#   - Never publish the literal "workspace:*" protocol — consumers cannot
#     resolve it.
#   - Treat the registry as the source of truth for which version numbers are
#     spent. It refuses a PUT for any version it has ever seen, including
#     staged and unpublished ones that the `latest` dist-tag cannot show you.
#
# Credentials, in order of preference:
#
#   1. Trusted publishing (OIDC) — no secret at all. Leave NPM_TOKEN unset and
#      name this repo + workflow as the trusted publisher for each package on
#      npmjs.com. Nothing to rotate, nothing to expire.
#   2. An NPM_TOKEN secret. Granular access tokens expire after 90 days at
#      most; classic automation tokens no longer work for direct publishing.

name: Publish to npm

on:
  push:
    branches: [{{DEFAULT_BRANCH}}]
  workflow_dispatch:

permissions:
  contents: write
  # Required for trusted publishing (OIDC) and for --provenance.
  id-token: write

concurrency:
  group: npm-publish
  cancel-in-progress: false

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          fetch-depth: 2

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: "https://registry.npmjs.org"

      - uses: oven-sh/setup-bun@v2

      # setup-node writes `always-auth` into the npmrc it generates. npm 10+
      # does not know the key and warns about it on every single invocation,
      # which is hundreds of lines of noise across a run. Authentication comes
      # from the _authToken line, which stays.
      - name: Drop the deprecated always-auth key from the generated npmrc
        run: |
          if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
            sed -i '/^always-auth[[:space:]]*[= ]/d' "$NPM_CONFIG_USERCONFIG"
          fi

      # Some published packages ship a broken `prepare: "husky install"` in
      # their package.json. When npm reconciles bun's linked node_modules store
      # it runs that hook, which dies with "husky: not found" (exit 127) — and
      # npm's --ignore-scripts does NOT suppress it for bun-linked packages.
      # Put a no-op `husky` on PATH so a stray hook becomes harmless.
      - name: Neutralize unused husky hooks
        run: |
          mkdir -p "$RUNNER_TEMP/husky-shim"
          printf '#!/bin/sh\nexit 0\n' > "$RUNNER_TEMP/husky-shim/husky"
          chmod +x "$RUNNER_TEMP/husky-shim/husky"
          echo "$RUNNER_TEMP/husky-shim" >> "$GITHUB_PATH"

      - name: Verify npm publish credentials
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          set -uo pipefail

          if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
            if whoami=$(npm whoami 2>&1); then
              echo "🔑 Publishing as npm user '$whoami' (NPM_TOKEN)"
              exit 0
            fi
            echo "$whoami"
            echo "::error title=npm token rejected::The NPM_TOKEN secret no longer authenticates with registry.npmjs.org — it has expired, been revoked, or was replaced. Granular access tokens last 90 days at most. Mint a new token with write access at npmjs.com/settings/<user>/tokens and update the NPM_TOKEN repository secret, or remove the secret entirely and configure trusted publishing."
            exit 1
          fi

          # No secret: trusted publishing. npm >= 11.5.1 trades this job's
          # id-token for a short-lived publish token on its own.
          if ! node -e "
            const need = [11, 5, 1];
            const have = process.argv[1].split('.').map(Number);
            for (let i = 0; i < 3; i++) {
              if ((have[i] || 0) !== need[i]) process.exit((have[i] || 0) > need[i] ? 0 : 1);
            }
          " "$(npm --version)"; then
            echo "⏫ npm $(npm --version) is older than 11.5.1 — upgrading for trusted publishing (OIDC)"
            npm install -g npm@latest
          fi

          # A half-configured empty token in the generated npmrc would make npm
          # try (and fail) to authenticate with it instead of using OIDC.
          if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
            sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG"
          fi
          echo "🔑 No NPM_TOKEN secret — publishing via trusted publishing (OIDC) with npm $(npm --version)"

      # Install the whole workspace once with the repo's package manager, which
      # understands the "workspace:*" protocol and links local packages. Plain
      # `npm install` cannot do this inside a bun/pnpm workspace — it dies on
      # sibling "workspace:*" deps (EUNSUPPORTEDPROTOCOL), which leaves
      # devDependencies uninstalled and builds failing with "vite: not found".
      - name: Install workspace dependencies
        run: {{PACKAGE_MANAGER}} install --ignore-scripts

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Publish packages whose content changed
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          # GitHub runs `run:` steps with `bash -e -o pipefail`, so not writing
          # `set -e` does not disable errexit. With it on, the first package
          # whose publish fails kills the step and every later package goes
          # unreleased. Turn it off explicitly; per-package outcomes are handled
          # through $rc, and the run still exits non-zero at the end.
          set -uo pipefail
          set +e

          repo_root="$PWD"
          failed_packages=""
          skipped_builds=""
          unauthorized_packages=""
          unattempted_packages=""
          auth_broken=""

          build_order=$(node .github/scripts/workspace-build-order.mjs)
          echo "📋 Build order:"
          echo "$build_order" | sed 's/^/   /'

          for dir in $build_order; do
            pkg="${dir}package.json"
            [ -f "$pkg" ] || { echo "⏭ Skipping $dir (no package.json)"; continue; }

            is_private=$(node -p "!!require('./$pkg').private")
            name=$(node -p "require('./$pkg').name")

            if [ "$is_private" = "true" ]; then
              echo "⏭ Skipping $name (private)"
              continue
            fi

            # One rejected PUT means the credential cannot write, and every
            # remaining package would fail the same way — after another build
            # each, and after signing provenance for a version that will never
            # exist. Stop attempting them; the run still fails at the end.
            if [ -n "$auth_broken" ]; then
              echo "⏭ Skipping $name (npm rejected the publish credential — see above)"
              unattempted_packages="$unattempted_packages $name"
              continue
            fi

            echo "📦 Evaluating $name from $dir"

            # Each package runs in a subshell with its own `set -e` so a failing
            # step propagates to $rc without killing the outer loop.
            (
              set -e
              cd "$dir"

              # npm keeps the literal "workspace:*" in the tarball, which
              # consumers cannot resolve. Replace it with a real semver range
              # from the referenced local package; drop deps on local packages
              # that are not published. Only committed back if the version is
              # bumped.
              node "$repo_root/.github/scripts/pin-workspace-deps.mjs"

              # Build before deciding anything: the tarball comparison below
              # needs real dist output, and a package whose build fails is never
              # published.
              if [ "$(node -p "!!(require('./package.json').scripts||{}).build")" = "true" ]; then
                if ! npm run build; then
                  echo "⚠ Build failed for $name — not publishing (won't ship a broken tarball)"
                  exit 42
                fi
              fi

              version=$(node -p "require('./package.json').version")
              latest=$(npm view "$name" version 2>/dev/null || echo "")
              already=$(npm view "$name@$version" version 2>/dev/null || echo "")

              # If the local version has fallen behind the registry (a bump
              # commit that never landed back in the repo), publishing fails
              # with "Cannot implicitly apply the latest tag". Sync first, then
              # let the content comparison decide whether to release at all.
              if [ -z "$already" ] && [ -n "$latest" ]; then
                behind=$(node -e "
                  const cmp = (x, y) => { const a = x.split('.').map(Number), b = y.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i]||0) !== (b[i]||0)) return (a[i]||0) - (b[i]||0); } return 0; };
                  console.log(cmp(process.argv[1], process.argv[2]) < 0);
                " "$version" "$latest")
                if [ "$behind" = "true" ]; then
                  echo "⏫ $name local version $version is behind npm latest $latest — syncing"
                  npm version "$latest" --no-git-tag-version --no-workspaces
                  version="$latest"
                  already="$latest"
                fi
              fi

              if [ -n "$already" ]; then
                # This exact version is on npm. Publish anyway only if the
                # content changed: npm tarballs are reproducible (normalized
                # mtimes), so pack integrity is a reliable comparison.
                local_integrity=$(npm pack --dry-run --ignore-scripts --json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s)[0].integrity))")
                remote_integrity=$(npm view "$name@$version" dist.integrity 2>/dev/null || echo "")

                if [ "$local_integrity" = "$remote_integrity" ]; then
                  echo "⏭ $name@$version already on npm with identical content — nothing new to release"
                  exit 0
                fi

                # Bumping one patch above `latest` is not enough: `latest` lags
                # any version staged by an interrupted publish, and npm answers
                # a PUT for one of those with E409 "Cannot publish over
                # previously staged version".
                next=$(node "$repo_root/.github/scripts/next-free-version.mjs" "$name" "$version")
                echo "🔼 $name content changed since $version — bumping to $next"
                # --no-workspaces stops npm from doing workspace-aware
                # processing over bun's symlinks, which trips
                # EUNSUPPORTEDPROTOCOL on workspace:* siblings.
                npm version "$next" --no-git-tag-version --no-workspaces
                version="$next"
              fi

              # Exit 43 for an authorization failure so the loop stops early.
              # A version the registry reserved between the query above and the
              # PUT is still possible, so E409 retries at the next free version
              # rather than failing the package. Five refusals in a row is a
              # problem no amount of bumping fixes.
              log="$RUNNER_TEMP/npm-publish.log"
              attempt=1

              while :; do
                echo "Publishing $name@$version"
                if npm publish --provenance --access public --ignore-scripts 2>&1 | tee "$log"; then
                  exit 0
                fi

                if grep -qE "npm error code (E401|E404|ENEEDAUTH|EAUTHUNKNOWN|EOTP)" "$log"; then
                  exit 43
                fi
                if grep -q "npm error code E403" "$log" && ! grep -qi "cannot publish over" "$log"; then
                  exit 43
                fi
                if ! grep -qi "cannot publish over" "$log"; then
                  exit 1
                fi
                if [ "$attempt" -ge 5 ]; then
                  echo "⚠ npm refused $attempt versions in a row for $name — giving up"
                  exit 1
                fi

                next=$(node "$repo_root/.github/scripts/next-free-version.mjs" "$name" "$version")
                echo "↩ npm has $name@$version reserved — retrying as $next"
                npm version "$next" --no-git-tag-version --no-workspaces
                version="$next"
                attempt=$((attempt + 1))
              done
            )
            rc=$?

            case "$rc" in
              0) ;;
              42) skipped_builds="$skipped_builds $name" ;;
              43) unauthorized_packages="$unauthorized_packages $name"; auth_broken="1" ;;
              *) failed_packages="$failed_packages $name" ;;
            esac
          done

          [ -n "$skipped_builds" ] && echo "::warning::Skipped (build failed, not published):$skipped_builds"
          [ -n "$unattempted_packages" ] && echo "⏭ Not attempted (the credential was already rejected):$unattempted_packages"

          if [ -n "$unauthorized_packages" ]; then
            echo "❌ Not authorized to publish:$unauthorized_packages"
            echo "::error title=npm rejected the publish credential::npm answers an unauthorized PUT with E404 ('could not be found or you do not have permission to access it'), so this reads as a missing package — the packages exist, the write was refused. The NPM_TOKEN secret is read-only, scoped to a different set of packages, or expired; or, with trusted publishing, this repo + workflow is not yet named as the trusted publisher for these packages on npmjs.com."
          fi

          [ -n "$failed_packages" ] && echo "❌ Failed to publish:$failed_packages"

          if [ -n "$failed_packages$unauthorized_packages$unattempted_packages" ]; then
            exit 1
          fi
          exit 0

      - name: Commit version bumps
        if: success()
        run: |
          # Bumps made during publish must land back in the repo so the next run
          # sees them. The workspace:* pinning done for the tarballs must not —
          # restore everything except the "version" field.
          node .github/scripts/restore-pinned-deps.mjs
          git add '{{PACKAGES_GLOB}}/package.json'
          git diff --staged --quiet && echo "No version changes to commit" && exit 0
          git commit -m "chore: bump published package versions [skip ci]"
          git push
