diff --git a/.github/workflows/installer-smoke-matrix.yml b/.github/workflows/installer-smoke-matrix.yml index 4ca560a46d..0709dae660 100644 --- a/.github/workflows/installer-smoke-matrix.yml +++ b/.github/workflows/installer-smoke-matrix.yml @@ -135,13 +135,19 @@ jobs: - name: Smoke install (empty target, no ancestors) shell: bash + env: + # Pass workspace via env to avoid backslash escape mangling on Windows + # (interpolating ${{ github.workspace }} inside node -e on Git Bash for + # Windows turns `D:\a\aiox-core\aiox-core` into `D:aaiox-coreaiox-core` + # because \a, \aiox are interpreted as escape sequences). + WORKSPACE_DIR: ${{ github.workspace }} run: | set -e WORKDIR=$(mktemp -d) cd "$WORKDIR" echo "Smoke target: $WORKDIR" node -e " - const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup'); (async () => { const fs = require('fs'); const path = require('path'); @@ -167,6 +173,8 @@ jobs: - name: Smoke install (target nested inside an ancestor workspace) shell: bash + env: + WORKSPACE_DIR: ${{ github.workspace }} run: | set -e WS_ROOT=$(mktemp -d) @@ -177,7 +185,7 @@ jobs: cd "$WS_ROOT/projects/aiox-install" echo "Smoke target: $(pwd)" node -e " - const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup'); (async () => { const fs = require('fs'); const path = require('path'); @@ -209,6 +217,8 @@ jobs: - name: Smoke install (target nested under plain package.json ancestor) shell: bash + env: + WORKSPACE_DIR: ${{ github.workspace }} run: | set -e PARENT_ROOT=$(mktemp -d) @@ -219,7 +229,7 @@ jobs: cd "$PARENT_ROOT/subdir" echo "Smoke target: $(pwd)" node -e " - const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup'); + const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup'); (async () => { const fs = require('fs'); const path = require('path'); diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 50df755762..b09cc32464 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -354,8 +354,13 @@ jobs: exit 1 publish_legacy_aiox_core: - needs: [test, build] - if: ${{ contains(format(',{0},', needs.build.outputs.packages), ',aiox-core,') }} + # MUST wait for `publish` because compat/aiox-core declares + # `@aiox-squads/core@` as a runtime dependency. If this job runs + # in parallel, the legacy smoke test races against npm propagation of the + # scoped package and times out — making the workflow appear failed even + # though both packages were published successfully. + needs: [test, build, publish] + if: ${{ contains(format(',{0},', needs.build.outputs.packages), ',aiox-core,') && (needs.publish.result == 'success' || needs.publish.result == 'skipped') }} runs-on: ubuntu-latest outputs: published: ${{ steps.should-publish.outputs.should_publish }} @@ -439,21 +444,35 @@ jobs: if: steps.should-publish.outputs.should_publish == 'true' env: PKG_SPEC: aiox-core@${{ steps.pkg.outputs.version }} + SCOPED_SPEC: '@aiox-squads/core@${{ steps.pkg.outputs.version }}' run: | - for i in 1 2 3 4 5 6; do + # Two phases: (1) wait for both packages to be visible in npm + # registry, (2) actually exercise npx. Total budget: 16 × 15s = 240s + # to accommodate slow CDN propagation (the legacy compat wraps the + # scoped package, so BOTH must be resolvable before npx works). + last_err="" + for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do sleep 15 - if npm view "${PKG_SPEC}" version > /dev/null 2>&1; then - SMOKE_DIR=$(mktemp -d) - if (cd "${SMOKE_DIR}" && npx --yes "${PKG_SPEC}" --version >/dev/null 2>&1); then - rm -rf "${SMOKE_DIR}" - echo "✅ Smoke test passed: npx --yes ${PKG_SPEC} --version works" - exit 0 - fi + if ! npm view "${PKG_SPEC}" version > /dev/null 2>&1; then + echo "⏳ Waiting for ${PKG_SPEC} propagation... (attempt $i/16)" + continue + fi + if ! npm view "${SCOPED_SPEC}" version > /dev/null 2>&1; then + echo "⏳ ${PKG_SPEC} visible but ${SCOPED_SPEC} not yet... (attempt $i/16)" + continue + fi + SMOKE_DIR=$(mktemp -d) + if last_err=$(cd "${SMOKE_DIR}" && npx --yes "${PKG_SPEC}" --version 2>&1); then rm -rf "${SMOKE_DIR}" + echo "✅ Smoke test passed: npx --yes ${PKG_SPEC} --version works" + exit 0 fi - echo "⏳ Waiting for npm propagation... (attempt $i/6)" + rm -rf "${SMOKE_DIR}" + echo "⏳ npx attempt failed, retrying... (attempt $i/16)" + echo " last npx stderr: $(echo "$last_err" | tail -3)" done - echo "❌ Smoke test timeout for ${PKG_SPEC}" + echo "❌ Smoke test timeout for ${PKG_SPEC} after 240s" + echo " Last npx stderr: $last_err" exit 1 smoke_test_exports: @@ -519,48 +538,74 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify completion + env: + VERSION: ${{ needs.build.outputs.version }} + PUBLISH_RESULT: ${{ needs.publish.result }} + WORKSPACE_RESULT: ${{ needs.publish_workspace_packages.result }} + LEGACY_RESULT: ${{ needs.publish_legacy_aiox_core.result }} + LEGACY_PUBLISHED: ${{ needs.publish_legacy_aiox_core.outputs.published }} + EXPORTS_RESULT: ${{ needs.smoke_test_exports.result }} run: | - if [ "${{ needs.publish.result }}" = "failure" ] || [ "${{ needs.publish_workspace_packages.result }}" = "failure" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "failure" ] || [ "${{ needs.smoke_test_exports.result }}" = "failure" ]; then - echo "❌ Publishing failed" + echo "=== Release pipeline summary for v${VERSION} ===" + echo "" + echo "Job results:" + printf " %-32s %s\n" "publish (@aiox-squads/core)" "$PUBLISH_RESULT" + printf " %-32s %s\n" "publish_workspace_packages" "$WORKSPACE_RESULT" + printf " %-32s %s\n" "publish_legacy_aiox_core" "$LEGACY_RESULT (published=$LEGACY_PUBLISHED)" + printf " %-32s %s\n" "smoke_test_exports" "$EXPORTS_RESULT" + echo "" + + # Hard failure: the scoped @aiox-squads/core publish IS THE release. + # Workspace packages and legacy compat wrapper are advisory — they + # can be republished out-of-band. Smoke tests are validation; if they + # fail but publish succeeded, log a regression warning but don't + # break the release signal (the artifact is still in the registry). + if [ "$PUBLISH_RESULT" = "failure" ] || [ "$PUBLISH_RESULT" = "cancelled" ]; then + echo "❌ HARD FAIL: scoped @aiox-squads/core@${VERSION} did not publish." + echo " The release did NOT reach npmjs.org." exit 1 fi - if [ "${{ needs.publish.result }}" = "cancelled" ] || [ "${{ needs.publish_workspace_packages.result }}" = "cancelled" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "cancelled" ] || [ "${{ needs.smoke_test_exports.result }}" = "cancelled" ]; then - echo "❌ Publishing was cancelled" - exit 1 + # Soft warnings on partial failures. + WARNINGS=() + if [ "$WORKSPACE_RESULT" = "failure" ] || [ "$WORKSPACE_RESULT" = "cancelled" ]; then + WARNINGS+=("workspace packages did not publish — investigate publish_workspace_packages") fi - - PUBLISHED_PACKAGES="" - - if [ "${{ needs.publish.result }}" = "success" ]; then - PUBLISHED_PACKAGES="@aiox-squads/core" + if [ "$LEGACY_RESULT" = "failure" ] || [ "$LEGACY_RESULT" = "cancelled" ]; then + WARNINGS+=("legacy aiox-core wrapper did not publish — check NPM_TOKEN with owner access to legacy package") fi - - if [ "${{ needs.publish_workspace_packages.result }}" = "success" ]; then - if [ -n "$PUBLISHED_PACKAGES" ]; then - PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES, selected @aiox-squads workspace packages" - else - PUBLISHED_PACKAGES="selected @aiox-squads workspace packages" - fi + if [ "$EXPORTS_RESULT" = "failure" ]; then + WARNINGS+=("smoke_test_exports failed — possible regression of issue #734 (ERR_PACKAGE_PATH_NOT_EXPORTED)") fi - if [ "${{ needs.publish_legacy_aiox_core.outputs.published }}" = "true" ]; then - if [ -n "$PUBLISHED_PACKAGES" ]; then - PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES, aiox-core legacy wrapper" - else - PUBLISHED_PACKAGES="aiox-core legacy wrapper" - fi + PUBLISHED=() + if [ "$PUBLISH_RESULT" = "success" ]; then + PUBLISHED+=("@aiox-squads/core@${VERSION}") + fi + if [ "$WORKSPACE_RESULT" = "success" ]; then + PUBLISHED+=("selected @aiox-squads workspace packages") + fi + if [ "$LEGACY_PUBLISHED" = "true" ]; then + PUBLISHED+=("aiox-core@${VERSION} (legacy wrapper)") fi - if [ -n "$PUBLISHED_PACKAGES" ]; then - echo "🎉 Successfully published to NPM:" - echo " Packages: $PUBLISHED_PACKAGES" - echo " Registry: ${{ env.NPM_REGISTRY }}" - elif [ "${{ needs.publish.result }}" = "skipped" ] && [ "${{ needs.publish_workspace_packages.result }}" = "skipped" ] && [ "${{ needs.publish_legacy_aiox_core.result }}" = "skipped" ]; then - echo "⏭️ No packages needed publishing (already up-to-date)" + if [ ${#PUBLISHED[@]} -gt 0 ]; then + echo "🎉 Successfully published to npmjs.org:" + for pkg in "${PUBLISHED[@]}"; do + echo " - $pkg" + done + elif [ "$PUBLISH_RESULT" = "skipped" ] && [ "$WORKSPACE_RESULT" = "skipped" ] && [ "$LEGACY_RESULT" = "skipped" ]; then + echo "⏭️ No packages needed publishing (version selector skipped them all — already up-to-date or excluded)." else - echo "❌ Publishing failed" - exit 1 + echo "⚠️ No packages were actually published. Check job results above." + fi + + if [ ${#WARNINGS[@]} -gt 0 ]; then + echo "" + echo "⚠️ Soft warnings (release shipped but follow-up needed):" + for w in "${WARNINGS[@]}"; do + echo " - $w" + done fi create-release-notes: diff --git a/docs/guides/release-procedure.md b/docs/guides/release-procedure.md new file mode 100644 index 0000000000..08b3fe4c14 --- /dev/null +++ b/docs/guides/release-procedure.md @@ -0,0 +1,265 @@ +# Release Procedure SOP — @aiox-squads/core + +Authoritative procedure for publishing a new version of `@aiox-squads/core` (and +its companion workspace packages) to npmjs.org. Replaces ad-hoc release lore. + +> **Authority:** `@devops` only (per Constitution Art. II — Agent Authority). +> Other agents propose changes; `@devops` runs this procedure. + +## Why this document exists + +Between 2026-04 and 2026-05 the installer received 11 patches in 30 days, +several of them issued mid-incident under pressure. Each release exposed a new +edge case because no SOP enumerated the gates that had to pass before tagging. +This document is the result of the 2026-05-17 retrospective on the npm-hijack +hotfix (PR #742) and the subsequent 5.2.6 release (PR #743). + +Use it. Update it when reality diverges. + +## Pre-flight (T-10 minutes) + +Block the release if any of these are red. + +| Check | Command | Pass criteria | +|---|---|---| +| Working tree clean | `git status --short` | Only intentional release changes shown | +| Local main synced with origin | `git fetch && git log origin/main..main --oneline` | Empty (you are not ahead of origin) | +| Lint | `npm run lint` | Exit 0 | +| Full test suite | `npm run test:ci` | 0 failures, 0 cancelled | +| Installer suite (regression surface) | `npx jest tests/installer/ --no-coverage` | 100% pass | +| Most recent CI run on main | `gh run list --branch main --limit 1` | Green | +| npm publish token | `gh secret list -R SynkraAI/aiox-core` | `NPM_TOKEN_AIOX_SQUADS` exists and was rotated within 90 days | +| Legacy npm token (`aiox-core`) | Same | `NPM_TOKEN` exists; if older than 90 days, flag for refresh — this token publishes the legacy compat wrapper only | + +## Version bump (T-5 minutes) + +Bump **all four** version sites in lockstep. Mismatches cause silent publish +failures that only show up in the smoke tests. + +| File | What to bump | +|---|---| +| `package.json` | `version` (scoped `@aiox-squads/core`) | +| `compat/aiox-core/package.json` | `version` AND `dependencies["@aiox-squads/core"]` (must equal `version`) | +| `packages/installer/package.json` | `version` (patch bump if installer changed; otherwise leave) | +| `package-lock.json` | Refresh via `npm install --package-lock-only --ignore-scripts` | +| `CHANGELOG.md` | New entry at top under `## [X.Y.Z] - YYYY-MM-DD` using Keep-a-Changelog sections (`### Fixed`, `### Added`, `### Notes`) | + +If `entity-registry.yaml` or `install-manifest.yaml` change during the commit +hook run, include them — they are SOT files and the pre-commit hook +intentionally regenerates them. + +## Branch + PR + +```bash +git checkout -b chore/release-X.Y.Z +git add +AIOX_ACTIVE_AGENT=devops git commit -m "chore(release): bump to X.Y.Z — " +AIOX_ACTIVE_AGENT=devops git push -u origin chore/release-X.Y.Z +gh pr create --base main --head chore/release-X.Y.Z --title "..." --body "..." +``` + +Wait for CI essentials to be green (CI, Pro Integration, Jest Tests across +Node versions, ESLint, TypeScript, CodeQL). Smoke matrix is advisory — it +exercises the install surface but the release does not depend on it. + +If CodeRabbit posts CHANGES_REQUESTED, address or explicitly justify-and-dismiss. +A bypass merge with unanswered actionable comments is a bad signal. + +## Branch protection bypass (CODEOWNERS dance) + +The repo has **TWO** independent branch protection systems on `main`: + +1. **Ruleset** `main-branch-protection` (id 13330052) — modern rulesets API +2. **Legacy branch protection** — `repos/SynkraAI/aiox-core/branches/main/protection` + +Both enforce `require_code_owner_reviews: true`. `gh pr merge --admin` does +**not** bypass either alone. You must relax both, merge, and restore both — +the restore is non-negotiable. + +### Snapshot + sanitize payloads + +Raw API responses contain read-only fields (`id`, `node_id`, `created_at`, +`updated_at`, `_links`, `url`) that GitHub **rejects on PUT/PATCH** with a 422. +You must sanitize them before reusing as input bodies. The raw snapshot stays +on disk only for diff verification at the end. + +```bash +# Raw snapshots — diff baseline, never sent back to the API as-is. +gh api repos/SynkraAI/aiox-core/rulesets/13330052 \ + > /tmp/aiox-core-ruleset-original.json +gh api repos/SynkraAI/aiox-core/branches/main/protection \ + > /tmp/aiox-core-branch-protection-original.json + +# Sanitized restore payload for the ruleset (only writable fields): +jq '{name, target, enforcement, conditions, bypass_actors, rules}' \ + /tmp/aiox-core-ruleset-original.json \ + > /tmp/aiox-core-ruleset-restore.json + +# Bypass payload (same writable surface, with the pull_request rule relaxed): +jq '{name, target, enforcement, conditions, bypass_actors, + rules: (.rules | map(if .type=="pull_request" + then .parameters.require_code_owner_review=false + | .parameters.required_approving_review_count=0 + else . end))}' /tmp/aiox-core-ruleset-original.json \ + > /tmp/aiox-core-ruleset-bypass.json + +# Required-PR-reviews restore payload (only the four writable fields): +jq '.required_pull_request_reviews | {dismiss_stale_reviews, require_code_owner_reviews, + require_last_push_approval, required_approving_review_count}' \ + /tmp/aiox-core-branch-protection-original.json \ + > /tmp/aiox-core-prr-restore.json +``` + +### Atomic bypass → merge → guaranteed restore + +Use `set -e` for early-exit and a `trap '... restore ...' EXIT` so that ANY +failure (network, merge conflict, hook crash, Ctrl-C, even a syntax error) +runs the restore before the shell exits. This is the load-bearing piece — +without the trap, a mid-script crash can leave production unprotected. + +```bash +set -e + +# 1. Capture state (run only if not already captured this session). +test -s /tmp/aiox-core-ruleset-restore.json || { echo "Sanitize payloads first."; exit 1; } +test -s /tmp/aiox-core-prr-restore.json || { echo "Sanitize payloads first."; exit 1; } + +# 2. Define the restore. Idempotent: re-applying the original state is safe. +restore_protections() { + local exit_code=$? + echo "→ Restoring branch protections (exit_code=$exit_code)..." + gh api -X PUT repos/SynkraAI/aiox-core/rulesets/13330052 \ + --input /tmp/aiox-core-ruleset-restore.json > /dev/null \ + || echo "::error::Ruleset restore FAILED — manual recovery required." + gh api -X PATCH repos/SynkraAI/aiox-core/branches/main/protection/required_pull_request_reviews \ + --input /tmp/aiox-core-prr-restore.json > /dev/null \ + || echo "::error::Legacy PRR restore FAILED — manual recovery required." + echo "→ Restore attempted. Verify with the diff block below." + return $exit_code +} +trap restore_protections EXIT + +# 3. Bypass both systems. +gh api -X PUT repos/SynkraAI/aiox-core/rulesets/13330052 \ + --input /tmp/aiox-core-ruleset-bypass.json > /dev/null +gh api -X DELETE repos/SynkraAI/aiox-core/branches/main/protection/required_pull_request_reviews + +# 4. Merge. If this fails, the EXIT trap restores anyway. +AIOX_ACTIVE_AGENT=devops gh pr merge "$PR_NUMBER" --squash --admin --delete-branch + +# 5. Falling off the script triggers the EXIT trap → restore runs. +``` + +### Verify restore matches snapshot (diff exit MUST be 0) + +```bash +diff <(jq -S '.rules[0].parameters' /tmp/aiox-core-ruleset-original.json) \ + <(gh api repos/SynkraAI/aiox-core/rulesets/13330052 | jq -S '.rules[0].parameters') + +diff <(jq -S '.required_pull_request_reviews | {dismiss_stale_reviews, require_code_owner_reviews, require_last_push_approval, required_approving_review_count}' /tmp/aiox-core-branch-protection-original.json) \ + <(gh api repos/SynkraAI/aiox-core/branches/main/protection | jq -S '.required_pull_request_reviews | {dismiss_stale_reviews, require_code_owner_reviews, require_last_push_approval, required_approving_review_count}') +``` + +If either diff is non-empty, the restore is incomplete — **stop and recover +manually before walking away**. The repo is currently in a degraded state. + +## Tag + push (triggers npm-publish.yml) + +```bash +git fetch origin --quiet +AIOX_ACTIVE_AGENT=devops git tag -a vX.Y.Z origin/main -m "" +AIOX_ACTIVE_AGENT=devops git push origin vX.Y.Z +``` + +This triggers `.github/workflows/npm-publish.yml`. The workflow runs: + +| Job | Purpose | Required for release? | +|---|---|---| +| `test` | Test suite | Yes | +| `build` | Determine version + package list | Yes | +| `publish` | Publish `@aiox-squads/core` (uses `NPM_TOKEN_AIOX_SQUADS`) | **YES — this is the release** | +| `publish_workspace_packages` | `installer`, `aiox-install`, `aiox-pro-cli` | Advisory | +| `publish_legacy_aiox_core` | `aiox-core` compat (uses `NPM_TOKEN` then falls back) | Advisory | +| `smoke_test_exports` | Validates `bin/*` exports across Node 20/22/24 (regression guard for #734) | Advisory | +| `notify` | Aggregates and reports | Soft-fail only on partial failures | + +## Post-publish verification + +```bash +# Sanity: registry index reflects the new version +npm view @aiox-squads/core version # must be X.Y.Z +npm view @aiox-squads/core dist-tags # latest: X.Y.Z + +# Cross-check: each workspace package +for pkg in @aiox-squads/installer @aiox-squads/aiox-install @aiox-squads/aiox-pro-cli aiox-core; do + echo "$pkg: $(npm view $pkg version)" +done + +# Verify the published artifact actually contains your fix +mkdir -p /tmp/aiox-verify && cd /tmp/aiox-verify +npm pack @aiox-squads/core@X.Y.Z +tar -xzf aiox-squads-core-X.Y.Z.tgz package/ +# inspect the extracted file +``` + +If your release fixes installer behavior, run an E2E: + +```bash +# Worst-case install topology: target inside an ancestor with package.json +mkdir -p /tmp/aiox-e2e/scenario && echo '{"name":"parent","workspaces":["scenario"]}' > /tmp/aiox-e2e/package.json +cd /tmp/aiox-e2e/scenario +npx --yes -p @aiox-squads/core@X.Y.Z aiox --version # must print X.Y.Z +``` + +## Known-and-tracked CI quirks + +These are not blockers — knowing them prevents wasted investigation. + +| Quirk | Symptom | Mitigation | +|---|---|---| +| `publish_legacy_aiox_core` smoke timeout | `❌ Smoke test timeout for aiox-core@X.Y.Z` even though the actual publish step shows `✅ Published` | Smoke now waits up to 240s and verifies both `aiox-core` and `@aiox-squads/core` are visible before invoking `npx`. If still failing, propagation took >240s — re-run the workflow | +| `notify` reports failure on partial publish | Notify job red even when `@aiox-squads/core` published | Notify now distinguishes hard fail (publish) from soft warnings (workspace/legacy/smoke). Check the job summary text | +| Installer Smoke Matrix Windows path mangling | `Cannot find module 'D:aaiox-coreaiox-core/...'` | Fixed: workspace path now passed via `WORKSPACE_DIR` env var instead of `${{ github.workspace }}` interpolation in `node -e` | +| `create-release-notes` skipped on tag push | Tag-only push doesn't trigger this job | Expected — this job runs on GitHub Release publish, not tag push | + +## Rollback + +If a release is broken: + +1. **Deprecate, don't unpublish** (unpublish has a 72h window and is destructive): + ```bash + npm deprecate @aiox-squads/core@X.Y.Z "Use X.Y.Z-1 — see issue #N" + ``` +2. Re-publish the previous good version under `latest`: + ```bash + npm dist-tag add @aiox-squads/core@X.Y.Z-1 latest + ``` +3. Open an incident issue and follow this procedure from the top for the fix. + +## Failure modes I have seen + +These are real incidents — keep them in mind when something goes sideways. + +- **Race condition between `publish` and `publish_legacy_aiox_core`** (fixed + 2026-05-17): compat depends on scoped package but jobs ran in parallel. + Smoke test legacy npx timed out because the scoped package wasn't yet on + the CDN. Fix: serialize `publish_legacy_aiox_core` after `publish`. +- **Two-system branch protection** (documented 2026-05-17): there is both a + ruleset AND a legacy branch protection. The `gh pr merge --admin` bypass + works for neither in isolation when CODEOWNERS is required by both. You + must relax both, in the same shell, with the restore as a `finally`. +- **`--silent` swallowing npm error context** (PR #742): the installer used + `--silent --no-save` and npm 10+ silently walked the directory tree past + the target into the first ancestor with a `package.json`, then exited 0, + then the post-install integrity check failed with a generic "did not + create" message. Fix: `--prefix= --workspaces=false`. +- **Windows backslash escape in `node -e` over Git Bash** (fixed 2026-05-17): + interpolating `${{ github.workspace }}` directly into a `node -e` script + inside Git Bash on a Windows runner turns `D:\a\...` into `D:a...` because + `\a` is interpreted as an escape sequence. Fix: pass via env var and read + from `process.env`. + +--- + +**Last updated:** 2026-05-17 (after PR #742 hotfix + 5.2.6 release). +**Owner:** `@devops` (Gage).