From 4d4b04e798206499916125880f3f7e0607f029ee Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:32:16 -0300 Subject: [PATCH 01/11] fix(ci): dispatch publish-shims from release-binaries instead of racing on release event publish-shims.yml and release-binaries.yml both triggered on `release: published`, racing each other: if the binary build needed a retry, publish-shims.yml's fixed-budget wait-for-binaries poll timed out and went `cancelled` (terminal) before the retry finished, silently skipping shim publishing for that release. publish-shims.yml is now workflow_dispatch-only; release-binaries.yml dispatches it via `gh workflow run` once binaries + provenance succeed. Signed-off-by: Rhuan Barreto --- .github/workflows/publish-shims.yml | 36 ++++++++++++++------------ .github/workflows/release-binaries.yml | 17 ++++++++++++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish-shims.yml b/.github/workflows/publish-shims.yml index 34e26273..7169e38f 100644 --- a/.github/workflows/publish-shims.yml +++ b/.github/workflows/publish-shims.yml @@ -1,8 +1,9 @@ name: Publish Shims +# No `release: published` trigger — release-binaries.yml dispatches this via +# `gh workflow run` once binaries are actually uploaded. Avoids racing a +# fixed-budget wait against a build that may need a retry (see CLAUDE.md). on: - release: - types: [published] workflow_dispatch: inputs: tag: @@ -15,21 +16,20 @@ env: ARCHGATE_TELEMETRY: "0" jobs: - # Wait for platform binaries to be uploaded by release-binaries.yml. - # Shim packages download binaries on first use, but smoke tests need them. + # Sanity-check assets are present (covers GitHub API read-after-write lag). wait-for-binaries: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 5 permissions: contents: read steps: - - name: Wait for release assets + - name: Verify release assets are present env: GH_TOKEN: ${{ github.token }} run: | - TAG="${{ github.event.release.tag_name || inputs.tag }}" + TAG="${{ inputs.tag }}" REQUIRED_ASSETS=("archgate-darwin-arm64.tar.gz" "archgate-linux-x64.tar.gz" "archgate-win32-x64.zip") - for i in $(seq 1 60); do + for i in $(seq 1 10); do ASSETS=$(gh release view "$TAG" --repo archgate/cli --json assets --jq '.assets[].name') ALL_FOUND=true for asset in "${REQUIRED_ASSETS[@]}"; do @@ -42,10 +42,12 @@ jobs: echo "All required release assets found" exit 0 fi - echo "Waiting for release assets... attempt $i/60" - sleep 30 + echo "Waiting for release assets... attempt $i/10" + sleep 15 done - echo "::error::Timed out waiting for release assets" + echo "::error::Required binaries for $TAG are still missing after 10 checks (~2.5 min)." + echo "::error::This workflow expects release-binaries.yml to have already succeeded for this tag." + echo "::error::Check the Release Binaries run for $TAG, then re-run: gh workflow run publish-shims.yml -f tag=$TAG" exit 1 publish-pypi: @@ -57,7 +59,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.12" @@ -80,7 +82,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: dotnet-version: "8.0.x" @@ -106,10 +108,10 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - name: Create Go module tag run: | - TAG="${{ github.event.release.tag_name || inputs.tag }}" + TAG="${{ inputs.tag }}" GO_TAG="shims/go/${TAG}" git tag "$GO_TAG" git push origin "$GO_TAG" @@ -122,7 +124,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: "temurin" @@ -151,7 +153,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 with: ruby-version: "4.0.5" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index edb653b7..d0ca627f 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -171,3 +171,20 @@ jobs: base64-subjects: "${{ needs.combine-hashes.outputs.digests }}" upload-assets: true upload-tag-name: ${{ github.event.release.tag_name || inputs.tag }} + + # Dispatch publish-shims.yml once binaries + provenance are confirmed + # uploaded, instead of racing it via a shared `release: published` trigger. + trigger-shim-publish: + name: Trigger shim publishing + needs: provenance + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Dispatch publish-shims.yml + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + gh workflow run publish-shims.yml --repo archgate/cli -f tag="$TAG" From 68b46d649ba3439d9ac0bf434358d50b2fb4b057 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:32:21 -0300 Subject: [PATCH 02/11] fix(cli): gate background update-check notice to interactive TTY sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main() unconditionally printed an "update available" notice to stdout after every command, with no gating. Any script, `| jq`, or agent parsing stdout as JSON would have it corrupted whenever a newer release happened to be available. Add shouldPerformUpdateCheck() and use it to skip the check entirely outside a genuine interactive terminal (piped output, CI, or the upgrade command itself) — same guard shape as the existing showFirstRunNoticeIfNeeded(). Signed-off-by: Rhuan Barreto --- src/cli.ts | 16 +++++++--- src/helpers/update-check.ts | 13 ++++++++ tests/helpers/update-check.test.ts | 50 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 1a0cdf53..f8ecdf44 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,7 +39,10 @@ import { trackCommand, } from "./helpers/telemetry"; import { showFirstRunNoticeIfNeeded } from "./helpers/telemetry-config"; -import { checkForUpdatesIfNeeded } from "./helpers/update-check"; +import { + checkForUpdatesIfNeeded, + shouldPerformUpdateCheck, +} from "./helpers/update-check"; // Pre-main environment guards — these are user-facing errors (exit 1), not bugs. // The Bun check must throw (logError requires Bun APIs). The rest use logError @@ -152,10 +155,13 @@ async function main() { registerDoctorCommand(program); registerTelemetryCommand(program); - const isUpgrade = process.argv.includes("upgrade"); - const updateCheckPromise = isUpgrade - ? Promise.resolve(null) - : checkForUpdatesIfNeeded(packageJson.version); + const updateCheckPromise = shouldPerformUpdateCheck({ + argv: process.argv, + isTTY: process.stdout.isTTY === true, + ci: Bun.env.CI, + }) + ? checkForUpdatesIfNeeded(packageJson.version) + : Promise.resolve(null); await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice) console.log(notice); diff --git a/src/helpers/update-check.ts b/src/helpers/update-check.ts index 08f493f5..b6d406ca 100644 --- a/src/helpers/update-check.ts +++ b/src/helpers/update-check.ts @@ -9,6 +9,19 @@ import { internalPath } from "./paths"; const CACHE_FILE = "last-update-check"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +/** + * Only check for updates in a genuine interactive terminal — never during + * `upgrade`, in CI, or when stdout is piped (avoids polluting parsed output). + */ +export function shouldPerformUpdateCheck(opts: { + argv: string[]; + isTTY: boolean; + ci: string | undefined; +}): boolean { + const isUpgrade = opts.argv.includes("upgrade"); + return !isUpgrade && opts.isTTY && !opts.ci; +} + /** * Checks GitHub Releases for a newer Archgate release (at most once per 24h). * Returns a human-readable notice string if an update is available, or null otherwise. diff --git a/tests/helpers/update-check.test.ts b/tests/helpers/update-check.test.ts index aa8ef2fa..030884c4 100644 --- a/tests/helpers/update-check.test.ts +++ b/tests/helpers/update-check.test.ts @@ -5,6 +5,56 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +describe("shouldPerformUpdateCheck", () => { + test("true in a genuine interactive terminal", async () => { + const { shouldPerformUpdateCheck } = + await import("../../src/helpers/update-check"); + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], + isTTY: true, + ci: undefined, + }) + ).toBe(true); + }); + + test("false when CI is set, even on a TTY", async () => { + const { shouldPerformUpdateCheck } = + await import("../../src/helpers/update-check"); + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], + isTTY: true, + ci: "1", + }) + ).toBe(false); + }); + + test("false when stdout is not a TTY (piped/redirected output)", async () => { + const { shouldPerformUpdateCheck } = + await import("../../src/helpers/update-check"); + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], + isTTY: false, + ci: undefined, + }) + ).toBe(false); + }); + + test("false for the upgrade command itself, even on an interactive TTY", async () => { + const { shouldPerformUpdateCheck } = + await import("../../src/helpers/update-check"); + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "upgrade"], + isTTY: true, + ci: undefined, + }) + ).toBe(false); + }); +}); + describe("checkForUpdatesIfNeeded", () => { let tempDir: string; let originalHome: string | undefined; From 49335cd725ed866ea2c0f0d24e68d7a4c948cd4d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:32:28 -0300 Subject: [PATCH 03/11] chore(memory): document release pipeline gotchas and comment-conciseness feedback Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 4 ++++ .../feedback_concise_comments.md | 17 +++++++++++++++++ .../project_release_pipeline_gotchas.md | 15 +++++++++++++++ CLAUDE.md | 6 ++++++ 4 files changed, 42 insertions(+) create mode 100644 .claude/agent-memory/archgate-developer/feedback_concise_comments.md create mode 100644 .claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 226bd1ca..b297c170 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -27,6 +27,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - [Pick the right enforcement layer](feedback_prefer_tests_over_adr_rules.md) — static syntactic invariants → custom oxlint rule in `.archgate/lint/oxlint.ts`; tests are for executable behavior; ADR .rules.ts for cross-file/governance checks - [This repo is PUBLIC — no private sibling-repo internals in memory/PRs](feedback_public_repo_privacy.md) — split captures: public-safe summary here, full detail in the private repo's own memory +- [Keep code comments and memory entries concise](feedback_concise_comments.md) — no multi-paragraph rationale blocks; one line + terse why, link out for detail ## Known Bugs @@ -70,6 +71,9 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`Bun.Glob.scan()` silently fails for brace patterns with path separators** — `new Bun.Glob("svc/{src/env.ts,env.ts}").scan(...)` returns zero results (no error), while `.match()` works correctly for the same pattern. The match engine was rewritten in Bun 1.2.3 (PR #16824) to expand braces recursively, but the scanner (`GlobWalker.zig`) was not updated. Filed upstream as [oven-sh/bun#32596](https://github.com/oven-sh/bun/issues/32596). Workaround: `expandBracePattern()` in `src/engine/runner.ts` pre-expands brace groups containing `/` before scanning. Applied in `ctx.glob()`, `ctx.grepFiles()`, and `resolveScopedFiles()`. - **ARCH-020 `glob-scan-dot` rule triggers on `.scan()` in comments** — The rule uses regex `/\.scan\(([^)]*)\)/gu` which matches `.scan()` text in JSDoc/inline comments (e.g., `Bun.Glob.scan() silently...`). Workaround: rephrase comments to avoid the exact `.scan()` text — e.g., "Bun.Glob scanning silently..." instead of "Bun.Glob.scan() silently...". - **GitHub Actions `secrets.*` and `vars.*` are separate namespaces — configuring a value as one does NOT make it readable via the other** — `release.yml`'s "Annotate release in PostHog" step read `POSTHOG_PROJECT_ID` via `${{ vars.POSTHOG_PROJECT_ID }}`, but the value was only ever configured as a repo **secret** (confirmed with `gh secret list` vs `gh variable list` — zero repo variables existed). `vars.POSTHOG_PROJECT_ID` therefore always resolved to an empty string. Combined with `continue-on-error: true`, an internal guard that does `exit 0` (not a failure) when required config is missing, and a low-visibility `::notice::` log line, the step silently no-opped on every release for ~3 weeks (9 releases, v0.45.0–v0.45.7) with zero CI failures to flag it — confirmed via `gh run view --log` showing `POSTHOG_PROJECT_ID: ` blank in the step's masked env dump. Before wiring a new `secrets.X`/`vars.X` reference, verify with `gh secret list`/`gh variable list` which namespace actually holds the value. For any `continue-on-error` step with a skip-on-missing-config guard, use `::warning::` (not `::notice::`) so misconfiguration surfaces in the Actions UI instead of being invisible indefinitely. Also documented in the project's `CLAUDE.md`. +- **Chain downstream release workflows via `gh workflow run`, not a parallel `release: published` trigger** — two workflows both listening for `release: published` race if the first needs a retry. See [[project_release_pipeline_gotchas]] and `CLAUDE.md` "Release Pipeline Gotchas". +- **`moonrepo/setup-toolchain` cache can silently leave `bun` off PATH after a `.prototools` bump** — check job log for `Cache hit for restore-key:`; self-heals on retry. See [[project_release_pipeline_gotchas]]. +- **CLI's background update-check notice can pollute stdout** — gated via `shouldPerformUpdateCheck()` (TTY + non-CI only). See [[project_release_pipeline_gotchas]]. ## Claude Code Harness Config diff --git a/.claude/agent-memory/archgate-developer/feedback_concise_comments.md b/.claude/agent-memory/archgate-developer/feedback_concise_comments.md new file mode 100644 index 00000000..0b3f6e6c --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_concise_comments.md @@ -0,0 +1,17 @@ +--- +name: feedback-concise-comments +description: Keep code comments and memory entries concise — do not overgenerate explanatory prose +metadata: + type: feedback +--- + +Code comments and memory entries must be concise. Do not write multi-paragraph explanatory comments in source/workflow files, and do not write long-winded memory bullets. + +**Why:** User feedback via `/feedback` (2026-07-03): "sonnet is overgenerating comments in code and memories. this is not good. those comments must be concise." Given directly as a correction after a session where I wrote long comment blocks in `.github/workflows/*.yml` and `src/cli.ts` (multi-sentence rationale blocks) and multi-sentence [[MEMORY.md]] bullets with full incident narratives. + +**How to apply:** + +- Code/workflow comments: one line stating _what_ and, if truly non-obvious, a terse _why_ — not a paragraph with incident timelines, confirmed-via citations, or full backstory. Link to a PR/issue/commit for detail instead of inlining it. +- Memory entries (`MEMORY.md` bullets and topic files): lead with the rule in one line; keep **Why:**/**How to apply:** to single short sentences, not multi-clause narratives with timestamps and evidence trails. +- If tempted to write a long comment or memory entry to "preserve context," prefer a short pointer (file/PR reference) over inlining the full story. +- Applies to all future sessions in this repo — re-check comment/memory length before writing. diff --git a/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md b/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md new file mode 100644 index 00000000..e55c01da --- /dev/null +++ b/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md @@ -0,0 +1,15 @@ +--- +name: project-release-pipeline-gotchas +description: Three causes behind archgate/cli release publishing failures found 2026-07-03 +metadata: + type: project +--- + +Three distinct issues caused shim/binary publishing failures across v0.45.1–v0.46.1, fixed in `fix/publish-shims-race`: + +1. **Workflow race**: `publish-shims.yml` and `release-binaries.yml` both triggered on `release: published`. If binaries needed a retry, `publish-shims.yml`'s fixed-budget `wait-for-binaries` poll timed out and went `cancelled` (terminal) before the retry finished. Fixed: `publish-shims.yml` is now `workflow_dispatch`-only; `release-binaries.yml`'s `trigger-shim-publish` job dispatches it via `gh workflow run` after binaries + provenance succeed. +2. **`moonrepo/setup-toolchain` cache bug**: right after a `.prototools` bump, the first macOS/Windows CI run can restore a stale `restore-key` cache fallback instead of an exact hit — the action reports success but `bun` isn't on PATH. Log signature: `Cache hit for restore-key:` (vs. exact `Cache hit for:`). Self-heals on retry (the failing job still saves a fresh exact-key cache in post-job cleanup). No code fix — just don't chase it as flakiness. +3. **Update-check stdout pollution**: `src/cli.ts` printed a background "update available" notice to stdout after every command, unconditionally. Broke `JSON.parse(stdout)` in CLI-subprocess tests (and would break real `| jq` usage) whenever a newer release existed. Fixed via `shouldPerformUpdateCheck()` in `src/helpers/update-check.ts` — only checks in a genuine TTY, non-CI session. + +**Why:** Diagnosed while investigating why v0.46.0/v0.46.1 kept failing to publish despite repeated manual retries. +**How to apply:** If a release build fails and a retry "just works," check which of these three it was before assuming generic flakiness. diff --git a/CLAUDE.md b/CLAUDE.md index 4e732cb8..a71dd152 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,12 @@ These are two distinct, non-overlapping namespaces in workflow expressions — c When adding any workflow step that reads repo-level config: confirm the value's actual location with `gh secret list` / `gh variable list` before writing `secrets.X` vs `vars.X`, and if the step is `continue-on-error: true` with an internal "not configured, skipping" guard, use `::warning::` (or higher) rather than `::notice::` so a misconfiguration is visible in the Actions UI instead of silently invisible indefinitely. +## Release Pipeline Gotchas + +- **Chain downstream release workflows, don't parallel-trigger them.** `publish-shims.yml` has no `release: published` trigger — `release-binaries.yml`'s `trigger-shim-publish` job dispatches it (`gh workflow run`) after binaries + provenance succeed. Two workflows both listening to `release: published` races: if the build needs a retry, a fixed-budget wait job can time out (`cancelled`, terminal) before the retry finishes. +- **`moonrepo/setup-toolchain`'s cache can silently break PATH.** Right after a `.prototools` bump, the first macOS/Windows CI run often restores a stale `restore-key` cache instead of an exact hit (check the log for `Cache hit for restore-key:`) — the action reports success but `bun` isn't wired onto PATH. Self-heals on retry (the failed job still saves a fresh exact-key cache). +- **The CLI's background update-check notice can pollute stdout.** `checkForUpdatesIfNeeded()` prints to stdout after command output; `shouldPerformUpdateCheck()` in `src/helpers/update-check.ts` gates it to TTY-only, non-CI sessions so piped/agent JSON output isn't corrupted. + ## Claude Code Harness Config (`.claude/settings.json`) The `hooks.WorktreeCreate` entry is **not** a post-creation setup step — once it's configured, the Claude Code harness defers the _entire_ worktree creation to it (it does not also create a git worktree on its own). The hook receives a JSON payload on stdin (`{ "cwd", "name", ... }`, same pattern as the `PostToolUse` hook reading `.tool_input.file_path` via `jq`) and **must** create the worktree itself and echo _only_ the resulting absolute path as its final stdout line — any other stdout (e.g. unsilenced `bun install` or `git worktree add` output) gets misread as the path and breaks `EnterWorktree`/`ExitWorktree` with errors like `path contains control characters` or `ENOENT: ... chdir`. Redirect all setup-command output to stderr (`>&2`) and keep the trailing `printf` as the only real stdout. Do not simplify this hook back down to a bare `bun install` — that regression is exactly what caused the worktree-creation bug fixed here. From aec901cbe08de9c19abc5f511b78115bc91d6187 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:41:27 -0300 Subject: [PATCH 04/11] fix(ci): grant workflows:write to publish-go-tag so its tag push isn't rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git push origin "shims/go/\$TAG" was failing with "refusing to allow a GitHub App to create or update workflow .github/workflows/release.yml without workflows permission" — GitHub rejects any ref push reachable through commits touching .github/workflows/*, even for an unrelated tag, unless the token has workflows: write. Confirmed on v0.45.7's publish-go-tag job log. Signed-off-by: Rhuan Barreto --- .github/workflows/publish-shims.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-shims.yml b/.github/workflows/publish-shims.yml index 7169e38f..940f6f34 100644 --- a/.github/workflows/publish-shims.yml +++ b/.github/workflows/publish-shims.yml @@ -103,8 +103,12 @@ jobs: needs: wait-for-binaries runs-on: ubuntu-latest timeout-minutes: 5 + # workflows: write is required to push a new ref — GitHub rejects any + # ref push reachable through commits that touch .github/workflows/* + # without it, even for an unrelated tag like this one. permissions: contents: write + workflows: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: From f45be44f608d1edfe4de01deb1dc1c42273bef9b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:41:42 -0300 Subject: [PATCH 05/11] chore(memory): note the publish-go-tag permissions fix Signed-off-by: Rhuan Barreto --- .../archgate-developer/project_release_pipeline_gotchas.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md b/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md index e55c01da..6bf62908 100644 --- a/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md +++ b/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md @@ -10,6 +10,7 @@ Three distinct issues caused shim/binary publishing failures across v0.45.1–v0 1. **Workflow race**: `publish-shims.yml` and `release-binaries.yml` both triggered on `release: published`. If binaries needed a retry, `publish-shims.yml`'s fixed-budget `wait-for-binaries` poll timed out and went `cancelled` (terminal) before the retry finished. Fixed: `publish-shims.yml` is now `workflow_dispatch`-only; `release-binaries.yml`'s `trigger-shim-publish` job dispatches it via `gh workflow run` after binaries + provenance succeed. 2. **`moonrepo/setup-toolchain` cache bug**: right after a `.prototools` bump, the first macOS/Windows CI run can restore a stale `restore-key` cache fallback instead of an exact hit — the action reports success but `bun` isn't on PATH. Log signature: `Cache hit for restore-key:` (vs. exact `Cache hit for:`). Self-heals on retry (the failing job still saves a fresh exact-key cache in post-job cleanup). No code fix — just don't chase it as flakiness. 3. **Update-check stdout pollution**: `src/cli.ts` printed a background "update available" notice to stdout after every command, unconditionally. Broke `JSON.parse(stdout)` in CLI-subprocess tests (and would break real `| jq` usage) whenever a newer release existed. Fixed via `shouldPerformUpdateCheck()` in `src/helpers/update-check.ts` — only checks in a genuine TTY, non-CI session. +4. **`publish-go-tag` missing `workflows: write`**: its `git push origin shims/go/$TAG` was rejected — GitHub blocks any ref push reachable through commits touching `.github/workflows/*` without that permission, even for an unrelated tag. Fixed by adding `workflows: write` to the job's `permissions:`. Confirmed on v0.45.7's job log. **Why:** Diagnosed while investigating why v0.46.0/v0.46.1 kept failing to publish despite repeated manual retries. **How to apply:** If a release build fails and a retry "just works," check which of these three it was before assuming generic flakiness. From ec84f2d068a22178740b17e3a946d583b9c05867 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:55:53 -0300 Subject: [PATCH 06/11] fix(ci): address code review findings on the release pipeline fix - publish-shims.yml/release-binaries.yml: pass tag/output values via env: instead of interpolating directly into run: scripts (zizmor template-injection), set persist-credentials: false on checkouts that don't push, name the wait-for-binaries job, comment the trigger-shim-publish permission - src/cli.ts, src/helpers/update-check.ts: shouldPerformUpdateCheck's ci param is now boolean (Boolean(Bun.env.CI) at the call site) per ARCH-014 - tests/helpers/update-check.test.ts: hoist the shouldPerformUpdateCheck import instead of re-importing per test (it's a pure function) - CLAUDE.md, agent memory: mention the upgrade-command exception; minor grammar fixes Signed-off-by: Rhuan Barreto --- .../feedback_concise_comments.md | 4 +-- .github/workflows/publish-shims.yml | 14 +++++++--- .github/workflows/release-binaries.yml | 17 +++++++----- CLAUDE.md | 2 +- src/cli.ts | 2 +- src/helpers/update-check.ts | 2 +- tests/helpers/update-check.test.ts | 26 +++++++------------ 7 files changed, 36 insertions(+), 31 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/feedback_concise_comments.md b/.claude/agent-memory/archgate-developer/feedback_concise_comments.md index 0b3f6e6c..fed93d83 100644 --- a/.claude/agent-memory/archgate-developer/feedback_concise_comments.md +++ b/.claude/agent-memory/archgate-developer/feedback_concise_comments.md @@ -7,11 +7,11 @@ metadata: Code comments and memory entries must be concise. Do not write multi-paragraph explanatory comments in source/workflow files, and do not write long-winded memory bullets. -**Why:** User feedback via `/feedback` (2026-07-03): "sonnet is overgenerating comments in code and memories. this is not good. those comments must be concise." Given directly as a correction after a session where I wrote long comment blocks in `.github/workflows/*.yml` and `src/cli.ts` (multi-sentence rationale blocks) and multi-sentence [[MEMORY.md]] bullets with full incident narratives. +**Why:** User feedback via `/feedback` (2026-07-03): "sonnet is overgenerating comments in code and memories. this is not good. those comments must be concise." Given after a session with long comment blocks in workflow files and `src/cli.ts`, and multi-sentence [[MEMORY.md]] bullets with full incident narratives. **How to apply:** -- Code/workflow comments: one line stating _what_ and, if truly non-obvious, a terse _why_ — not a paragraph with incident timelines, confirmed-via citations, or full backstory. Link to a PR/issue/commit for detail instead of inlining it. +- Code/workflow comments: one line stating _what_ and, if truly non-obvious, a terse _why_ — not a paragraph with timelines or backstory. Link to a PR/issue/commit for detail instead of inlining it. - Memory entries (`MEMORY.md` bullets and topic files): lead with the rule in one line; keep **Why:**/**How to apply:** to single short sentences, not multi-clause narratives with timestamps and evidence trails. - If tempted to write a long comment or memory entry to "preserve context," prefer a short pointer (file/PR reference) over inlining the full story. - Applies to all future sessions in this repo — re-check comment/memory length before writing. diff --git a/.github/workflows/publish-shims.yml b/.github/workflows/publish-shims.yml index 940f6f34..6c3b54bf 100644 --- a/.github/workflows/publish-shims.yml +++ b/.github/workflows/publish-shims.yml @@ -18,6 +18,7 @@ env: jobs: # Sanity-check assets are present (covers GitHub API read-after-write lag). wait-for-binaries: + name: Wait for binaries runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -26,8 +27,8 @@ jobs: - name: Verify release assets are present env: GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" REQUIRED_ASSETS=("archgate-darwin-arm64.tar.gz" "archgate-linux-x64.tar.gz" "archgate-win32-x64.zip") for i in $(seq 1 10); do ASSETS=$(gh release view "$TAG" --repo archgate/cli --json assets --jq '.assets[].name') @@ -60,6 +61,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ inputs.tag }} + persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.12" @@ -83,6 +85,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ inputs.tag }} + persist-credentials: false - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: dotnet-version: "8.0.x" @@ -93,11 +96,13 @@ jobs: user: rhuan.barreto - name: Pack and publish working-directory: shims/nuget/Archgate.Tool + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: | dotnet pack -c Release dotnet nuget push bin/Release/*.nupkg \ --source https://api.nuget.org/v3/index.json \ - --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + --api-key "$NUGET_API_KEY" publish-go-tag: needs: wait-for-binaries @@ -114,8 +119,9 @@ jobs: with: ref: ${{ inputs.tag }} - name: Create Go module tag + env: + TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" GO_TAG="shims/go/${TAG}" git tag "$GO_TAG" git push origin "$GO_TAG" @@ -129,6 +135,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ inputs.tag }} + persist-credentials: false - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: "temurin" @@ -158,6 +165,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ inputs.tag }} + persist-credentials: false - uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 with: ruby-version: "4.0.5" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index d0ca627f..4c4aedac 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -44,6 +44,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.release.tag_name || inputs.tag }} + persist-credentials: false - uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0 with: @@ -98,9 +99,10 @@ jobs: if: runner.os != 'Windows' env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + BUNDLE_PATH: ${{ steps.attest-unix.outputs.bundle-path }} run: | - TAG="${{ github.event.release.tag_name || inputs.tag }}" - cp "${{ steps.attest-unix.outputs.bundle-path }}" "${{ matrix.artifact }}.tar.gz.sigstore.json" + cp "$BUNDLE_PATH" "${{ matrix.artifact }}.tar.gz.sigstore.json" gh release upload "$TAG" \ "${{ matrix.artifact }}.tar.gz" \ "${{ matrix.artifact }}.tar.gz.sha256" \ @@ -112,10 +114,11 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + BUNDLE_PATH: ${{ steps.attest-win.outputs.bundle-path }} run: | - $tag = "${{ github.event.release.tag_name || inputs.tag }}" - Copy-Item "${{ steps.attest-win.outputs.bundle-path }}" "${{ matrix.artifact }}.zip.sigstore.json" - gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" "${{ matrix.artifact }}.zip.sigstore.json" --clobber + Copy-Item "$env:BUNDLE_PATH" "${{ matrix.artifact }}.zip.sigstore.json" + gh release upload "$env:TAG" "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" "${{ matrix.artifact }}.zip.sigstore.json" --clobber - name: Upload digest for SLSA provenance (Unix) if: runner.os != 'Windows' @@ -180,11 +183,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: - actions: write + actions: write # required by `gh workflow run` to dispatch publish-shims.yml steps: - name: Dispatch publish-shims.yml env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} run: | - TAG="${{ github.event.release.tag_name || inputs.tag }}" gh workflow run publish-shims.yml --repo archgate/cli -f tag="$TAG" diff --git a/CLAUDE.md b/CLAUDE.md index a71dd152..c02aee7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ When adding any workflow step that reads repo-level config: confirm the value's - **Chain downstream release workflows, don't parallel-trigger them.** `publish-shims.yml` has no `release: published` trigger — `release-binaries.yml`'s `trigger-shim-publish` job dispatches it (`gh workflow run`) after binaries + provenance succeed. Two workflows both listening to `release: published` races: if the build needs a retry, a fixed-budget wait job can time out (`cancelled`, terminal) before the retry finishes. - **`moonrepo/setup-toolchain`'s cache can silently break PATH.** Right after a `.prototools` bump, the first macOS/Windows CI run often restores a stale `restore-key` cache instead of an exact hit (check the log for `Cache hit for restore-key:`) — the action reports success but `bun` isn't wired onto PATH. Self-heals on retry (the failed job still saves a fresh exact-key cache). -- **The CLI's background update-check notice can pollute stdout.** `checkForUpdatesIfNeeded()` prints to stdout after command output; `shouldPerformUpdateCheck()` in `src/helpers/update-check.ts` gates it to TTY-only, non-CI sessions so piped/agent JSON output isn't corrupted. +- **The CLI's background update-check notice can pollute stdout.** `checkForUpdatesIfNeeded()` prints to stdout after command output; `shouldPerformUpdateCheck()` in `src/helpers/update-check.ts` gates it to TTY-only, non-CI, non-`upgrade` sessions so piped/agent JSON output isn't corrupted. ## Claude Code Harness Config (`.claude/settings.json`) diff --git a/src/cli.ts b/src/cli.ts index f8ecdf44..f9274b65 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -158,7 +158,7 @@ async function main() { const updateCheckPromise = shouldPerformUpdateCheck({ argv: process.argv, isTTY: process.stdout.isTTY === true, - ci: Bun.env.CI, + ci: Boolean(Bun.env.CI), }) ? checkForUpdatesIfNeeded(packageJson.version) : Promise.resolve(null); diff --git a/src/helpers/update-check.ts b/src/helpers/update-check.ts index b6d406ca..c794e40e 100644 --- a/src/helpers/update-check.ts +++ b/src/helpers/update-check.ts @@ -16,7 +16,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours export function shouldPerformUpdateCheck(opts: { argv: string[]; isTTY: boolean; - ci: string | undefined; + ci: boolean; }): boolean { const isUpgrade = opts.argv.includes("upgrade"); return !isUpgrade && opts.isTTY && !opts.ci; diff --git a/tests/helpers/update-check.test.ts b/tests/helpers/update-check.test.ts index 030884c4..729147a8 100644 --- a/tests/helpers/update-check.test.ts +++ b/tests/helpers/update-check.test.ts @@ -5,51 +5,45 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { shouldPerformUpdateCheck } from "../../src/helpers/update-check"; + describe("shouldPerformUpdateCheck", () => { - test("true in a genuine interactive terminal", async () => { - const { shouldPerformUpdateCheck } = - await import("../../src/helpers/update-check"); + test("true in a genuine interactive terminal", () => { expect( shouldPerformUpdateCheck({ argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], isTTY: true, - ci: undefined, + ci: false, }) ).toBe(true); }); - test("false when CI is set, even on a TTY", async () => { - const { shouldPerformUpdateCheck } = - await import("../../src/helpers/update-check"); + test("false when CI is set, even on a TTY", () => { expect( shouldPerformUpdateCheck({ argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], isTTY: true, - ci: "1", + ci: true, }) ).toBe(false); }); - test("false when stdout is not a TTY (piped/redirected output)", async () => { - const { shouldPerformUpdateCheck } = - await import("../../src/helpers/update-check"); + test("false when stdout is not a TTY (piped/redirected output)", () => { expect( shouldPerformUpdateCheck({ argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], isTTY: false, - ci: undefined, + ci: false, }) ).toBe(false); }); - test("false for the upgrade command itself, even on an interactive TTY", async () => { - const { shouldPerformUpdateCheck } = - await import("../../src/helpers/update-check"); + test("false for the upgrade command itself, even on an interactive TTY", () => { expect( shouldPerformUpdateCheck({ argv: ["bun", "cli.ts", "upgrade"], isTTY: true, - ci: undefined, + ci: false, }) ).toBe(false); }); From 7608cd65de3d0238b8150f219b851d792b34da2d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 10:58:34 -0300 Subject: [PATCH 07/11] feat(ci): add zizmor workflow security scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs zizmor (GitHub Actions static analysis) on every PR via zizmor-action in GitHub Advanced Security mode — findings upload to the Security tab (free for public repos) rather than failing the build, since there's an existing unreviewed findings backlog across several workflow files. Can be promoted to a hard gate later via code-scanning merge protection once triaged. zizmor.yml suppresses the one already-adjudicated exception: the SLSA reusable workflow's tag-not-SHA reference, already documented in CI-001. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 34 ++++++++++++++++++++++++- zizmor.yml | 7 +++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 zizmor.yml diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 4f50b4a3..2b79ed60 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -182,6 +182,28 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false uses: ./.github/workflows/smoke-test-linux.yml + # Uploads findings to the Security tab (GitHub Advanced Security, free for + # public repos) rather than failing the build — there's an existing + # unreviewed findings backlog across several workflow files. Once that's + # triaged, this can be promoted to a hard gate via code-scanning merge + # protection (repo Settings, not this workflow). + zizmor: + name: Zizmor (Workflow Security) + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + permissions: + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + with: + config: zizmor.yml + coverage: name: Coverage Report runs-on: ubuntu-latest @@ -236,7 +258,15 @@ jobs: runs-on: ubuntu-latest if: always() needs: - [validate, shim-changes, shim-tests, smoke-windows, smoke-linux, coverage] + [ + validate, + shim-changes, + shim-tests, + smoke-windows, + smoke-linux, + zizmor, + coverage, + ] steps: - name: Check job results run: | @@ -248,12 +278,14 @@ jobs: [[ "$SHIM_OK" != "success" ]] || \ [[ "${{ needs.smoke-windows.result }}" != "success" ]] || \ [[ "${{ needs.smoke-linux.result }}" != "success" ]] || \ + [[ "${{ needs.zizmor.result }}" != "success" ]] || \ [[ "${{ needs.coverage.result }}" != "success" ]]; then echo "::error::One or more jobs failed:" echo " validate: ${{ needs.validate.result }}" echo " shim-tests: ${{ needs.shim-tests.result }}" echo " smoke-windows: ${{ needs.smoke-windows.result }}" echo " smoke-linux: ${{ needs.smoke-linux.result }}" + echo " zizmor: ${{ needs.zizmor.result }}" echo " coverage: ${{ needs.coverage.result }}" exit 1 fi diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 00000000..7ef8b236 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,7 @@ +rules: + # slsa-framework/slsa-github-generator MUST be referenced by tag, not SHA — + # its bootstrap script rejects non-tag refs. Documented exception in + # CI-001 (see .archgate/adrs/CI-001-pin-github-actions-by-hash.md). + unpinned-uses: + ignore: + - release-binaries.yml:172 From f33410108a19cb10b4e7f710e838cd1e08cafd0a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 11:08:36 -0300 Subject: [PATCH 08/11] fix(ci): don't let fork PRs fail the required zizmor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork PRs get a read-only GITHUB_TOKEN no matter what permissions the workflow declares, so the zizmor job's security-events: write request was silently denied and SARIF upload would fail — permanently breaking the required status gate for any external contributor. For fork PRs, fall back to advanced-security: false (console-only, no write permission needed) wrapped in continue-on-error, so neither the permission mismatch nor the pre-existing findings backlog can fail the gate for contributions that don't originate from this repo. Reported by Cursor Bugbot on PR #450. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 2b79ed60..815e97d9 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -200,9 +200,15 @@ jobs: with: persist-credentials: false - name: Run zizmor + # Fork PRs get a read-only GITHUB_TOKEN regardless of the + # `permissions:` above, so SARIF upload would fail — fall back to + # console-only output and don't let that (or the pre-existing + # findings backlog) fail the gate for external contributors. uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + continue-on-error: ${{ github.event.pull_request.head.repo.fork == true }} with: config: zizmor.yml + advanced-security: ${{ github.event.pull_request.head.repo.fork != true }} coverage: name: Coverage Report From 9ea652e33418beb2326813e19651c9c9ee89572d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 11:14:58 -0300 Subject: [PATCH 09/11] chore(memory): trim release-pipeline bullets to one-line pointers CodeRabbit's re-check still flagged these as longer than a pointer after the prior trim. Cut to single-clause bullets matching its own suggested style. Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index b297c170..42787702 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -71,9 +71,9 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`Bun.Glob.scan()` silently fails for brace patterns with path separators** — `new Bun.Glob("svc/{src/env.ts,env.ts}").scan(...)` returns zero results (no error), while `.match()` works correctly for the same pattern. The match engine was rewritten in Bun 1.2.3 (PR #16824) to expand braces recursively, but the scanner (`GlobWalker.zig`) was not updated. Filed upstream as [oven-sh/bun#32596](https://github.com/oven-sh/bun/issues/32596). Workaround: `expandBracePattern()` in `src/engine/runner.ts` pre-expands brace groups containing `/` before scanning. Applied in `ctx.glob()`, `ctx.grepFiles()`, and `resolveScopedFiles()`. - **ARCH-020 `glob-scan-dot` rule triggers on `.scan()` in comments** — The rule uses regex `/\.scan\(([^)]*)\)/gu` which matches `.scan()` text in JSDoc/inline comments (e.g., `Bun.Glob.scan() silently...`). Workaround: rephrase comments to avoid the exact `.scan()` text — e.g., "Bun.Glob scanning silently..." instead of "Bun.Glob.scan() silently...". - **GitHub Actions `secrets.*` and `vars.*` are separate namespaces — configuring a value as one does NOT make it readable via the other** — `release.yml`'s "Annotate release in PostHog" step read `POSTHOG_PROJECT_ID` via `${{ vars.POSTHOG_PROJECT_ID }}`, but the value was only ever configured as a repo **secret** (confirmed with `gh secret list` vs `gh variable list` — zero repo variables existed). `vars.POSTHOG_PROJECT_ID` therefore always resolved to an empty string. Combined with `continue-on-error: true`, an internal guard that does `exit 0` (not a failure) when required config is missing, and a low-visibility `::notice::` log line, the step silently no-opped on every release for ~3 weeks (9 releases, v0.45.0–v0.45.7) with zero CI failures to flag it — confirmed via `gh run view --log` showing `POSTHOG_PROJECT_ID: ` blank in the step's masked env dump. Before wiring a new `secrets.X`/`vars.X` reference, verify with `gh secret list`/`gh variable list` which namespace actually holds the value. For any `continue-on-error` step with a skip-on-missing-config guard, use `::warning::` (not `::notice::`) so misconfiguration surfaces in the Actions UI instead of being invisible indefinitely. Also documented in the project's `CLAUDE.md`. -- **Chain downstream release workflows via `gh workflow run`, not a parallel `release: published` trigger** — two workflows both listening for `release: published` race if the first needs a retry. See [[project_release_pipeline_gotchas]] and `CLAUDE.md` "Release Pipeline Gotchas". -- **`moonrepo/setup-toolchain` cache can silently leave `bun` off PATH after a `.prototools` bump** — check job log for `Cache hit for restore-key:`; self-heals on retry. See [[project_release_pipeline_gotchas]]. -- **CLI's background update-check notice can pollute stdout** — gated via `shouldPerformUpdateCheck()` (TTY + non-CI only). See [[project_release_pipeline_gotchas]]. +- **Release workflow chaining:** `release-binaries.yml` dispatches `publish-shims.yml` after binaries + provenance succeed, avoiding a `release: published` race. See [[project_release_pipeline_gotchas]]. +- **`moonrepo/setup-toolchain` cache can break PATH** after a `.prototools` bump; self-heals on retry. See [[project_release_pipeline_gotchas]]. +- **CLI update-check notice can pollute stdout** — gated via `shouldPerformUpdateCheck()`. See [[project_release_pipeline_gotchas]]. ## Claude Code Harness Config From 761a00fc158f71733bf34ee351c98848562b00b6 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 11:21:20 -0300 Subject: [PATCH 10/11] refactor(cli): move update-check gating/invocation logic into the helper main() built the updateCheckPromise inline (gate check + ternary + Promise.resolve(null)). Moved that wiring into a new maybeCheckForUpdates() in src/helpers/update-check.ts so cli.ts just calls one function; shouldPerformUpdateCheck() stays exported and tested separately as the pure gating decision. Added tests covering the gated-off (no network call) and gated-on (calls through to checkForUpdatesIfNeeded) paths. Signed-off-by: Rhuan Barreto --- src/cli.ts | 13 +---- src/helpers/update-check.ts | 18 ++++++ tests/helpers/update-check.test.ts | 90 +++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f9274b65..66467bf2 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,10 +39,7 @@ import { trackCommand, } from "./helpers/telemetry"; import { showFirstRunNoticeIfNeeded } from "./helpers/telemetry-config"; -import { - checkForUpdatesIfNeeded, - shouldPerformUpdateCheck, -} from "./helpers/update-check"; +import { maybeCheckForUpdates } from "./helpers/update-check"; // Pre-main environment guards — these are user-facing errors (exit 1), not bugs. // The Bun check must throw (logError requires Bun APIs). The rest use logError @@ -155,13 +152,7 @@ async function main() { registerDoctorCommand(program); registerTelemetryCommand(program); - const updateCheckPromise = shouldPerformUpdateCheck({ - argv: process.argv, - isTTY: process.stdout.isTTY === true, - ci: Boolean(Bun.env.CI), - }) - ? checkForUpdatesIfNeeded(packageJson.version) - : Promise.resolve(null); + const updateCheckPromise = maybeCheckForUpdates(packageJson.version); await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice) console.log(notice); diff --git a/src/helpers/update-check.ts b/src/helpers/update-check.ts index c794e40e..bf88929f 100644 --- a/src/helpers/update-check.ts +++ b/src/helpers/update-check.ts @@ -75,3 +75,21 @@ export async function checkForUpdatesIfNeeded( return null; } } + +/** + * Starts the background update check for this invocation, gated by + * shouldPerformUpdateCheck(). Resolves to a notice string, or null if the + * check didn't run or found nothing. + */ +export function maybeCheckForUpdates( + currentVersion: string +): Promise { + const shouldCheck = shouldPerformUpdateCheck({ + argv: process.argv, + isTTY: process.stdout.isTTY === true, + ci: Boolean(Bun.env.CI), + }); + return shouldCheck + ? checkForUpdatesIfNeeded(currentVersion) + : Promise.resolve(null); +} diff --git a/tests/helpers/update-check.test.ts b/tests/helpers/update-check.test.ts index 729147a8..b4eed289 100644 --- a/tests/helpers/update-check.test.ts +++ b/tests/helpers/update-check.test.ts @@ -5,7 +5,10 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { shouldPerformUpdateCheck } from "../../src/helpers/update-check"; +import { + maybeCheckForUpdates, + shouldPerformUpdateCheck, +} from "../../src/helpers/update-check"; describe("shouldPerformUpdateCheck", () => { test("true in a genuine interactive terminal", () => { @@ -272,3 +275,88 @@ describe("checkForUpdatesIfNeeded", () => { expect(result).toBeNull(); }); }); + +describe("maybeCheckForUpdates", () => { + let tempDir: string; + let originalHome: string | undefined; + let originalIsTTY: boolean | undefined; + let originalCI: string | undefined; + let originalArgv: string[]; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-maybe-update-test-")); + originalHome = process.env.HOME; + originalIsTTY = process.stdout.isTTY; + originalCI = Bun.env.CI; + originalArgv = process.argv; + }); + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* temp dir may already be removed */ + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + writable: true, + configurable: true, + }); + if (originalCI === undefined) { + delete Bun.env.CI; + } else { + Bun.env.CI = originalCI; + } + process.argv = originalArgv; + mock.restore(); + }); + + test("does not touch the network when gated off", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Bun.env.CI = "1"; + process.argv = ["bun", "cli.ts", "session-context", "claude-code", "list"]; + + const fetchSpy = mock(() => Promise.resolve({ ok: true })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await maybeCheckForUpdates("0.1.0"); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("calls through to checkForUpdatesIfNeeded when gated on", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + delete Bun.env.CI; + process.argv = ["bun", "cli.ts", "session-context", "claude-code", "list"]; + process.env.HOME = tempDir; + + const mockFetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "v0.2.0" }), + }) + ); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { maybeCheckForUpdates: freshMaybeCheckForUpdates } = await import( + `../../src/helpers/update-check?t=${Date.now()}` + ); + + const result = await freshMaybeCheckForUpdates("0.1.0"); + expect(result).toContain("0.1.0"); + expect(result).toContain("0.2.0"); + }); +}); From 1c0120d1a0217528f421af7bbaac2b494528f7e6 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 3 Jul 2026 11:28:57 -0300 Subject: [PATCH 11/11] fix(ci): grant contents: read to the zizmor job explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically this wasn't failing — actions/checkout succeeds on public repos without contents: read since the git-protocol clone doesn't gate on the job's declared permissions (confirmed: 7 successful runs of this job before this commit). Adding it anyway: it's what zizmor-action's own quickstart example declares, costs nothing, and removes any doubt. Reported by Cursor Bugbot on PR #450. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 815e97d9..71ec72b6 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -193,6 +193,7 @@ jobs: timeout-minutes: 5 if: github.event_name != 'pull_request' || github.event.pull_request.draft == false permissions: + contents: read # actions/checkout security-events: write steps: - name: Checkout code