From 3acd6b637c8853d40af3fd9dd852ed5b91841830 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:30:02 +0700 Subject: [PATCH 01/12] Move PR checks to CircleCI --- .circleci/config.yml | 181 ++++++++++++++++++ .github/workflows/pr-check.yml | 18 +- .gitignore | 6 +- CONTEXT.md | 74 +++++-- ...0005-pr-check-moves-to-circleci-windows.md | 74 +++++++ docs/release/ci-cd.md | 61 ++++-- scripts/local-check.ps1 | 26 ++- 7 files changed, 396 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0005-pr-check-moves-to-circleci-windows.md diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d00092bb2..9a75247298 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,6 +118,177 @@ jobs: & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $publisher -AssetsDir (Join-Path $root 'release-output') -Tag $env:CIRCLE_TAG if ($LASTEXITCODE -ne 0) { throw "publish-github-release.ps1 failed with exit code $LASTEXITCODE" } + + pr-check: + executor: + name: win/default + size: medium + environment: + CARGO_TERM_COLOR: always + steps: + - checkout + - run: + name: Budget and trigger gates + shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass + command: | + $ErrorActionPreference = 'Stop' + # Gate 1 - budget: CI_BUDGET_MODE=off is the emergency stop (unset/empty = normal). + if ($env:CI_BUDGET_MODE -eq 'off') { + Write-Host 'CI_BUDGET_MODE is off: hosted pr-check skips (emergency stop).' + & circleci-agent step halt + if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } + exit 0 + } + Write-Host "CI_BUDGET_MODE is '$($env:CI_BUDGET_MODE)': hosted pr-check passes budget gate." + # Gate 2 - branch/PR scope: the GitHub workflow fires on PRs plus pushes to + # main/master only. CircleCI keeps the branch filter wide (same-repo PR + # builds arrive as branch pipelines with PR variables set), so this gate + # reproduces the narrower contract. Non-PR branch pushes halt the job + # before any cache or toolchain work. + $isPr = (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUEST)) -or (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUESTS)) -or (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PR_NUMBER)) + $isMainPush = $env:CIRCLE_BRANCH -in @('main', 'master') + if (-not $isPr -and -not $isMainPush) { + Write-Host "Branch push to '$($env:CIRCLE_BRANCH)' (not a PR, not main/master): hosted pr-check skips." + & circleci-agent step halt + if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } + exit 0 + } + Write-Host "Trigger gate passed (PR: $isPr, branch: $($env:CIRCLE_BRANCH)): hosted pr-check passes scope gate." + # Gate 3 - docs-only: mirror paths-ignore (docs/**, **/*.md, CONTEXT.md, + # .github/CI.md). Skip only when every changed file matches an ignored + # path; when the base cannot be determined, run the checks (fail open). + $base = $env:CIRCLE_PR_BASE_REVISION # undocumented CircleCI variable; used only if present + if ([string]::IsNullOrWhiteSpace($base) -and $isPr) { + # Resolve the PR base from the public GitHub pulls API. + $prNumber = $env:CIRCLE_PR_NUMBER + if ([string]::IsNullOrWhiteSpace($prNumber) -and -not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUEST)) { + if ($env:CIRCLE_PULL_REQUEST -match '/pull/(\d+)') { $prNumber = $Matches[1] } + } + if ([string]::IsNullOrWhiteSpace($prNumber) -and -not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUESTS)) { + if ($env:CIRCLE_PULL_REQUESTS -match '/pull/(\d+)') { $prNumber = $Matches[1] } + } + try { + if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from CircleCI variables.' } + $repo = "$($env:CIRCLE_PROJECT_USERNAME)/$($env:CIRCLE_PROJECT_REPONAME)" + $pr = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls/$prNumber" -Headers @{ 'User-Agent' = 'codexbar-pr-check' } + $baseSha = [string]$pr.base.sha + if ([string]::IsNullOrWhiteSpace($baseSha)) { throw 'pulls API returned no base.sha.' } + & git fetch origin $baseSha --depth=1 + if ($LASTEXITCODE -ne 0) { throw "git fetch base.sha exited with code $LASTEXITCODE" } + $base = $baseSha + Write-Host "Docs gate base resolved from GitHub pulls API: $baseSha" + } catch { + Write-Host "Docs gate base resolution failed ($($_.Exception.Message)): running full checks (fail open)." + $base = '' + } + } elseif ([string]::IsNullOrWhiteSpace($base) -and $isMainPush) { + & git rev-parse --verify --quiet 'HEAD^' *> $null + if ($LASTEXITCODE -eq 0) { $base = 'HEAD^' } else { Write-Host 'No parent commit on main push: running full checks (fail open).' } + } + if ([string]::IsNullOrWhiteSpace($base)) { exit 0 } # fail open: gate step ends, job continues + $changed = & git diff --name-only "$base..$env:CIRCLE_SHA1" # two-dot tree diff: base fetched at depth 1 may lack a merge base for three-dot + if ($LASTEXITCODE -ne 0) { + Write-Host 'git diff against base failed: cannot evaluate docs-only gate; running full checks (fail open).' + exit 0 + } + $ignoredPattern = '^(docs/.*)|(.*\.md)$' + $codeFiles = @($changed | Where-Object { $_ -and ($_ -notmatch $ignoredPattern) -and ($_ -ne 'CONTEXT.md') -and ($_ -ne '.github/CI.md') }) + if ($codeFiles.Count -eq 0) { + Write-Host "All $($changed.Count) changed files match paths-ignore (docs/**, **/*.md, CONTEXT.md, .github/CI.md): hosted pr-check skips." + & circleci-agent step halt + if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } + exit 0 + } + Write-Host "$($codeFiles.Count) code file(s) changed: hosted pr-check passes docs gate." + - restore_cache: + name: Restore Cargo registry and git cache + keys: + - pr-check-cargo-{{ checksum "Cargo.lock" }} + - pr-check-cargo- + - restore_cache: + name: Restore pnpm store cache + keys: + - pr-check-pnpm-{{ checksum "apps/desktop-tauri/pnpm-lock.yaml" }} + - pr-check-pnpm- + - run: + name: Provision toolchain and run local-check ci slice + shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass + no_output_timeout: 60m + command: | + $ErrorActionPreference = 'Stop' + # Rust stable with rustfmt, clippy, and the MSVC target. + if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { + Write-Host 'rustup is unavailable on the image; installing winget package Rustlang.Rustup.' + & winget install --id Rustlang.Rustup --exact --source winget --silent --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { throw "winget install Rustlang.Rustup exited with code $LASTEXITCODE" } + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $paths = @($env:Path, $machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($paths.Count -gt 0) { $env:Path = $paths -join ';' } + } + & rustup toolchain install stable --profile default -c rustfmt,clippy -t x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install exited with code $LASTEXITCODE" } + & rustup default stable + if ($LASTEXITCODE -ne 0) { throw "rustup default exited with code $LASTEXITCODE" } + Write-Host "[ok] rustup default stable (rustc $((& rustc --version)))" + # Node exact major 24 (GH workflow baseline) with pnpm from the + # packageManager pin. + $env:Path = "C:\Program Files\nodejs;$env:Path" + $requiredNodeMajor = 24 + $nodeFallbackVersion = '24.18.0' + $nodeVersion = $null + if (Get-Command node -ErrorAction SilentlyContinue) { $nodeVersion = (& node --version).Trim() } + $nodeMajor = 0 + if ($nodeVersion -match '^v(\d+)\.') { $nodeMajor = [int]$Matches[1] } + if ($nodeMajor -ne $requiredNodeMajor) { + $found = if ($nodeVersion) { $nodeVersion } else { 'none' } + Write-Host "Node $requiredNodeMajor.x is required (image has $found); installing winget package OpenJS.NodeJS.LTS --version $nodeFallbackVersion." + & winget install --id OpenJS.NodeJS.LTS --exact --version $nodeFallbackVersion --source winget --silent --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { throw "winget install OpenJS.NodeJS.LTS exited with code $LASTEXITCODE" } + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $paths = @($env:Path, $machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($paths.Count -gt 0) { $env:Path = $paths -join ';' } + $env:Path = "C:\Program Files\nodejs;$env:Path" + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'node is still unavailable after winget provisioning.' } + $nodeVersion = (& node --version).Trim() + $nodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') + if ($nodeMajor -ne $requiredNodeMajor) { throw "Node $nodeVersion is installed; required major $requiredNodeMajor." } + } + Write-Host "[ok] Node $nodeVersion" + $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm' + New-Item -ItemType Directory -Force -Path $pnpmShimDir | Out-Null + & corepack enable --install-directory $pnpmShimDir + if ($LASTEXITCODE -ne 0) { throw "corepack enable exited with code $LASTEXITCODE" } + # packageManager in apps/desktop-tauri/package.json is the source of + # truth for the pnpm version (no hardcoded drift). + $packageManager = [string]((Get-Content -Raw -LiteralPath 'apps/desktop-tauri/package.json' | ConvertFrom-Json).packageManager) + if ($packageManager -notmatch '^pnpm@(.+)$') { throw "packageManager '$packageManager' does not match ^pnpm@." } + $expectedPnpm = $Matches[1] + & corepack prepare $packageManager --activate + if ($LASTEXITCODE -ne 0) { throw "corepack prepare $packageManager --activate exited with code $LASTEXITCODE" } + $env:Path = "$pnpmShimDir;$env:Path" + $pnpmVersion = (& pnpm --version).Trim() + if ($pnpmVersion -ne $expectedPnpm) { throw "pnpm $pnpmVersion is active; expected exact $expectedPnpm (packageManager $packageManager)." } + Write-Host "[ok] pnpm $pnpmVersion" + # Delegate the check slice to the local-check script (fmt/clippy/test, + # frontend install/test/build, interaction-guard tests) with the PATH + # prefix set once. + $env:Path = "$env:USERPROFILE\.cargo\bin;C:\Program Files\nodejs;$pnpmShimDir;$env:Path" + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\local-check.ps1') -Slice ci + if ($LASTEXITCODE -ne 0) { throw "local-check.ps1 -Slice ci failed with exit code $LASTEXITCODE" } + - save_cache: + name: Save Cargo registry and git cache + key: pr-check-cargo-{{ checksum "Cargo.lock" }} + paths: + - ~/.cargo/registry + - ~/.cargo/git + - save_cache: + name: Save pnpm store cache + key: pr-check-pnpm-{{ checksum "apps/desktop-tauri/pnpm-lock.yaml" }} + paths: + - ~/AppData/Local/pnpm/store + workflows: release: jobs: @@ -137,3 +308,13 @@ workflows: requires: - release-approval filters: *release-tag-filter + + pr-check: + jobs: + - pr-check: + filters: + tags: + ignore: + - /^v[0-9]+\.[0-9]+\.[0-9]+$/ + branches: + only: /.*/ diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 08d2670259..d806c81457 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,20 +1,10 @@ name: PR check -# Blacksmith CI only: PR validation. Release assets stay in CircleCI. +# Manual-only historical fallback: the hosted PR check moved to CircleCI +# (ADR 0005). This workflow no longer schedules on push or pull_request; +# run it by hand for Blacksmith diagnostics. The job body is kept intact +# for that purpose. on: - push: - branches: [main, master] - paths-ignore: - - 'docs/**' - - '**/*.md' - - 'CONTEXT.md' - - '.github/CI.md' - pull_request: - paths-ignore: - - 'docs/**' - - '**/*.md' - - 'CONTEXT.md' - - '.github/CI.md' workflow_dispatch: concurrency: diff --git a/.gitignore b/.gitignore index ea817629e3..e788718d75 100755 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,11 @@ nul .env .env.* /.local/ -/docs/ +# Docs children ignored generally; ADRs are product docs and stay trackable +/docs/* +!/docs/adr/ +/docs/adr/* +!/docs/adr/*.md /scripts/* /docs/ui-parity-workflow.md /scripts/crop_vm_preferences_proof.sh diff --git a/CONTEXT.md b/CONTEXT.md index ad0f0e7881..20a4b3b057 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,12 +10,17 @@ sister repo (`linear-cli`) draw from. All `blacksmith-*` runners bill against this one pool. The intent-share (60%) and $0 spend alert are defined against the combined draw on this pool, not per repo. +**Retired for Win-CodexBar (2026-08):** the shared pool is exhausted, so this +repo's jobs no longer draw on it; the hosted PR check now runs on CircleCI +Windows (see "Hosted PR check (CircleCI)" below). The definitions here remain +for the sister repo and as pool history. + ## CI budget mode A repository variable `CI_BUDGET_MODE` controls how much CI runs per change. It is intentionally coarse: `normal`, `thin`, or `off`. -| Mode | PR check | Interaction guard | Release | +| Mode | PR check (CircleCI) | Interaction guard | Release | |--------|----------|--------------------|----------------------------------| | normal | runs | runs | local (Win-CodexBar only) | | thin | runs | runs | local (Win-CodexBar only) | @@ -28,8 +33,9 @@ It is intentionally coarse: `normal`, `thin`, or `off`. > mode. - `normal` — default. Unset is treated as `normal`. -- `thin` — Win-CodexBar's PR check survives `thin`: it is the single Windows - Blacksmith job and stays within the 60% intent share, so it still runs. The +- `thin` — Win-CodexBar's PR check survives `thin`: it is the single hosted + Windows job (now CircleCI; Blacksmith is retired for this repo), so it still + runs. The sister repo (`linear-cli`) skips its PR check entirely under `thin` (`if: mode != 'off' && mode != 'thin'`); its default PR check is already a single Linux-only job, so there is no matrix to trim — `thin` simply drops @@ -38,20 +44,58 @@ It is intentionally coarse: `normal`, `thin`, or `off`. Use this when the bill approaches the `$0 spend` alert threshold or when a runaway workflow is burning minutes. -Set it in **Settings → Secrets and variables → Actions → Variables**, not in -code. The workflows read it as `vars.CI_BUDGET_MODE`. +Set it in both CI surfaces, not in code: + +- **CircleCI** (the hosted `pr-check` job): CircleCI Project Settings → + Environment Variables → `CI_BUDGET_MODE`. The job's budget guard reads it as + `$env:CI_BUDGET_MODE`; unset/empty is treated as `normal`. CircleCI + withholds project environment variables from forked-PR builds by default, + so forked PRs arrive empty → `normal` → runs (the same effective behavior as + GitHub's `vars.` withholding). +- **GitHub Actions** (`interaction-guard.yml` unchanged; `pr-check.yml` now a + manual-dispatch-only fallback with no automatic push/PR scheduling): + Settings → Secrets and variables → Actions → Variables; the workflows read + it as `vars.CI_BUDGET_MODE`. + +## Hosted PR check (CircleCI) + +As of 2026-08, Win-CodexBar's hosted PR/push gate is the `pr-check` job in +`.circleci/config.yml` (workflow `pr-check`): a single CircleCI Windows job on +the `circleci/windows@5.0` executor (`win/default`, `size: medium`). One +fused step provisions the toolchain and delegates the checks to +`scripts/local-check.ps1 -Slice ci` (the GitHub-workflow mirror; see "Local +check slice" below). Early gates reproduce the GitHub workflow's trigger +contract and log why they skip, then call `circleci-agent step halt` to stop +the job before any cache or toolchain spend: budget `off`, non-PR branch +pushes (PRs and `main`/`master` pushes run), and docs-only diffs +(`docs/**`, `**/*.md`, `CONTEXT.md`, `.github/CI.md`; fails open if the +base revision cannot be determined). The workflow ignores release tags +(`/^v[0-9]+\.[0-9]+\.[0-9]+$/`), so it never double-runs with the tag-gated +`release` workflow. The repository stays public, so the job spends CircleCI's +Free Plan open-source allowance: open-source builds are not subject to the +Free Plan's 30,000-credit personal-usage block and keep running even when a +personal credit balance is exhausted +(https://circleci.com/docs/guides/plans-pricing/credits). CircleCI's OSS +program also documents a monthly allowance for macOS/Windows OSS builds, so +budget the thin slice's Windows credits on the CircleCI plan, not the retired +Blacksmith intent share. ## Intent share (60/30/10) The Blacksmith Pool minutes intent is divided: roughly **60% Win-CodexBar**, **30% linear-cli**, and **10% buffer**. This is an intent allocation of the pool's minutes, not a measure of time spent in `normal`/`thin`/`off` modes. -Win-CodexBar's single Blacksmith Windows PR check is the only recurring -Windows job; release builds stay local. If you add a second recurring job -here, reassess the 60% share before merging. +Win-CodexBar's hosted PR check is now a single CircleCI Windows job (the +Blacksmith runner is retired for this repo), so this repo no longer draws on +the pool; release builds stay local. ## Blacksmith billing note +**Retired for this repo (2026-08):** the shared Blacksmith pool is exhausted, +so Win-CodexBar's hosted PR check no longer draws on it; it runs on CircleCI +Windows instead (see "Hosted PR check (CircleCI)"). The notes below are kept +as pool history. + On the Blacksmith free tier, **Windows minutes bill at 2x** (one Windows minute consumes two free-tier minutes). `blacksmith-4vcpu-windows-2025` is a Windows Server 2025 runner with VS Build Tools available, so Rust + Tauri @@ -61,9 +105,11 @@ install, or upload. ## Local check slice -The PR check mirrors `scripts/local-check.ps1`'s default slice only: -`cargo fmt --check`, `cargo clippy -D warnings` on both crates, `cargo test` -on both crates, and the frontend `pnpm test` / `tsc --noEmit` (via -`pnpm run build`). It deliberately excludes `tauri:build` release, the -installer, smoke install, and release upload — those stay on the local -Windows release path. +The hosted PR check runs `scripts/local-check.ps1 -Slice ci`, which mirrors +`.github/workflows/pr-check.yml` step for step: workspace-wide +`cargo fmt --check` and `cargo clippy -D warnings`, workspace `cargo test`, +and the frontend `pnpm install --frozen-lockfile`, `pnpm test`, and `tsc +--noEmit` (via `pnpm run build`), plus the interaction-guard script tests. +The script's default (no-parameter) slice is unchanged for developers. The +PR check deliberately excludes `tauri:build` release, the installer, smoke +install, and release upload — those stay on the local Windows release path. diff --git a/docs/adr/0005-pr-check-moves-to-circleci-windows.md b/docs/adr/0005-pr-check-moves-to-circleci-windows.md new file mode 100644 index 0000000000..3e93fa6bcd --- /dev/null +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -0,0 +1,74 @@ +# ADR 0005: Hosted PR check moves to CircleCI Windows + +Date: 2026-08-31 +Status: Accepted; supersedes the hosted-runner decision of ADR 0001 and the +Blacksmith gate description in ADR 0002 + +## Context + +The shared Blacksmith free-tier minute pool is exhausted, so the Blacksmith +GitHub Actions runners behind ADR 0001's PR check +(`.github/workflows/pr-check.yml`) can no longer be relied on for scheduled +PR validation. CircleCI is already this repo's hosted Windows platform for +releases (ADR 0004), with a working `circleci/windows@5.0` configuration, and +the repository is public, so its PR builds draw on CircleCI's Free Plan +open-source allowance rather than the personal credit block. + +## Decision + +Move the hosted PR/push gate to CircleCI as a new `pr-check` job in +`.circleci/config.yml` (workflow `pr-check`): + +- `win/default` executor, `size: medium`. The check steps are not re-declared + in the config: one fused step provisions the toolchain, then delegates the + whole check to `scripts/local-check.ps1 -Slice ci`, a new opt-in slice that + mirrors `.github/workflows/pr-check.yml` step for step (`cargo fmt --all + --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo + test --workspace`, `pnpm install --frozen-lockfile`, `pnpm test`, `pnpm run + build`, plus the interaction-guard script tests). The script's default + (no-parameter) local behavior is unchanged. +- Rust stable is installed via rustup with the `rustfmt`/`clippy` components + and the `x86_64-pc-windows-msvc` target; Node 24.x and pnpm 11.24.0 (the + `packageManager` pin) are provisioned via winget and corepack, mirroring + `scripts/install-release-prerequisites.ps1`. +- Trigger: the workflow filter stays wide (`branches: only: /.*/`) because + CircleCI delivers same-repo PR builds as branch pipelines; parity with the + GitHub workflow (PRs plus pushes to `main`/`master`, `paths-ignore` for + `docs/**`, `**/*.md`, `CONTEXT.md`, `.github/CI.md`) is enforced inside the + job by early gates. Each true skip condition logs its reason and then calls + `circleci-agent step halt`, which stops the entire job — before the cache + restore steps and the fused provision/run step — so a skip spends no cache + or toolchain minutes (the throw on a non-zero `circleci-agent` exit code + prevents a silent no-op). Docs-only detection diffs the fetched base commit + against the head with a two-dot tree diff (robust when the base is fetched + at depth 1): on PRs, + `CIRCLE_PR_BASE_REVISION` is used only if present (it is not a documented + CircleCI variable), otherwise the PR base SHA is resolved from the public + GitHub pulls API and fetched with `--depth=1`; on `main`/`master` pushes + `HEAD^` is used when available. Any resolution, fetch, or diff failure + fails open: it exits only the gate step and therefore continues the job + into the checks; the gate never silently skips on an unknown base. +- Budget gating stays honest and coarse: the job reads `CI_BUDGET_MODE` as a + CircleCI project environment variable (unset/empty = `normal`) and halts + the job via `circleci-agent step halt` when it equals `off`. + +`.github/workflows/pr-check.yml` is retained as a manual-dispatch-only +fallback: its `on:` block now holds `workflow_dispatch` only (push and +`pull_request` triggers removed), so it no longer schedules automatically and +can be run by hand for Blacksmith diagnostics with the job body intact. The +interaction guard (`.github/workflows/interaction-guard.yml`) remains a +GitHub Actions workflow and is unaffected. + +## Consequences + +- Hosted PR feedback continues on the real Windows target with no Blacksmith + dependency. +- `CI_BUDGET_MODE` must now be set in two places to control both surfaces: + CircleCI project environment variables (read by the `pr-check` guard) and + GitHub Actions repository variables (read by `vars.CI_BUDGET_MODE`). The + glossary in `CONTEXT.md` documents both. +- CircleCI Windows credits become the recurring PR cost, spent against the + open-source allowance; organization credit alerts should cover the PR check + as well as releases. +- ADRs 0001 and 0002 remain as immutable history describing the Blacksmith + era; this ADR supersedes only where the hosted PR check runs. diff --git a/docs/release/ci-cd.md b/docs/release/ci-cd.md index c9ed0cc635..6a4a376845 100644 --- a/docs/release/ci-cd.md +++ b/docs/release/ci-cd.md @@ -2,15 +2,38 @@ ## Responsibilities -**Blacksmith GitHub Actions** (`.github/workflows/pr-check.yml`) remains the -primary PR/push validation path. It runs the existing format, clippy, Rust -test, frontend test, and frontend build checks on hosted Blacksmith Windows. - -**CircleCI** (`.circleci/config.yml`) is release-only. The workflow is filtered -to the canonical `nesszer/Win-CodexBar` project and exact protected tags -`vX.Y.Z`; branch and pull-request pipelines cannot enter it. The CircleCI -Windows build is credential-free. Only its explicit approval-gated publisher -gets the restricted `GH_TOKEN` context. +**CircleCI** (`.circleci/config.yml`) is now the hosted PR/push validation +path and the hosted release path. The `pr-check` workflow (added 2026-08) runs +the format, clippy, Rust test, frontend test, and frontend build checks on +hosted CircleCI Windows. It mirrors the GitHub workflow's trigger contract: +pull requests and pushes to `main`/`master` run the checks (delegated to +`scripts/local-check.ps1 -Slice ci`); other branch pushes and docs-only +diffs (`docs/**`, `**/*.md`, `CONTEXT.md`, `.github/CI.md`) skip via early +gates that log their reason and then call `circleci-agent step halt`, stopping +the job before any cache or toolchain spend (docs-only detection fails open +when the base revision cannot be determined). The release-tag pattern (`vX.Y.Z`) is +ignored so it never double-runs with the release workflow. The former +Blacksmith GitHub Actions gate (`.github/workflows/pr-check.yml`) is retired +for this repo — the Blacksmith pool is exhausted — and the workflow is now a +manual-dispatch-only fallback (`on: workflow_dispatch` only; no automatic +push/PR scheduling) kept for Blacksmith diagnostics. + +**Fork-PR coverage note:** CircleCI does not build pull requests from forks +by default (unlike GitHub Actions). External fork PRs are therefore **not +covered** by the CircleCI gate until the project enables fork PR builds in +Project Settings → Advanced. When enabled, CircleCI still withholds project +environment variables from forked-PR builds by default (so `CI_BUDGET_MODE` +arrives unset → `normal` → checks run), which is the same withholding the +`CI_BUDGET_MODE` note in `CONTEXT.md` relies on. The tradeoff: leaving fork +builds disabled keeps the OSS credit spend bounded but means external +contributors get no hosted Windows validation; enabling it restores coverage +at the cost of those credits. + +The `release` workflow remains filtered to the canonical +`nesszer/Win-CodexBar` project and exact protected tags `vX.Y.Z`; branch and +pull-request pipelines cannot enter it. The CircleCI Windows release build is +credential-free. Only its explicit approval-gated publisher gets the +restricted `GH_TOKEN` context. ## CircleCI release flow @@ -76,8 +99,12 @@ for `nesszer/Win-CodexBar`, enable `.circleci/config.yml`, and create a project-restricted context named `github-release-publisher`. Store `GH_TOKEN` there only, using a fine-grained GitHub token scoped to this repository with Contents read/write for release APIs. Do not grant Workflows permission. +Add `CI_BUDGET_MODE` as a CircleCI project environment variable (Project +Settings → Environment Variables) so the `pr-check` budget guard can read it; +unset/empty is treated as `normal`. -Protect `main`, require the existing Blacksmith checks, and protect the `v*` +Protect `main`, require the hosted CircleCI `pr-check` for branch/PR +validation, and protect the `v*` tag namespace so only authorized maintainers can create canonical `vX.Y.Z` tags. Configure CircleCI credit/spend alerts and notifications as appropriate for the organization. These project, context, token, ruleset, and billing changes @@ -85,10 +112,16 @@ are intentionally manual. ## Cost, retry, and rollback -Blacksmith billing remains the recurring PR cost. CircleCI Windows credits are -incurred only for a protected release tag and its short approval/publish path; -there is no CircleCI branch or PR build. Windows executor rates depend on the -CircleCI plan, so set an organization credit alert before enabling releases. +The hosted PR check now runs on CircleCI Windows: its thin-slice Windows +credits recur on PRs and `main`/`master` pushes (other branch pushes and +docs-only diffs skip before spending), alongside the protected release tag +path. The repository is public, so the PR check spends the Free Plan +open-source allowance (open-source builds are not subject to the Free Plan's +30,000-credit personal block). Fork PRs are not covered until fork builds are +enabled (see the fork-PR coverage note above). Blacksmith is retired for +this repo and no longer bills recurring PR cost. Windows executor rates +depend on the CircleCI plan, so set an organization credit alert before +enabling the PR check or releases. Rerunning a failed build creates a new temporary WorkRoot and remains pinned to the tag's full SHA. If a publish job partially uploads, rerun it after approval: diff --git a/scripts/local-check.ps1 b/scripts/local-check.ps1 index eb9d4b8835..a16efd0e34 100644 --- a/scripts/local-check.ps1 +++ b/scripts/local-check.ps1 @@ -7,7 +7,9 @@ param( [switch]$Clippy, [switch]$ReleaseDoctor, [switch]$All, - [string]$Version = "" + [string]$Version = "", + [ValidateSet("ci")] + [string]$Slice = "" ) Set-StrictMode -Version 3.0 @@ -30,6 +32,28 @@ function Invoke-Step { } } +# Hosted pr-check slice (-Slice ci): mirrors .github/workflows/pr-check.yml +# step for step (workspace-wide fmt/clippy/test, frozen frontend install, +# frontend test/build, interaction-guard script tests). The guard's script +# tests are pure Node, so the mirror stays honest locally too. +if ($Slice -eq 'ci') { + Push-Location $RepoRoot + try { + Invoke-Step "Rust format check" "cargo" @("fmt", "--all", "--check") + Invoke-Step "Rust clippy (workspace)" "cargo" @("clippy", "--workspace", "--all-targets", "--", "-D", "warnings") + Invoke-Step "Rust tests (workspace)" "cargo" @("test", "--workspace") + Invoke-Step "Frontend install" "pnpm" @("--dir", "apps\desktop-tauri", "install", "--frozen-lockfile") + Invoke-Step "Frontend tests" "pnpm" @("--dir", "apps\desktop-tauri", "test") + Invoke-Step "Frontend type check / build" "pnpm" @("--dir", "apps\desktop-tauri", "run", "build") + Invoke-Step "Interaction guard script tests" "node" @("--test", ".github/scripts/interaction-guard.test.mjs") + } finally { + Pop-Location + } + Write-Host "" + Write-Host "Local checks passed." -ForegroundColor Green + return +} + if (-not ($Rust -or $Tauri -or $Frontend -or $Format -or $Clippy -or $ReleaseDoctor -or $All)) { $Rust = $true $Tauri = $true From 398e1f98be84966ab281ed858715e37eee23b0ab Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:53:40 +0700 Subject: [PATCH 02/12] Fix CircleCI PR detection via pipeline.event values --- .circleci/config.yml | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9a75247298..b7e6b0bb9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -141,11 +141,15 @@ jobs: } Write-Host "CI_BUDGET_MODE is '$($env:CI_BUDGET_MODE)': hosted pr-check passes budget gate." # Gate 2 - branch/PR scope: the GitHub workflow fires on PRs plus pushes to - # main/master only. CircleCI keeps the branch filter wide (same-repo PR - # builds arrive as branch pipelines with PR variables set), so this gate - # reproduces the narrower contract. Non-PR branch pushes halt the job - # before any cache or toolchain work. - $isPr = (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUEST)) -or (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUESTS)) -or (-not [string]::IsNullOrWhiteSpace($env:CIRCLE_PR_NUMBER)) + # main/master only. GitHub App pipelines expose PR association via the + # compile-time pipeline value pipeline.event.context.github.pr_url (docs: + # "matches environment variable CIRCLE_PULL_REQUEST"); the legacy + # CIRCLE_PULL_REQUEST*/CIRCLE_PR_NUMBER env vars are OAuth-integration + # variables and are not populated on GitHub App pipelines. On push + # pipelines the PR values interpolate to empty strings, so the step + # compiles and runs for push, PR, and api triggers alike. + $prUrl = '<< pipeline.event.context.github.pr_url >>' + $isPr = -not [string]::IsNullOrWhiteSpace($prUrl) $isMainPush = $env:CIRCLE_BRANCH -in @('main', 'master') if (-not $isPr -and -not $isMainPush) { Write-Host "Branch push to '$($env:CIRCLE_BRANCH)' (not a PR, not main/master): hosted pr-check skips." @@ -157,19 +161,18 @@ jobs: # Gate 3 - docs-only: mirror paths-ignore (docs/**, **/*.md, CONTEXT.md, # .github/CI.md). Skip only when every changed file matches an ignored # path; when the base cannot be determined, run the checks (fail open). - $base = $env:CIRCLE_PR_BASE_REVISION # undocumented CircleCI variable; used only if present + # PR pipelines carry the base SHA directly via the compile-time value + # pipeline.event.github.pull_request.base.sha (docs: "The SHA of the base + # branch of the pull request... Only populated for pull request events"); + # it interpolates to an empty string on push pipelines. + $base = '<< pipeline.event.github.pull_request.base.sha >>' if ([string]::IsNullOrWhiteSpace($base) -and $isPr) { - # Resolve the PR base from the public GitHub pulls API. - $prNumber = $env:CIRCLE_PR_NUMBER - if ([string]::IsNullOrWhiteSpace($prNumber) -and -not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUEST)) { - if ($env:CIRCLE_PULL_REQUEST -match '/pull/(\d+)') { $prNumber = $Matches[1] } - } - if ([string]::IsNullOrWhiteSpace($prNumber) -and -not [string]::IsNullOrWhiteSpace($env:CIRCLE_PULL_REQUESTS)) { - if ($env:CIRCLE_PULL_REQUESTS -match '/pull/(\d+)') { $prNumber = $Matches[1] } - } + # PR association without a populated event value (e.g. api trigger): + # resolve the base from the public GitHub pulls API. + $prNumber = '' + if ($prUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] } try { - if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from CircleCI variables.' } - $repo = "$($env:CIRCLE_PROJECT_USERNAME)/$($env:CIRCLE_PROJECT_REPONAME)" + if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' } $pr = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls/$prNumber" -Headers @{ 'User-Agent' = 'codexbar-pr-check' } $baseSha = [string]$pr.base.sha if ([string]::IsNullOrWhiteSpace($baseSha)) { throw 'pulls API returned no base.sha.' } From 46e5e27883810845fb39ca3aa70068cd792c98e9 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:25:05 +0700 Subject: [PATCH 03/12] Trigger CircleCI PR check From 64a76bd939be27f3c8983f8df17f84ff3410ba1c Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:31:44 +0700 Subject: [PATCH 04/12] Provision Rust and Node without winget on CircleCI image --- .circleci/config.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b7e6b0bb9b..f1726c709d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -221,13 +221,14 @@ jobs: $ErrorActionPreference = 'Stop' # Rust stable with rustfmt, clippy, and the MSVC target. if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { - Write-Host 'rustup is unavailable on the image; installing winget package Rustlang.Rustup.' - & winget install --id Rustlang.Rustup --exact --source winget --silent --accept-source-agreements --accept-package-agreements - if ($LASTEXITCODE -ne 0) { throw "winget install Rustlang.Rustup exited with code $LASTEXITCODE" } - $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $paths = @($env:Path, $machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - if ($paths.Count -gt 0) { $env:Path = $paths -join ';' } + Write-Host 'rustup is unavailable on the image; installing via rustup-init.exe (winget is not on the hosted image).' + $rustupInit = Join-Path $env:TEMP 'rustup-init.exe' + & curl.exe -sSfL -o $rustupInit https://win.rustup.rs/x86_64 + if ($LASTEXITCODE -ne 0) { throw "downloading rustup-init.exe exited with code $LASTEXITCODE" } + & $rustupInit -y --default-toolchain none --profile minimal + if ($LASTEXITCODE -ne 0) { throw "rustup-init exited with code $LASTEXITCODE" } + $cargoBin = Join-Path $env:USERPROFILE '.cargo\bin' + $env:Path = "$cargoBin;$env:Path" } & rustup toolchain install stable --profile default -c rustfmt,clippy -t x86_64-pc-windows-msvc if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install exited with code $LASTEXITCODE" } @@ -245,15 +246,15 @@ jobs: if ($nodeVersion -match '^v(\d+)\.') { $nodeMajor = [int]$Matches[1] } if ($nodeMajor -ne $requiredNodeMajor) { $found = if ($nodeVersion) { $nodeVersion } else { 'none' } - Write-Host "Node $requiredNodeMajor.x is required (image has $found); installing winget package OpenJS.NodeJS.LTS --version $nodeFallbackVersion." - & winget install --id OpenJS.NodeJS.LTS --exact --version $nodeFallbackVersion --source winget --silent --accept-source-agreements --accept-package-agreements - if ($LASTEXITCODE -ne 0) { throw "winget install OpenJS.NodeJS.LTS exited with code $LASTEXITCODE" } - $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $paths = @($env:Path, $machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - if ($paths.Count -gt 0) { $env:Path = $paths -join ';' } + Write-Host "Node $requiredNodeMajor.x is required (image has $found); installing Node $nodeFallbackVersion via official MSI (winget is not on the hosted image)." + $nodeMsi = Join-Path $env:TEMP "node-v$nodeFallbackVersion-x64.msi" + & curl.exe -sSfL -o $nodeMsi "https://nodejs.org/dist/v$nodeFallbackVersion/node-v$nodeFallbackVersion-x64.msi" + if ($LASTEXITCODE -ne 0) { throw "downloading Node MSI exited with code $LASTEXITCODE" } + $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart') + $proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } $env:Path = "C:\Program Files\nodejs;$env:Path" - if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'node is still unavailable after winget provisioning.' } + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'node is still unavailable after MSI provisioning.' } $nodeVersion = (& node --version).Trim() $nodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') if ($nodeMajor -ne $requiredNodeMajor) { throw "Node $nodeVersion is installed; required major $requiredNodeMajor." } From e90e5fcef80adf0c8f7170d4e2e04793295488f2 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:34:51 +0700 Subject: [PATCH 05/12] Resolve Node strictly from MSI dir on CircleCI image --- .circleci/config.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f1726c709d..90b6e6d984 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -253,11 +253,14 @@ jobs: $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart') $proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } - $env:Path = "C:\Program Files\nodejs;$env:Path" - if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'node is still unavailable after MSI provisioning.' } - $nodeVersion = (& node --version).Trim() + # The image's preinstalled Node (a different major) may sit + # earlier on PATH; resolve node strictly from the MSI dir. + $nodeExe = 'C:\Program Files\nodejs\node.exe' + if (-not (Test-Path $nodeExe)) { throw 'node.exe is still unavailable after MSI provisioning.' } + $nodeVersion = (& $nodeExe --version).Trim() $nodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') if ($nodeMajor -ne $requiredNodeMajor) { throw "Node $nodeVersion is installed; required major $requiredNodeMajor." } + $env:Path = "C:\Program Files\nodejs;$env:Path" } Write-Host "[ok] Node $nodeVersion" $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm' From 933d50ffe8753b15b6b644594b6b06af7c4d391a Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:40:20 +0700 Subject: [PATCH 06/12] Install Node MSI to dedicated dir to bypass image no-op --- .circleci/config.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 90b6e6d984..679e9dd7ce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -250,17 +250,19 @@ jobs: $nodeMsi = Join-Path $env:TEMP "node-v$nodeFallbackVersion-x64.msi" & curl.exe -sSfL -o $nodeMsi "https://nodejs.org/dist/v$nodeFallbackVersion/node-v$nodeFallbackVersion-x64.msi" if ($LASTEXITCODE -ne 0) { throw "downloading Node MSI exited with code $LASTEXITCODE" } - $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart') + # The image's own Node may occupy C:\Program Files\nodejs and a + # same-product MSI upgrade can silently no-op; install into a + # dedicated per-version directory and put it first on PATH. + $nodeDir = "C:\node-v$nodeFallbackVersion" + $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart', "INSTALLDIR=$nodeDir") $proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } - # The image's preinstalled Node (a different major) may sit - # earlier on PATH; resolve node strictly from the MSI dir. - $nodeExe = 'C:\Program Files\nodejs\node.exe' + $nodeExe = Join-Path $nodeDir 'node.exe' if (-not (Test-Path $nodeExe)) { throw 'node.exe is still unavailable after MSI provisioning.' } $nodeVersion = (& $nodeExe --version).Trim() $nodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') if ($nodeMajor -ne $requiredNodeMajor) { throw "Node $nodeVersion is installed; required major $requiredNodeMajor." } - $env:Path = "C:\Program Files\nodejs;$env:Path" + $env:Path = "$nodeDir;$env:Path" } Write-Host "[ok] Node $nodeVersion" $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm' @@ -281,7 +283,7 @@ jobs: # Delegate the check slice to the local-check script (fmt/clippy/test, # frontend install/test/build, interaction-guard tests) with the PATH # prefix set once. - $env:Path = "$env:USERPROFILE\.cargo\bin;C:\Program Files\nodejs;$pnpmShimDir;$env:Path" + $env:Path = "$env:USERPROFILE\.cargo\bin;$nodeDir;C:\Program Files\nodejs;$pnpmShimDir;$env:Path" & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\local-check.ps1') -Slice ci if ($LASTEXITCODE -ne 0) { throw "local-check.ps1 -Slice ci failed with exit code $LASTEXITCODE" } - save_cache: From dd962755561a38301d032d4e84540ec0429ae9c5 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:10:52 +0700 Subject: [PATCH 07/12] Fix CircleCI PR fallback documentation --- .circleci/config.yml | 2 ++ ...0005-pr-check-moves-to-circleci-windows.md | 25 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 679e9dd7ce..ad61fefeef 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -173,6 +173,8 @@ jobs: if ($prUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] } try { if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' } + $repo = "$($env:CIRCLE_PROJECT_USERNAME)/$($env:CIRCLE_PROJECT_REPONAME)" + if ([string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_USERNAME) -or [string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_REPONAME)) { throw 'CIRCLE_PROJECT_USERNAME/REPONAME unavailable for pulls API fallback.' } $pr = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls/$prNumber" -Headers @{ 'User-Agent' = 'codexbar-pr-check' } $baseSha = [string]$pr.base.sha if ([string]::IsNullOrWhiteSpace($baseSha)) { throw 'pulls API returned no base.sha.' } diff --git a/docs/adr/0005-pr-check-moves-to-circleci-windows.md b/docs/adr/0005-pr-check-moves-to-circleci-windows.md index 3e93fa6bcd..d902f797fb 100644 --- a/docs/adr/0005-pr-check-moves-to-circleci-windows.md +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -27,10 +27,15 @@ Move the hosted PR/push gate to CircleCI as a new `pr-check` job in test --workspace`, `pnpm install --frozen-lockfile`, `pnpm test`, `pnpm run build`, plus the interaction-guard script tests). The script's default (no-parameter) local behavior is unchanged. -- Rust stable is installed via rustup with the `rustfmt`/`clippy` components - and the `x86_64-pc-windows-msvc` target; Node 24.x and pnpm 11.24.0 (the - `packageManager` pin) are provisioned via winget and corepack, mirroring - `scripts/install-release-prerequisites.ps1`. +- Rust stable is installed by downloading `rustup-init.exe` directly from + `https://win.rustup.rs/x86_64` (the hosted image has no winget) and then + installing the stable toolchain with the `rustfmt`/`clippy` components and + the `x86_64-pc-windows-msvc` target; Node 24.18.0 is installed from the + official x64 MSI (`msiexec /qn /norestart` with a dedicated per-version + `INSTALLDIR`, because the image's preinstalled Node is a different major and + a same-product MSI upgrade silently no-ops); pnpm 11.24.0 is activated by + corepack from the exact `packageManager` pin in + `apps/desktop-tauri/package.json`. - Trigger: the workflow filter stays wide (`branches: only: /.*/`) because CircleCI delivers same-repo PR builds as branch pipelines; parity with the GitHub workflow (PRs plus pushes to `main`/`master`, `paths-ignore` for @@ -39,12 +44,14 @@ Move the hosted PR/push gate to CircleCI as a new `pr-check` job in `circleci-agent step halt`, which stops the entire job — before the cache restore steps and the fused provision/run step — so a skip spends no cache or toolchain minutes (the throw on a non-zero `circleci-agent` exit code - prevents a silent no-op). Docs-only detection diffs the fetched base commit + prevents a silent no-op). Docs-only detection diffs the base commit against the head with a two-dot tree diff (robust when the base is fetched - at depth 1): on PRs, - `CIRCLE_PR_BASE_REVISION` is used only if present (it is not a documented - CircleCI variable), otherwise the PR base SHA is resolved from the public - GitHub pulls API and fetched with `--depth=1`; on `main`/`master` pushes + at depth 1): on PRs the base SHA comes primarily from the compile-time + GitHub App pipeline value `pipeline.event.github.pull_request.base.sha` + (populated on pull request events); if that is empty while the pipeline is + still PR-associated, the base SHA is resolved from the public GitHub pulls + API using the documented `CIRCLE_PROJECT_USERNAME`/`CIRCLE_PROJECT_REPONAME` + variables and fetched with `--depth=1`; on `main`/`master` pushes `HEAD^` is used when available. Any resolution, fetch, or diff failure fails open: it exits only the gate step and therefore continues the job into the checks; the gate never silently skips on an unknown base. From 8e27cea4ad69f3993ab634acd5692bb5386578aa Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:50:00 +0700 Subject: [PATCH 08/12] Update CircleCI operator guidance --- .github/CI.md | 66 ++++++++++--------- ...0005-pr-check-moves-to-circleci-windows.md | 2 +- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/.github/CI.md b/.github/CI.md index 94bbbfb6c7..f349889a16 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -2,23 +2,28 @@ Win-CodexBar has two deliberately separate hosted CI responsibilities: -- **Blacksmith GitHub Actions** remains the primary PR/push validation path. -- **CircleCI** is a release-only Windows path. It can start only for a - canonical protected semver tag (`vX.Y.Z`), never for a branch or PR. +- **CircleCI** hosts the primary PR/push validation path: the `pr-check` + job/workflow in `.circleci/config.yml` runs the full local-check slice on + CircleCI's hosted Windows executor for every branch pipeline that passes + its gates. +- The **Blacksmith GitHub Actions** PR check + (`.github/workflows/pr-check.yml`) is a manual-dispatch-only fallback: its + `on:` block holds `workflow_dispatch` only, so it no longer schedules + automatically and is run by hand only for Blacksmith diagnostics. -The CircleCI pipeline does not replace or weaken the Blacksmith checks. Its -build job has no GitHub write credential; only the post-approval publisher -receives the restricted `GH_TOKEN` context. +These responsibilities do not overlap: the CircleCI PR check replaces the +former Blacksmith PR/push gate (see ADR 0005). Its build job has no GitHub +write credential; only the post-approval release publisher receives the +restricted `GH_TOKEN` context. -## Blacksmith GitHub Actions +## CircleCI hosted PR check (primary PR/push gate) -### PR check — `.github/workflows/pr-check.yml` +### Workflow — `.circleci/config.yml` -Runs on `pull_request`, on `push` to `main`/`master`, and on -`workflow_dispatch`. Runner: `blacksmith-4vcpu-windows-2025` -(Windows Server 2025; VS Build Tools available per Blacksmith docs). - -Exact commands run, in order: +The hosted PR/push validation gate now runs on **CircleCI** as the +`pr-check` job in the `pr-check` workflow (project `nesszer/Win-CodexBar`), +not on Blacksmith. The CircleCI job delegates the whole check to +`scripts/local-check.ps1 -Slice ci`, so the exact commands it runs are: ```powershell cargo fmt --all --check @@ -28,9 +33,6 @@ pnpm --dir apps/desktop-tauri test pnpm --dir apps/desktop-tauri run build ``` -This is the **local-check slice** from `scripts/local-check.ps1` only. It does -not run release packaging, installer smoke, or publication. - `concurrency.cancel-in-progress` is on, keyed by ref, so superseded pushes cancel the in-flight run. @@ -42,22 +44,23 @@ permissions. It is unrelated to release publication. ## GitHub Actions budget mode -Both GitHub workflows carry `if: vars.CI_BUDGET_MODE != 'off'`, so they run -when the variable is unset (`normal`), `normal`, or `thin`, and skip only when -it is `off`. This gate does not disable CircleCI releases. +Only the interaction guard carries the `if: vars.CI_BUDGET_MODE != 'off'` +gate now; it runs when the variable is unset (`normal`), `normal`, or +`thin`, and skips only when it is `off`. This gate does not disable +CircleCI releases. Set `CI_BUDGET_MODE` in **Settings → Secrets and variables → Actions → Variables**. Do not hard-code it in a workflow. -| Mode | PR check | Interaction guard | Circle release | -|--------|----------|-------------------|----------------| -| normal | runs | runs | tag-triggered | -| thin | runs | runs | tag-triggered | -| off | skip | skip | tag-triggered | +| Mode | Interaction guard | Circle release | +|--------|-------------------|----------------| +| normal | runs | tag-triggered | +| thin | runs | tag-triggered | +| off | skip | tag-triggered | The Blacksmith Pool minutes intent remains roughly **60% Win-CodexBar**, -**30% linear-cli**, and **10% buffer**. CircleCI credits are separate and must -be budgeted in CircleCI. +**30% linear-cli**, and **10% buffer**. CircleCI credits are separate and +must be budgeted in CircleCI. ## CircleCI release pipeline @@ -112,12 +115,11 @@ CircleCI project setup and GitHub tag/context/ruleset changes are the current manual setup scope. ## Cost, retry, and rollback behavior - -Blacksmith Windows minutes remain the recurring PR cost and are still billed -according to the existing Blacksmith plan (Windows has historically billed at -2x on its free tier). CircleCI release builds add Windows executor credits only -for protected semver tags, plus the short approval/publish job. Do not use -CircleCI for branch validation or ad-hoc release testing. +CircleCI Windows credits are now the recurring PR cost (spent against the +open-source allowance; see ADR 0005), plus release builds for protected +semver tags and the short approval/publish job. Blacksmith minutes remain +relevant only for the interaction guard and manual-dispatch diagnostics. +Do not use CircleCI for ad-hoc release testing outside its gated triggers. Reruns are safe: the build is tied to the immutable SHA from the tag and produces a fresh temporary WorkRoot. If publication stops after some uploads, diff --git a/docs/adr/0005-pr-check-moves-to-circleci-windows.md b/docs/adr/0005-pr-check-moves-to-circleci-windows.md index d902f797fb..aed4aa3744 100644 --- a/docs/adr/0005-pr-check-moves-to-circleci-windows.md +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -1,6 +1,6 @@ # ADR 0005: Hosted PR check moves to CircleCI Windows -Date: 2026-08-31 +Date: 2026-08-30 Status: Accepted; supersedes the hosted-runner decision of ADR 0001 and the Blacksmith gate description in ADR 0002 From bd8736a678a692aebbb572f125826022940bdf35 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:34:08 +0700 Subject: [PATCH 09/12] Simplify and harden CircleCI checks --- .circleci/config.yml | 172 +++--------------- .github/CI.md | 15 +- .github/workflows/pr-check.yml | 25 +-- CONTEXT.md | 10 +- ...0005-pr-check-moves-to-circleci-windows.md | 49 +++-- docs/release/ci-cd.md | 25 +-- scripts/circleci-pinned-rust.txt | 1 + scripts/circleci-pr-common.ps1 | 132 ++++++++++++++ scripts/circleci-pr-gates.ps1 | 133 ++++++++++++++ scripts/circleci-pr.tests.ps1 | 92 ++++++++++ scripts/run-circleci-pr-check.ps1 | 146 +++++++++++++++ 11 files changed, 603 insertions(+), 197 deletions(-) create mode 100644 scripts/circleci-pinned-rust.txt create mode 100644 scripts/circleci-pr-common.ps1 create mode 100644 scripts/circleci-pr-gates.ps1 create mode 100644 scripts/circleci-pr.tests.ps1 create mode 100644 scripts/run-circleci-pr-check.ps1 diff --git a/.circleci/config.yml b/.circleci/config.yml index ad61fefeef..a0fedd3876 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,6 @@ version: 2.1 + orbs: win: circleci/windows@5.0 @@ -125,86 +126,23 @@ jobs: size: medium environment: CARGO_TERM_COLOR: always + # Stable relative target dir so the target cache below is portable and + # every cargo invocation in local-check.ps1 shares one cache. + CARGO_TARGET_DIR: target steps: - checkout - run: - name: Budget and trigger gates + name: Budget, trigger, and docs-only gates shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass command: | $ErrorActionPreference = 'Stop' - # Gate 1 - budget: CI_BUDGET_MODE=off is the emergency stop (unset/empty = normal). - if ($env:CI_BUDGET_MODE -eq 'off') { - Write-Host 'CI_BUDGET_MODE is off: hosted pr-check skips (emergency stop).' - & circleci-agent step halt - if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } - exit 0 - } - Write-Host "CI_BUDGET_MODE is '$($env:CI_BUDGET_MODE)': hosted pr-check passes budget gate." - # Gate 2 - branch/PR scope: the GitHub workflow fires on PRs plus pushes to - # main/master only. GitHub App pipelines expose PR association via the - # compile-time pipeline value pipeline.event.context.github.pr_url (docs: - # "matches environment variable CIRCLE_PULL_REQUEST"); the legacy - # CIRCLE_PULL_REQUEST*/CIRCLE_PR_NUMBER env vars are OAuth-integration - # variables and are not populated on GitHub App pipelines. On push - # pipelines the PR values interpolate to empty strings, so the step - # compiles and runs for push, PR, and api triggers alike. - $prUrl = '<< pipeline.event.context.github.pr_url >>' - $isPr = -not [string]::IsNullOrWhiteSpace($prUrl) - $isMainPush = $env:CIRCLE_BRANCH -in @('main', 'master') - if (-not $isPr -and -not $isMainPush) { - Write-Host "Branch push to '$($env:CIRCLE_BRANCH)' (not a PR, not main/master): hosted pr-check skips." - & circleci-agent step halt - if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } - exit 0 - } - Write-Host "Trigger gate passed (PR: $isPr, branch: $($env:CIRCLE_BRANCH)): hosted pr-check passes scope gate." - # Gate 3 - docs-only: mirror paths-ignore (docs/**, **/*.md, CONTEXT.md, - # .github/CI.md). Skip only when every changed file matches an ignored - # path; when the base cannot be determined, run the checks (fail open). - # PR pipelines carry the base SHA directly via the compile-time value - # pipeline.event.github.pull_request.base.sha (docs: "The SHA of the base - # branch of the pull request... Only populated for pull request events"); - # it interpolates to an empty string on push pipelines. - $base = '<< pipeline.event.github.pull_request.base.sha >>' - if ([string]::IsNullOrWhiteSpace($base) -and $isPr) { - # PR association without a populated event value (e.g. api trigger): - # resolve the base from the public GitHub pulls API. - $prNumber = '' - if ($prUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] } - try { - if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' } - $repo = "$($env:CIRCLE_PROJECT_USERNAME)/$($env:CIRCLE_PROJECT_REPONAME)" - if ([string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_USERNAME) -or [string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_REPONAME)) { throw 'CIRCLE_PROJECT_USERNAME/REPONAME unavailable for pulls API fallback.' } - $pr = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls/$prNumber" -Headers @{ 'User-Agent' = 'codexbar-pr-check' } - $baseSha = [string]$pr.base.sha - if ([string]::IsNullOrWhiteSpace($baseSha)) { throw 'pulls API returned no base.sha.' } - & git fetch origin $baseSha --depth=1 - if ($LASTEXITCODE -ne 0) { throw "git fetch base.sha exited with code $LASTEXITCODE" } - $base = $baseSha - Write-Host "Docs gate base resolved from GitHub pulls API: $baseSha" - } catch { - Write-Host "Docs gate base resolution failed ($($_.Exception.Message)): running full checks (fail open)." - $base = '' - } - } elseif ([string]::IsNullOrWhiteSpace($base) -and $isMainPush) { - & git rev-parse --verify --quiet 'HEAD^' *> $null - if ($LASTEXITCODE -eq 0) { $base = 'HEAD^' } else { Write-Host 'No parent commit on main push: running full checks (fail open).' } - } - if ([string]::IsNullOrWhiteSpace($base)) { exit 0 } # fail open: gate step ends, job continues - $changed = & git diff --name-only "$base..$env:CIRCLE_SHA1" # two-dot tree diff: base fetched at depth 1 may lack a merge base for three-dot - if ($LASTEXITCODE -ne 0) { - Write-Host 'git diff against base failed: cannot evaluate docs-only gate; running full checks (fail open).' - exit 0 - } - $ignoredPattern = '^(docs/.*)|(.*\.md)$' - $codeFiles = @($changed | Where-Object { $_ -and ($_ -notmatch $ignoredPattern) -and ($_ -ne 'CONTEXT.md') -and ($_ -ne '.github/CI.md') }) - if ($codeFiles.Count -eq 0) { - Write-Host "All $($changed.Count) changed files match paths-ignore (docs/**, **/*.md, CONTEXT.md, .github/CI.md): hosted pr-check skips." - & circleci-agent step halt - if ($LASTEXITCODE -ne 0) { throw "circleci-agent step halt exited with code $LASTEXITCODE" } - exit 0 - } - Write-Host "$($codeFiles.Count) code file(s) changed: hosted pr-check passes docs gate." + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\circleci-pr-gates.ps1') ` + -BudgetMode $env:CI_BUDGET_MODE ` + -Branch $env:CIRCLE_BRANCH ` + -PrUrl '<< pipeline.event.context.github.pr_url >>' ` + -PrBaseSha '<< pipeline.event.github.pull_request.base.sha >>' ` + -Sha $env:CIRCLE_SHA1 + if ($LASTEXITCODE -ne 0) { throw "circleci-pr-gates.ps1 failed with exit code $LASTEXITCODE" } - restore_cache: name: Restore Cargo registry and git cache keys: @@ -215,79 +153,23 @@ jobs: keys: - pr-check-pnpm-{{ checksum "apps/desktop-tauri/pnpm-lock.yaml" }} - pr-check-pnpm- + - restore_cache: + name: Restore Cargo target cache (exact or older same-Rust fallback) + keys: + - pr-check-target-v1-{{ checksum "scripts/circleci-pinned-rust.txt" }}-{{ checksum "Cargo.lock" }}-{{ checksum "Cargo.toml" }}-{{ checksum "rust/Cargo.toml" }}-{{ checksum "apps/desktop-tauri/src-tauri/Cargo.toml" }} + - pr-check-target-v1-{{ checksum "scripts/circleci-pinned-rust.txt" }}-{{ checksum "Cargo.lock" }}- - run: - name: Provision toolchain and run local-check ci slice + name: Provision pinned toolchain and run local-check ci slice shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass no_output_timeout: 60m command: | $ErrorActionPreference = 'Stop' - # Rust stable with rustfmt, clippy, and the MSVC target. - if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { - Write-Host 'rustup is unavailable on the image; installing via rustup-init.exe (winget is not on the hosted image).' - $rustupInit = Join-Path $env:TEMP 'rustup-init.exe' - & curl.exe -sSfL -o $rustupInit https://win.rustup.rs/x86_64 - if ($LASTEXITCODE -ne 0) { throw "downloading rustup-init.exe exited with code $LASTEXITCODE" } - & $rustupInit -y --default-toolchain none --profile minimal - if ($LASTEXITCODE -ne 0) { throw "rustup-init exited with code $LASTEXITCODE" } - $cargoBin = Join-Path $env:USERPROFILE '.cargo\bin' - $env:Path = "$cargoBin;$env:Path" - } - & rustup toolchain install stable --profile default -c rustfmt,clippy -t x86_64-pc-windows-msvc - if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install exited with code $LASTEXITCODE" } - & rustup default stable - if ($LASTEXITCODE -ne 0) { throw "rustup default exited with code $LASTEXITCODE" } - Write-Host "[ok] rustup default stable (rustc $((& rustc --version)))" - # Node exact major 24 (GH workflow baseline) with pnpm from the - # packageManager pin. - $env:Path = "C:\Program Files\nodejs;$env:Path" - $requiredNodeMajor = 24 - $nodeFallbackVersion = '24.18.0' - $nodeVersion = $null - if (Get-Command node -ErrorAction SilentlyContinue) { $nodeVersion = (& node --version).Trim() } - $nodeMajor = 0 - if ($nodeVersion -match '^v(\d+)\.') { $nodeMajor = [int]$Matches[1] } - if ($nodeMajor -ne $requiredNodeMajor) { - $found = if ($nodeVersion) { $nodeVersion } else { 'none' } - Write-Host "Node $requiredNodeMajor.x is required (image has $found); installing Node $nodeFallbackVersion via official MSI (winget is not on the hosted image)." - $nodeMsi = Join-Path $env:TEMP "node-v$nodeFallbackVersion-x64.msi" - & curl.exe -sSfL -o $nodeMsi "https://nodejs.org/dist/v$nodeFallbackVersion/node-v$nodeFallbackVersion-x64.msi" - if ($LASTEXITCODE -ne 0) { throw "downloading Node MSI exited with code $LASTEXITCODE" } - # The image's own Node may occupy C:\Program Files\nodejs and a - # same-product MSI upgrade can silently no-op; install into a - # dedicated per-version directory and put it first on PATH. - $nodeDir = "C:\node-v$nodeFallbackVersion" - $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart', "INSTALLDIR=$nodeDir") - $proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru - if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } - $nodeExe = Join-Path $nodeDir 'node.exe' - if (-not (Test-Path $nodeExe)) { throw 'node.exe is still unavailable after MSI provisioning.' } - $nodeVersion = (& $nodeExe --version).Trim() - $nodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') - if ($nodeMajor -ne $requiredNodeMajor) { throw "Node $nodeVersion is installed; required major $requiredNodeMajor." } - $env:Path = "$nodeDir;$env:Path" - } - Write-Host "[ok] Node $nodeVersion" - $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm' - New-Item -ItemType Directory -Force -Path $pnpmShimDir | Out-Null - & corepack enable --install-directory $pnpmShimDir - if ($LASTEXITCODE -ne 0) { throw "corepack enable exited with code $LASTEXITCODE" } - # packageManager in apps/desktop-tauri/package.json is the source of - # truth for the pnpm version (no hardcoded drift). - $packageManager = [string]((Get-Content -Raw -LiteralPath 'apps/desktop-tauri/package.json' | ConvertFrom-Json).packageManager) - if ($packageManager -notmatch '^pnpm@(.+)$') { throw "packageManager '$packageManager' does not match ^pnpm@." } - $expectedPnpm = $Matches[1] - & corepack prepare $packageManager --activate - if ($LASTEXITCODE -ne 0) { throw "corepack prepare $packageManager --activate exited with code $LASTEXITCODE" } - $env:Path = "$pnpmShimDir;$env:Path" - $pnpmVersion = (& pnpm --version).Trim() - if ($pnpmVersion -ne $expectedPnpm) { throw "pnpm $pnpmVersion is active; expected exact $expectedPnpm (packageManager $packageManager)." } - Write-Host "[ok] pnpm $pnpmVersion" - # Delegate the check slice to the local-check script (fmt/clippy/test, - # frontend install/test/build, interaction-guard tests) with the PATH - # prefix set once. - $env:Path = "$env:USERPROFILE\.cargo\bin;$nodeDir;C:\Program Files\nodejs;$pnpmShimDir;$env:Path" - & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\local-check.ps1') -Slice ci - if ($LASTEXITCODE -ne 0) { throw "local-check.ps1 -Slice ci failed with exit code $LASTEXITCODE" } + $pinnedRust = (Get-Content -Raw -LiteralPath 'scripts\circleci-pinned-rust.txt').Trim() + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\run-circleci-pr-check.ps1') ` + -RustVersion $pinnedRust ` + -NodeVersion '24.18.0' ` + -NodeMajor 24 + if ($LASTEXITCODE -ne 0) { throw "run-circleci-pr-check.ps1 failed with exit code $LASTEXITCODE" } - save_cache: name: Save Cargo registry and git cache key: pr-check-cargo-{{ checksum "Cargo.lock" }} @@ -299,6 +181,12 @@ jobs: key: pr-check-pnpm-{{ checksum "apps/desktop-tauri/pnpm-lock.yaml" }} paths: - ~/AppData/Local/pnpm/store + - save_cache: + name: Save Cargo target cache + key: pr-check-target-v1-{{ checksum "scripts/circleci-pinned-rust.txt" }}-{{ checksum "Cargo.lock" }}-{{ checksum "Cargo.toml" }}-{{ checksum "rust/Cargo.toml" }}-{{ checksum "apps/desktop-tauri/src-tauri/Cargo.toml" }} + paths: + - target + workflows: release: diff --git a/.github/CI.md b/.github/CI.md index f349889a16..307c7b70c7 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -33,8 +33,9 @@ pnpm --dir apps/desktop-tauri test pnpm --dir apps/desktop-tauri run build ``` -`concurrency.cancel-in-progress` is on, keyed by ref, so superseded pushes -cancel the in-flight run. +Auto-cancel of superseded pushes is a CircleCI **project setting** ("Auto-cancel +redundant workflows", Project Settings → Advanced), not GitHub `concurrency` +YAML — the CircleCI job has none. ### Interaction guard — `.github/workflows/interaction-guard.yml` @@ -58,9 +59,10 @@ Variables**. Do not hard-code it in a workflow. | thin | runs | tag-triggered | | off | skip | tag-triggered | -The Blacksmith Pool minutes intent remains roughly **60% Win-CodexBar**, -**30% linear-cli**, and **10% buffer**. CircleCI credits are separate and -must be budgeted in CircleCI. +The Blacksmith Pool minutes intent was roughly **60% Win-CodexBar**, +**30% linear-cli**, and **10% buffer**; that allocation is historical for this +repo — Win-CodexBar no longer draws on the pool. CircleCI credits are separate +and must be budgeted in CircleCI. ## CircleCI release pipeline @@ -104,7 +106,8 @@ automated by this repository: permission; no workflow file is changed by the publisher. 4. Protect the `v*` tag namespace with a GitHub ruleset/tag protection policy that permits only authorized release maintainers to create canonical - `vX.Y.Z` tags. Protect `main` and require the normal Blacksmith checks. + `vX.Y.Z` tags. Protect `main` and require the `ci/circleci: pr-check` + status check. 5. Configure CircleCI notifications and a spending/credit alert appropriate to the organization. Do not approve a release until the build artifacts and manifest have been reviewed. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d806c81457..fb8d52487a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -50,23 +50,8 @@ jobs: cache: pnpm cache-dependency-path: apps/desktop-tauri/pnpm-lock.yaml - - name: Install frontend deps - run: pnpm --dir apps/desktop-tauri install --frozen-lockfile - - - name: Rust format check - run: cargo fmt --all --check - - - name: Rust clippy (workspace) - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Rust tests (workspace) - run: cargo test --workspace - - - name: Frontend tests - run: pnpm --dir apps/desktop-tauri test - - - name: Frontend type check / build - run: pnpm --dir apps/desktop-tauri run build - - - name: Interaction guard script tests - run: node --test .github/scripts/interaction-guard.test.mjs + # Canonical check source of truth: the same slice the CircleCI + # pr-check job runs (fmt/clippy/test, frontend install/test/build, + # interaction-guard script tests). No duplicated inline steps here. + - name: Run local-check ci slice + run: powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/local-check.ps1 -Slice ci diff --git a/CONTEXT.md b/CONTEXT.md index 20a4b3b057..bbb3c6fa0f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,12 +80,14 @@ program also documents a monthly allowance for macOS/Windows OSS builds, so budget the thin slice's Windows credits on the CircleCI plan, not the retired Blacksmith intent share. +The required branch/PR status check for `main` is `ci/circleci: pr-check` +(the CircleCI `pr-check` job), not a Blacksmith check. + ## Intent share (60/30/10) -The Blacksmith Pool minutes intent is divided: roughly **60% Win-CodexBar**, -**30% linear-cli**, and **10% buffer**. This is an intent allocation of the -pool's minutes, not a measure of time spent in `normal`/`thin`/`off` modes. -Win-CodexBar's hosted PR check is now a single CircleCI Windows job (the +The Blacksmith Pool minutes intent was divided roughly **60% Win-CodexBar**, +**30% linear-cli**, and **10% buffer**. That allocation is historical for this +repo: Win-CodexBar's hosted PR check is now a single CircleCI Windows job (the Blacksmith runner is retired for this repo), so this repo no longer draws on the pool; release builds stay local. diff --git a/docs/adr/0005-pr-check-moves-to-circleci-windows.md b/docs/adr/0005-pr-check-moves-to-circleci-windows.md index aed4aa3744..c7bc6199ce 100644 --- a/docs/adr/0005-pr-check-moves-to-circleci-windows.md +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -20,22 +20,39 @@ Move the hosted PR/push gate to CircleCI as a new `pr-check` job in `.circleci/config.yml` (workflow `pr-check`): - `win/default` executor, `size: medium`. The check steps are not re-declared - in the config: one fused step provisions the toolchain, then delegates the - whole check to `scripts/local-check.ps1 -Slice ci`, a new opt-in slice that - mirrors `.github/workflows/pr-check.yml` step for step (`cargo fmt --all - --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo - test --workspace`, `pnpm install --frozen-lockfile`, `pnpm test`, `pnpm run + in the config: the provisioning and gate logic is extracted into scripts + that the config calls, and the whole check delegates to + `scripts/local-check.ps1 -Slice ci`, an opt-in slice that mirrors + `.github/workflows/pr-check.yml` step for step (`cargo fmt --all --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test + --workspace`, `pnpm install --frozen-lockfile`, `pnpm test`, `pnpm run build`, plus the interaction-guard script tests). The script's default (no-parameter) local behavior is unchanged. -- Rust stable is installed by downloading `rustup-init.exe` directly from - `https://win.rustup.rs/x86_64` (the hosted image has no winget) and then - installing the stable toolchain with the `rustfmt`/`clippy` components and - the `x86_64-pc-windows-msvc` target; Node 24.18.0 is installed from the - official x64 MSI (`msiexec /qn /norestart` with a dedicated per-version - `INSTALLDIR`, because the image's preinstalled Node is a different major and - a same-product MSI upgrade silently no-ops); pnpm 11.24.0 is activated by - corepack from the exact `packageManager` pin in - `apps/desktop-tauri/package.json`. + - `scripts/circleci-pr-gates.ps1` owns the three skip decisions that used + to live inline in the config (budget `off`, non-PR/non-main branch + pushes, docs-only PR diffs); each true skip calls `circleci-agent step + halt`. + - `scripts/run-circleci-pr-check.ps1` owns toolchain provisioning with + official checksum verification: `rustup-init.exe` is downloaded from + static.rust-lang.org and verified against the official adjacent + `.sha256` file; the Node 24.18.0 x64 MSI is verified against the + official `SHASUMS256.txt` entry before `msiexec` runs (into a dedicated + per-version `INSTALLDIR`, because a same-product MSI upgrade silently + no-ops); pnpm is activated by corepack from the exact `packageManager` + pin in `apps/desktop-tauri/package.json`. + - Pure checksum/gate logic lives in `scripts/circleci-pr-common.ps1` and + is exercised offline by `scripts/circleci-pr.tests.ps1`. + - The pinned Rust version is read from + `scripts/circleci-pinned-rust.txt`; the Cargo target cache keys embed + `{{ checksum "scripts/circleci-pinned-rust.txt" }}` so a Rust pin bump + invalidates the target cache (no pipeline parameter, no config edit). + - The config depends on compile-time GitHub App PR pipeline values: + `pipeline.event.context.github.pr_url` and + `pipeline.event.github.pull_request.base.sha` (populated only on pull + request events; empty on push pipelines). They come from CircleCI's + GitHub App integration, so fork-PR pipelines are never built and a + manual same-repo branch fallback is needed for external contributors' + changes. - Trigger: the workflow filter stays wide (`branches: only: /.*/`) because CircleCI delivers same-repo PR builds as branch pipelines; parity with the GitHub workflow (PRs plus pushes to `main`/`master`, `paths-ignore` for @@ -68,6 +85,10 @@ GitHub Actions workflow and is unaffected. ## Consequences +- Auto-cancel of superseded pushes is a CircleCI **project setting** + ("Auto-cancel redundant workflows", Project Settings → Advanced), keyed by + branch the same way the GitHub workflow's `concurrency` group was; the + `pr-check` job carries no `concurrency` YAML. - Hosted PR feedback continues on the real Windows target with no Blacksmith dependency. - `CI_BUDGET_MODE` must now be set in two places to control both surfaces: diff --git a/docs/release/ci-cd.md b/docs/release/ci-cd.md index 6a4a376845..5e032c9592 100644 --- a/docs/release/ci-cd.md +++ b/docs/release/ci-cd.md @@ -19,15 +19,17 @@ manual-dispatch-only fallback (`on: workflow_dispatch` only; no automatic push/PR scheduling) kept for Blacksmith diagnostics. **Fork-PR coverage note:** CircleCI does not build pull requests from forks -by default (unlike GitHub Actions). External fork PRs are therefore **not -covered** by the CircleCI gate until the project enables fork PR builds in -Project Settings → Advanced. When enabled, CircleCI still withholds project -environment variables from forked-PR builds by default (so `CI_BUDGET_MODE` -arrives unset → `normal` → checks run), which is the same withholding the -`CI_BUDGET_MODE` note in `CONTEXT.md` relies on. The tradeoff: leaving fork -builds disabled keeps the OSS credit spend bounded but means external -contributors get no hosted Windows validation; enabling it restores coverage -at the cost of those credits. +by default (unlike GitHub Actions). The GitHub App integration does not build +fork-PR pipelines, and there is no Advanced setting that can enable them; +external fork PRs are therefore **not covered** by the CircleCI gate. The +manual fallback for forks is needed: an external contributor's changes must +be pulled onto a same-repo branch (or the fork changes committed to a +maintainer branch) so a CircleCI branch pipeline runs the checks. When those +pipelines run, CircleCI withholds project environment variables from +untrusted builds by default (so `CI_BUDGET_MODE` arrives unset → `normal` → +checks run), which is the same withholding the `CI_BUDGET_MODE` note in +`CONTEXT.md` relies on. Tradeoff: bounded OSS credit spend, but external +contributors get no direct hosted Windows validation of their own PRs. The `release` workflow remains filtered to the canonical `nesszer/Win-CodexBar` project and exact protected tags `vX.Y.Z`; branch and @@ -117,8 +119,9 @@ credits recur on PRs and `main`/`master` pushes (other branch pushes and docs-only diffs skip before spending), alongside the protected release tag path. The repository is public, so the PR check spends the Free Plan open-source allowance (open-source builds are not subject to the Free Plan's -30,000-credit personal block). Fork PRs are not covered until fork builds are -enabled (see the fork-PR coverage note above). Blacksmith is retired for +30,000-credit personal block). Fork PRs remain uncovered — the GitHub App does +not build fork-PR pipelines and a manual same-repo branch fallback is needed +(see the fork-PR coverage note above). Blacksmith is retired for this repo and no longer bills recurring PR cost. Windows executor rates depend on the CircleCI plan, so set an organization credit alert before enabling the PR check or releases. diff --git a/scripts/circleci-pinned-rust.txt b/scripts/circleci-pinned-rust.txt new file mode 100644 index 0000000000..783fda8643 --- /dev/null +++ b/scripts/circleci-pinned-rust.txt @@ -0,0 +1 @@ +1.98.0 diff --git a/scripts/circleci-pr-common.ps1 b/scripts/circleci-pr-common.ps1 new file mode 100644 index 0000000000..7464efc392 --- /dev/null +++ b/scripts/circleci-pr-common.ps1 @@ -0,0 +1,132 @@ +#Requires -Version 5.1 +<## +Pure, side-effect-free helpers shared by the CircleCI pr-check scripts. +Dot-source only (from scripts\circleci-pr-gates.ps1, +scripts\run-circleci-pr-check.ps1, and scripts\circleci-pr.tests.ps1); +this file must never execute work on its own. +#> + +Set-StrictMode -Version Latest + +<# +.SYNOPSIS + Docs-only diff test mirroring the GitHub workflow's paths-ignore set + (docs/**, **/*.md, CONTEXT.md, .github/CI.md). CONTEXT.md and + .github/CI.md end in .md, so the docs/ prefix plus .md suffix covers the + exact ignore set. +#> +function Test-DocsOnlyDiff { + param([AllowEmptyCollection()][string[]]$ChangedFiles) + + $codeFiles = @( + $ChangedFiles | Where-Object { $_ -and ($_ -notmatch '^(docs/.*)|(.*\.md)$') } + ) + return ($codeFiles.Count -eq 0) +} + +<# +.SYNOPSIS + Gates 1 and 2 of the hosted pr-check: budget emergency stop and + branch/PR scope. Gate 3 (docs-only) needs a diff and lives in + scripts\circleci-pr-gates.ps1; main/master pushes never reach it. +#> +function Get-TriggerGateDecision { + param( + [AllowEmptyString()][string]$BudgetMode, + [AllowEmptyString()][string]$Branch, + [AllowEmptyString()][string]$PrUrl + ) + + # Gate 1 - budget: CI_BUDGET_MODE=off is the emergency stop + # (unset/empty = normal). + if ($BudgetMode -eq 'off') { + return [pscustomobject]@{ + Skip = $true + Reason = 'CI_BUDGET_MODE is off: hosted pr-check skips (emergency stop).' + } + } + + # Gate 2 - scope: PR pipelines and main/master pushes run the checks; + # every other branch push skips. CircleCI delivers same-repo PR builds + # as branch pipelines, so PR association comes from the compile-time + # GitHub App pipeline value pipeline.event.context.github.pr_url. + $isPr = -not [string]::IsNullOrWhiteSpace($PrUrl) + $isMainPush = $Branch -in @('main', 'master') + if (-not $isPr -and -not $isMainPush) { + return [pscustomobject]@{ + Skip = $true + Reason = "Branch push to '$Branch' (not a PR, not main/master): hosted pr-check skips." + } + } + + return [pscustomobject]@{ + Skip = $false + Reason = "Trigger gate passed (PR: $isPr, branch: '$Branch')." + } +} + +<# +.SYNOPSIS + Extract a SHA-256 digest for FileName from official checksum text. + Accepts either a bare 64-hex digest (rustup-init.exe.sha256 style) or a + SHASUMS256.txt-style list of " " lines (an optional + GNU binary-mode '*' before the filename is tolerated). Throws instead of + guessing on anything empty, malformed, or missing. +#> +function Get-ExpectedSha256 { + param( + [Parameter(Mandatory)][string]$ChecksumText, + [Parameter(Mandatory)][string]$FileName + ) + + $lines = @( + $ChecksumText -split "`r?`n" | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) + if ($lines.Count -eq 0) { throw 'Checksum source is empty.' } + + if ($lines.Count -eq 1 -and $lines[0] -match '^[0-9a-fA-F]{64}$') { + return $lines[0].ToLowerInvariant() + } + + foreach ($line in $lines) { + if ($line -notmatch '^([0-9a-fA-F]{64})\s+\*?(.+)$') { continue } + if ($Matches[2].Trim() -eq $FileName) { + return $Matches[1].ToLowerInvariant() + } + } + throw "No SHA-256 entry for '$FileName' in the checksum source." +} + +<# +.SYNOPSIS + Compare a downloaded file's SHA-256 with the expected digest and throw + before the file is executed on mismatch. +#> +function Assert-FileSha256 { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedSha256 + ) + + if ($ExpectedSha256 -notmatch '^[0-9a-fA-F]{64}$') { + throw "Expected checksum '$ExpectedSha256' is not a 64-hex SHA-256 digest." + } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $stream = [System.IO.File]::OpenRead($Path) + try { + $hashBytes = $sha.ComputeHash($stream) + } finally { + $stream.Dispose() + } + } finally { + $sha.Dispose() + } + $actual = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() + if ($actual -ne $ExpectedSha256.ToLowerInvariant()) { + throw "SHA-256 mismatch for '$Path': expected $ExpectedSha256, got $actual." + } + return $actual +} diff --git a/scripts/circleci-pr-gates.ps1 b/scripts/circleci-pr-gates.ps1 new file mode 100644 index 0000000000..7396d588ab --- /dev/null +++ b/scripts/circleci-pr-gates.ps1 @@ -0,0 +1,133 @@ +#Requires -Version 5.1 +<## +.SYNOPSIS + Hosted pr-check trigger gates for the CircleCI Windows executor. + +.DESCRIPTION + Owns the three skip decisions that used to live inline in + .circleci/config.yml: + + 1. Budget gate - CI_BUDGET_MODE=off is the emergency stop + (unset/empty = normal). + 2. Scope gate - PR pipelines and main/master pushes run the checks; + any other branch push skips. + 3. Docs-only gate - PRs whose diff touches only docs/**, **/*.md, + CONTEXT.md, and .github/CI.md skip. + + Each true skip calls `circleci-agent step halt`. main/master pushes never + reach the docs-only gate, so they can never be skipped by a multi-commit + docs-only diff. Unknown bases fail open (the checks run). + + Pure decision logic lives in scripts\circleci-pr-common.ps1 and is + exercised by scripts\circleci-pr.tests.ps1 without CircleCI. +#> +[CmdletBinding()] +param( + # CI_BUDGET_MODE project variable value; unset/empty = normal. + [AllowEmptyString()][string]$BudgetMode = $env:CI_BUDGET_MODE, + + # CIRCLE_BRANCH. + [AllowEmptyString()][string]$Branch = $env:CIRCLE_BRANCH, + + # Compile-time pipeline value pipeline.event.context.github.pr_url + # (empty on push pipelines). + [AllowEmptyString()][string]$PrUrl = '', + + # Compile-time pipeline value + # pipeline.event.github.pull_request.base.sha (populated only on pull + # request events; empty on push pipelines). + [AllowEmptyString()][string]$PrBaseSha = '', + + # CIRCLE_SHA1. + [AllowEmptyString()][string]$Sha = $env:CIRCLE_SHA1, + + # Print the decision and exit without calling circleci-agent, for + # local proof of the gate logic. + [switch]$PlanOnly, + + # Repository root; defaults to the checkout this script lives in. + [AllowEmptyString()][string]$RepoRoot = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = Split-Path -Parent $PSScriptRoot +} + +. (Join-Path $PSScriptRoot 'circleci-pr-common.ps1') + +function Invoke-GateHalt { + param([Parameter(Mandatory)][string]$Reason) + + Write-Host $Reason + if ($PlanOnly) { return } + & circleci-agent step halt + if ($LASTEXITCODE -ne 0) { + throw "circleci-agent step halt exited with code $LASTEXITCODE" + } +} + +# Gates 1 and 2: budget emergency stop, then PR/main-master scope. +$trigger = Get-TriggerGateDecision -BudgetMode $BudgetMode -Branch $Branch -PrUrl $PrUrl +if ($trigger.Skip) { + Invoke-GateHalt -Reason $trigger.Reason + exit 0 +} +Write-Host "CI_BUDGET_MODE is '$BudgetMode': hosted pr-check passes budget gate." +Write-Host $trigger.Reason + +# Gate 3 - docs-only: mirror paths-ignore (docs/**, **/*.md, CONTEXT.md, +# .github/CI.md). Applies to PR pipelines only; main/master pushes always +# run the checks, so a multi-commit docs-only history can never suppress +# them. When the base cannot be determined the gate fails open. +$isMainPush = $Branch -in @('main', 'master') +$base = $PrBaseSha +if ($isMainPush) { + Write-Host "Push to '$Branch' runs the full checks (docs-only skip never applies to main/master)." +} elseif ([string]::IsNullOrWhiteSpace($base)) { + # PR association without a populated event value (e.g. api trigger): + # resolve the base from the public GitHub pulls API. + try { + $prNumber = '' + if ($PrUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] } + if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' } + if ([string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_USERNAME) -or [string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_REPONAME)) { + throw 'CIRCLE_PROJECT_USERNAME/REPONAME unavailable for pulls API fallback.' + } + $repo = "$($env:CIRCLE_PROJECT_USERNAME)/$($env:CIRCLE_PROJECT_REPONAME)" + $pr = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls/$prNumber" -Headers @{ 'User-Agent' = 'codexbar-pr-check' } + $baseSha = [string]$pr.base.sha + if ([string]::IsNullOrWhiteSpace($baseSha)) { throw 'pulls API returned no base.sha.' } + & git fetch origin $baseSha --depth=1 + if ($LASTEXITCODE -ne 0) { throw "git fetch base.sha exited with code $LASTEXITCODE" } + $base = $baseSha + Write-Host "Docs gate base resolved from GitHub pulls API: $baseSha" + } catch { + Write-Host "Docs gate base resolution failed ($($_.Exception.Message)): running full checks (fail open)." + $base = '' + } +} + +if ($isMainPush -or -not [string]::IsNullOrWhiteSpace($base)) { + if (-not $isMainPush) { + # Two-dot tree diff: the base fetched at depth 1 may lack a merge + # base for a three-dot diff. + $changed = & git -C $RepoRoot diff --name-only "$base..$Sha" + if ($LASTEXITCODE -ne 0) { + Write-Host 'git diff against base failed: cannot evaluate docs-only gate; running full checks (fail open).' + $changed = $null + } + if ($null -ne $changed) { + $changed = @($changed) + if (Test-DocsOnlyDiff -ChangedFiles $changed) { + Invoke-GateHalt -Reason "All $($changed.Count) changed file(s) match paths-ignore (docs/**, **/*.md, CONTEXT.md, .github/CI.md): hosted pr-check skips." + exit 0 + } + Write-Host "$($changed.Count) changed file(s), at least one outside paths-ignore: hosted pr-check passes docs gate." + } + } +} + +exit 0 diff --git a/scripts/circleci-pr.tests.ps1 b/scripts/circleci-pr.tests.ps1 new file mode 100644 index 0000000000..7959350e49 --- /dev/null +++ b/scripts/circleci-pr.tests.ps1 @@ -0,0 +1,92 @@ +#Requires -Version 5.1 +<## +Focused, dependency-free checks for the CircleCI pr-check helpers: checksum +parsing, gate decisions, script parsing, and YAML validation. Runs entirely +without CircleCI or network access. + +Run with: powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\circleci-pr.tests.ps1 +#> + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptRoot +. (Join-Path $scriptRoot 'circleci-pr-common.ps1') + +function Assert-True { + param([Parameter(Mandatory)][bool]$Condition, [Parameter(Mandatory)][string]$Message) + if (-not $Condition) { throw "Assertion failed: $Message" } +} + +function Assert-Equal { + param([Parameter(Mandatory)]$Actual, [Parameter(Mandatory)]$Expected, [Parameter(Mandatory)][string]$Message) + if ($Actual -ne $Expected) { throw "Assertion failed: $Message (actual '$Actual', expected '$Expected')" } +} + +function Assert-Throws { + param([Parameter(Mandatory)][scriptblock]$Block, [Parameter(Mandatory)][string]$Message) + $thrown = $false + try { & $Block } catch { $thrown = $true } + Assert-True $thrown $Message +} + +Write-Host '==> Checksum parsing (Get-ExpectedSha256)' +$goodDigest = 'd8f2a0d5d8ba5d4d5d7c6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c' +$shasums = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4 node-v24.18.0-x64.msi`n$goodDigest rustup-init.exe`n" +Assert-Equal (Get-ExpectedSha256 -ChecksumText $shasums -FileName 'rustup-init.exe') $goodDigest 'SHASUMS256 entry for rustup-init' +Assert-Throws { Get-ExpectedSha256 -ChecksumText $shasums -FileName 'node-v99.0.0-x64.msi' } 'missing SHASUMS256 entry throws' +$binary = "$goodDigest *node-v24.18.0-x64.msi`n" +Assert-Equal (Get-ExpectedSha256 -ChecksumText $binary -FileName 'node-v24.18.0-x64.msi') $goodDigest 'binary-mode SHASUMS256 entry' +$crlf = "$goodDigest node-v24.18.0-x64.msi`r`n" +Assert-Equal (Get-ExpectedSha256 -ChecksumText $crlf -FileName 'node-v24.18.0-x64.msi') $goodDigest 'CRLF SHASUMS256 entry' +Assert-Equal (Get-ExpectedSha256 -ChecksumText "$goodDigest`n" -FileName 'rustup-init.exe') $goodDigest 'bare adjacent .sha256 digest' +Assert-Equal (Get-ExpectedSha256 -ChecksumText $goodDigest -FileName 'ignored') $goodDigest 'bare digest ignores filename' +Assert-Throws { Get-ExpectedSha256 -ChecksumText '' -FileName 'x' } 'empty checksum text throws' +Assert-Throws { Get-ExpectedSha256 -ChecksumText 'not-a-digest' -FileName 'x' } 'malformed checksum text throws' +Assert-Throws { Get-ExpectedSha256 -ChecksumText 'abc123 node.msi' -FileName 'node.msi' } 'short digest entry throws' + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('win-codexbar-circleci-tests-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $testRoot | Out-Null +$fixture = Join-Path $testRoot 'fixture.bin' +[IO.File]::WriteAllText($fixture, 'deterministic fixture') +$shaForTest = [System.Security.Cryptography.SHA256]::Create() +try { + $streamForTest = [System.IO.File]::OpenRead($fixture) + try { + $hashBytesForTest = $shaForTest.ComputeHash($streamForTest) + } finally { + $streamForTest.Dispose() + } +} finally { + $shaForTest.Dispose() +} +$actualSha = ([System.BitConverter]::ToString($hashBytesForTest) -replace '-', '').ToLowerInvariant() +Assert-Equal (Assert-FileSha256 -Path $fixture -ExpectedSha256 $actualSha) $actualSha 'matching digest passes' +Assert-Throws { Assert-FileSha256 -Path $fixture -ExpectedSha256 $goodDigest } 'mismatching digest throws' +Assert-Throws { Assert-FileSha256 -Path $fixture -ExpectedSha256 'tooshort' } 'non-64-hex expected digest throws' + +Write-Host '==> Docs-only diff test (Test-DocsOnlyDiff)' +Assert-True (Test-DocsOnlyDiff -ChangedFiles @('docs/release/ci-cd.md', 'CONTEXT.md', 'notes.md')) 'docs-only diff detected' +Assert-True (-not (Test-DocsOnlyDiff -ChangedFiles @('docs/x.md', 'Cargo.toml'))) 'code file breaks docs-only' +Assert-True (Test-DocsOnlyDiff -ChangedFiles @()) 'empty diff treated as docs-only' + +Write-Host '==> Trigger trigger gate decisions (Get-TriggerGateDecision)' +$decision = Get-TriggerGateDecision -BudgetMode 'off' -Branch 'feature/x' -PrUrl 'https://github.com/nesszer/Win-CodexBar/pull/405' +Assert-True $decision.Skip 'budget off skips' +Assert-True ($decision.Reason -match 'emergency') 'budget off reason' +$decision = Get-TriggerGateDecision -BudgetMode '' -Branch 'main' -PrUrl '' +Assert-True (-not $decision.Skip) 'main push runs (budget normal)' +$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'master' -PrUrl '' +Assert-True (-not $decision.Skip) 'master push runs' +$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'main' -PrUrl 'https://github.com/nesszer/Win-CodexBar/pull/405' +Assert-True (-not $decision.Skip) 'PR on main runs' +$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'codex/topic' -PrUrl '' +Assert-True $decision.Skip 'non-PR topic branch skips' +Assert-True ($decision.Reason -match 'codex/topic') 'topic branch skip reason' +$decision = Get-TriggerGateDecision -BudgetMode 'off' -Branch 'main' -PrUrl '' +Assert-True $decision.Skip 'budget off wins over main push' + +Write-Host 'CircleCI focused tests passed.' \ No newline at end of file diff --git a/scripts/run-circleci-pr-check.ps1 b/scripts/run-circleci-pr-check.ps1 new file mode 100644 index 0000000000..9fd2f09f9a --- /dev/null +++ b/scripts/run-circleci-pr-check.ps1 @@ -0,0 +1,146 @@ +#Requires -Version 5.1 +<## +.SYNOPSIS + Provision the pinned toolchain and run the canonical check slice on the + CircleCI Windows executor. + +.DESCRIPTION + Owns the provisioning that used to live inline in .circleci/config.yml: + + - Rust: pinned by -RustVersion (the config passes the same version the + config's cache key and restore fallback are keyed on). If rustup is + absent it is installed from static.rust-lang.org with the adjacent + official .sha256 file verified (Get-FileHash) before execution. Never + `curl | iex`. + - Node: exact major pinned by -NodeVersion (and -NodeMajor), installed + from the official nodejs.org MSI whose SHA-256 is verified against the + official SHASUMS256.txt entry for that exact MSI before msiexec runs. + The MSI installs into a dedicated per-version directory because the + image's preinstalled Node is a different major and a same-product MSI + upgrade silently no-ops. + - pnpm: the exact packageManager pin from apps/desktop-tauri/package.json + is activated by corepack and asserted. + - Checks: delegates everything else to scripts\local-check.ps1 -Slice ci, + the single source of truth for fmt/clippy/test/frontend/guard steps. + + Pure checksum logic lives in scripts\circleci-pr-common.ps1 and is + exercised by scripts\circleci-pr.tests.ps1 without network access. +#> +[CmdletBinding()] +param( + # Pinned Rust version installed and asserted (single source of truth: + # .circleci/config.yml passes the same value its cache keys use). + [Parameter(Mandatory)][string]$RustVersion, + + # Exact Node version to install if the image does not already provide it. + [Parameter(Mandatory)][string]$NodeVersion, + + # Major of -NodeVersion; asserted against whatever Node is active. + [Parameter(Mandatory)][ValidateRange(1, 99)][int]$NodeMajor, + + # Repository root; defaults to the checkout this script lives in. + [AllowEmptyString()][string]$RepoRoot = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = Split-Path -Parent $PSScriptRoot +} + +. (Join-Path $PSScriptRoot 'circleci-pr-common.ps1') + +Push-Location $RepoRoot +try { + # --- Rust --------------------------------------------------------------- + if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { + Write-Host 'rustup is unavailable on the image; installing via verified rustup-init.exe (winget is not on the hosted image).' + $rustupInit = Join-Path $env:TEMP 'rustup-init.exe' + $rustupUrl = 'https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe' + & curl.exe -sSfL -o $rustupInit $rustupUrl + if ($LASTEXITCODE -ne 0) { throw "downloading rustup-init.exe exited with code $LASTEXITCODE" } + # Official checksum published adjacent to the binary. + $rustupShaFile = Join-Path $env:TEMP 'rustup-init.exe.sha256' + & curl.exe -sSfL -o $rustupShaFile "$rustupUrl.sha256" + if ($LASTEXITCODE -ne 0) { throw "downloading rustup-init.exe.sha256 exited with code $LASTEXITCODE" } + $expectedRustupSha = Get-ExpectedSha256 -ChecksumText (Get-Content -Raw -LiteralPath $rustupShaFile) -FileName 'rustup-init.exe' + Assert-FileSha256 -Path $rustupInit -ExpectedSha256 $expectedRustupSha + Write-Host "[ok] rustup-init.exe SHA-256 verified ($expectedRustupSha)" + & $rustupInit -y --default-toolchain none --profile minimal + if ($LASTEXITCODE -ne 0) { throw "rustup-init exited with code $LASTEXITCODE" } + $env:Path = "$(Join-Path $env:USERPROFILE '.cargo\bin');$env:Path" + } + & rustup toolchain install $RustVersion --profile default -c rustfmt,clippy -t x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install exited with code $LASTEXITCODE" } + & rustup default $RustVersion + if ($LASTEXITCODE -ne 0) { throw "rustup default exited with code $LASTEXITCODE" } + $activeRust = ((& rustc --version) -join ' ').Trim() + if ($activeRust -notmatch [regex]::Escape($RustVersion)) { throw "rustc reports '$activeRust'; expected pinned $RustVersion." } + Write-Host "[ok] rustup default $RustVersion (rustc $activeRust)" + + # --- Node --------------------------------------------------------------- + # The image's own Node may occupy C:\Program Files\nodejs, so probe with + # that prefix first and keep it on PATH for every branch below. + $env:Path = "C:\Program Files\nodejs;$env:Path" + $nodeDir = '' + $nodeVersion = $null + if (Get-Command node -ErrorAction SilentlyContinue) { $nodeVersion = (& node --version).Trim() } + $activeNodeMajor = 0 + if ($nodeVersion -match '^v(\d+)\.') { $activeNodeMajor = [int]$Matches[1] } + if ($activeNodeMajor -ne $NodeMajor) { + $found = if ($nodeVersion) { $nodeVersion } else { 'none' } + Write-Host "Node $NodeMajor.x is required (image has $found); installing Node $NodeVersion via the official MSI (winget is not on the hosted image)." + $msiName = "node-v$NodeVersion-x64.msi" + $nodeMsi = Join-Path $env:TEMP $msiName + & curl.exe -sSfL -o $nodeMsi "https://nodejs.org/dist/v$NodeVersion/$msiName" + if ($LASTEXITCODE -ne 0) { throw "downloading Node MSI exited with code $LASTEXITCODE" } + # Official SHASUMS256.txt entry for this exact MSI. + $shasumsFile = Join-Path $env:TEMP 'SHASUMS256.txt' + & curl.exe -sSfL -o $shasumsFile "https://nodejs.org/dist/v$NodeVersion/SHASUMS256.txt" + if ($LASTEXITCODE -ne 0) { throw "downloading SHASUMS256.txt exited with code $LASTEXITCODE" } + $expectedNodeSha = Get-ExpectedSha256 -ChecksumText (Get-Content -Raw -LiteralPath $shasumsFile) -FileName $msiName + Assert-FileSha256 -Path $nodeMsi -ExpectedSha256 $expectedNodeSha + Write-Host "[ok] $msiName SHA-256 verified ($expectedNodeSha)" + # A same-product MSI upgrade can silently no-op; install into a + # dedicated per-version directory and put it first on PATH. + $nodeDir = "C:\node-v$NodeVersion" + $msiArgs = @('/i', $nodeMsi, '/qn', '/norestart', "INSTALLDIR=$nodeDir") + $proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } + $nodeExe = Join-Path $nodeDir 'node.exe' + if (-not (Test-Path $nodeExe)) { throw 'node.exe is still unavailable after MSI provisioning.' } + $nodeVersion = (& $nodeExe --version).Trim() + $activeNodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') + if ($activeNodeMajor -ne $NodeMajor) { throw "Node $nodeVersion is installed; required major $NodeMajor." } + } + $env:Path = "$(if ($nodeDir) { "$nodeDir;" })$env:Path" + Write-Host "[ok] Node $nodeVersion" + + # --- pnpm --------------------------------------------------------------- + $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm' + New-Item -ItemType Directory -Force -Path $pnpmShimDir | Out-Null + & corepack enable --install-directory $pnpmShimDir + if ($LASTEXITCODE -ne 0) { throw "corepack enable exited with code $LASTEXITCODE" } + # packageManager in apps/desktop-tauri/package.json is the source of + # truth for the pnpm version (no hardcoded drift). + $packageManager = [string]((Get-Content -Raw -LiteralPath (Join-Path $RepoRoot 'apps\desktop-tauri\package.json') | ConvertFrom-Json).packageManager) + if ($packageManager -notmatch '^pnpm@(.+)$') { throw "packageManager '$packageManager' does not match ^pnpm@." } + $expectedPnpm = $Matches[1] + & corepack prepare $packageManager --activate + if ($LASTEXITCODE -ne 0) { throw "corepack prepare $packageManager --activate exited with code $LASTEXITCODE" } + $env:Path = "$pnpmShimDir;$env:Path" + $pnpmVersion = (& pnpm --version).Trim() + if ($pnpmVersion -ne $expectedPnpm) { throw "pnpm $pnpmVersion is active; expected exact $expectedPnpm (packageManager $packageManager)." } + Write-Host "[ok] pnpm $pnpmVersion" + + # --- Checks ------------------------------------------------------------- + # Delegate the check slice to the local-check script (fmt/clippy/test, + # frontend install/test/build, interaction-guard tests) with the PATH + # prefix set once. local-check.ps1 resolves commands through PATH, so + # this in-process call inherits the provisioned toolchain. + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path $RepoRoot 'scripts\local-check.ps1') -Slice ci + if ($LASTEXITCODE -ne 0) { throw "local-check.ps1 -Slice ci failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location +} From c6d2c93d03e1562366816a8ea96cbe441e36cbbf Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:38:32 +0700 Subject: [PATCH 10/12] Correct CircleCI coverage documentation --- CONTEXT.md | 8 ++++---- .../0005-pr-check-moves-to-circleci-windows.md | 7 ++++--- docs/release/ci-cd.md | 17 ++++++++++------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bbb3c6fa0f..78ff7c9fb2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,10 +48,10 @@ Set it in both CI surfaces, not in code: - **CircleCI** (the hosted `pr-check` job): CircleCI Project Settings → Environment Variables → `CI_BUDGET_MODE`. The job's budget guard reads it as - `$env:CI_BUDGET_MODE`; unset/empty is treated as `normal`. CircleCI - withholds project environment variables from forked-PR builds by default, - so forked PRs arrive empty → `normal` → runs (the same effective behavior as - GitHub's `vars.` withholding). + `$env:CI_BUDGET_MODE`; unset/empty is treated as `normal`. CircleCI's + GitHub App integration does not build fork-PR pipelines, so fork-PR + changes need the manual same-repo branch fallback (see "Hosted PR check + (CircleCI)"); `CI_BUDGET_MODE` applies whenever a pipeline actually runs. - **GitHub Actions** (`interaction-guard.yml` unchanged; `pr-check.yml` now a manual-dispatch-only fallback with no automatic push/PR scheduling): Settings → Secrets and variables → Actions → Variables; the workflows read diff --git a/docs/adr/0005-pr-check-moves-to-circleci-windows.md b/docs/adr/0005-pr-check-moves-to-circleci-windows.md index c7bc6199ce..8c8e6468ab 100644 --- a/docs/adr/0005-pr-check-moves-to-circleci-windows.md +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -68,9 +68,10 @@ Move the hosted PR/push gate to CircleCI as a new `pr-check` job in (populated on pull request events); if that is empty while the pipeline is still PR-associated, the base SHA is resolved from the public GitHub pulls API using the documented `CIRCLE_PROJECT_USERNAME`/`CIRCLE_PROJECT_REPONAME` - variables and fetched with `--depth=1`; on `main`/`master` pushes - `HEAD^` is used when available. Any resolution, fetch, or diff failure - fails open: it exits only the gate step and therefore continues the job + variables and fetched with `--depth=1`. Docs-only evaluation applies only + to PR-associated pipelines; every `main`/`master` push runs the full + checks. Any resolution, fetch, or diff failure on a PR pipeline fails + open: it exits only the gate step and therefore continues the job into the checks; the gate never silently skips on an unknown base. - Budget gating stays honest and coarse: the job reads `CI_BUDGET_MODE` as a CircleCI project environment variable (unset/empty = `normal`) and halts diff --git a/docs/release/ci-cd.md b/docs/release/ci-cd.md index 5e032c9592..bdb881f99a 100644 --- a/docs/release/ci-cd.md +++ b/docs/release/ci-cd.md @@ -7,11 +7,13 @@ path and the hosted release path. The `pr-check` workflow (added 2026-08) runs the format, clippy, Rust test, frontend test, and frontend build checks on hosted CircleCI Windows. It mirrors the GitHub workflow's trigger contract: pull requests and pushes to `main`/`master` run the checks (delegated to -`scripts/local-check.ps1 -Slice ci`); other branch pushes and docs-only -diffs (`docs/**`, `**/*.md`, `CONTEXT.md`, `.github/CI.md`) skip via early -gates that log their reason and then call `circleci-agent step halt`, stopping -the job before any cache or toolchain spend (docs-only detection fails open -when the base revision cannot be determined). The release-tag pattern (`vX.Y.Z`) is +`scripts/local-check.ps1 -Slice ci`); every `main`/`master` push runs the +full checks — the docs-only skip never applies to them. Docs-only PRs +(`docs/**`, `**/*.md`, `CONTEXT.md`, `.github/CI.md`) and other branch +pushes skip via early gates that log their reason and then call +`circleci-agent step halt`, stopping the job before any cache or toolchain +spend (docs-only detection fails open when the base revision cannot be +determined). The release-tag pattern (`vX.Y.Z`) is ignored so it never double-runs with the release workflow. The former Blacksmith GitHub Actions gate (`.github/workflows/pr-check.yml`) is retired for this repo — the Blacksmith pool is exhausted — and the workflow is now a @@ -115,8 +117,9 @@ are intentionally manual. ## Cost, retry, and rollback The hosted PR check now runs on CircleCI Windows: its thin-slice Windows -credits recur on PRs and `main`/`master` pushes (other branch pushes and -docs-only diffs skip before spending), alongside the protected release tag +credits recur on PRs and `main`/`master` pushes — every `main`/`master` push +runs the full checks, while other branch pushes and docs-only PRs skip +before spending — alongside the protected release tag path. The repository is public, so the PR check spends the Free Plan open-source allowance (open-source builds are not subject to the Free Plan's 30,000-credit personal block). Fork PRs remain uncovered — the GitHub App does From 32c21dacdb212e715669000121de465f48e7ad9b Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:53:54 +0700 Subject: [PATCH 11/12] Fix empty-string pipeline values dropping CircleCI gate args --- .circleci/config.yml | 15 +++++++++------ scripts/circleci-pr-gates.ps1 | 16 ++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a0fedd3876..36078f924f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -129,6 +129,14 @@ jobs: # Stable relative target dir so the target cache below is portable and # every cargo invocation in local-check.ps1 shares one cache. CARGO_TARGET_DIR: target + # Compile-time pipeline values are exported as environment variables, + # never passed as command-line arguments: an empty-string argument does + # not survive the Windows argv handoff to a child powershell.exe -File + # invocation (MissingArgument). gates.ps1 reads CBX_PR_URL / + # CBX_PR_BASE_SHA, plus CI_BUDGET_MODE / CIRCLE_BRANCH / CIRCLE_SHA1, + # from the environment with [AllowEmptyString] defaults. + CBX_PR_URL: << pipeline.event.context.github.pr_url >> + CBX_PR_BASE_SHA: << pipeline.event.github.pull_request.base.sha >> steps: - checkout - run: @@ -136,12 +144,7 @@ jobs: shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass command: | $ErrorActionPreference = 'Stop' - & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\circleci-pr-gates.ps1') ` - -BudgetMode $env:CI_BUDGET_MODE ` - -Branch $env:CIRCLE_BRANCH ` - -PrUrl '<< pipeline.event.context.github.pr_url >>' ` - -PrBaseSha '<< pipeline.event.github.pull_request.base.sha >>' ` - -Sha $env:CIRCLE_SHA1 + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path (Get-Location).Path 'scripts\circleci-pr-gates.ps1') if ($LASTEXITCODE -ne 0) { throw "circleci-pr-gates.ps1 failed with exit code $LASTEXITCODE" } - restore_cache: name: Restore Cargo registry and git cache diff --git a/scripts/circleci-pr-gates.ps1 b/scripts/circleci-pr-gates.ps1 index 7396d588ab..bd229fe1d2 100644 --- a/scripts/circleci-pr-gates.ps1 +++ b/scripts/circleci-pr-gates.ps1 @@ -29,14 +29,18 @@ param( # CIRCLE_BRANCH. [AllowEmptyString()][string]$Branch = $env:CIRCLE_BRANCH, - # Compile-time pipeline value pipeline.event.context.github.pr_url - # (empty on push pipelines). - [AllowEmptyString()][string]$PrUrl = '', + # Compile-time pipeline value pipeline.event.context.github.pr_url, + # exported as CBX_PR_URL by the job environment (empty on push + # pipelines). Delivered via environment, never as a command-line + # argument: an empty-string argument does not survive the Windows argv + # handoff to a child powershell.exe -File invocation (MissingArgument). + [AllowEmptyString()][string]$PrUrl = $env:CBX_PR_URL, # Compile-time pipeline value - # pipeline.event.github.pull_request.base.sha (populated only on pull - # request events; empty on push pipelines). - [AllowEmptyString()][string]$PrBaseSha = '', + # pipeline.event.github.pull_request.base.sha, exported as CBX_PR_BASE_SHA + # by the job environment (populated only on pull request events; empty on + # push pipelines). + [AllowEmptyString()][string]$PrBaseSha = $env:CBX_PR_BASE_SHA, # CIRCLE_SHA1. [AllowEmptyString()][string]$Sha = $env:CIRCLE_SHA1, From 0b617fedd4635e639c0f9f934ae30f9688eac347 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:09:19 +0700 Subject: [PATCH 12/12] Fix NodeVersion param clobbered by case-insensitive local --- scripts/run-circleci-pr-check.ps1 | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/run-circleci-pr-check.ps1 b/scripts/run-circleci-pr-check.ps1 index 9fd2f09f9a..a3a0827d04 100644 --- a/scripts/run-circleci-pr-check.ps1 +++ b/scripts/run-circleci-pr-check.ps1 @@ -83,13 +83,16 @@ try { # The image's own Node may occupy C:\Program Files\nodejs, so probe with # that prefix first and keep it on PATH for every branch below. $env:Path = "C:\Program Files\nodejs;$env:Path" + # NOTE: these locals are case-insensitively distinct from the $NodeVersion + # parameter; never reuse $nodeVersion here or it clobbers the pinned + # version before the MSI URL is built (dist/vv/... -> 404). $nodeDir = '' - $nodeVersion = $null - if (Get-Command node -ErrorAction SilentlyContinue) { $nodeVersion = (& node --version).Trim() } + $imageNodeVersion = $null + if (Get-Command node -ErrorAction SilentlyContinue) { $imageNodeVersion = (& node --version).Trim() } $activeNodeMajor = 0 - if ($nodeVersion -match '^v(\d+)\.') { $activeNodeMajor = [int]$Matches[1] } + if ($imageNodeVersion -match '^v(\d+)\.') { $activeNodeMajor = [int]$Matches[1] } if ($activeNodeMajor -ne $NodeMajor) { - $found = if ($nodeVersion) { $nodeVersion } else { 'none' } + $found = if ($imageNodeVersion) { $imageNodeVersion } else { 'none' } Write-Host "Node $NodeMajor.x is required (image has $found); installing Node $NodeVersion via the official MSI (winget is not on the hosted image)." $msiName = "node-v$NodeVersion-x64.msi" $nodeMsi = Join-Path $env:TEMP $msiName @@ -110,12 +113,12 @@ try { if ($proc.ExitCode -ne 0) { throw "msiexec install exited with code $($proc.ExitCode)" } $nodeExe = Join-Path $nodeDir 'node.exe' if (-not (Test-Path $nodeExe)) { throw 'node.exe is still unavailable after MSI provisioning.' } - $nodeVersion = (& $nodeExe --version).Trim() - $activeNodeMajor = [int]($nodeVersion -replace '^v(\d+)\..*$', '$1') - if ($activeNodeMajor -ne $NodeMajor) { throw "Node $nodeVersion is installed; required major $NodeMajor." } + $installedNodeVersion = (& $nodeExe --version).Trim() + $activeNodeMajor = [int]($installedNodeVersion -replace '^v(\d+)\..*$', '$1') + if ($activeNodeMajor -ne $NodeMajor) { throw "Node $installedNodeVersion is installed; required major $NodeMajor." } } $env:Path = "$(if ($nodeDir) { "$nodeDir;" })$env:Path" - Write-Host "[ok] Node $nodeVersion" + Write-Host "[ok] Node $installedNodeVersion" # --- pnpm --------------------------------------------------------------- $pnpmShimDir = Join-Path $env:LOCALAPPDATA 'CodexBar\ci-toolchain\pnpm'