diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 226bd1ca..42787702 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`. +- **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 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..fed93d83 --- /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 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 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/.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..6bf62908 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/project_release_pipeline_gotchas.md @@ -0,0 +1,16 @@ +--- +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. +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. diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 4f50b4a3..71ec72b6 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -182,6 +182,35 @@ 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: + contents: read # actions/checkout + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + 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 runs-on: ubuntu-latest @@ -236,7 +265,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 +285,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/.github/workflows/publish-shims.yml b/.github/workflows/publish-shims.yml index 34e26273..6c3b54bf 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,21 @@ 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: + name: 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 }} + TAG: ${{ inputs.tag }} run: | - TAG="${{ github.event.release.tag_name || 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 +43,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 +60,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} + persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.12" @@ -80,7 +84,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} + persist-credentials: false - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: dotnet-version: "8.0.x" @@ -91,25 +96,32 @@ 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 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: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} - name: Create Go module tag + env: + TAG: ${{ inputs.tag }} run: | - TAG="${{ github.event.release.tag_name || inputs.tag }}" GO_TAG="shims/go/${TAG}" git tag "$GO_TAG" git push origin "$GO_TAG" @@ -122,7 +134,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ inputs.tag }} + persist-credentials: false - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: "temurin" @@ -151,7 +164,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + 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 edb653b7..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' @@ -171,3 +174,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 # 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: | + gh workflow run publish-shims.yml --repo archgate/cli -f tag="$TAG" diff --git a/CLAUDE.md b/CLAUDE.md index 4e732cb8..c02aee7d 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, non-`upgrade` 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. diff --git a/src/cli.ts b/src/cli.ts index 1a0cdf53..66467bf2 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,7 +39,7 @@ import { trackCommand, } from "./helpers/telemetry"; import { showFirstRunNoticeIfNeeded } from "./helpers/telemetry-config"; -import { checkForUpdatesIfNeeded } 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 @@ -152,10 +152,7 @@ async function main() { registerDoctorCommand(program); registerTelemetryCommand(program); - const isUpgrade = process.argv.includes("upgrade"); - const updateCheckPromise = isUpgrade - ? Promise.resolve(null) - : checkForUpdatesIfNeeded(packageJson.version); + 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 08f493f5..bf88929f 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: boolean; +}): 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. @@ -62,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 aa8ef2fa..b4eed289 100644 --- a/tests/helpers/update-check.test.ts +++ b/tests/helpers/update-check.test.ts @@ -5,6 +5,53 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + maybeCheckForUpdates, + shouldPerformUpdateCheck, +} from "../../src/helpers/update-check"; + +describe("shouldPerformUpdateCheck", () => { + test("true in a genuine interactive terminal", () => { + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], + isTTY: true, + ci: false, + }) + ).toBe(true); + }); + + test("false when CI is set, even on a TTY", () => { + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "session-context", "claude-code", "list"], + isTTY: true, + ci: true, + }) + ).toBe(false); + }); + + 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: false, + }) + ).toBe(false); + }); + + test("false for the upgrade command itself, even on an interactive TTY", () => { + expect( + shouldPerformUpdateCheck({ + argv: ["bun", "cli.ts", "upgrade"], + isTTY: true, + ci: false, + }) + ).toBe(false); + }); +}); + describe("checkForUpdatesIfNeeded", () => { let tempDir: string; let originalHome: string | undefined; @@ -228,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"); + }); +}); 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