From 175569d743b828b2fb96e8f1d8640fb0da23a86b Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:07:56 -0400 Subject: [PATCH 1/9] ci: add GitHub Actions checks and unsigned installer builds Runs the header, desktop, and Go test suites on pull requests and pushes to main and develop, plus unsigned installers across a six-way matrix. Windows builds natively rather than cross-compiled under wine. Also runs services/build.sh and build.bat natively on each platform. That is a second, independent build path over the same Go code: it parses versions with jq and stamps -ldflags -X main.Version, where the desktop path cross-compiles through a TypeScript script. Either can break alone. Neither workflow holds a secret or requests an OIDC token: both execute code from the pull request, so there must be no credential for that code to reach. Actions are pinned to full commit SHAs. Signed releases are built elsewhere, from a tag. Signed-off-by: Terve --- .github/workflows/build.yml | 114 ++++++++++++++++++++++++ .github/workflows/ci.yml | 172 ++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..cdad506a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Unsigned installer builds on GitHub-hosted runners. +# +# These are verification and pull-request artifacts, never releases: every +# target ends in `electron-builder --publish never`, and this workflow holds no +# signing or publishing credentials. Signed releases are produced separately +# from a tag, and are rebuilt from source rather than signing anything this +# workflow produced. +name: Build + +on: + workflow_dispatch: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + paths: + - '.github/workflows/**' + - 'scripts/**' + - 'services/**' + - 'desktop/package.json' + - 'desktop/package-lock.json' + - 'desktop/src/**' + - 'desktop/scripts/**' + - 'desktop/native/**' + - 'desktop/resources/**' + - 'desktop/tsconfig*.json' + - 'desktop/vite.*.config.ts' + - 'desktop/electron.vite.config.ts' + - 'desktop/electron-builder.config.ts' + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + NODE_VERSION: '25' + GO_VERSION: '1.26.7' + GOTOOLCHAIN: local + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + # One platform failing should not hide the state of the other five. + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-latest + script: build:linux:x64 + - name: linux-arm64 + os: ubuntu-latest + script: build:linux:arm64 + - name: win-x64 + os: windows-latest + script: build:win:x64 + - name: win-arm64 + os: windows-latest + script: build:win:arm64 + - name: mac-arm64 + os: macos-latest + script: build:mac:arm64 + - name: mac-x64 + os: macos-15-intel + script: build:mac:x64 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + # Recorded so a failure can be read against the machine it ran on. + - name: Report runner capacity + shell: bash + run: | + echo "label: ${{ matrix.os }}" + echo "kernel: $(uname -s) $(uname -m)" + echo "cpus: $(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo unknown)" + free -h 2>/dev/null || sysctl -n hw.memsize 2>/dev/null || echo "mem: unknown" + df -h . | tail -1 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: desktop/package-lock.json + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: ${{ env.GO_VERSION }} + + # electron-builder needs fakeroot and rpm to stage Linux packages; + # ubuntu-latest ships neither. + - name: Install Linux packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot rpm + + - run: npm --prefix desktop ci --prefer-offline + + - name: Build ${{ matrix.name }} + run: npm --prefix desktop run ${{ matrix.script }} + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: unsigned-${{ matrix.name }} + path: desktop/release/** + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c6b8b3c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs on every pull request, including from forks. +# +# This workflow holds no secrets and requests no OIDC token. That is +# deliberate: it executes code from the pull request — npm postinstall hooks, +# `go test`, the tsx build scripts — so there must be no credential for that +# code to reach. Keep it that way, and keep it on GitHub-hosted runners, which +# are destroyed after each job. +# +# Signed release builds happen elsewhere, from a tag, and never from a pull +# request. +name: CI + +# Pull requests target `develop`; periodic `develop` -> `main` merges are the +# release cut. Both branches are gated identically. +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # desktop/package.json engines requires >=25.5.0. + NODE_VERSION: '25' + # Pinned >= the max `go` directive across services/*/go.mod. GOTOOLCHAIN=local + # forbids a surprise toolchain auto-download. + GO_VERSION: '1.26.7' + GOTOOLCHAIN: local + +jobs: + # Release-intent validation lives in release-intent-check.yml, not here: it + # also needs the `edited` pull request type, and re-running this whole gate + # on every description edit would rebuild six installers for a typo fix. + + # Dependency-free and therefore the cheapest gate: no npm install, no cache. + # Deliberately unfiltered by path — a missing header can arrive anywhere. + headers: + name: SPDX headers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: ${{ env.NODE_VERSION }} + - run: node scripts/spdx-headers.mjs + + desktop: + name: Desktop checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: desktop/package-lock.json + - run: npm --prefix desktop ci --prefer-offline + + # Same order as the local gate documented in CONTRIBUTING.md, so a + # local run and CI fail on the same thing first. + - run: npm --prefix desktop run verify:build-scripts + - run: npm --prefix desktop run service-contracts:check + - run: npm --prefix desktop run typecheck + - run: npm --prefix desktop run lint + - run: npm --prefix desktop run dead-code:check + - run: npm --prefix desktop run test:unit + + services: + name: Services build and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: desktop/package-lock.json + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: ${{ env.GO_VERSION }} + # Engine-manager orphan-reclaim tests need lsof and ss. + - run: sudo apt-get update -qq && sudo apt-get install -y -qq lsof iproute2 + - run: npm --prefix desktop ci --prefer-offline + + - name: Build modular binaries + run: npm --prefix desktop run build:modular-binaries -- --force + + # Skip shared/ and eap-noob/ (libraries) and tests/ (run separately + # below with a longer timeout — it also matches services/*/go.mod). + - name: Go component tests + run: | + failed=0 + for d in services/*/go.mod; do + dir=$(dirname "$d") + name=$(basename "$dir") + case "$name" in shared|eap-noob|tests) continue ;; esac + echo "==> go test $name" + (cd "$dir" && go test ./... -count=1 -timeout=10m) || failed=1 + done + exit $failed + + - name: Go cross-process tests + run: cd services/tests && go test ./... -count=1 -timeout=20m + + # Support tooling outside services/ has its own module and is not + # matched by the glob above. + - name: Go tooling tests + run: | + failed=0 + for d in scripts/*/go.mod; do + dir=$(dirname "$d") + echo "==> go test $dir" + (cd "$dir" && go test ./... -count=1 -timeout=5m) || failed=1 + done + exit $failed + + # The services tree has two independent build paths over the same Go code. + # The `services` job above drives `scripts/build-modular-binaries.ts`, which + # cross-compiles for the desktop app. This drives `services/build.sh`, which + # builds natively, parses versions.json with jq, stamps each binary with + # `-ldflags -X main.Version`, and stages `build/bin`. They read versions and + # pass flags differently, so one can break while the other stays green. + # + # Runs on each supported platform because the Windows path is a separate + # script (`build.bat`) rather than a branch of the same one. + build-script: + name: Build script (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: ${{ env.GO_VERSION }} + + # Both scripts fail fast with their own message when go or jq is + # missing, so there is no separate tool-check step here. + - name: Build services (bash) + if: runner.os != 'Windows' + working-directory: services + run: ./build.sh + + - name: Build services (Windows) + if: runner.os == 'Windows' + working-directory: services + shell: cmd + run: build.bat + + # Catches a script that exits 0 having staged nothing. The binaries + # are not executed: several are servers that would ignore an + # unrecognized flag and start listening. + - name: Check staged binaries + working-directory: services + shell: bash + run: | + ls -l build/bin + count=$(ls build/bin | wc -l) + echo "staged $count binaries" + test "$count" -ge 13 From f86228795181c1b310585a285f4f2ac9b195be49 Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:07:56 -0400 Subject: [PATCH 2/9] docs: add public changelog seeded from the published releases Records the 0.1.0 and 0.1.1 notes as published on the releases page. This repository had no changelog, so release history was only discoverable there. Seeded from the published notes rather than carrying over historical development entries, and tracks the 0.1.x version that desktop/package.json and the release tags use. Signed-off-by: Terve --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f635b520 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ + + +# Changelog + +Release history for Personal AI Router, newest first. Entries match the +published releases on GitHub. + +Builds from this repository are unsigned and configure no update feed, so +automatic updates are unavailable in them. + +## 0.1.1 + +### Fixed + +- Building from source for an Intel/AMD (`x64`) target failed while compiling + the bundled log sanitizer. The packaging script passed Electron's `x64` + architecture name straight to the Go toolchain, which expects `amd64`. + Arm64 targets were unaffected. + +This release contains no application changes. The fix is to the build tooling +only, and the published 0.1.0 installers were not affected by it, so 0.1.1 is +functionally identical to 0.1.0 for anyone installing it. + +## 0.1.0 + +### Fixed + +- **Windows on Arm installations were missing every executable.** The + installer's compressed payload used a compression filter its extractor could + not decode, so an install reported success with the application tree present + but all `.exe` and `.dll` files silently skipped. Arm64 installs now + complete correctly. +- **Windows on Arm now installs under 64-bit Program Files** instead of the + 32-bit location. +- **Silent installs no longer abort** on the installer's payload check. + +### Changed + +- Raised the Electron floor and updated the Go toolchain and service + dependencies to pick up security fixes. +- Trimmed the README, and added an `AGENTS.md` so an agent working from a fork + has an entry point. From 033b394cd37d81265945d22e6bfff95af7cb984f Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:07:56 -0400 Subject: [PATCH 3/9] refactor: rename versions.json product to services, drop installer The field called `product` only ever versioned the services suite: it is declared inside services/, stamps the standalone installer and Go main.Version, and the version users install lives in desktop/package.json, which was explicitly out of scope for it. The two had drifted to 0.91.7 and 0.1.1, which is the clearest evidence they were never one number. `installer` is dropped: documented as 'always equals product', it could never differ from another field. Readers of '.installer // .product' now read '.services'. Updates all nine consumers, including build.sh which was reading .product and would have silently resolved null. The versions card already displayed this as 'Services', so no user-visible string changes. Signed-off-by: Terve --- desktop/scripts/build-modular-binaries.ts | 12 +++++----- desktop/scripts/verify-service-contracts.ts | 2 +- desktop/src/electron/ipc/service.ipc.ts | 2 +- .../service-bridge/modular-supervisor.ts | 22 +++++++++---------- desktop/src/shared/types/ipc-channels.ts | 2 +- .../ServiceSettings/VersionsCard.tsx | 4 ++-- services/build.bat | 12 +++++----- services/build.sh | 8 +++---- services/installer_build.bat | 7 ++---- services/installer_build.sh | 7 +++--- services/readme.md | 2 +- services/versions.json | 3 +-- 12 files changed, 39 insertions(+), 44 deletions(-) diff --git a/desktop/scripts/build-modular-binaries.ts b/desktop/scripts/build-modular-binaries.ts index bfdc3cb1..ae9a4c45 100644 --- a/desktop/scripts/build-modular-binaries.ts +++ b/desktop/scripts/build-modular-binaries.ts @@ -71,7 +71,7 @@ interface ManifestFile { interface BuildManifest { source: 'services-build' sourceFingerprint: string - product: string + services: string platform: SupportedPlatform arch: ModularPackageArch components: Record @@ -145,7 +145,7 @@ function stringRecord(value: JsonValue | undefined): Record { } interface ParsedVersions { - product: string + services: string components: Record } @@ -173,8 +173,8 @@ function readVersions(repo: string): ParsedVersions { const versionsPath = path.join(repo, 'versions.json') const parsed: JsonValue = JSON.parse(readFileSync(versionsPath, 'utf8')) if (!isJsonObject(parsed)) throw new Error(`${versionsPath} is not a JSON object`) - const product = typeof parsed['product'] === 'string' ? parsed['product'] : '' - return { product, components: stringRecord(parsed['components']) } + const services = typeof parsed['services'] === 'string' ? parsed['services'] : '' + return { services, components: stringRecord(parsed['components']) } } function ensureGoToolchain(): void { @@ -267,7 +267,7 @@ function parseManifest(text: string): BuildManifest | null { return { source: 'services-build', sourceFingerprint, - product: typeof parsed['product'] === 'string' ? parsed['product'] : '', + services: typeof parsed['services'] === 'string' ? parsed['services'] : '', platform, arch, components: stringRecord(parsed['components']), @@ -423,7 +423,7 @@ function main(): void { const manifest: BuildManifest = { source: 'services-build', sourceFingerprint, - product: versions.product, + services: versions.services, platform: options.platform, arch: options.arch, components: versions.components, diff --git a/desktop/scripts/verify-service-contracts.ts b/desktop/scripts/verify-service-contracts.ts index c3cd9c6c..b85ff9a6 100644 --- a/desktop/scripts/verify-service-contracts.ts +++ b/desktop/scripts/verify-service-contracts.ts @@ -329,7 +329,7 @@ function renderApiDoc(reports: BinaryReport[], drift: Drift): string { L.push('> capability status lives in `docs/services-parity.md`.') L.push('') L.push(`- **Source tree**: \`services/\` in this monorepo`) - L.push(`- **Versions**: see \`services/versions.json\` (product, installer, and per-component)`) + L.push(`- **Versions**: see \`services/versions.json\` (services suite and per-component)`) L.push( '- **Legend**: ✅ referenced by the bridge · ❌ MISSING (no consumer/caller) · ➖ ignored (see `docs/service-contract-exceptions.json`)' ) diff --git a/desktop/src/electron/ipc/service.ipc.ts b/desktop/src/electron/ipc/service.ipc.ts index b7b1ebcd..7480e685 100644 --- a/desktop/src/electron/ipc/service.ipc.ts +++ b/desktop/src/electron/ipc/service.ipc.ts @@ -80,7 +80,7 @@ export function registerServiceIpc(): void { : '' return { appVersion: app.getVersion(), - modularProduct: manifest.product, + modularServices: manifest.services, binaries, licenseType } diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index 943d0f84..323827e7 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -87,7 +87,7 @@ function getModularBinaryPath(baseName: string): string { } /** - * Read the build provenance (`sourceFingerprint` + `product`) and per-component + * Read the build provenance (`sourceFingerprint` + `services`) and per-component * versions that `scripts/build-modular-binaries.ts` stamps into * `cli-bin/manifest.json`. * `components` is keyed by binary base name (e.g. `ollama-proxy`). Returns empty @@ -95,15 +95,15 @@ function getModularBinaryPath(baseName: string): string { */ export function readCliBinManifest(): { commit: string - product: string + services: string components: Record } { try { const manifestPath = path.join(getCliBinDir(), 'manifest.json') - if (!fs.existsSync(manifestPath)) return { commit: '', product: '', components: {} } + if (!fs.existsSync(manifestPath)) return { commit: '', services: '', components: {} } const parsed: JsonValue = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) const obj = objectValue(parsed) - if (!obj) return { commit: '', product: '', components: {} } + if (!obj) return { commit: '', services: '', components: {} } const components: Record = {} const componentsObj = objectValue(obj['components']) if (componentsObj) { @@ -114,22 +114,22 @@ export function readCliBinManifest(): { } return { commit: stringValue(obj['sourceFingerprint']) || stringValue(obj['commit']), - product: stringValue(obj['product']), + services: stringValue(obj['services']), components } } catch { - return { commit: '', product: '', components: {} } + return { commit: '', services: '', components: {} } } } /** - * Read the build provenance (`commit` + `product`) that + * Read the build provenance (`commit` + `services`) that * `scripts/build-modular-binaries.ts` stamps into `cli-bin/manifest.json`. Used * for diagnostics only; returns empty strings when the manifest is absent. */ -function readCliBinManifestInfo(): { commit: string; product: string } { - const { commit, product } = readCliBinManifest() - return { commit, product } +function readCliBinManifestInfo(): { commit: string; services: string } { + const { commit, services } = readCliBinManifest() + return { commit, services } } function objectValue(value: JsonValue | undefined): JsonObject | null { @@ -484,7 +484,7 @@ class ModularSupervisor { mode: app.isPackaged ? 'packaged' : 'dev', binDir, modularCommit: manifestInfo.commit, - modularProduct: manifestInfo.product, + modularServices: manifestInfo.services, resourcesPath: process.resourcesPath, appPath: app.getAppPath() } diff --git a/desktop/src/shared/types/ipc-channels.ts b/desktop/src/shared/types/ipc-channels.ts index 289319d3..ff9c03db 100644 --- a/desktop/src/shared/types/ipc-channels.ts +++ b/desktop/src/shared/types/ipc-channels.ts @@ -46,7 +46,7 @@ export interface ServiceStatus { */ export interface ServiceVersions { appVersion: string - modularProduct: string + modularServices: string binaries: { name: string; version: string }[] /** SPDX-ish id parsed at runtime from the shipped LICENSE; '' if unavailable. */ licenseType: string diff --git a/desktop/src/ui/components/ServiceSettings/VersionsCard.tsx b/desktop/src/ui/components/ServiceSettings/VersionsCard.tsx index 63195418..8e571d91 100644 --- a/desktop/src/ui/components/ServiceSettings/VersionsCard.tsx +++ b/desktop/src/ui/components/ServiceSettings/VersionsCard.tsx @@ -79,8 +79,8 @@ export default function VersionsCard() { - {versions?.modularProduct && ( - + {versions?.modularServices && ( + )} diff --git a/services/build.bat b/services/build.bat index 6311e423..f1b375f6 100644 --- a/services/build.bat +++ b/services/build.bat @@ -37,7 +37,7 @@ REM Parse versions.json with jq. We use --arg to pass each component key as REM a string variable, which sidesteps cmd's hostility toward embedded REM double quotes inside the jq filter (component keys contain hyphens, so REM bare .components.nvpair-ui-broker would parse as subtraction). -for /f "delims=" %%V in ('jq -r ".product" "%VERSIONS_FILE%"') do set "V_PRODUCT=%%V" +for /f "delims=" %%V in ('jq -r ".services" "%VERSIONS_FILE%"') do set "V_SERVICES=%%V" for /f "delims=" %%V in ('jq -r --arg k "ollama-proxy" ".components[$k]" "%VERSIONS_FILE%"') do set "V_PROXY=%%V" for /f "delims=" %%V in ('jq -r --arg k "lmstudio-proxy" ".components[$k]" "%VERSIONS_FILE%"') do set "V_LMPROXY=%%V" for /f "delims=" %%V in ('jq -r --arg k "nvpair-node-info" ".components[$k]" "%VERSIONS_FILE%"') do set "V_NINFO=%%V" @@ -52,13 +52,13 @@ for /f "delims=" %%V in ('jq -r --arg k "nvpair-cluster-manager" ".components[$ for /f "delims=" %%V in ('jq -r --arg k "nvpair-job-scheduler" ".components[$k]" "%VERSIONS_FILE%"') do set "V_SCHED=%%V" for /f "delims=" %%V in ('jq -r --arg k "nvpair-tui" ".components[$k]" "%VERSIONS_FILE%"') do set "V_TUI=%%V" -if "%V_PRODUCT%"=="" ( +if "%V_SERVICES%"=="" ( echo ERROR: failed to parse versions.json endlocal exit /b 1 ) -echo product = %V_PRODUCT% +echo services = %V_SERVICES% echo ollama-proxy = %V_PROXY% echo lmstudio-proxy = %V_LMPROXY% echo nvpair-node-info = %V_NINFO% @@ -171,7 +171,7 @@ copy /y "%ROOT%nvpair-tui\nvpair-tui.exe" "%BIN_OUT%\nvpair-tui.exe" >nul || got echo. echo ======================================== -echo Build complete (product v%V_PRODUCT%) +echo Build complete (services v%V_SERVICES%) echo ======================================== echo. echo Proxy: %BIN_OUT%\ollama-proxy.exe @@ -189,9 +189,9 @@ echo Job Scheduler: %BIN_OUT%\nvpair-job-scheduler.exe echo TUI: %BIN_OUT%\nvpair-tui.exe echo. -REM Surface the product version to any caller (e.g. installer_build.bat) so +REM Surface the services version to any caller (e.g. installer_build.bat) so REM they don't have to re-parse versions.json. -endlocal & set "NVPAIR_PRODUCT_VERSION=%V_PRODUCT%" +endlocal & set "NVPAIR_SERVICES_VERSION=%V_SERVICES%" exit /b 0 :fail diff --git a/services/build.sh b/services/build.sh index feadff14..d8d8b583 100755 --- a/services/build.sh +++ b/services/build.sh @@ -54,7 +54,7 @@ echo # Mirror build.bat's --arg trick: component keys contain hyphens, which jq's # bare-identifier syntax would parse as subtraction. Passing the key as a # string variable sidesteps the ambiguity. -V_PRODUCT=$(jq -r '.product' "$VERSIONS_FILE") +V_SERVICES=$(jq -r '.services' "$VERSIONS_FILE") V_PROXY=$( jq -r --arg k 'ollama-proxy' '.components[$k]' "$VERSIONS_FILE") V_LMPROXY=$(jq -r --arg k 'lmstudio-proxy' '.components[$k]' "$VERSIONS_FILE") V_NINFO=$( jq -r --arg k 'nvpair-node-info' '.components[$k]' "$VERSIONS_FILE") @@ -69,12 +69,12 @@ V_CLUMGR=$( jq -r --arg k 'nvpair-cluster-manager' '.components[$k]' "$VERSIONS_ V_SCHED=$( jq -r --arg k 'nvpair-job-scheduler' '.components[$k]' "$VERSIONS_FILE") V_TUI=$( jq -r --arg k 'nvpair-tui' '.components[$k]' "$VERSIONS_FILE") -if [[ -z "$V_PRODUCT" || "$V_PRODUCT" == "null" ]]; then +if [[ -z "$V_SERVICES" || "$V_SERVICES" == "null" ]]; then echo "ERROR: failed to parse versions.json" >&2 exit 1 fi -printf ' product = %s\n' "$V_PRODUCT" +printf ' services = %s\n' "$V_SERVICES" printf ' ollama-proxy = %s\n' "$V_PROXY" printf ' lmstudio-proxy = %s\n' "$V_LMPROXY" printf ' nvpair-node-info = %s\n' "$V_NINFO" @@ -146,7 +146,7 @@ cp "$ROOT/nvpair-tui/nvpair-tui" "$BIN_OUT/nvpair-tui" echo echo "========================================" -echo " Build complete (product v$V_PRODUCT)" +echo " Build complete (services v$V_SERVICES)" echo "========================================" echo printf ' Proxy: %s\n' "$BIN_OUT/ollama-proxy" diff --git a/services/installer_build.bat b/services/installer_build.bat index f5083f64..acb045f5 100644 --- a/services/installer_build.bat +++ b/services/installer_build.bat @@ -66,8 +66,7 @@ if not exist "%ROOT%dist" mkdir "%ROOT%dist" REM Resolve installer version. Precedence: REM 1. Explicit CLI arg: installer_build.bat 1.2.3 -REM 2. versions.json "installer" field -REM 3. versions.json "product" field (fallback) +REM 2. versions.json "services" field REM REM build.bat (called above) already verified jq is on PATH, so we don't REM re-check here. Using a :get_version subroutine keeps the for /f out of @@ -105,8 +104,6 @@ exit /b 0 :get_version if not exist "%VERSIONS_FILE%" exit /b 0 -REM jq's `//` operator returns the right-hand side when the left is null or -REM false, giving us the installer-then-product fallback in one filter. -for /f "delims=" %%V in ('jq -r ".installer // .product" "%VERSIONS_FILE%"') do set "VERSION=%%V" +for /f "delims=" %%V in ('jq -r ".services" "%VERSIONS_FILE%"') do set "VERSION=%%V" if /i "%VERSION%"=="null" set "VERSION=" exit /b 0 diff --git a/services/installer_build.sh b/services/installer_build.sh index cba2c440..cb5ec573 100755 --- a/services/installer_build.sh +++ b/services/installer_build.sh @@ -23,8 +23,7 @@ # # Version precedence (same as installer_build.bat): # 1. Explicit CLI arg: installer_build.sh 1.2.3 -# 2. versions.json "installer" field -# 3. versions.json "product" field (fallback) +# 2. versions.json "services" field # # The tarball uses a nested layout — every entry lives under # NVIDIA-Personal-AI-Router-/ — so `tar xf` produces a single clean @@ -89,9 +88,9 @@ resolve_version() { return fi # `//` returns the right-hand side when the left is null or false, - # giving us the installer-then-product fallback in one filter. + # The services suite version is the installer's version. local v - v=$(jq -r '.installer // .product' "$VERSIONS_FILE") + v=$(jq -r '.services' "$VERSIONS_FILE") if [[ "$v" != "null" && -n "$v" ]]; then echo "$v" fi diff --git a/services/readme.md b/services/readme.md index ee3523d5..e8abe79d 100644 --- a/services/readme.md +++ b/services/readme.md @@ -232,7 +232,7 @@ A skip is not a pass. If you are relying on a test, check it actually ran. ## Versioning -`services/versions.json` is the single source of truth for every component version and the umbrella `product` version. The build scripts read it and stamp each binary via `-ldflags "-X main.Version=..."`. Bump the components your change affects in the same pull request, and describe any user-facing change in the pull-request description so it reaches the release notes. [`VERSIONING.md`](VERSIONING.md) has the bump rules. +`services/versions.json` is the single source of truth for every component version and the `services` suite version. The build scripts read it and stamp each binary via `-ldflags "-X main.Version=..."`. Declare the bumps your change needs in the `pair-release-intent:v1` block in your pull-request description — automation writes `versions.json`, so do not edit it by hand — and describe any user-facing change there so it reaches the changelog. [`VERSIONING.md`](VERSIONING.md) has the bump rules. You can verify a built binary's stamped version at any time: diff --git a/services/versions.json b/services/versions.json index 29d8c230..09550ca0 100644 --- a/services/versions.json +++ b/services/versions.json @@ -1,7 +1,6 @@ { "$comment": "Single source of truth for all version numbers. See VERSIONING.md for bump rules.", - "product": "0.91.7", - "installer": "0.91.7", + "services": "0.91.7", "components": { "ollama-proxy": "0.26.2", "lmstudio-proxy": "0.16.2", From 59cb542f0c08e5dfd7cd012c9247bba8685778a6 Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:07:56 -0400 Subject: [PATCH 4/9] ci: add release-intent automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributors declare version bumps and changelog text in the pull request body; a check validates it, and after the merge lands on develop a bot applies it to desktop/package.json, services/versions.json, and CHANGELOG.md in one commit via the git data API. The contents API is one commit per file, which would land a release in pieces with a window where the changelog names a version package.json does not carry. The release version is not declared. It patch-bumps automatically whenever an intent declares a release, so the only judgement left is services and component severity; a minor or major release is a deliberate manual bump. validate_pr.py reads the body from the webhook payload, so it needs no credential and runs on fork pull requests. It has its own workflow so it can also trigger on 'edited': a body can change after checks go green, and apply reads the live body at merge time. Apply rejects unknown keys for the same reason, but tolerates missing ones, which is a concurrent pull request adding a component rather than tampering. The app token gets contents: write and not the Workflows permission, so a compromise of the apply job cannot rewrite the pipeline. Ports from the GitLab implementation, dropping its description-truncation fallback, the protected-variable split that fallback needed, and its url.insteadOf workaround — all GitLab Runner specifics. Also corrects the pull request template, which told contributors to hand-edit services/versions.json; the bot-owned path check rejects exactly that, so every pull request would have failed. Signed-off-by: Terve --- .github/PULL_REQUEST_TEMPLATE.md | 35 +- .github/workflows/release-intent-apply.yml | 53 +++ .github/workflows/release-intent-check.yml | 49 +++ scripts/release-intent/README.md | 116 ++++++ scripts/release-intent/apply_pr.py | 422 +++++++++++++++++++++ scripts/release-intent/lib.py | 409 ++++++++++++++++++++ scripts/release-intent/test_lib.py | 335 ++++++++++++++++ scripts/release-intent/validate_pr.py | 138 +++++++ services/VERSIONING.md | 56 ++- 9 files changed, 1592 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release-intent-apply.yml create mode 100644 .github/workflows/release-intent-check.yml create mode 100644 scripts/release-intent/README.md create mode 100644 scripts/release-intent/apply_pr.py create mode 100644 scripts/release-intent/lib.py create mode 100644 scripts/release-intent/test_lib.py create mode 100644 scripts/release-intent/validate_pr.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 38a09394..5530d832 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,39 @@ +## Release intent + + + + + + + + +### Changelog title +n/a + +### Changelog body +n/a + +### Bumps +- services: none +- lmstudio-proxy: none +- nvpair-cluster-manager: none +- nvpair-engine-manager: none +- nvpair-errors: none +- nvpair-job-scheduler: none +- nvpair-manual-nodes: none +- nvpair-node-info: none +- nvpair-node-scanner: none +- nvpair-node-settings: none +- nvpair-tui: none +- nvpair-ui-broker: none +- nvpair-workload-manager: none +- ollama-proxy: none + + ## Scope @@ -23,4 +56,4 @@ - [ ] Relevant documentation is updated. - [ ] I checked the diff, changed filenames, and commit messages for credentials, private data, internal URLs, internal issue identifiers, and generated artifacts. - [ ] I recorded the validation commands and results above. -- [ ] I bumped any affected component in `services/versions.json`, and described user-visible changes above so they reach the release notes. +- [ ] I declared version bumps in the release-intent block above. `services/versions.json` is written by automation — do not edit it by hand. diff --git a/.github/workflows/release-intent-apply.yml b/.github/workflows/release-intent-apply.yml new file mode 100644 index 00000000..58165cfa --- /dev/null +++ b/.github/workflows/release-intent-apply.yml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Applies a merged pull request's release-intent block to the version files. +# +# Kept in its own file precisely because it holds a credential. ci.yml and +# release-intent-check.yml run on pull requests from forks and must stay free +# of secrets; this one is only ever reachable from a push to an integration +# branch, which requires write access. Do not add a pull_request or +# pull_request_target trigger here. +# +# Runs on `develop`, not `main`: one merge produces one apply, and the +# Applies-PR trailer assumes exactly that. A release cut merges an accumulated +# range into `main`, which this would see as a single push covering many pull +# requests — and the script cannot aggregate them. The cut carries the already +# applied bumps forward instead. +name: Release intent + +on: + push: + branches: [develop] + +permissions: + contents: read + +# Serializes applies so two merges cannot race to move the ref. The script also +# retries a non-fast-forward, so this is belt and braces. +concurrency: + group: release-intent-${{ github.ref }} + cancel-in-progress: false + +jobs: + apply: + name: Apply + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + # An installation token, not a PAT: it expires in an hour and is + # scoped to this repository. Granted `contents: write` and + # deliberately NOT the Workflows permission, so a compromise of this + # job cannot rewrite .github/workflows. + - name: Mint an app token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_INTENT_APP_ID }} + private-key: ${{ secrets.RELEASE_INTENT_APP_PRIVATE_KEY }} + + - name: Apply release intent + env: + RELEASE_INTENT_TOKEN: ${{ steps.app-token.outputs.token }} + run: python3 scripts/release-intent/apply_pr.py diff --git a/.github/workflows/release-intent-check.yml b/.github/workflows/release-intent-check.yml new file mode 100644 index 00000000..7fe886e7 --- /dev/null +++ b/.github/workflows/release-intent-check.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gates a pull request on a valid pair-release-intent:v1 block. +# +# Separate from ci.yml for one reason: `edited`. A pull request body can change +# after checks go green, and the apply job reads the live body from the API at +# merge time — so validating only on opened/synchronize/reopened (the defaults) +# leaves a window where the declared bumps differ from the ones CI enforced. +# Adding `edited` to ci.yml would re-run the whole gate, six installer builds +# included, on every typo fix in a description. This check is cheap, so it gets +# its own trigger. +# +# Needs no secret: the body arrives in the webhook payload, so this runs on +# pull requests from forks like every other check. +name: Release intent check + +on: + pull_request: + branches: [main, develop] + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +concurrency: + group: release-intent-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + # The bot-owned path check diffs against the merge base, which + # a shallow clone does not contain. + fetch-depth: 0 + - name: Validate release intent + env: + # Author-controlled, so it is passed through `env:` and never + # interpolated into the command. + PR_BODY: ${{ github.event.pull_request.body }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + python3 scripts/release-intent/test_lib.py + python3 scripts/release-intent/validate_pr.py diff --git a/scripts/release-intent/README.md b/scripts/release-intent/README.md new file mode 100644 index 00000000..653dbf61 --- /dev/null +++ b/scripts/release-intent/README.md @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Release-intent automation + +Contributors declare version bumps and changelog text in the +`pair-release-intent:v1` block in a pull request description. CI validates the +block on the pull request, and after the merge lands on `develop` a bot applies +it to the version files. + +SemVer meaning for each number lives in [`services/VERSIONING.md`](../../services/VERSIONING.md). + +## Scripts + +| Script | When | Needs a credential | +| ------ | ---- | ------------------ | +| `validate_pr.py` | Pull requests (`release-intent-check.yml`) | No | +| `apply_pr.py` | Push to `develop` (`release-intent-apply.yml`) | Yes | +| `lib.py` | Shared parse / bump / render | — | +| `test_lib.py` | Offline unit checks, also run in CI | — | + +`validate_pr.py` needs no credential on purpose: the body arrives in the webhook +payload, so there is nothing to fetch. That is what lets it run on pull requests +from forks like every other check. + +The check has its own workflow rather than living in `ci.yml` so it can also +trigger on the `edited` pull request type. A body can change after checks go +green, and apply reads the live body at merge time; validating only on the +default types would leave a window where the applied bumps are not the ones CI +enforced. Putting `edited` on `ci.yml` would rebuild six installers for a typo +fix in a description. + +## Key policy + +How hard each entry point is about the block's key set not matching +`versions.json`: + +| Caller | Policy | Unknown key | Missing key | +| ------ | ------ | ----------- | ----------- | +| `validate_pr.py` | `strict` | fails | fails | +| `apply_pr.py` | `reject-unknown` | fails | warns, treated as `none` | + +Apply rejects an unknown key because that means the block was edited into a +state a pull request check would have refused. It tolerates a missing key +because that is the legitimate case of a concurrent pull request adding a +component after this one was written, and reading it as `none` is what the +author meant. Full strictness there would fail the apply job on `develop` for +an ordering accident nobody did wrong. + +## What apply writes + +Three files, in **one** commit built through the git data API: + +- `desktop/package.json` — the release version, one PATCH forward +- `services/versions.json` — the declared `services` and component bumps +- `CHANGELOG.md` — a new section titled with the new release version, citing + the pull request number + +The contents API would be one commit per file, which means a release landing in +pieces and a window where the changelog names a version `package.json` does not +yet carry. Moving the ref once avoids that, and a rejected fast-forward is the +concurrency check — the script retries that and only that, up to three times. + +## Idempotency + +Each bot commit carries an `Applies-PR: #N` trailer, and every attempt scans the +branch's recent commits for it first, so a re-run after a successful apply is a +no-op. A commit whose message already starts with `[release-intent]` is skipped +outright, so the bot never reacts to itself. + +## Credential + +`apply_pr.py` authenticates with a GitHub App installation token, minted per run +and expiring in an hour. + +- **Grant `contents: write` and nothing else.** In particular do **not** grant + the Workflows permission: without it the token cannot modify + `.github/workflows`, so a compromise of this job cannot rewrite the pipeline. +- Add the app as a bypass actor on `develop`, the only branch it writes. +- Configure `RELEASE_INTENT_APP_ID` as a repository variable and + `RELEASE_INTENT_APP_PRIVATE_KEY` as a secret. + +If the credential is missing, `apply_pr.py` exits 2 and says so rather than +silently skipping the bump. Backfill by re-running the job once it is configured, +or apply from a saved body locally. + +## Bot-owned files + +`services/versions.json` and `CHANGELOG.md` are written only by the bot; +`validate_pr.py` rejects a pull request that edits them. Override with +`` in the description when adding +or removing a component key, which the bot cannot invent. + +`desktop/package.json` is deliberately **not** on that list even though the bot +writes its `version`: dependency work touches the file constantly, and the check +is path-granular, so listing it would block ordinary pull requests. + +## Local checks + +```bash +python3 scripts/release-intent/test_lib.py + +python3 scripts/release-intent/validate_pr.py \ + --description-file /path/to/body.md \ + --skip-owned-files-check + +python3 scripts/release-intent/apply_pr.py \ + --description-file /path/to/body.md \ + --dry-run +``` + +`--dry-run` **writes the working tree** despite the name. Restore afterwards: + +```bash +git restore desktop/package.json services/versions.json CHANGELOG.md +``` diff --git a/scripts/release-intent/apply_pr.py b/scripts/release-intent/apply_pr.py new file mode 100644 index 00000000..759ac270 --- /dev/null +++ b/scripts/release-intent/apply_pr.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Apply a merged pull request's release-intent block to the version files. + +Writes three files in ONE commit via the git data API: desktop/package.json +(the release version, patch-bumped), services/versions.json (declared services +and component bumps), and CHANGELOG.md (a new section). + +The contents API would be one commit per file, which for three files means a +release landing in pieces and a window where the changelog names a version that +package.json does not yet carry. Building a tree and moving the ref once avoids +that, and the ref update doubles as the concurrency check: a non-fast-forward +means the branch moved underneath us, which is the only condition worth +retrying. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib import ( # noqa: E402 + BOT_COMMIT_PREFIX, + CHANGELOG_PATH, + PACKAGE_JSON_PATH, + REPO_ROOT, + VERSIONS_PATH, + apply_bumps, + applies_pr_trailer, + bump_semver, + format_changelog_entry, + load_versions, + parse_release_intent, + parse_versions, + prepend_changelog, + read_release_version, + render_package_json, + render_versions_json, +) + +VERSIONS_REPO_PATH = str(VERSIONS_PATH.relative_to(REPO_ROOT)) +CHANGELOG_REPO_PATH = str(CHANGELOG_PATH.relative_to(REPO_ROOT)) +PACKAGE_REPO_PATH = str(PACKAGE_JSON_PATH.relative_to(REPO_ROOT)) + +BLOB_MODE = '100644' + + +@dataclass(frozen=True) +class ReleaseUpdate: + """The three file bodies a release intent produces, and what to call it.""" + + package: str + versions: str + changelog: str + release: str + + def as_paths(self) -> dict[str, str]: + return { + PACKAGE_REPO_PATH: self.package, + VERSIONS_REPO_PATH: self.versions, + CHANGELOG_REPO_PATH: self.changelog, + } + + +def _api_base() -> str: + return os.environ.get('GITHUB_API_URL', 'https://api.github.com').rstrip('/') + + +def _headers(token: str) -> dict[str, str]: + return { + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + + +def _request( + method: str, url: str, token: str, payload: dict[str, object] | None = None +) -> tuple[int, object]: + data = json.dumps(payload).encode('utf-8') if payload is not None else None + headers = _headers(token) + if data is not None: + headers['Content-Type'] = 'application/json' + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=60) as response: + body = response.read().decode('utf-8') + return response.status, (json.loads(body) if body else {}) + except urllib.error.HTTPError as exc: + raw = exc.read().decode('utf-8', errors='replace') + try: + return exc.code, json.loads(raw) + except json.JSONDecodeError: + return exc.code, raw + + +def _get(url: str, token: str) -> object: + status, body = _request('GET', url, token) + if status != 200: + raise SystemExit(f'GitHub API HTTP {status} for {url}: {body}') + return body + + +def _post(url: str, token: str, payload: dict[str, object]) -> tuple[int, object]: + return _request('POST', url, token, payload) + + +def current_commit_message() -> str: + return subprocess.run( + ['git', 'log', '-1', '--pretty=%B'], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout + + +def fetch_merged_pr(repo: str, sha: str, token: str) -> tuple[str, str] | None: + """The pull request that brought `sha` to the branch, as (number, body). + + GitHub's commits/{sha}/pulls is the direct analogue of GitLab's + commits/{sha}/merge_requests. + """ + url = f'{_api_base()}/repos/{repo}/commits/{urllib.parse.quote(sha)}/pulls' + payload = _get(url, token) + if not isinstance(payload, list) or not payload: + return None + chosen: dict[str, object] | None = None + for item in payload: + if isinstance(item, dict) and item.get('merged_at'): + chosen = item + break + if chosen is None and isinstance(payload[0], dict): + chosen = payload[0] + if chosen is None: + return None + number = chosen.get('number') + body = chosen.get('body') + if not isinstance(number, int): + return None + # A pull request with an empty body has body: null. + return (str(number), body if isinstance(body, str) else '') + + +def compute_release_update( + package_text: str, + versions_text: str, + changelog_text: str, + description: str, + pr_ref: str, +) -> ReleaseUpdate | None: + """Apply the intent to three file bodies. None when nothing changes.""" + versions, raw = parse_versions(versions_text, VERSIONS_REPO_PATH) + intent = parse_release_intent( + description, versions.bump_keys, key_policy='reject-unknown' + ) + if not intent.has_release: + return None + + updated = apply_bumps(versions, intent) + release_before = read_release_version(package_text, PACKAGE_REPO_PATH) + # The release version is never declared: any release is one patch forward. + release_after = bump_semver(release_before, 'patch') + + print(f'Applying release {release_before} → {release_after}') + if updated.services != versions.services: + print(f' services: {versions.services} → {updated.services}') + for name in sorted(versions.components): + before, after = versions.components[name], updated.components[name] + if before != after: + print(f' {name}: {before} → {after}') + + entry = format_changelog_entry( + release_after, intent.changelog_title, intent.changelog_body, pr_ref + ) + return ReleaseUpdate( + package=render_package_json(package_text, release_after), + versions=render_versions_json(updated, raw), + changelog=prepend_changelog(changelog_text, entry), + release=release_after, + ) + + +def read_file_at(repo: str, token: str, path: str, ref: str) -> str: + url = ( + f'{_api_base()}/repos/{repo}/contents/{urllib.parse.quote(path)}' + f'?ref={urllib.parse.quote(ref)}' + ) + payload = _get(url, token) + if not isinstance(payload, dict): + raise SystemExit(f'Unexpected payload reading {path} at {ref}') + content = payload.get('content') + if not isinstance(content, str): + raise SystemExit(f'Incomplete payload reading {path} at {ref}') + return base64.b64decode(content).decode('utf-8') + + +def branch_has_trailer(repo: str, token: str, branch: str, trailer: str) -> bool: + url = ( + f'{_api_base()}/repos/{repo}/commits' + f'?sha={urllib.parse.quote(branch)}&per_page=50' + ) + payload = _get(url, token) + if not isinstance(payload, list): + return False + for item in payload: + if not isinstance(item, dict): + continue + commit = item.get('commit') + if isinstance(commit, dict) and trailer in str(commit.get('message', '')): + return True + return False + + +def commit_tree( + repo: str, token: str, branch: str, head: str, message: str, files: dict[str, str] +) -> tuple[bool, object]: + """Commit every file at once and move the branch. Returns (stale, detail).""" + head_commit = _get(f'{_api_base()}/repos/{repo}/git/commits/{head}', token) + if not isinstance(head_commit, dict): + raise SystemExit(f'Unexpected payload for commit {head}') + tree_info = head_commit.get('tree') + if not isinstance(tree_info, dict) or not isinstance(tree_info.get('sha'), str): + raise SystemExit(f'Commit {head} has no tree sha') + + entries: list[dict[str, object]] = [] + for path, content in files.items(): + status, blob = _post( + f'{_api_base()}/repos/{repo}/git/blobs', + token, + {'content': content, 'encoding': 'utf-8'}, + ) + if status != 201 or not isinstance(blob, dict): + raise SystemExit(f'Could not create a blob for {path}: HTTP {status} {blob}') + entries.append( + {'path': path, 'mode': BLOB_MODE, 'type': 'blob', 'sha': blob.get('sha')} + ) + + status, tree = _post( + f'{_api_base()}/repos/{repo}/git/trees', + token, + {'base_tree': tree_info['sha'], 'tree': entries}, + ) + if status != 201 or not isinstance(tree, dict): + raise SystemExit(f'Could not create a tree: HTTP {status} {tree}') + + status, commit = _post( + f'{_api_base()}/repos/{repo}/git/commits', + token, + {'message': message, 'tree': tree.get('sha'), 'parents': [head]}, + ) + if status != 201 or not isinstance(commit, dict): + raise SystemExit(f'Could not create a commit: HTTP {status} {commit}') + + status, detail = _request( + 'PATCH', + f'{_api_base()}/repos/{repo}/git/refs/heads/{urllib.parse.quote(branch)}', + token, + {'sha': commit.get('sha'), 'force': False}, + ) + if status == 200: + print(f'Committed {BOT_COMMIT_PREFIX} to {branch}') + return False, detail + # A rejected fast-forward means the branch moved while we were building. + if status in (409, 422): + return True, detail + raise SystemExit( + f'GitHub refused the release-intent commit on {branch} with HTTP {status}: ' + f'{detail}\nCheck that the app is a bypass actor for {branch} and holds ' + 'contents: write.' + ) + + +def apply_release( + message: str, description: str, trailer: str, pr_ref: str, dry_run: bool +) -> None: + if dry_run: + update = compute_release_update( + PACKAGE_JSON_PATH.read_text(encoding='utf-8'), + VERSIONS_PATH.read_text(encoding='utf-8'), + CHANGELOG_PATH.read_text(encoding='utf-8'), + description, + pr_ref, + ) + if update is None: + print('Dry-run: nothing to apply') + return + PACKAGE_JSON_PATH.write_text(update.package, encoding='utf-8') + VERSIONS_PATH.write_text(update.versions, encoding='utf-8') + CHANGELOG_PATH.write_text(update.changelog, encoding='utf-8') + print( + 'Dry-run MODIFIED the working tree (package.json, versions.json, ' + 'CHANGELOG.md). Restore with: git restore ' + f'{PACKAGE_REPO_PATH} {VERSIONS_REPO_PATH} {CHANGELOG_REPO_PATH}' + ) + print(message) + return + + token = os.environ.get('RELEASE_INTENT_TOKEN') + repo = os.environ.get('GITHUB_REPOSITORY') + branch = os.environ.get('GITHUB_REF_NAME') + if not token: + raise SystemExit('RELEASE_INTENT_TOKEN is required to apply release-intent') + if not repo or not branch: + raise SystemExit('GITHUB_REPOSITORY and GITHUB_REF_NAME are required to apply') + + for attempt in range(1, 4): + if trailer and branch_has_trailer(repo, token, branch, trailer): + print(f'{trailer} is already on {branch}; nothing to do') + return + + ref = _get( + f'{_api_base()}/repos/{repo}/git/ref/heads/{urllib.parse.quote(branch)}', + token, + ) + if not isinstance(ref, dict) or not isinstance(ref.get('object'), dict): + raise SystemExit(f'Could not read the {branch} ref') + head = ref['object'].get('sha') + if not isinstance(head, str): + raise SystemExit(f'Could not read the {branch} head sha') + + update = compute_release_update( + read_file_at(repo, token, PACKAGE_REPO_PATH, head), + read_file_at(repo, token, VERSIONS_REPO_PATH, head), + read_file_at(repo, token, CHANGELOG_REPO_PATH, head), + description, + pr_ref, + ) + if update is None: + print(f'Already applied on current {branch}; nothing to do') + return + + stale, detail = commit_tree( + repo, token, branch, head, message, update.as_paths() + ) + if not stale: + return + print(f'{branch} advanced during attempt {attempt}; retrying\n{detail}') + + raise SystemExit('Could not apply release intent after 3 attempts') + + +def main() -> int: + # Without this, buffered progress output reaches the job log after the + # failure written to stderr, which reads as though it happened first. + sys.stdout.reconfigure(line_buffering=True) + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--description-file', type=Path) + parser.add_argument('--dry-run', action='store_true') + parser.add_argument( + '--sha', + default=os.environ.get('GITHUB_SHA', ''), + help='Commit SHA whose merged pull request body should be applied', + ) + args = parser.parse_args() + + message = current_commit_message() + if message.lstrip().startswith(BOT_COMMIT_PREFIX): + print(f'Skipping: commit already starts with {BOT_COMMIT_PREFIX}') + return 0 + + if args.description_file is not None: + description = args.description_file.read_text(encoding='utf-8') + pr_ref = '' + else: + token = os.environ.get('RELEASE_INTENT_TOKEN') + repo = os.environ.get('GITHUB_REPOSITORY') + if not token or not repo: + print( + 'RELEASE_INTENT_TOKEN is not configured, so this release intent ' + 'was NOT applied. Record the merged pull request body for ' + 'backfill (see scripts/release-intent/README.md).', + file=sys.stderr, + ) + return 2 + if not args.sha: + raise SystemExit('--sha or GITHUB_SHA is required') + found = fetch_merged_pr(repo, args.sha, token) + if found is None: + print('No pull request associated with this commit; nothing to apply') + return 0 + pr_ref, description = found + + try: + versions, _raw = load_versions() + intent = parse_release_intent( + description, versions.bump_keys, key_policy='reject-unknown' + ) + except ValueError as exc: + print('release-intent parse failed on merged pull request:', file=sys.stderr) + print(str(exc), file=sys.stderr) + return 1 + + if not intent.has_release: + label = f'PR #{pr_ref}' if pr_ref else 'this body' + print(f'{label}: all bumps none — no version files to update') + return 0 + + trailer = applies_pr_trailer(pr_ref) if pr_ref else '' + suffix = f' after PR #{pr_ref}' if pr_ref else '' + commit_message = f'{BOT_COMMIT_PREFIX} bump release{suffix}\n\n' + if trailer: + commit_message += f'Apply release intent from PR #{pr_ref}.\n\n{trailer}\n' + apply_release(commit_message, description, trailer, pr_ref, dry_run=args.dry_run) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/release-intent/lib.py b/scripts/release-intent/lib.py new file mode 100644 index 00000000..9eaf5d7b --- /dev/null +++ b/scripts/release-intent/lib.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Parse and validate PAIR pull request release-intent blocks (v1). + +Three numbers, each with one job: + +- the **release** version in `desktop/package.json`, which is what users + install. It is never declared in an intent block: it patch-bumps + automatically whenever an intent declares a release, and a minor or major + release is a deliberate manual bump at cut time. +- **services** in `services/versions.json`, the services suite version that + stamps the standalone installer and Go `main.Version`. +- **components.\\*** in `services/versions.json`, per-binary SemVer. + +Only the last two are declared, because only they carry SemVer meaning that a +human has to judge. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +BumpKind = Literal['none', 'patch', 'minor', 'major'] +BUMP_KINDS: tuple[BumpKind, ...] = ('none', 'patch', 'minor', 'major') +BUMP_RANK: dict[BumpKind, int] = {'none': 0, 'patch': 1, 'minor': 2, 'major': 3} + +# How hard to be about the block's key set not matching versions.json. +# +# - 'strict' — any mismatch fails. Used on pull requests, where a typo +# should be corrected rather than silently absorbed. +# - 'reject-unknown' — an unknown key fails; a missing key warns and is treated +# as none. Used when applying. An unknown key means the +# block was edited to something a pull request check would +# have rejected, which is worth failing on. A *missing* key +# is the legitimate case of a concurrent pull request having +# added a component since this one was written, and reading +# it as none is what its author meant. +# - 'lenient' — both warn. Local inspection only. +KeyPolicy = Literal['strict', 'reject-unknown', 'lenient'] + +INTENT_VERSION = 'v1' +# Canonical forms (template / docs). Parsers also accept HTML comments with +# optional internal whitespace — GitLab often collapses `` to +# `` when the description is saved or re-rendered. +INTENT_START = f'' +INTENT_END = f'' +ALLOW_OWNED_FILES_MARKER = '' +_INTENT_START_RE = re.compile( + rf'' +) +_INTENT_END_RE = re.compile( + rf'' +) +_ALLOW_OWNED_FILES_RE = re.compile( + r'' +) + +BOT_COMMIT_PREFIX = '[release-intent]' +APPLIES_PR_TRAILER_PREFIX = 'Applies-PR: #' + +# Path-granular, and check_forbidden_paths rejects any edit to a listed file. +# desktop/package.json is deliberately absent even though the bot writes its +# `version`: dependency work touches that file constantly, so listing it would +# block ordinary pull requests. +BOT_OWNED_MODIFY_PATHS = ( + 'services/versions.json', + 'CHANGELOG.md', +) +SERVICES_CHANGELOG_PATH = 'services/changelog.md' + +REPO_ROOT = Path(__file__).resolve().parents[2] +VERSIONS_PATH = REPO_ROOT / 'services' / 'versions.json' +CHANGELOG_PATH = REPO_ROOT / 'CHANGELOG.md' +PACKAGE_JSON_PATH = REPO_ROOT / 'desktop' / 'package.json' + +VERSIONS_COMMENT = ( + 'Single source of truth for all version numbers. See VERSIONING.md for bump rules.' +) + + +@dataclass(frozen=True) +class ReleaseIntent: + changelog_title: str + changelog_body: str + bumps: dict[str, BumpKind] + + @property + def has_release(self) -> bool: + return any(kind != 'none' for kind in self.bumps.values()) + + +@dataclass(frozen=True) +class VersionsManifest: + services: str + components: dict[str, str] + + @property + def bump_keys(self) -> list[str]: + return ['services', *sorted(self.components)] + + +def parse_versions(text: str, source: str) -> tuple[VersionsManifest, dict[str, Any]]: + """Parse a versions manifest. `source` names it in errors.""" + raw_obj: Any = json.loads(text) + if not isinstance(raw_obj, dict): + raise ValueError(f'{source}: top-level value must be a JSON object') + raw: dict[str, Any] = raw_obj + components_raw = raw.get('components') + if not isinstance(components_raw, dict) or not components_raw: + raise ValueError(f'{source}: missing non-empty components object') + components: dict[str, str] = {} + for key, value in components_raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError(f'{source}: component entries must be string→string') + components[key] = value + services = raw.get('services') + if not isinstance(services, str) or not services: + raise ValueError(f'{source}: services must be a non-empty string') + return (VersionsManifest(services=services, components=components), raw) + + +def read_release_version(text: str, source: str) -> str: + """The release version from a desktop/package.json body.""" + raw: Any = json.loads(text) + if not isinstance(raw, dict): + raise ValueError(f'{source}: top-level value must be a JSON object') + version = raw.get('version') + if not isinstance(version, str) or not version: + raise ValueError(f'{source}: version must be a non-empty string') + return version + + +def load_release_version(path: Path = PACKAGE_JSON_PATH) -> str: + return read_release_version(path.read_text(encoding='utf-8'), str(path)) + + +_PACKAGE_VERSION_RE = re.compile(r'^(?P\s*"version"\s*:\s*")[^"]*(?P")', re.M) + + +def render_package_json(text: str, version: str) -> str: + """Replace only the version value, leaving the rest byte-for-byte intact. + + desktop/package.json is hand-maintained, so it is rewritten surgically + rather than re-serialized: a json.dumps round trip would reformat the whole + file and bury the one-line bump in an unreviewable diff. + """ + replaced, count = _PACKAGE_VERSION_RE.subn( + lambda m: f'{m.group("lead")}{version}{m.group("tail")}', text, count=1 + ) + if count != 1: + raise ValueError('Could not locate a single "version" field in package.json') + return replaced + + +def load_versions(path: Path = VERSIONS_PATH) -> tuple[VersionsManifest, dict[str, Any]]: + return parse_versions(path.read_text(encoding='utf-8'), str(path)) + + +def extract_intent_block(description: str) -> str: + start_match = _INTENT_START_RE.search(description) + end_match = _INTENT_END_RE.search(description) + if ( + start_match is None + or end_match is None + or end_match.start() <= start_match.start() + ): + raise ValueError( + 'Pull request body is missing the exact release-intent fences:\n' + f' {INTENT_START}\n' + f' ...\n' + f' {INTENT_END}\n' + 'Copy them from the pull request template and fill them in.' + ) + return description[start_match.end() : end_match.start()] + + +def _parse_section(inner: str, heading: str) -> str: + pattern = rf'(?m)^### {re.escape(heading)}\s*\n(.*?)(?=^### |\Z)' + match = re.search(pattern, inner, flags=re.S) + if not match: + raise ValueError(f'Missing required section heading: ### {heading}') + kept = [ + line + for line in match.group(1).splitlines() + if not _is_ignorable_line(line.strip()) + ] + return '\n'.join(kept).strip() + + +def _parse_bump_kind(key: str, value: str) -> BumpKind: + for kind in BUMP_KINDS: + if value == kind: + return kind + raise ValueError(f'{key}: expected one of {", ".join(BUMP_KINDS)}, got {value!r}') + + +def _is_ignorable_line(line: str) -> bool: + return line.startswith('#') or (line.startswith('')) + + +def _strip_bump_line(line: str) -> str: + if line.startswith(('- ', '* ')): + return line[2:].strip() + return line + + +def _parse_bumps( + section: str, + expected_keys: list[str], + *, + key_policy: KeyPolicy, +) -> dict[str, BumpKind]: + lines = [ + _strip_bump_line(line.strip()) + for line in section.splitlines() + if line.strip() and not _is_ignorable_line(line.strip()) + ] + if not lines: + raise ValueError('### Bumps section is empty') + + parsed: dict[str, BumpKind] = {} + for line in lines: + if ':' not in line: + raise ValueError( + f'Bump line must be "- key: none|patch|minor|major", got: {line!r}' + ) + key, value = line.split(':', 1) + key = key.strip() + value = value.strip().lower() + if key in parsed: + raise ValueError(f'Duplicate bump key: {key}') + kind = _parse_bump_kind(key, value) + parsed[key] = kind + + expected = set(expected_keys) + got = set(parsed) + missing = sorted(expected - got) + extra = sorted(got - expected) + if missing or extra: + fatal_missing = missing if key_policy == 'strict' else [] + fatal_extra = extra if key_policy in ('strict', 'reject-unknown') else [] + if fatal_missing or fatal_extra: + parts: list[str] = [] + if fatal_missing: + parts.append('missing keys: ' + ', '.join(fatal_missing)) + if fatal_extra: + parts.append('unknown keys: ' + ', '.join(fatal_extra)) + raise ValueError( + 'Bump keys must match services/versions.json exactly (' + + '; '.join(parts) + + ')' + ) + for key in missing: + print( + f'warning: {key} absent from the intent block; treating as none', + flush=True, + ) + for key in extra: + print( + f'warning: ignoring unknown bump key {key} (not in versions.json)', + flush=True, + ) + return {key: parsed.get(key, 'none') for key in expected_keys} + return {key: parsed[key] for key in expected_keys} + + +def _is_na(text: str) -> bool: + return text.strip().lower() == 'n/a' + + +def parse_release_intent( + description: str, + expected_bump_keys: list[str], + *, + key_policy: KeyPolicy = 'strict', +) -> ReleaseIntent: + inner = extract_intent_block(description) + title = _parse_section(inner, 'Changelog title') + body = _parse_section(inner, 'Changelog body') + bumps = _parse_bumps( + _parse_section(inner, 'Bumps'), + expected_bump_keys, + key_policy=key_policy, + ) + + if not title: + raise ValueError('Changelog title must be non-empty (use n/a when there is no release)') + if not body: + raise ValueError('Changelog body must be non-empty (use n/a when there is no release)') + + component_keys = [key for key in expected_bump_keys if key != 'services'] + max_component = max((BUMP_RANK[bumps[key]] for key in component_keys), default=0) + services_rank = BUMP_RANK[bumps['services']] + + if max_component > 0 and services_rank == 0: + raise ValueError( + 'services must be patch|minor|major when any component bump is not none' + ) + if services_rank < max_component: + raise ValueError( + 'services bump severity must be >= the highest component bump ' + f'(services={bumps["services"]}, highest component rank requires >= ' + f'{next(k for k, r in BUMP_RANK.items() if r == max_component)})' + ) + + has_release = services_rank > 0 or max_component > 0 + if has_release: + if _is_na(title) or _is_na(body): + raise ValueError( + 'Changelog title and body are required when any bump is not none ' + '(do not use n/a)' + ) + else: + if not _is_na(title) or not _is_na(body): + raise ValueError( + 'When every bump is none, Changelog title and body must both be exactly n/a' + ) + + return ReleaseIntent(changelog_title=title, changelog_body=body, bumps=bumps) + + +def allows_owned_file_edits(description: str) -> bool: + return _ALLOW_OWNED_FILES_RE.search(description) is not None + + +def bump_semver(version: str, kind: BumpKind) -> str: + if kind == 'none': + return version + parts = version.split('.') + if len(parts) != 3 or not all(part.isdigit() for part in parts): + raise ValueError(f'Version must be MAJOR.MINOR.PATCH, got {version!r}') + major, minor, patch = (int(parts[0]), int(parts[1]), int(parts[2])) + if kind == 'major': + return f'{major + 1}.0.0' + if kind == 'minor': + return f'{major}.{minor + 1}.0' + return f'{major}.{minor}.{patch + 1}' + + +def apply_bumps(manifest: VersionsManifest, intent: ReleaseIntent) -> VersionsManifest: + services = bump_semver(manifest.services, intent.bumps['services']) + components = { + name: bump_semver(version, intent.bumps[name]) + for name, version in manifest.components.items() + } + return VersionsManifest(services=services, components=components) + + +def render_versions_json(manifest: VersionsManifest, original: dict[str, Any]) -> str: + doc = dict(original) + doc.pop('product', None) + doc.pop('installer', None) + doc['$comment'] = VERSIONS_COMMENT + doc['services'] = manifest.services + doc['components'] = dict(manifest.components) + return json.dumps(doc, indent=2, ensure_ascii=False) + '\n' + + +def format_changelog_entry( + release_version: str, title: str, body: str, pr_ref: str +) -> str: + """One changelog section. `pr_ref` is cited so an entry traces to its PR.""" + bullets = '\n'.join( + line + if line.lstrip().startswith(('-', '*')) + else f'- {line}' + for line in body.strip().splitlines() + if line.strip() + ) + suffix = f' (#{pr_ref})' if pr_ref else '' + return f'## {release_version} — {title.strip()}{suffix}\n\n{bullets}\n' + + +def prepend_changelog(existing: str, entry: str) -> str: + match = re.search(r'(?m)^## ', existing) + if not match: + raise ValueError('CHANGELOG.md has no ## release headings to prepend before') + insert_at = match.start() + return existing[:insert_at] + entry + '\n' + existing[insert_at:] + + +def applies_pr_trailer(pr_number: str) -> str: + return f'{APPLIES_PR_TRAILER_PREFIX}{pr_number}' + + +def check_forbidden_paths(changed_name_status: list[tuple[str, str]], description: str) -> None: + if allows_owned_file_edits(description): + return + + errors: list[str] = [] + for status, path in changed_name_status: + status = status.upper() + if path in BOT_OWNED_MODIFY_PATHS and status != 'D': + errors.append( + f'{path} is bot-owned; do not modify it in a pull request ' + f'(declare bumps in the release-intent block instead)' + ) + if path == SERVICES_CHANGELOG_PATH and status != 'D': + errors.append( + f'{path} is retired; delete it if present, do not add or edit it. ' + 'Product notes go in root CHANGELOG.md via the release-intent bot.' + ) + if errors: + raise ValueError('Forbidden path changes:\n- ' + '\n- '.join(errors)) diff --git a/scripts/release-intent/test_lib.py b/scripts/release-intent/test_lib.py new file mode 100644 index 00000000..664b7757 --- /dev/null +++ b/scripts/release-intent/test_lib.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Offline unit checks for ci/release-intent/lib.py.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib import ( # noqa: E402 + ALLOW_OWNED_FILES_MARKER, + INTENT_END, + INTENT_START, + VERSIONS_PATH, + VersionsManifest, + apply_bumps, + bump_semver, + check_forbidden_paths, + format_changelog_entry, + load_versions, + parse_release_intent, + prepend_changelog, + read_release_version, + render_package_json, + render_versions_json, +) + +KEYS = [ + 'services', + 'lmstudio-proxy', + 'nvpair-cluster-manager', + 'nvpair-engine-manager', + 'nvpair-errors', + 'nvpair-job-scheduler', + 'nvpair-manual-nodes', + 'nvpair-node-info', + 'nvpair-node-scanner', + 'nvpair-node-settings', + 'nvpair-tui', + 'nvpair-ui-broker', + 'nvpair-workload-manager', + 'ollama-proxy', +] + + +def _block(title: str, body: str, bumps: dict[str, str], *, bullets: bool = True) -> str: + if bullets: + bump_lines = '\n'.join(f'- {key}: {bumps[key]}' for key in KEYS) + else: + bump_lines = '\n'.join(f'{key}: {bumps[key]}' for key in KEYS) + return ( + f'{INTENT_START}\n' + f'### Changelog title\n{title}\n\n' + f'### Changelog body\n{body}\n\n' + f'### Bumps\n{bump_lines}\n' + f'{INTENT_END}\n' + ) + + +class ReleaseVersionTests(unittest.TestCase): + """The release version is read from desktop/package.json and patch-bumped. + + It is never declared in an intent block, so the only judgement a human makes + is the services and component severity. + """ + + PACKAGE = '{\n "name": "pair",\n "version": "0.1.1",\n "private": true\n}\n' + + def test_read_release_version(self) -> None: + self.assertEqual(read_release_version(self.PACKAGE, 'pkg'), '0.1.1') + + def test_release_patch_bump(self) -> None: + self.assertEqual(bump_semver('0.1.1', 'patch'), '0.1.2') + self.assertEqual(bump_semver('0.1.9', 'patch'), '0.1.10') + + def test_render_package_json_touches_only_the_version(self) -> None: + rendered = render_package_json(self.PACKAGE, '0.1.2') + self.assertIn('"version": "0.1.2"', rendered) + # Every other byte is untouched, so the diff is one line. + self.assertEqual( + rendered.replace('"version": "0.1.2"', '"version": "0.1.1"'), + self.PACKAGE, + ) + + def test_render_package_json_requires_a_version_field(self) -> None: + with self.assertRaises(ValueError): + render_package_json('{\n "name": "pair"\n}\n', '0.1.2') + + def test_missing_fences_rejected(self) -> None: + with self.assertRaises(ValueError): + parse_release_intent('## Summary\nno fences here', KEYS) + + +class ReleaseIntentTests(unittest.TestCase): + def test_all_none_requires_na(self) -> None: + bumps = {key: 'none' for key in KEYS} + intent = parse_release_intent(_block('n/a', 'n/a', bumps), KEYS) + self.assertFalse(intent.has_release) + + def test_all_none_rejects_prose(self) -> None: + bumps = {key: 'none' for key in KEYS} + with self.assertRaises(ValueError): + parse_release_intent(_block('Oops', 'n/a', bumps), KEYS) + + def test_component_requires_services(self) -> None: + bumps = {key: 'none' for key in KEYS} + bumps['ollama-proxy'] = 'patch' + with self.assertRaises(ValueError): + parse_release_intent(_block('Title', '- body', bumps), KEYS) + + def test_services_must_dominate(self) -> None: + bumps = {key: 'none' for key in KEYS} + bumps['ollama-proxy'] = 'major' + bumps['services'] = 'patch' + with self.assertRaises(ValueError): + parse_release_intent(_block('Title', '- body', bumps), KEYS) + + def test_release_ok(self) -> None: + bumps = {key: 'none' for key in KEYS} + bumps['services'] = 'minor' + bumps['nvpair-engine-manager'] = 'minor' + intent = parse_release_intent( + _block('Engine load progress', '- Shows percent while pulling.', bumps), + KEYS, + ) + self.assertTrue(intent.has_release) + manifest = VersionsManifest( + services='0.82.0', + components={key: '1.0.0' for key in KEYS if key != 'services'}, + ) + updated = apply_bumps(manifest, intent) + self.assertEqual(updated.services, '0.83.0') + self.assertEqual(updated.components['nvpair-engine-manager'], '1.1.0') + self.assertEqual(updated.components['ollama-proxy'], '1.0.0') + + def test_inline_comments_are_ignored(self) -> None: + bumps = {key: 'none' for key in KEYS} + block = _block('n/a', 'n/a', bumps) + block = block.replace( + '### Bumps\n', + '### Bumps\n\n', + ) + block = block.replace( + '### Changelog title\nn/a', + '### Changelog title\n\nn/a', + ) + intent = parse_release_intent(block, KEYS) + self.assertFalse(intent.has_release) + + def test_bullet_bumps_parse(self) -> None: + bumps = {key: 'none' for key in KEYS} + bumps['services'] = 'patch' + bumps['ollama-proxy'] = 'patch' + intent = parse_release_intent( + _block('Routing fix', '- Fixes routing.', bumps, bullets=True), + KEYS, + ) + self.assertTrue(intent.has_release) + self.assertEqual(intent.bumps['ollama-proxy'], 'patch') + + def test_plain_bumps_still_parse(self) -> None: + bumps = {key: 'none' for key in KEYS} + intent = parse_release_intent(_block('n/a', 'n/a', bumps, bullets=False), KEYS) + self.assertFalse(intent.has_release) + + def _with_unknown_key(self) -> str: + bumps = {key: 'none' for key in KEYS} + return _block('n/a', 'n/a', bumps).replace( + 'ollama-proxy: none\n', + 'ollama-proxy: none\nextra-thing: none\n', + ) + + def _without_ollama_proxy(self) -> str: + bumps = {key: 'none' for key in KEYS if key != 'ollama-proxy'} + bump_lines = '\n'.join(f'- {key}: {bumps[key]}' for key in bumps) + return ( + f'{INTENT_START}\n' + f'### Changelog title\nn/a\n\n' + f'### Changelog body\nn/a\n\n' + f'### Bumps\n{bump_lines}\n' + f'{INTENT_END}\n' + ) + + def test_unknown_key_strict(self) -> None: + with self.assertRaises(ValueError): + parse_release_intent(self._with_unknown_key(), KEYS, key_policy='strict') + + def test_missing_key_strict(self) -> None: + with self.assertRaises(ValueError): + parse_release_intent(self._without_ollama_proxy(), KEYS, key_policy='strict') + + def test_unknown_key_rejected_when_applying(self) -> None: + """An unknown key at apply time means the block was edited past CI.""" + with self.assertRaises(ValueError): + parse_release_intent( + self._with_unknown_key(), KEYS, key_policy='reject-unknown' + ) + + def test_missing_key_tolerated_when_applying(self) -> None: + """A concurrent pull request can add a component after this one is written.""" + intent = parse_release_intent( + self._without_ollama_proxy(), KEYS, key_policy='reject-unknown' + ) + self.assertEqual(intent.bumps['ollama-proxy'], 'none') + + def test_unknown_key_lenient(self) -> None: + intent = parse_release_intent( + self._with_unknown_key(), KEYS, key_policy='lenient' + ) + self.assertFalse(intent.has_release) + self.assertNotIn('extra-thing', intent.bumps) + + def test_missing_key_lenient(self) -> None: + intent = parse_release_intent( + self._without_ollama_proxy(), KEYS, key_policy='lenient' + ) + self.assertEqual(intent.bumps['ollama-proxy'], 'none') + + def test_bump_semver(self) -> None: + self.assertEqual(bump_semver('1.2.3', 'none'), '1.2.3') + self.assertEqual(bump_semver('1.2.3', 'patch'), '1.2.4') + self.assertEqual(bump_semver('1.2.3', 'minor'), '1.3.0') + self.assertEqual(bump_semver('1.2.3', 'major'), '2.0.0') + + def test_changelog_prepend(self) -> None: + existing = '# Changelog\n\nIntro\n\n## 0.1.0 — Old\n\n- old\n' + entry = format_changelog_entry('0.1.2', 'New thing', '- bullet\n', '123') + out = prepend_changelog(existing, entry) + self.assertIn('## 0.1.2 — New thing (#123)', out) + self.assertTrue(out.index('## 0.1.2') < out.index('## 0.1.0')) + + def test_changelog_entry_without_pr_ref_has_no_suffix(self) -> None: + entry = format_changelog_entry('0.1.2', 'Local', '- bullet\n', '') + self.assertIn('## 0.1.2 — Local\n', entry) + self.assertNotIn('(#', entry) + + def test_changelog_mixed_prose_no_double_dash(self) -> None: + entry = format_changelog_entry( + '0.1.2', + 'Mixed', + 'Plain prose line\n- Already a bullet\nAnother prose', + '7', + ) + self.assertIn('- Plain prose line\n', entry) + self.assertIn('- Already a bullet\n', entry) + self.assertIn('- Another prose\n', entry) + self.assertNotIn('- - ', entry) + + def test_forbidden_paths(self) -> None: + with self.assertRaises(ValueError): + check_forbidden_paths([('M', 'services/versions.json')], 'no marker') + check_forbidden_paths( + [('M', 'services/versions.json')], + f'hello {ALLOW_OWNED_FILES_MARKER}', + ) + check_forbidden_paths( + [('M', 'services/versions.json')], + 'hello ', + ) + check_forbidden_paths([('D', 'services/changelog.md')], 'no marker') + with self.assertRaises(ValueError): + check_forbidden_paths([('M', 'services/changelog.md')], 'no marker') + + def test_collapsed_html_comment_fences(self) -> None: + bumps = {key: 'none' for key in KEYS} + bump_lines = '\n'.join(f'- {key}: {bumps[key]}' for key in KEYS) + text = ( + '\n' + '\n' + '### Changelog title\nn/a\n\n' + '### Changelog body\nn/a\n\n' + f'### Bumps\n{bump_lines}\n' + '\n' + ) + intent = parse_release_intent(text, KEYS) + self.assertFalse(intent.has_release) + + def test_render_versions_mutates_original_keys(self) -> None: + original = { + '$comment': 'keep me', + 'services': '0.82.0', + 'components': { + 'ollama-proxy': '0.23.0', + 'lmstudio-proxy': '0.13.1', + }, + 'extra': 'preserved', + } + manifest = VersionsManifest( + services='0.83.0', + components={'ollama-proxy': '0.23.1', 'lmstudio-proxy': '0.13.1'}, + ) + text = render_versions_json(manifest, original) + parsed = json.loads(text) + self.assertEqual(parsed['services'], '0.83.0') + self.assertEqual(parsed['extra'], 'preserved') + self.assertEqual(parsed['components']['ollama-proxy'], '0.23.1') + self.assertTrue(text.endswith('\n')) + + def test_render_versions_drops_retired_keys(self) -> None: + """A manifest still carrying product/installer renders without them.""" + original = { + 'product': '0.91.7', + 'installer': '0.91.7', + 'components': {'ollama-proxy': '0.26.2'}, + } + manifest = VersionsManifest( + services='0.91.8', components={'ollama-proxy': '0.26.3'} + ) + parsed = json.loads(render_versions_json(manifest, original)) + self.assertEqual(parsed['services'], '0.91.8') + self.assertNotIn('product', parsed) + self.assertNotIn('installer', parsed) + + def test_repo_versions_roundtrip(self) -> None: + manifest, raw = load_versions(VERSIONS_PATH) + text = render_versions_json(manifest, raw) + with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False) as handle: + handle.write(text) + temp_path = Path(handle.name) + try: + again, _ = load_versions(temp_path) + self.assertEqual(again.services, manifest.services) + self.assertEqual(again.components, manifest.components) + finally: + temp_path.unlink(missing_ok=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/release-intent/validate_pr.py b/scripts/release-intent/validate_pr.py new file mode 100644 index 00000000..4326f93d --- /dev/null +++ b/scripts/release-intent/validate_pr.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Validate a pull request body against the PAIR release-intent contract.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib import ( # noqa: E402 + INTENT_END, + INTENT_START, + REPO_ROOT, + check_forbidden_paths, + load_versions, + parse_release_intent, +) + + +def pull_request_body() -> str: + """The pull request body, as the workflow passed it in. + + The workflow sets PR_BODY from github.event.pull_request.body through `env:` + rather than interpolating it into a shell command, because the body is + author-controlled and would otherwise be a shell injection. + + There is no API fallback: the webhook payload carries the whole body, so + unlike GitLab's CI_MERGE_REQUEST_DESCRIPTION there is nothing to un-truncate + and no token needed to read it. That is also why this script needs no + secret, which is what lets it run on pull requests from forks. + """ + body = os.environ.get('PR_BODY') + if body is None: + raise SystemExit( + 'PR_BODY is not set. In CI the workflow must pass it via `env:` from ' + 'github.event.pull_request.body. Locally, use --description-file.' + ) + return body + + +def changed_name_status(base: str, head: str) -> list[tuple[str, str]]: + result = subprocess.run( + ['git', 'diff', '--name-status', f'{base}...{head}'], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + rows: list[tuple[str, str]] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + parts = line.split('\t') + if len(parts) == 2: + rows.append((parts[0], parts[1])) + elif len(parts) == 3 and parts[0].startswith('R'): + rows.append((parts[0], parts[2])) + else: + raise SystemExit(f'Unrecognized git name-status line: {line!r}') + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--description-file', + type=Path, + help='Read the body from a file instead of the PR_BODY environment variable', + ) + parser.add_argument( + '--skip-owned-files-check', + action='store_true', + help='Skip bot-owned path enforcement (local testing only)', + ) + args = parser.parse_args() + + if args.description_file is not None: + description = args.description_file.read_text(encoding='utf-8') + else: + description = pull_request_body() + + try: + versions, _raw = load_versions() + except ValueError as exc: + # A traceback here reads as a script crash when it is really a malformed + # manifest, which is the likely state mid-schema-change. + print(f'services/versions.json could not be read: {exc}', file=sys.stderr) + return 1 + + try: + intent = parse_release_intent( + description, versions.bump_keys, key_policy='strict' + ) + if not args.skip_owned_files_check: + base = os.environ.get('PR_BASE_SHA') + head = os.environ.get('PR_HEAD_SHA') + if base and head: + check_forbidden_paths(changed_name_status(base, head), description) + elif os.environ.get('GITHUB_EVENT_NAME') == 'pull_request': + # The merge-base diff is the only way to catch a hand edit to a + # bot-owned file, so a pull request that cannot compute it must + # fail rather than pass the check vacuously. Needs + # actions/checkout with fetch-depth: 0. + raise SystemExit( + 'PR_BASE_SHA and PR_HEAD_SHA are required to enforce ' + 'bot-owned file rules on a pull request' + ) + except ValueError as exc: + print('release-intent validation failed:', file=sys.stderr) + print(str(exc), file=sys.stderr) + print(file=sys.stderr) + print('Expected fences:', file=sys.stderr) + print(f' {INTENT_START}', file=sys.stderr) + print(f' {INTENT_END}', file=sys.stderr) + return 1 + + print('release-intent OK') + print(f' services bump: {intent.bumps["services"]}') + for key in versions.bump_keys: + if key == 'services': + continue + print(f' {key}: {intent.bumps[key]}') + if intent.has_release: + print(f' changelog title: {intent.changelog_title}') + print(' release version: patch-bumps automatically on merge') + else: + print(' no version release (all bumps none)') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/services/VERSIONING.md b/services/VERSIONING.md index 915d73f6..a3e8b425 100644 --- a/services/VERSIONING.md +++ b/services/VERSIONING.md @@ -11,19 +11,30 @@ numbers. Build scripts read it and stamp every binary at build time via Go's ``` services/versions.json -├── product # umbrella / product release -├── installer # always equals product +├── services # the services suite as a whole └── components.* # per-binary versions (independent SemVer) ``` -Product release notes are maintained outside this tree. Do not add or edit -`services/changelog.md`. +Do not add or edit `services/changelog.md`. -`desktop/package.json` `version` is **out of scope** for automated service -bumps. Bump it manually when cutting an Electron / update-feed release. +## Three numbers, three jobs -`product` / `installer` follow the product release series (for example -`0.82.0`), not a separate services-only major line. +| Number | Lives in | Stamps | Bumped | +| ------ | -------- | ------ | ------ | +| release | `desktop/package.json` `version` | The app users install, the update feed, the GitHub release tag | Automatically: one PATCH forward whenever a release-intent block declares a release | +| `services` | `services/versions.json` | The standalone services installer and Go `main.Version` | Declared in the release-intent block | +| `components.*` | `services/versions.json` | Each binary's own `--version` | Declared in the release-intent block | + +Only the last two are declared, because only they carry SemVer meaning a human +has to judge. The release version is a counter: it answers "which build is +this?", not "how compatible is it?". + +A MINOR or MAJOR release version is a deliberate manual edit at cut time. The +automation only ever moves it one PATCH forward, so it cannot promote a release +on its own. + +The release version and `services` are **not** held equal, and no attempt is +made to align them. They version different artifacts. ## Bumping rules (SemVer meaning) @@ -41,24 +52,29 @@ We follow [SemVer](https://semver.org/) (`MAJOR.MINOR.PATCH`). Ask: would a user reading `--version` learn something useful? If not, leave it `none`. -### Product version (`product`) +### Services suite version (`services`) -| Change | Product bump | -| ------ | ------------ | -| No product-facing release | none | -| Only PATCH-level notes / component bumps | PATCH | -| At least one MINOR component bump, or user-visible product change | MINOR | +| Change | `services` bump | +| ------ | --------------- | +| No release | none | +| Only PATCH-level component bumps | PATCH | +| At least one MINOR component bump, or user-visible change | MINOR | | At least one MAJOR component bump, or breaking UX/data change | MAJOR | -`installer` always equals `product` after a release apply. +`services` must be at least as severe as the highest component bump; CI rejects +a block where it is not. ## Declaring a version change -Update `versions.json` in the same pull request that changes compiled output, -using the tables above to pick the severity. Say in the pull-request description -which components you bumped and why, and describe any user-facing change in plain -terms so it can be carried into the release notes. A reviewer should be able to -see the version decision without inferring it from the diff. +Declare bumps in the `pair-release-intent:v1` block in your pull request +description, using the tables above to pick the severity. **Do not edit +`services/versions.json` or `CHANGELOG.md` by hand** — they are written by +automation, and CI rejects a pull request that modifies them. + +Describe any user-facing change in the block's changelog title and body in plain +terms; that text becomes the changelog entry verbatim, cited back to your pull +request number. A reviewer should be able to see the version decision without +inferring it from the diff. ## Verifying From a4ab54ecd409bf17246520a9f13d9b47780094ee Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:18:19 -0400 Subject: [PATCH 5/9] ci: declare toolchain versions once in a composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node and Go versions were duplicated between ci.yml and build.yml. GitHub Actions has no shared constants file — env: is per-workflow and one workflow cannot read another — so a local composite action is the only way to declare them once without moving them out of the repository into repository variables, where they would stop being reviewable. Bumping one copy and forgetting the other would have pull request checks and release builds running different toolchains, which is the failure this removes. Inputs keep what each job actually needs: the header check takes Node without an npm cache it has no install to populate, and the build-script job takes Go without Node. Checkout stays in the callers, since a local action cannot exist before its own repository is on disk. Signed-off-by: Terve --- .github/actions/setup/action.yml | 57 ++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 15 ++------- .github/workflows/ci.yml | 32 +++++------------- 3 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 .github/actions/setup/action.yml diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..4c3c5de5 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The single place the Node and Go versions are declared. +# +# GitHub Actions has no shared constants file: `env:` is per-workflow and one +# workflow cannot read another. Four workflows exist because each needs a +# distinct `on:` block, so without this they would each carry their own copy of +# the versions, and bumping one while forgetting another would have pull request +# checks and release builds running different toolchains. +# +# The caller must check out the repository first — a local action cannot exist +# until its own repo is on disk, so checkout stays in the workflow. +# +# On a fork pull request this file comes from the fork, like every other script +# those jobs run. That adds no exposure: those jobs already execute the pull +# request's `npm ci` and `go test`, hold no secret, and run on hosted runners. +name: Set up toolchains +description: Installs the pinned Node and Go toolchains used across all workflows. + +inputs: + node: + description: Install Node. + default: 'true' + go: + description: Install Go. + default: 'false' + npm-cache: + description: Cache the npm download cache. Pointless without an npm install. + default: 'true' + +runs: + using: composite + steps: + # desktop/package.json engines requires >=25.5.0. Pinned to a major + # rather than read from that range, which would drift to whatever Node + # is newest. + - if: inputs.node == 'true' + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '25' + cache: ${{ inputs.npm-cache == 'true' && 'npm' || '' }} + cache-dependency-path: desktop/package-lock.json + + # Forbids the surprise toolchain download, so the version below is the + # one that actually builds. Set before setup-go because setup-go reads + # GOTOOLCHAIN when resolving a version file. + - if: inputs.go == 'true' + shell: bash + run: echo 'GOTOOLCHAIN=local' >> "$GITHUB_ENV" + + # Must be >= the highest `go` directive across services/*/go.mod, which + # GOTOOLCHAIN=local would otherwise refuse to satisfy. + - if: inputs.go == 'true' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.26.7' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cdad506a..f2b42f8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,10 +38,7 @@ concurrency: group: build-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - NODE_VERSION: '25' - GO_VERSION: '1.26.7' - GOTOOLCHAIN: local +# Toolchain versions are declared once, in .github/actions/setup. jobs: build: @@ -84,15 +81,9 @@ jobs: free -h 2>/dev/null || sysctl -n hw.memsize 2>/dev/null || echo "mem: unknown" df -h . | tail -1 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: ./.github/actions/setup with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: desktop/package-lock.json - - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: ${{ env.GO_VERSION }} + go: 'true' # electron-builder needs fakeroot and rpm to stage Linux packages; # ubuntu-latest ships neither. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6b8b3c3..25fe1e4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,13 +28,7 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - # desktop/package.json engines requires >=25.5.0. - NODE_VERSION: '25' - # Pinned >= the max `go` directive across services/*/go.mod. GOTOOLCHAIN=local - # forbids a surprise toolchain auto-download. - GO_VERSION: '1.26.7' - GOTOOLCHAIN: local +# Toolchain versions are declared once, in .github/actions/setup. jobs: # Release-intent validation lives in release-intent-check.yml, not here: it @@ -48,9 +42,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: ./.github/actions/setup with: - node-version: ${{ env.NODE_VERSION }} + npm-cache: 'false' - run: node scripts/spdx-headers.mjs desktop: @@ -58,11 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: desktop/package-lock.json + - uses: ./.github/actions/setup - run: npm --prefix desktop ci --prefer-offline # Same order as the local gate documented in CONTRIBUTING.md, so a @@ -79,14 +69,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: desktop/package-lock.json - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: ./.github/actions/setup with: - go-version: ${{ env.GO_VERSION }} + go: 'true' # Engine-manager orphan-reclaim tests need lsof and ss. - run: sudo apt-get update -qq && sudo apt-get install -y -qq lsof iproute2 - run: npm --prefix desktop ci --prefer-offline @@ -142,9 +127,10 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: ./.github/actions/setup with: - go-version: ${{ env.GO_VERSION }} + node: 'false' + go: 'true' # Both scripts fail fast with their own message when go or jq is # missing, so there is no separate tool-check step here. From 77ac75f8496c0b47ea4451c563f4f09c3377da70 Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:21:47 -0400 Subject: [PATCH 6/9] ci: point setup-go at the module lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-go caches by default and looks for a dependency file at the repository root. Every Go module lives under services/, so the action found nothing and reported 'Dependencies file is not found' on each run — a warning in current versions, a hard failure in some earlier ones. Naming the path fixes that and makes the caching real: a cold build pulls roughly 40 seconds of modules, repeated across the services job, the three-platform build-script job, and the installer builds. It also pins the cache key, which is version-dependent otherwise: older setup-go hashes go.sum, newer hashes go.mod. Signed-off-by: Terve --- .github/actions/setup/action.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 4c3c5de5..8fddd562 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -51,7 +51,20 @@ runs: # Must be >= the highest `go` directive across services/*/go.mod, which # GOTOOLCHAIN=local would otherwise refuse to satisfy. + # + # cache-dependency-path is required, not an optimization. setup-go + # caches by default and looks for the dependency file at the repository + # root, where there is none — every Go module lives under services/. + # Left unset it reports "Dependencies file is not found" on every run. + # Naming the path also pins the cache key: older versions hash go.sum, + # newer ones hash go.mod. + # + # scripts/ matches nothing today; it is listed so a Go dependency added + # to the support tooling joins the key instead of silently missing it. - if: inputs.go == 'true' uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.7' + cache-dependency-path: | + services/**/go.sum + scripts/**/go.sum From 161cda031046696255a7b04d0dd83d1ff2451aae Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 11:41:31 -0400 Subject: [PATCH 7/9] ci: assert staged binaries match versions.json, not a count The check asserted 'at least 13'. A floor stops asserting anything the moment the real number grows: add a fourteenth component and it keeps passing, so the check quietly becomes decoration. A hardcoded '-eq 13' fixes that but still only sees the count, and 13 is a magic number with no relationship to where components are actually declared. Compares the staged names against the component keys in versions.json instead. Adding, removing, or renaming a component now fails until the build script and the manifest agree, and the failure names which side is missing what rather than reporting a number. Signed-off-by: Terve --- .github/workflows/ci.yml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25fe1e4f..c5ce7f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,14 +145,27 @@ jobs: shell: cmd run: build.bat - # Catches a script that exits 0 having staged nothing. The binaries - # are not executed: several are servers that would ignore an - # unrecognized flag and start listening. - - name: Check staged binaries + # Compares the staged set against versions.json rather than a count. + # A floor like `-ge 13` passes quietly the moment the real number + # grows, so it stops asserting anything; and a hardcoded `-eq 13` + # only catches the count, not a rename. Comparing names means + # adding, removing, or renaming a component fails here until the + # build script and the manifest agree again. + # + # The binaries are not executed to check them: several are servers + # that would ignore an unrecognized flag and start listening. + - name: Check staged binaries match versions.json working-directory: services shell: bash run: | - ls -l build/bin - count=$(ls build/bin | wc -l) - echo "staged $count binaries" - test "$count" -ge 13 + expected=$(jq -r '.components | keys[]' versions.json | sort) + staged=$(ls build/bin | sed 's/\.exe$//' | sort) + if [ "$expected" != "$staged" ]; then + echo "::error::build/bin does not match versions.json components" + echo "declared but not staged (build script missing a component?):" + comm -23 <(echo "$expected") <(echo "$staged") + echo "staged but not declared (versions.json missing a component?):" + comm -13 <(echo "$expected") <(echo "$staged") + exit 1 + fi + echo "all $(echo "$expected" | wc -l | tr -d ' ') declared components staged" From 9156995e63c5011a0d12f468df3687be1b12a565 Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 14:50:19 -0400 Subject: [PATCH 8/9] docs: regenerate services-api.md after the versions rename The product-to-services rename changed a line that verify-service-contracts generates into docs/services-api.md, but the generated file was not regenerated to match, so the freshness gate failed. Generated output only; produced by npm run service-contracts:write. Signed-off-by: Terve --- desktop/docs/services-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/docs/services-api.md b/desktop/docs/services-api.md index 590e7264..862f1d00 100644 --- a/desktop/docs/services-api.md +++ b/desktop/docs/services-api.md @@ -7,7 +7,7 @@ > capability status lives in `docs/services-parity.md`. - **Source tree**: `services/` in this monorepo -- **Versions**: see `services/versions.json` (product, installer, and per-component) +- **Versions**: see `services/versions.json` (services suite and per-component) - **Legend**: ✅ referenced by the bridge · ❌ MISSING (no consumer/caller) · ➖ ignored (see `docs/service-contract-exceptions.json`) ## Drift summary From fd2c35a55e44cc056134a27b30a71984a60683e2 Mon Sep 17 00:00:00 2001 From: Terve Date: Tue, 15 Sep 2026 14:56:53 -0400 Subject: [PATCH 9/9] ci: make the staged-binary check immune to line endings The check compared two lists built by different pipelines, so a stray carriage return made every entry differ while printing identically. The failure output then showed the same names on both sides, which reads as nonsense rather than as a diagnosis. Both sides now run through one normalizer, in an order that matters: CR is stripped before the .exe suffix, because under Git Bash a CRLF line leaves .exe mid-string and 's/\.exe$//' never matches. LC_ALL=C makes the sort byte-wise, which is what the comm calls already assumed. On failure it also dumps the raw listing and both lists through 'sed -n l', so an invisible character shows up as \r instead of as two identical lists. Verified against a CRLF versions.json with .exe binaries, a POSIX checkout, a missing component, and an undeclared one. Signed-off-by: Terve --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ce7f7a..24ae0510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,14 +158,34 @@ jobs: working-directory: services shell: bash run: | - expected=$(jq -r '.components | keys[]' versions.json | sort) - staged=$(ls build/bin | sed 's/\.exe$//' | sort) - if [ "$expected" != "$staged" ]; then - echo "::error::build/bin does not match versions.json components" - echo "declared but not staged (build script missing a component?):" - comm -23 <(echo "$expected") <(echo "$staged") - echo "staged but not declared (versions.json missing a component?):" - comm -13 <(echo "$expected") <(echo "$staged") - exit 1 + # Both sides run through the same normalizer, so they cannot + # disagree on collation or line endings. Order matters: CR has + # to go before the .exe suffix, because under Git Bash a CRLF + # line leaves .exe mid-string and `s/\.exe$//` never matches. + # LC_ALL=C makes the sort byte-wise, which is also what comm + # below assumes. + norm() { tr -d '\r' | sed 's/\.exe$//' | LC_ALL=C sort; } + expected=$(jq -r '.components | keys[]' versions.json | norm) + staged=$(ls -1 build/bin | norm) + + if [ "$expected" = "$staged" ]; then + printf 'all %s declared components staged\n' \ + "$(printf '%s\n' "$expected" | wc -l | tr -d ' ')" + exit 0 fi - echo "all $(echo "$expected" | wc -l | tr -d ' ') declared components staged" + + echo "::error::build/bin does not match versions.json components" + echo "--- declared but not staged (build script missing a component?)" + comm -23 <(printf '%s\n' "$expected") <(printf '%s\n' "$staged") + echo "--- staged but not declared (versions.json missing a component?)" + comm -13 <(printf '%s\n' "$expected") <(printf '%s\n' "$staged") + # When both lists above look identical the difference is a + # character you cannot see. `sed -n l` renders CR as \r and + # marks end of line with $. + echo "--- raw build/bin" + ls -l build/bin + echo "--- expected, escaped" + printf '%s\n' "$expected" | sed -n l + echo "--- staged, escaped" + printf '%s\n' "$staged" | sed -n l + exit 1