Files
hermes-agent/.github/workflows/nix.yml
ethernet c13ea774e6 refactor: make install-stamp.json the single runtime version identity
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.

Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).

Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
2026-09-23 11:41:01 -04:00

170 lines
7.5 KiB
YAML

name: Nix flake check
# Builds every output of the flake: the package, the devShell, and the 21
# checks under nix/checks.nix — module evaluation, option parity, .env
# assembly, service argv, and the rest.
#
# This workflow owns its triggers and ci.yml does not call it, for the reason
# docker.yml gives: a reusable-workflow call holds the caller run in progress
# for the full build, and GitHub refuses `gh run rerun` on a run that is still
# in progress. One slow advisory job in the CI lane blocks every rerun of the
# fast required jobs beside it. A separate run reruns and cancels on its own.
on:
pull_request:
push:
branches: [main]
workflow_call:
inputs:
release:
description: 'Stable-release candidate run: force the flake-check lane regardless of the classifier.'
required: false
type: boolean
default: false
version:
description: 'Release version stamped into an isolated flake source tree.'
required: false
type: string
default: ''
permissions:
contents: read
# A workflow_call run (stable release) is never cancelled: its group uses
# github.run_id so a parent rerun cannot kill this child mid-flight, and
# cancel-in-progress is false there — each run saves the store cache that
# later PRs restore from.
concurrency:
group: nix-${{ inputs.release == true && github.run_id || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ inputs.release != true && github.event_name == 'pull_request' }}
jobs:
# A `paths:` filter cannot gate this workflow correctly. The flake packages
# the product, and nine of the checks then run the built binary, so a change
# to hermes_cli/ alone can fail `nix flake check` without touching one file
# under nix/. The `nix` lane therefore follows python_prod as well as the
# flake inputs. On push the classifier fails open and every lane is true.
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
nix: ${{ steps.classify.outputs.nix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# github.sha is the exact candidate commit on a stable tag-dispatched
# caller and the head SHA on every other event.
ref: ${{ github.sha }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ github.token }}
flake-check:
name: nix flake check
needs: [detect]
# Release runs force the lane: a stable candidate must build the flake
# no matter what its diff touches.
if: needs.detect.outputs.nix == 'true' || inputs.release == true
# The build compiles the package and its whole dependency closure, so this
# takes minutes and not seconds when the cache misses. `nix flake check`
# builds 21 checks, and --max-jobs defaults to the core count. It uses the
# wider runner with no more configuration.
runs-on: ubuntu-latest-32-core
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# github.sha is the exact candidate commit on a stable tag-dispatched
# caller and the head SHA on every other event.
ref: ${{ github.sha }}
- name: Install Nix
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
with:
extra_nix_config: |
experimental-features = nix-command flakes
# A store path that does not substitute is a cache miss and not a
# build failure. Build it here instead.
fallback = true
# Each source archive is fetched one time in a run, and not one
# time for each evaluation.
tarball-ttl = 3600
# Restores /nix/store from the GitHub Actions cache. The store holds the
# whole dependency closure, so a hit turns a build of several minutes
# into a short evaluation.
#
# The Magic Nix Cache is not an option here. Its free tier ended in
# February 2025 with the GitHub cache API that it was built on. This
# action uses the current API and needs no account and no secret.
- name: Restore and save the Nix store
uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7
with:
# The closure changes when the flake inputs change or when the
# dependencies of the project change. The key hashes both, so an
# edit to the source alone keeps the hit.
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'nix/**', 'pyproject.toml', 'uv.lock') }}
# On a miss, restore the newest store for this runner. Most of the
# closure — Python, node, each transitive library — survives a bump
# of the lockfile, so an old store still removes most of the work.
restore-prefixes-first-match: nix-${{ runner.os }}-
# Save from main only. A cache that a PR writes is visible to that
# PR alone and never to another branch, so a save there spends the
# 10 GB quota of the repository and helps no later run. A PR still
# restores: it reads the cache that the merge to main wrote. This is
# the same rule that docker.yml applies to `cache-to`.
save: ${{ github.event_name != 'pull_request' }}
# Collect garbage before the save, so the store stays inside the
# 10 GB quota of the repository. Without a limit the store grows at
# each merge until GitHub removes the entry, and the next PR then
# gets nothing. This number is the size of the store and not the
# size of the compressed archive.
gc-max-store-size-linux: 5G
# Delete the caches that this key replaces. GitHub removes caches by
# least recent use across the whole repository, so a Nix store that
# is never purged pushes out the caches of the other workflows.
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-created: 0
purge-primary-key: never
- name: Prepare isolated release source
if: inputs.release == true
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
mkdir -p "$RUNNER_TEMP/release-source"
git archive "$GITHUB_SHA" | tar -x -C "$RUNNER_TEMP/release-source"
python3 scripts/releases/stamping.py --tree "$RUNNER_TEMP/release-source" \
--version "$RELEASE_VERSION"
- name: nix flake check
# --print-build-logs: a check that fails then prints the assertion
# that failed, and not only the derivation that failed to build.
run: nix flake check "${{ inputs.release == true && format('path:{0}/release-source', runner.temp) || '.' }}" --print-build-logs
- name: Verify release package runtime identity
if: inputs.release == true
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
package="$(nix build "path:$RUNNER_TEMP/release-source#hermes-agent" --no-link --print-out-paths)"
actual="$("$package/bin/hermes" --version)"
ACTUAL_VERSION="$actual" python3 - <<'PY'
import os
expected = os.environ["RELEASE_VERSION"]
actual = os.environ["ACTUAL_VERSION"].split()[-1].removeprefix("v")
if actual != expected:
raise SystemExit(f"Nix package version mismatch: expected {expected}, got {actual}")
PY