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: a push to main must run it # whatever changed, because the flake packages the product and nine of the # checks run the built binary. The `nix` lane is a slow lane # (classify_changes.py): on a pull request it runs for the flake files and # the dependency manifests, or the run-e2e label. On push the classifier # fails open and every lane is true, so a product change that breaks the # flake turns main red after the merge. detect: name: Detect affected areas runs-on: ubuntu-latest timeout-minutes: 10 outputs: nix: ${{ steps.classify.outputs.nix }} steps: - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 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@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 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. # A path: flake has no git metadata, so the release source names its # commit with ?rev=: without it self.rev is null, the install stamp # carries no commit, and the packaged CLI reports "unknown". run: nix flake check "${{ inputs.release == true && format('path:{0}/release-source?rev={1}', runner.temp, github.sha) || '.' }}" --print-build-logs - name: Verify release package runtime identity if: inputs.release == true env: RELEASE_VERSION: ${{ inputs.version }} run: | source="path:$RUNNER_TEMP/release-source?rev=$GITHUB_SHA" package="$(nix build "$source#default" --no-link --print-out-paths)" actual="$("$package/bin/hermes" --version)" ACTUAL_VERSION="$actual" python3 - <<'PY' import os expected = os.environ["RELEASE_VERSION"] # Line one is "Hermes Agent v (...)"; later lines (Python, OpenAI SDK) carry other versions. first = os.environ["ACTUAL_VERSION"].splitlines()[0].split() actual = first[2].removeprefix("v") if first[:2] == ["Hermes", "Agent"] and len(first) > 2 else "" if actual != expected: raise SystemExit(f"Nix package version mismatch: expected {expected}, got {actual}") PY