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/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..8fddd562 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,70 @@ +# 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. + # + # 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..f2b42f8a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,105 @@ +# 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' }} + +# Toolchain versions are declared once, in .github/actions/setup. + +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: ./.github/actions/setup + with: + go: 'true' + + # 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..24ae0510 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,191 @@ +# 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' }} + +# Toolchain versions are declared once, in .github/actions/setup. + +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: ./.github/actions/setup + with: + npm-cache: 'false' + - run: node scripts/spdx-headers.mjs + + desktop: + name: Desktop checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup + - 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: ./.github/actions/setup + with: + 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 + + - 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: ./.github/actions/setup + with: + 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. + - 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 + + # 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: | + # 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 "::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 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/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. 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 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/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 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",