name: Sync indicator count

# Indicator count appears in four places that must stay in sync with
# the number of public indicator types exported from
# crates/wickra-core/src/lib.rs (the `pub use indicators::{ ... }` block,
# minus the `FAMILIES` constant and any `*Output` companion structs):
#
#   1. README.md prose                       — synced on PR branches (this workflow)
#   2. GitHub repo "About" description       — synced on push to main / v* tag
#   3. Docs site: index.md / overview.md / Indicators-Overview.md
#      (wickra-lib/wickra-docs) — synced on push to main / v* tag*
#   4. Marketing site count (wickra-lib/webpage: index.md /
#      .vitepress/config.ts) — push to main / v* tag*
#   5. org profile README count (wickra-lib/.github, profile/README.md)
#                                            — synced on push to main / v* tag*
#   6. org description ("… N indicators, install-free.")
#                                            — synced on push to main / v* tag*
#   7. docs site published version (wickra-lib/wickra-docs: the
#      "Published versions" table in overview.md + the Rust quickstart prose)
#                                            — synced on v* tag only*
#   8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
#      nav version label, and the wickra-wasm dep) — synced on v* tag only*
#   9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
#      collapsed to a single page that points at docs.wickra.org but still names
#      the count) — synced on push to main / v* tag*
#
#   *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
#   wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
#   need write on wickra-lib/.github and admin:org for the org-description PATCH;
#   surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
#   repo's permission).
#   Until that scope is granted these steps emit a ::warning:: and soft-skip —
#   they never fail the run. The repo "About" homepage URL is also enforced in
#   step 2 (constant value, no extra scope); it points at docs.wickra.org.
#
#   Note: surface 7 carries the release *version*, not the indicator count, so
#   it is driven by the v* tag (which is the version) rather than the count.
#
# We count public types (not `mod xxx;` lines) because some modules export
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
# single source of truth for what the bindings reach.
#
# Design: keep README in sync *before* a PR is merged, by pushing a
# fix-up commit to the PR head branch. After squash-merge into main
# the bot commit is folded into the single signed merge commit, so
# main's history never shows an unsigned "sync indicator count" entry.
#
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
# explicitly do NOT trigger downstream workflows (anti-recursion
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
# — it does, however, re-trigger sync-about.yml on the next PR
# `synchronize` event, which is what we want (a no-op if the counter
# is now correct).
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    types: [opened, synchronize, reopened]
  workflow_dispatch:

# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
# write` the workflow needs — to push the counter fix-up commit to the PR head
# branch — is raised at the job level below, not here, so the top-level default
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
# docs / webpage / org writes still go through the fine-grained PAT
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
permissions:
  contents: read
  pull-requests: read

jobs:
  sync:
    runs-on: ubuntu-latest
    # The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
    # commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
    permissions:
      contents: write
      pull-requests: read
    steps:
      # On PRs from forks the head ref lives in another repo; pushing
      # back to it from this workflow is blocked by GitHub. We still
      # want the PR to surface the missing counter, so the check below
      # falls back to a hard failure when push isn't possible.
      - name: Determine if push to PR head is possible
        id: ctx
        # Untrusted PR contexts (head.ref / head.repo.full_name are attacker
        # controlled on fork PRs) are passed through the environment, never
        # interpolated straight into the shell, so a crafted branch name cannot
        # inject commands (OpenSSF Scorecard: Dangerous-Workflow).
        env:
          EVENT_NAME: ${{ github.event_name }}
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          BASE_REPO: ${{ github.repository }}
          HEAD_REF: ${{ github.event.pull_request.head.ref }}
        run: |
          if [ "$EVENT_NAME" = "pull_request" ]; then
            if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
              echo "can_push=true" >> "$GITHUB_OUTPUT"
              echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
            else
              echo "can_push=false" >> "$GITHUB_OUTPUT"
              echo "head_ref=" >> "$GITHUB_OUTPUT"
            fi
          else
            echo "can_push=false" >> "$GITHUB_OUTPUT"
            echo "head_ref=" >> "$GITHUB_OUTPUT"
          fi

      # On PRs we check out the *head* commit (not the merge ref) so
      # any fix-up commit we make goes onto the PR branch itself. On
      # push events we check out the default ref. fetch-depth: 0 lets
      # us push back without "shallow update not allowed".
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
          repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
          # Default GITHUB_TOKEN is fine for the same-repo PR-branch
          # push; the About / Wiki steps re-authenticate with the PAT
          # below where needed.

      - name: Count indicators
        id: count
        run: |
          # Parse the `pub use indicators::{ ... }` block from lib.rs, strip
          # the FAMILIES constant and any `*Output` companion structs, count
          # the remaining identifiers. Pure-shell so the workflow doesn't
          # require a python runtime.
          n=$(sed -n '/^pub use indicators::{/,/^};/p' crates/wickra-core/src/lib.rs \
            | tr ',{}' '\n' \
            | sed 's/[[:space:]]//g' \
            | grep -E '^[A-Z][A-Za-z0-9_]*$' \
            | grep -vE '^FAMILIES$|Output$' \
            | sort -u | wc -l)
          echo "count=$n" >> "$GITHUB_OUTPUT"
          echo "Indicator count: $n"

      # ----- PR flow ---------------------------------------------------

      - name: Check README counter (PR)
        if: github.event_name == 'pull_request'
        id: pr_check
        run: |
          n="${{ steps.count.outputs.count }}"
          if grep -qE "^${n} streaming-first indicators" README.md \
            && grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
            echo "matches=true" >> "$GITHUB_OUTPUT"
            echo "README + docs/README counter already at ${n}; nothing to do."
          else
            echo "matches=false" >> "$GITHUB_OUTPUT"
            echo "README/docs counter does not match ${n}; will fix up."
          fi

      - name: Fix counter on fork PR head (read-only, fail loud)
        if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
        run: |
          n="${{ steps.count.outputs.count }}"
          echo "::error::README.md / docs/README.md say a different indicator count than lib.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please set README.md to '${n} streaming-first indicators' and docs/README.md to '**${n} indicators**', then push again."
          exit 1

      - name: Patch README on PR head
        if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
        id: pr_patch
        run: |
          n="${{ steps.count.outputs.count }}"
          # docs/README.md carries the count in its docs.wickra.org pointer prose
          # ("**N indicators**"); keep it in sync with README's prose count.
          sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md docs/README.md
          # Bump the banner cache-buster so GitHub's Camo proxy refetches the org
          # profile image (regenerated with the new count by .github/banner.yml)
          # instead of serving a stale cached copy. (README only — docs has no banner.)
          sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
          if git diff --quiet; then
            echo "No README changes after sed (counter regex did not match anything); skipping push."
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Commit & push counter fix to PR head
        if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
        # head_ref still carries the (untrusted) PR branch name forwarded by the
        # ctx step; pass it through the environment so the push refspec cannot be
        # used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
        env:
          COUNT: ${{ steps.count.outputs.count }}
          HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
        run: |
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add README.md docs/README.md
          git commit -m "chore: sync indicator count to ${COUNT}"
          git push origin "HEAD:${HEAD_REF}"

      # ----- main / tag flow ------------------------------------------
      #
      # After a PR squash-merges, this workflow runs again on the push
      # to main. README is already correct (it was fixed on the PR
      # branch before the merge); the only outward syncs left are the
      # GitHub About description (repo metadata, not a commit) and the
      # wiki repo (separate repo, no main history pollution). README is
      # not touched on main any more.

      - name: Update GitHub About (description + homepage)
        if: github.event_name != 'pull_request'
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          # Canonical homepage — the docs site (P8.3). This is enforced on every
          # run, so it must only point at docs.wickra.org once that domain is
          # actually live (Cloudflare Pages, P8.1); merging this PR is therefore
          # gated on the domain resolving, otherwise the About link would 404.
          homepage="https://docs.wickra.org"
          desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
          # Enforce the homepage unconditionally — it is a constant, so this both
          # corrects the stale kingchenc URL and self-heals any future drift.
          # Same Administration-write permission as --description (no extra scope).
          gh repo edit --homepage "$homepage"
          current=$(gh repo view --json description -q .description)
          if [ "$current" = "$desc" ]; then
            echo "About description unchanged; homepage enforced."
          else
            gh repo edit --description "$desc"
            echo "About description + homepage updated."
          fi

      # Counter sync target moved from the retired GitHub wiki to the docs site
      # repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
      # overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
      # org steps so a token/scope gap never fails the run. Uses its own clone
      # dir (docs-count) so it cannot collide with the tag-only version step
      # below, which clones the same repo into `docs`.
      - name: Sync docs indicator count (wickra-docs)
        if: github.event_name != 'pull_request'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
            exit 0
          fi
          cd docs-count
          sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
          if git diff --quiet; then
            echo "Docs indicator count unchanged."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add index.md overview.md Indicators-Overview.md
          git commit -m "chore: sync indicator count to ${n}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
          else
            echo "Docs indicator count synced to ${n}."
          fi

      # The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
      # Home.md pointer page that sends visitors to docs.wickra.org, but that
      # page still names the count ("… for all N indicators"), so keep it in
      # sync here too. Mirrors the docs/webpage count steps: own clone dir
      # (wiki-count) and the same soft-skip contract. Wiki write rides on the
      # parent repo's permission, so the PAT needs write on wickra-lib/wickra;
      # the wiki has no signing gate, so a plain wickra-bot commit is fine.
      - name: Sync wiki pointer indicator count (wickra.wiki)
        if: github.event_name != 'pull_request'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra.wiki.git" wiki-count 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
            exit 0
          fi
          cd wiki-count
          sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
          if git diff --quiet; then
            echo "Wiki pointer indicator count unchanged."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add Home.md
          git commit -m "chore: sync indicator count to ${n}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
          else
            echo "Wiki pointer indicator count synced to ${n}."
          fi

      # ----- org-profile sync (soft-skip until PAT scope lands) -------
      #
      # These two steps keep the org page (github.com/wickra-lib) in sync
      # with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
      # syncs do not: write on wickra-lib/.github, and admin:org for the org
      # description PATCH. Both are written to soft-skip with a ::warning::
      # (never fail the run) so this workflow stays green before the scope is
      # granted — once it is, they start syncing with no further code change.

      - name: Sync org profile README count
        if: github.event_name != 'pull_request'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
            exit 0
          fi
          cd orgprofile
          sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
          if git diff --quiet; then
            echo "Org profile README count unchanged."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add profile/README.md
          git commit -m "chore: sync indicator count to ${n}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
          else
            echo "Org profile README synced to ${n}."
          fi

      - name: Sync org description
        if: github.event_name != 'pull_request'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          org="wickra-lib"
          # Reading the org description is public; the PATCH needs admin:org.
          current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
          if [ -z "$current" ]; then
            echo "::warning::could not read org description (network/PAT?). Skipping."
            exit 0
          fi
          updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
          if [ "$current" = "$updated" ]; then
            echo "Org description count unchanged."
            exit 0
          fi
          if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
            echo "Org description synced to ${n}."
          else
            echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
          fi

      # ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
      #
      # Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
      # version in the "Published versions" table (overview.md) and the Rust
      # quickstart prose. Unlike the indicator count these change only on a
      # release, so this step runs on v* tag pushes only and takes the version
      # straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
      # wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
      # soft-skips with a ::warning:: and never fails the run; once granted, every
      # release self-heals the docs version with no code change (replaces the old
      # manual P0.5 post-release wiki bump).
      - name: Sync docs version (wickra-docs)
        if: startsWith(github.ref, 'refs/tags/v')
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          version="${GITHUB_REF#refs/tags/v}"
          if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
            echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
            exit 0
          fi
          # Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
          # the wickra repo at the workspace root, which already contains a
          # top-level `docs/` directory, so `git clone … docs` fails with
          # "destination path 'docs' already exists" — silently, because of the
          # 2>/dev/null below — and the version sync never runs (this is exactly
          # why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
          # `docs-count` dir used by the count step above and collides with
          # nothing in the repo.
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
            exit 0
          fi
          cd docs-ver
          # Published-versions table rows (crates.io / PyPI / npm): replace only the
          # version number, leaving the trailing padding + pipe intact. The '.' in
          # the quickstart pattern matches the literal backtick around the version
          # without needing a backtick in this shell string. Historical "since
          # X.Y.Z" references contain no such anchor and are never matched.
          sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
          sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
          if git diff --quiet; then
            echo "Docs version already at ${version}."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add overview.md Quickstart-Rust.md
          git commit -m "chore: sync published version to ${version}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
          else
            echo "Docs version synced to ${version}."
          fi

      # ----- webpage (marketing site) self-update (findings P12.1) ------------
      #
      # The marketing site (wickra-lib/webpage) carries the same indicator count
      # and published version as the docs. Mirrors the docs steps above: the
      # count syncs on push-to-main + tag, the version syncs on v* tags only.
      # Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
      # a tag run. Soft-skips with a ::warning:: if the token can't reach the
      # repo, so the run never fails.
      - name: Sync webpage indicator count (wickra-lib/webpage)
        if: github.event_name != 'pull_request'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          n="${{ steps.count.outputs.count }}"
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
            exit 0
          fi
          cd webpage-count
          sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
          if git diff --quiet; then
            echo "Webpage indicator count unchanged."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add index.md .vitepress/config.ts
          git commit -m "chore: sync indicator count to ${n}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
          else
            echo "Webpage indicator count synced to ${n}."
          fi

      - name: Sync webpage version (wickra-lib/webpage)
        if: startsWith(github.ref, 'refs/tags/v')
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
        run: |
          version="${GITHUB_REF#refs/tags/v}"
          if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
            echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
            exit 0
          fi
          # The webpage pins wickra-wasm to the released version in package.json,
          # and its Cloudflare Pages build runs `npm clean-install`. release.yml
          # publishes wickra-wasm to npm in parallel on this same tag and finishes
          # minutes later, so committing the bump immediately would point the site
          # at a version npm cannot resolve yet (ETARGET) and break the build —
          # exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
          # actually live on npm before committing; if it never appears (the wasm
          # publish failed), skip rather than push a build-breaking commit.
          echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
          attempts=0
          until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
            attempts=$((attempts + 1))
            if [ "$attempts" -ge 30 ]; then
              echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
              exit 0
            fi
            echo "  not on npm yet (attempt ${attempts}/30); waiting 30s..."
            sleep 30
          done
          echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
          if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
            echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
            exit 0
          fi
          cd webpage-ver
          # api/*.md "Latest" lines, the nav version label, and the wickra-wasm
          # dep pin. The '.' anchors match the backtick / quote / caret without a
          # literal in this shell string; historical "Since X.Y.Z" prose has no
          # such anchor and is never matched.
          sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
          sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
          sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
          # Keep package-lock.json in sync with the package.json bump. The site's
          # Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
          # with EUSAGE if the lockfile still pins the previous wickra-wasm —
          # editing package.json alone is not enough. The npm-wait above already
          # proved wickra-wasm@$version is resolvable, so --package-lock-only
          # regenerates the lock (version + resolved + integrity) without fetching
          # node_modules. Guard it: if the regen fails, skip the whole commit so we
          # never push a package.json/lock mismatch that would break the build.
          if ! npm install --package-lock-only --no-audit --no-fund; then
            echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
            exit 0
          fi
          if git diff --quiet; then
            echo "Webpage version already at ${version}."
            exit 0
          fi
          git config user.name "wickra-bot"
          git config user.email "wickra-bot@users.noreply.github.com"
          git add api/*.md .vitepress/config.ts package.json package-lock.json
          git commit -m "chore: sync published version to ${version}"
          if ! git push 2>/dev/null; then
            echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
          else
            echo "Webpage version synced to ${version}."
          fi
