diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d00092bb2..36078f924f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,6 @@ version: 2.1 + orbs: win: circleci/windows@5.0 @@ -118,6 +119,78 @@ 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 + # 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: + name: Budget, trigger, and docs-only gates + 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') + if ($LASTEXITCODE -ne 0) { throw "circleci-pr-gates.ps1 failed with exit code $LASTEXITCODE" } + - 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- + - 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 pinned toolchain and run local-check ci slice + shell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass + no_output_timeout: 60m + command: | + $ErrorActionPreference = 'Stop' + $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" }} + 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 + - 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: jobs: @@ -137,3 +210,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/CI.md b/.github/CI.md index 94bbbfb6c7..307c7b70c7 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,11 +33,9 @@ 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. +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` @@ -42,22 +45,24 @@ 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. +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 @@ -101,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. @@ -112,12 +118,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/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 08d2670259..fb8d52487a 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: @@ -60,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/.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..78ff7c9fb2 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,60 @@ 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'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 + 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. + +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 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. +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. ## 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 +107,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..8c8e6468ab --- /dev/null +++ b/docs/adr/0005-pr-check-moves-to-circleci-windows.md @@ -0,0 +1,103 @@ +# ADR 0005: Hosted PR check moves to CircleCI Windows + +Date: 2026-08-30 +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: 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. + - `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 + `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 base commit + against the head with a two-dot tree diff (robust when the base is fetched + 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`. 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 + 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 + +- 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: + 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..bdb881f99a 100644 --- a/docs/release/ci-cd.md +++ b/docs/release/ci-cd.md @@ -2,15 +2,42 @@ ## 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`); 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 +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). 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 +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 +103,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 +116,18 @@ 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 — 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 +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. 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/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..bd229fe1d2 --- /dev/null +++ b/scripts/circleci-pr-gates.ps1 @@ -0,0 +1,137 @@ +#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, + # 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, 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, + + # 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/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 diff --git a/scripts/run-circleci-pr-check.ps1 b/scripts/run-circleci-pr-check.ps1 new file mode 100644 index 0000000000..a3a0827d04 --- /dev/null +++ b/scripts/run-circleci-pr-check.ps1 @@ -0,0 +1,149 @@ +#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" + # 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 = '' + $imageNodeVersion = $null + if (Get-Command node -ErrorAction SilentlyContinue) { $imageNodeVersion = (& node --version).Trim() } + $activeNodeMajor = 0 + if ($imageNodeVersion -match '^v(\d+)\.') { $activeNodeMajor = [int]$Matches[1] } + if ($activeNodeMajor -ne $NodeMajor) { + $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 + & 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.' } + $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 $installedNodeVersion" + + # --- 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 +}