From 6808aba2c02bbf258d395b5f3a948639cc2027b6 Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 15 Oct 2026 14:33:18 +0200 Subject: [PATCH 1/4] fix(ci): isolate Nix cache publication from candidate builds Make shared Nix setup read-only and populate the cache from main in a dedicated environment. Remove write-token forwarding from reusable workflows and cover the boundary with regression tests. Requires revoking the legacy token and provisioning cachix-write as documented in CI.md. Signed-off-by: Adrien Langou --- .agents/skills/watch-github-actions/SKILL.md | 7 ++ .github/actions/setup-nix/action.yml | 10 +-- .github/workflows/branch-checks.yml | 14 +-- .github/workflows/branch-e2e.yml | 6 -- .github/workflows/build-binaries.yml | 4 - .github/workflows/build-vm-driver.yml | 4 - .github/workflows/cachix.yml | 67 ++++++++++++++ .github/workflows/release-build.yml | 7 -- .github/workflows/release-dev.yml | 4 - .github/workflows/release-prerelease.yml | 3 - .github/workflows/release-qualification.yml | 4 - .github/workflows/release-tag.yml | 3 - .github/workflows/security-scan.yml | 7 -- .github/workflows/trivy-scan.yml | 4 - .github/workflows/workflow-security.yml | 5 -- CI.md | 42 ++++++++- tasks/scripts/check_cachix_isolation_test.py | 93 ++++++++++++++++++++ tasks/test.toml | 6 ++ 18 files changed, 217 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/cachix.yml create mode 100644 tasks/scripts/check_cachix_isolation_test.py diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index 6a5893a234..15b98db232 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -145,6 +145,13 @@ The public diagnostic intentionally omits them; detailed review needs an approved private destination. Helpers must come from the workflow revision, not from the candidate under scan. +`setup-nix` always uses read-only Cachix access. `Populate Nix Cache` is the only +cache writer and runs after pushes to `main`. If it fails because its credential +is missing, follow the environment setup in `CI.md#nix-cache-access`; never add +a Cachix write token to repository or organization secrets. PR and release jobs +build cache misses locally. `mise run test:cachix-isolation` checks the source +wiring, not the environment restrictions configured in GitHub. + `setup-nix` retries development-shell preparation once when `prepare-shell` is enabled. Inspect both attempts in the job log; `setup-rust` assumes the shell has already been prepared. Cargo, lint, and test commands are not retried. diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml index 5ad0a071fa..3ca4b14f70 100644 --- a/.github/actions/setup-nix/action.yml +++ b/.github/actions/setup-nix/action.yml @@ -2,14 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 name: Setup Nix -description: Install Determinate Nix, configure Cachix, and optionally prepare the development shell +description: Install Determinate Nix, configure read-only Cachix access, and optionally prepare the development shell inputs: - cachix-auth-token: - description: Token used to write build outputs to Cachix - required: false - default: "" - prepare-shell: description: Prepare the development shell, retrying once on failure required: false @@ -30,8 +25,7 @@ runs: - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: openshell - authToken: ${{ inputs.cachix-auth-token }} - skipPush: ${{ inputs.cachix-auth-token == '' }} + skipPush: true - name: Prepare Nix development shell if: inputs.prepare-shell == 'true' diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 075cf5b2c8..bf9383e520 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -192,7 +192,6 @@ jobs: - uses: ./.github/actions/setup-nix with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} prepare-shell: "true" shell-installable: .#devShells.x86_64-linux.default @@ -211,7 +210,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Format shell: nix develop -c bash -euo pipefail {0} @@ -233,7 +231,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Verify Cargo lockfiles shell: nix develop -c bash -euo pipefail {0} @@ -261,7 +258,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -298,7 +294,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -329,7 +324,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -365,7 +359,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -415,7 +408,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -440,7 +432,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -467,7 +458,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/setup-rust with: @@ -524,6 +514,10 @@ jobs: - name: Test release qualification summary run: mise run test:qualification-summary + + - name: Test Cachix credential isolation + run: mise run test:cachix-isolation + go: name: Go SDK needs: pr_metadata diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index db4c96b936..6a997bdc66 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -108,8 +108,6 @@ jobs: uses: ./.github/workflows/build-binaries.yml with: cargo-version: ${{ needs.version.outputs.cargo }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} build-gateway-plain: name: openshell-gateway driver-free (${{ matrix.triple }}) @@ -134,7 +132,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: package: openshell-gateway @@ -175,7 +172,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: package: ${{ matrix.package }} @@ -191,8 +187,6 @@ jobs: uses: ./.github/workflows/build-vm-driver.yml with: cargo-version: ${{ needs.version.outputs.cargo }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} build-images: needs: [pr_metadata, build-binaries] diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 1289968d14..0eba8b96c5 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -17,9 +17,6 @@ on: required: false type: string default: "" - secrets: - CACHIX_AUTH_TOKEN: - required: true permissions: contents: read @@ -52,7 +49,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - uses: ./.github/actions/build-rust-binary with: package: ${{ env.PACKAGE }} diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml index a93f6928da..635d4a97ec 100644 --- a/.github/workflows/build-vm-driver.yml +++ b/.github/workflows/build-vm-driver.yml @@ -17,9 +17,6 @@ on: required: false type: string default: "" - secrets: - CACHIX_AUTH_TOKEN: - required: true permissions: contents: read @@ -54,7 +51,6 @@ jobs: - uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Download openshell-sandbox uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml new file mode 100644 index 0000000000..80c1cf5247 --- /dev/null +++ b/.github/workflows/cachix.yml @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Populate Nix Cache + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: cachix-write + cancel-in-progress: false + +jobs: + populate: + if: github.repository == 'NVIDIA/OpenShell' && github.event_name == 'push' && github.ref == 'refs/heads/main' + # Configure this environment to allow only the main branch BEFORE adding + # its secret. Ref checks in YAML cannot protect a repository-wide secret. + environment: cachix-write + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: ubuntu-24.04 + - system: aarch64-linux + runner: ubuntu-24.04-arm + - system: aarch64-darwin + runner: macos-15-xlarge + # Keep credential-bearing work on fresh GitHub-hosted runners. + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + steps: + - name: Require the environment credential + env: + CACHIX_WRITE_TOKEN: ${{ secrets.CACHIX_WRITE_TOKEN }} + run: | + if [ -z "$CACHIX_WRITE_TOKEN" ]; then + echo "::error::Configure the cachix-write environment as documented in CI.md." + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - uses: DeterminateSystems/determinate-nix-action@e847f25223456b3058e12f7fc9d777b6a6f25060 # v3.23.2 + with: + github-token: ${{ github.token }} + summarize: false + + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ secrets.CACHIX_WRITE_TOKEN }} + + - name: Build the development shell and VM runtime + env: + NIX_SYSTEM: ${{ matrix.system }} + run: | + # HTTP 416 is not retried by Nix; restart the build once on failure. + nix build --no-link ".#devShells.${NIX_SYSTEM}.default" .#vm-runtime || \ + nix build --no-link ".#devShells.${NIX_SYSTEM}.default" .#vm-runtime diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 957d9130ec..8a6babfa80 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -27,9 +27,6 @@ on: source_sha: description: Source commit resolved from the release tag value: ${{ jobs.compute-versions.outputs.source_sha }} - secrets: - CACHIX_AUTH_TOKEN: - required: false permissions: contents: read @@ -116,8 +113,6 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} supervisor-image-tag: ${{ needs.compute-versions.outputs.semver }} checkout-ref: ${{ needs.compute-versions.outputs.source_sha }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} build-vm-driver: needs: [compute-versions, build-binaries] @@ -128,8 +123,6 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} supervisor-image-tag: ${{ needs.compute-versions.outputs.semver }} checkout-ref: ${{ needs.compute-versions.outputs.source_sha }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} package-binaries: needs: [compute-versions, build-binaries, build-vm-driver] diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index aec77972ee..68030237e3 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -68,8 +68,6 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} supervisor-image-tag: dev checkout-ref: ${{ github.sha }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} build-vm-driver: needs: [compute-versions, build-binaries] @@ -80,8 +78,6 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} supervisor-image-tag: dev checkout-ref: ${{ github.sha }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} package-binaries: needs: [build-binaries, build-vm-driver] diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index 72486958c1..eebdd68543 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -28,8 +28,6 @@ jobs: with: release-tag: ${{ inputs.tag || github.ref_name }} is-prerelease: true - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} publish: name: Publish @@ -66,7 +64,6 @@ jobs: is-prerelease: true secrets: CODEX_SECURITY_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} notify-prerelease-success: name: Notify Prerelease Success diff --git a/.github/workflows/release-qualification.yml b/.github/workflows/release-qualification.yml index 3dc0ca3c9e..c1bb2c2069 100644 --- a/.github/workflows/release-qualification.yml +++ b/.github/workflows/release-qualification.yml @@ -21,8 +21,6 @@ on: secrets: CODEX_SECURITY_API_KEY: required: true - CACHIX_AUTH_TOKEN: - required: false permissions: contents: read @@ -51,8 +49,6 @@ jobs: ghcr.io/nvidia/openshell/gateway:${{ inputs.source-sha }} ghcr.io/nvidia/openshell/sandbox:${{ inputs.source-sha }} ghcr.io/nvidia/openshell/supervisor:${{ inputs.source-sha }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} prepare-integration: permissions: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e3ac6f5947..5d9ae10cbc 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -26,8 +26,6 @@ jobs: with: release-tag: ${{ inputs.tag || github.ref_name }} is-prerelease: false - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} qualify: name: Qualify @@ -45,7 +43,6 @@ jobs: is-prerelease: false secrets: CODEX_SECURITY_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} publish: name: Publish diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index f224f8ab14..80cb170300 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -40,9 +40,6 @@ on: description: Newline-separated packaged Helm chart OCI references for Trivy default: "" type: string - secrets: - CACHIX_AUTH_TOKEN: - required: false permissions: contents: read @@ -73,8 +70,6 @@ jobs: ignore-unfixed: false images: ${{ inputs.images }} charts: ${{ inputs.charts }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} cargo-deny: name: Cargo Deny @@ -95,5 +90,3 @@ jobs: with: candidate_ref: ${{ inputs.candidate_ref }} fail-on-findings: ${{ inputs.fail-on-static-findings }} - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index 5b6e0156cc..8afc2f3730 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -37,9 +37,6 @@ on: description: Upload results to GitHub Code Scanning type: boolean default: true - secrets: - CACHIX_AUTH_TOKEN: - description: Token used to write Nix build outputs to Cachix workflow_dispatch: inputs: @@ -106,7 +103,6 @@ jobs: uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Test scan reporting run: tasks/scripts/trivy-scan-test.sh diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index c5a7f9ac76..855e263af7 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -22,9 +22,6 @@ on: description: Fail on Zizmor HIGH findings; Actionlint remains informational default: false type: boolean - secrets: - CACHIX_AUTH_TOKEN: - required: false permissions: contents: read @@ -55,7 +52,6 @@ jobs: uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Run Actionlint run: | @@ -143,7 +139,6 @@ jobs: uses: ./.github/actions/setup-nix with: prepare-shell: "true" - cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} - name: Run Zizmor id: findings diff --git a/CI.md b/CI.md index b886bd70e3..bf62b0f837 100644 --- a/CI.md +++ b/CI.md @@ -17,6 +17,41 @@ Manual admission does not change the bot's automatic trust policy for ready PRs. Merge queue validation is a second integration gate for `main`. After a PR has passed the required PR-head statuses, a maintainer adds it to the merge queue. GitHub creates a temporary merge-group branch that combines the latest `main`, the queued PR, and any earlier queued PRs. The same required `OpenShell / ...` status contexts are then published against the merge-group SHA before GitHub merges it. +### Nix cache access + +PR, merge-group, scan, and release jobs read the public `openshell` Cachix cache +without a write credential. The shared `setup-nix` action always disables uploads. +`Populate Nix Cache` populates the default development shell and VM runtime for +Linux x86-64, Linux ARM64, and macOS ARM64 after pushes to `main`. It checks out +the triggering commit on fresh GitHub-hosted runners and uses the `cachix-write` +environment. Cache misses still build locally; cache population is independent +of the release and PR gates. + +Before enabling cache writes, a repository administrator must: + +1. Revoke the old Cachix write token and remove `CACHIX_AUTH_TOKEN` from + repository secrets and organization secrets available to this repository. + Audit cache uploads made with that token and invalidate untrusted entries. +2. Create the `cachix-write` environment. Under **Deployment branches and tags**, + choose **Selected branches and tags** and allow only the exact branch `main`, + with no tag rules. Protect `main` from unreviewed changes and disable + administrator bypass of the environment rules. +3. Only after those restrictions are saved, add a new, `openshell`-scoped Cachix + write token as the environment secret `CACHIX_WRITE_TOKEN`. Neither token + name may hold a write credential at repository or organization scope. Keep + cache writes disabled until this migration is complete. + +These settings are part of the security boundary. Mirrored PRs can change +workflow YAML, so a workflow condition cannot protect a repository secret. +GitHub enforces [environment branch restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) +outside that YAML. A workflow referencing a nonexistent environment creates it +without protection rules; the workflow does not configure these restrictions. +The cache writer fails if its environment credential is missing. This isolation +applies to Cachix; other CI credentials need their own access controls. + +Run `mise run test:cachix-isolation` to check the workflow wiring locally. +The test does not verify live environment settings or token permissions. + ### Protobuf API compatibility `Protobuf Compatibility` runs in Branch Checks, Release, and Pre-release through the shared @@ -317,8 +352,6 @@ jobs: ${{ needs.build.outputs.sandbox_image }} charts: ${{ needs.build.outputs.chart_ref }} fail-on-static-findings: true - secrets: - CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} ``` Set `needs: security` on a downstream promotion job to require successful scans. @@ -338,7 +371,7 @@ The summary contains qualification results and policy coverage only; artifact identity belongs in the release manifest. The run ID and attempt distinguish reruns without overwriting earlier evidence. -`CACHIX_AUTH_TOKEN` is optional. The parent publishes SARIF from CodeQL, Trivy, +The scan workflows use read-only Nix cache access. The parent publishes SARIF from CodeQL, Trivy, and workflow reports against the candidate tag and commit. Existing standalone triggers stay active. @@ -560,7 +593,8 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | `.github/workflows/e2e-docker-test.yml`, `e2e-podman-test.yml`, `e2e-vm-test.yml`, `e2e-kubernetes-test.yml` | Reusable runtime lanes called directly by branch and release workflows. Callers select suites and declare only the artifacts each runtime consumes. | | `.github/actions/setup-e2e-*` | Shared artifact, Podman, KVM, and kind setup used by the runtime lanes. | | `.github/workflows/helm-lint.yml` | Helm chart validation. PR mirror pushes skip lint jobs unless Helm inputs changed; merge groups always validate Helm because they represent the final integration state. | -| `.github/actions/setup-nix/action.yml` | Installs Nix and configures the OpenShell Cachix cache, using read-only cache access when no authentication token is available. | +| `.github/actions/setup-nix/action.yml` | Installs Nix and configures read-only access to the OpenShell Cachix cache. | +| `.github/workflows/cachix.yml` | Populates the Nix cache from `main` using the protected `cachix-write` environment. | | `.github/actions/pr-gate/action.yml` | Composite action that resolves PR metadata and verifies the required label is set for PR mirror pushes. Non-push events are allowed through. | | `.github/actions/pr-merge-base/action.yml` | Composite action that resolves and fetches the merge-base commit for `pull-request/` push workflows. | | `.github/workflows/required-ci-gates.yml` | Posts required PR-head and merge-group statuses for gated CI workflows. This is what branch protection and merge queue should require. | diff --git a/tasks/scripts/check_cachix_isolation_test.py b/tasks/scripts/check_cachix_isolation_test.py new file mode 100644 index 0000000000..44dea23b7c --- /dev/null +++ b/tasks/scripts/check_cachix_isolation_test.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Guard the checked-in credential boundary; GitHub environment rules are external.""" + +import os +import subprocess +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRITER = REPO_ROOT / ".github/workflows/cachix.yml" + + +def load_yaml(path: Path) -> dict: + # BaseLoader preserves the Actions `on` key instead of treating it as a + # YAML 1.1 boolean. Scalars remain strings, including true/false. + return yaml.load(path.read_text(), Loader=yaml.BaseLoader) + + +def test_cachix_credentials_are_not_forwarded_to_other_workflows_or_actions(): + for path in (REPO_ROOT / ".github").rglob("*"): + if path.suffix not in {".yml", ".yaml"}: + continue + content = path.read_text().upper() + assert "CACHIX_AUTH_TOKEN" not in content, path + if path != WRITER: + assert "CACHIX_WRITE_TOKEN" not in content, path + assert "CACHIX-AUTH-TOKEN" not in content, path + + +def test_shared_nix_setup_only_reads_the_cache(): + setup = load_yaml(REPO_ROOT / ".github/actions/setup-nix/action.yml") + cache_steps = [ + step + for step in setup["runs"]["steps"] + if step.get("uses", "").startswith("cachix/cachix-action@") + ] + assert len(cache_steps) == 1 + assert cache_steps[0]["with"] == {"name": "openshell", "skipPush": "true"} + assert "cachix-auth-token" not in setup.get("inputs", {}) + + +def test_cache_writer_uses_only_the_main_push_revision(): + writer = load_yaml(WRITER) + assert writer["on"] == {"push": {"branches": ["main"]}} + assert writer["permissions"] == {"contents": "read"} + assert set(writer["jobs"]) == {"populate"} + job = writer["jobs"]["populate"] + assert job["environment"] == "cachix-write" + assert job["if"] == ( + "github.repository == 'NVIDIA/OpenShell' && " + "github.event_name == 'push' && github.ref == 'refs/heads/main'" + ) + assert job["runs-on"] == "${{ matrix.runner }}" + assert job["strategy"]["matrix"]["include"] == [ + {"system": "x86_64-linux", "runner": "ubuntu-24.04"}, + {"system": "aarch64-linux", "runner": "ubuntu-24.04-arm"}, + {"system": "aarch64-darwin", "runner": "macos-15-xlarge"}, + ] + # No PR artifacts, local actions or caller-supplied source enter this job. + actions = { + step["uses"].split("@")[0]: step for step in job["steps"] if "uses" in step + } + assert set(actions) == { + "actions/checkout", + "DeterminateSystems/determinate-nix-action", + "cachix/cachix-action", + } + assert actions["actions/checkout"]["with"] == { + "persist-credentials": "false", + "ref": "${{ github.sha }}", + } + assert actions["cachix/cachix-action"]["with"] == { + "name": "openshell", + "authToken": "${{ secrets.CACHIX_WRITE_TOKEN }}", + } + + +def test_cache_writer_requires_its_environment_credential(): + step = load_yaml(WRITER)["jobs"]["populate"]["steps"][0] + assert step["env"] == {"CACHIX_WRITE_TOKEN": "${{ secrets.CACHIX_WRITE_TOKEN }}"} + for value, expected in (("", 1), ("local-test-sentinel", 0)): + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-eu", "-c", step["run"]], + env={"PATH": os.defpath, "CACHIX_WRITE_TOKEN": value}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == expected, result.stdout + result.stderr + assert "local-test-sentinel" not in result.stdout + result.stderr diff --git a/tasks/test.toml b/tasks/test.toml index c1df20262a..9a1bcd0ba7 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,6 +23,7 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cachix-isolation", ] ["test:docs-website"] @@ -86,6 +87,11 @@ description = "Test public Codex Security diagnostics and sensitive-data filteri run = "uv run --no-project --with pytest pytest tasks/scripts/codex_security_diagnostics_test.py" hide = true +["test:cachix-isolation"] +description = "Verify the CI Cachix credential boundary" +run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/check_cachix_isolation_test.py" +hide = true + ["test:maintainer-approval"] description = "Test the maintainer approval gate and change-alert tools" run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/check_maintainer_approval_test.py tasks/scripts/alert_maintainer_change_test.py" From c2a9b49587e3dc08e9545f7f53f6d94ee1e9e869 Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 15 Oct 2026 15:09:46 +0200 Subject: [PATCH 2/4] test(ci): enforce cache writer step ordering Preserve action order and multiplicity when checking the credential-bearing cache writer. Require immutable action revisions and cover candidate checkout/restore and reordered-step regressions. Signed-off-by: Adrien Langou --- tasks/scripts/check_cachix_isolation_test.py | 67 +++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/tasks/scripts/check_cachix_isolation_test.py b/tasks/scripts/check_cachix_isolation_test.py index 44dea23b7c..d86bc3bdcd 100644 --- a/tasks/scripts/check_cachix_isolation_test.py +++ b/tasks/scripts/check_cachix_isolation_test.py @@ -4,9 +4,11 @@ """Guard the checked-in credential boundary; GitHub environment rules are external.""" import os +import re import subprocess from pathlib import Path +import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[2] @@ -42,8 +44,7 @@ def test_shared_nix_setup_only_reads_the_cache(): assert "cachix-auth-token" not in setup.get("inputs", {}) -def test_cache_writer_uses_only_the_main_push_revision(): - writer = load_yaml(WRITER) +def check_cache_writer(writer: dict) -> None: assert writer["on"] == {"push": {"branches": ["main"]}} assert writer["permissions"] == {"contents": "read"} assert set(writer["jobs"]) == {"populate"} @@ -59,25 +60,71 @@ def test_cache_writer_uses_only_the_main_push_revision(): {"system": "aarch64-linux", "runner": "ubuntu-24.04-arm"}, {"system": "aarch64-darwin", "runner": "macos-15-xlarge"}, ] - # No PR artifacts, local actions or caller-supplied source enter this job. - actions = { - step["uses"].split("@")[0]: step for step in job["steps"] if "uses" in step - } - assert set(actions) == { + # Review any new step in this credential-bearing job. Preserve order and + # duplicates: a later main checkout must not hide an earlier candidate build. + steps = job["steps"] + assert [step.get("uses", "run").split("@")[0] for step in steps] == [ + "run", "actions/checkout", "DeterminateSystems/determinate-nix-action", "cachix/cachix-action", - } - assert actions["actions/checkout"]["with"] == { + "run", + ] + credential, checkout, install_nix, cache, build = steps + assert "run" in credential and "run" in build + for step in (checkout, install_nix, cache): + assert re.fullmatch(r"[0-9a-f]{40}", step["uses"].partition("@")[2]) + assert checkout["with"] == { "persist-credentials": "false", "ref": "${{ github.sha }}", } - assert actions["cachix/cachix-action"]["with"] == { + assert cache["with"] == { "name": "openshell", "authToken": "${{ secrets.CACHIX_WRITE_TOKEN }}", } +def test_cache_writer_uses_only_the_main_push_revision(): + check_cache_writer(load_yaml(WRITER)) + + +def test_cache_writer_rejects_candidate_checkout_followed_by_main_restore(): + writer = load_yaml(WRITER) + steps = writer["jobs"]["populate"]["steps"] + checkout = steps[1] + candidate_checkout = { + **checkout, + "with": {**checkout["with"], "ref": "refs/pull/123/head"}, + } + steps.insert(-1, candidate_checkout) + steps.append(checkout) + + with pytest.raises(AssertionError): + check_cache_writer(writer) + + +@pytest.mark.parametrize( + ("source", "destination"), + [(1, 4), (4, 1)], + ids=["checkout-after-build", "build-before-checkout"], +) +def test_cache_writer_rejects_reordered_steps(source: int, destination: int): + writer = load_yaml(WRITER) + steps = writer["jobs"]["populate"]["steps"] + steps.insert(destination, steps.pop(source)) + + with pytest.raises(AssertionError): + check_cache_writer(writer) + + +def test_cache_writer_rejects_mutable_action_refs(): + writer = load_yaml(WRITER) + writer["jobs"]["populate"]["steps"][1]["uses"] = "actions/checkout@main" + + with pytest.raises(AssertionError): + check_cache_writer(writer) + + def test_cache_writer_requires_its_environment_credential(): step = load_yaml(WRITER)["jobs"]["populate"]["steps"][0] assert step["env"] == {"CACHIX_WRITE_TOKEN": "${{ secrets.CACHIX_WRITE_TOKEN }}"} From 206d0d6843f0615a27f24977bed0ccd86f3b1484 Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 15 Oct 2026 18:47:47 +0200 Subject: [PATCH 3/4] docs(ci): condense Nix cache setup guidance Signed-off-by: Adrien Langou --- CI.md | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/CI.md b/CI.md index bf62b0f837..ac394d55d2 100644 --- a/CI.md +++ b/CI.md @@ -19,38 +19,21 @@ Merge queue validation is a second integration gate for `main`. After a PR has p ### Nix cache access -PR, merge-group, scan, and release jobs read the public `openshell` Cachix cache -without a write credential. The shared `setup-nix` action always disables uploads. -`Populate Nix Cache` populates the default development shell and VM runtime for -Linux x86-64, Linux ARM64, and macOS ARM64 after pushes to `main`. It checks out -the triggering commit on fresh GitHub-hosted runners and uses the `cachix-write` -environment. Cache misses still build locally; cache population is independent -of the release and PR gates. +All CI jobs use the public `openshell` Cachix cache read-only. `Populate Nix +Cache` is the sole writer and runs on GitHub-hosted runners after pushes to `main`. Before enabling cache writes, a repository administrator must: 1. Revoke the old Cachix write token and remove `CACHIX_AUTH_TOKEN` from - repository secrets and organization secrets available to this repository. - Audit cache uploads made with that token and invalidate untrusted entries. -2. Create the `cachix-write` environment. Under **Deployment branches and tags**, - choose **Selected branches and tags** and allow only the exact branch `main`, - with no tag rules. Protect `main` from unreviewed changes and disable - administrator bypass of the environment rules. -3. Only after those restrictions are saved, add a new, `openshell`-scoped Cachix - write token as the environment secret `CACHIX_WRITE_TOKEN`. Neither token - name may hold a write credential at repository or organization scope. Keep - cache writes disabled until this migration is complete. - -These settings are part of the security boundary. Mirrored PRs can change -workflow YAML, so a workflow condition cannot protect a repository secret. -GitHub enforces [environment branch restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) -outside that YAML. A workflow referencing a nonexistent environment creates it -without protection rules; the workflow does not configure these restrictions. -The cache writer fails if its environment credential is missing. This isolation -applies to Cachix; other CI credentials need their own access controls. - -Run `mise run test:cachix-isolation` to check the workflow wiring locally. -The test does not verify live environment settings or token permissions. + repository and organization secrets; audit its uploads and invalidate + untrusted entries. +2. Create the `cachix-write` environment, restrict it to the exact protected + `main` branch with no tag rules or administrator bypass, then add a fresh + `CACHIX_WRITE_TOKEN` only to that environment. + +Mirrored PRs can edit workflow YAML, so the GitHub [environment restriction](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) +is the security boundary. Keep writes disabled until this migration is complete. +`mise run test:cachix-isolation` checks source wiring, not GitHub settings. ### Protobuf API compatibility From 42df33353c39dd1e2726c13a25abe2ec8edcde9f Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 15 Oct 2026 18:56:09 +0200 Subject: [PATCH 4/4] test(ci): remove Cachix isolation checker Signed-off-by: Adrien Langou --- .agents/skills/watch-github-actions/SKILL.md | 4 +- .github/workflows/branch-checks.yml | 3 - CI.md | 1 - tasks/scripts/check_cachix_isolation_test.py | 140 ------------------- tasks/test.toml | 6 - 5 files changed, 1 insertion(+), 153 deletions(-) delete mode 100644 tasks/scripts/check_cachix_isolation_test.py diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index 15b98db232..33652398e5 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -148,9 +148,7 @@ not from the candidate under scan. `setup-nix` always uses read-only Cachix access. `Populate Nix Cache` is the only cache writer and runs after pushes to `main`. If it fails because its credential is missing, follow the environment setup in `CI.md#nix-cache-access`; never add -a Cachix write token to repository or organization secrets. PR and release jobs -build cache misses locally. `mise run test:cachix-isolation` checks the source -wiring, not the environment restrictions configured in GitHub. +a Cachix write token to repository or organization secrets. `setup-nix` retries development-shell preparation once when `prepare-shell` is enabled. Inspect both attempts in the job log; `setup-rust` assumes the diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index bf9383e520..a25e3198a9 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -515,9 +515,6 @@ jobs: - name: Test release qualification summary run: mise run test:qualification-summary - - name: Test Cachix credential isolation - run: mise run test:cachix-isolation - go: name: Go SDK needs: pr_metadata diff --git a/CI.md b/CI.md index ac394d55d2..fb1f5a7965 100644 --- a/CI.md +++ b/CI.md @@ -33,7 +33,6 @@ Before enabling cache writes, a repository administrator must: Mirrored PRs can edit workflow YAML, so the GitHub [environment restriction](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) is the security boundary. Keep writes disabled until this migration is complete. -`mise run test:cachix-isolation` checks source wiring, not GitHub settings. ### Protobuf API compatibility diff --git a/tasks/scripts/check_cachix_isolation_test.py b/tasks/scripts/check_cachix_isolation_test.py deleted file mode 100644 index d86bc3bdcd..0000000000 --- a/tasks/scripts/check_cachix_isolation_test.py +++ /dev/null @@ -1,140 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Guard the checked-in credential boundary; GitHub environment rules are external.""" - -import os -import re -import subprocess -from pathlib import Path - -import pytest -import yaml - -REPO_ROOT = Path(__file__).resolve().parents[2] -WRITER = REPO_ROOT / ".github/workflows/cachix.yml" - - -def load_yaml(path: Path) -> dict: - # BaseLoader preserves the Actions `on` key instead of treating it as a - # YAML 1.1 boolean. Scalars remain strings, including true/false. - return yaml.load(path.read_text(), Loader=yaml.BaseLoader) - - -def test_cachix_credentials_are_not_forwarded_to_other_workflows_or_actions(): - for path in (REPO_ROOT / ".github").rglob("*"): - if path.suffix not in {".yml", ".yaml"}: - continue - content = path.read_text().upper() - assert "CACHIX_AUTH_TOKEN" not in content, path - if path != WRITER: - assert "CACHIX_WRITE_TOKEN" not in content, path - assert "CACHIX-AUTH-TOKEN" not in content, path - - -def test_shared_nix_setup_only_reads_the_cache(): - setup = load_yaml(REPO_ROOT / ".github/actions/setup-nix/action.yml") - cache_steps = [ - step - for step in setup["runs"]["steps"] - if step.get("uses", "").startswith("cachix/cachix-action@") - ] - assert len(cache_steps) == 1 - assert cache_steps[0]["with"] == {"name": "openshell", "skipPush": "true"} - assert "cachix-auth-token" not in setup.get("inputs", {}) - - -def check_cache_writer(writer: dict) -> None: - assert writer["on"] == {"push": {"branches": ["main"]}} - assert writer["permissions"] == {"contents": "read"} - assert set(writer["jobs"]) == {"populate"} - job = writer["jobs"]["populate"] - assert job["environment"] == "cachix-write" - assert job["if"] == ( - "github.repository == 'NVIDIA/OpenShell' && " - "github.event_name == 'push' && github.ref == 'refs/heads/main'" - ) - assert job["runs-on"] == "${{ matrix.runner }}" - assert job["strategy"]["matrix"]["include"] == [ - {"system": "x86_64-linux", "runner": "ubuntu-24.04"}, - {"system": "aarch64-linux", "runner": "ubuntu-24.04-arm"}, - {"system": "aarch64-darwin", "runner": "macos-15-xlarge"}, - ] - # Review any new step in this credential-bearing job. Preserve order and - # duplicates: a later main checkout must not hide an earlier candidate build. - steps = job["steps"] - assert [step.get("uses", "run").split("@")[0] for step in steps] == [ - "run", - "actions/checkout", - "DeterminateSystems/determinate-nix-action", - "cachix/cachix-action", - "run", - ] - credential, checkout, install_nix, cache, build = steps - assert "run" in credential and "run" in build - for step in (checkout, install_nix, cache): - assert re.fullmatch(r"[0-9a-f]{40}", step["uses"].partition("@")[2]) - assert checkout["with"] == { - "persist-credentials": "false", - "ref": "${{ github.sha }}", - } - assert cache["with"] == { - "name": "openshell", - "authToken": "${{ secrets.CACHIX_WRITE_TOKEN }}", - } - - -def test_cache_writer_uses_only_the_main_push_revision(): - check_cache_writer(load_yaml(WRITER)) - - -def test_cache_writer_rejects_candidate_checkout_followed_by_main_restore(): - writer = load_yaml(WRITER) - steps = writer["jobs"]["populate"]["steps"] - checkout = steps[1] - candidate_checkout = { - **checkout, - "with": {**checkout["with"], "ref": "refs/pull/123/head"}, - } - steps.insert(-1, candidate_checkout) - steps.append(checkout) - - with pytest.raises(AssertionError): - check_cache_writer(writer) - - -@pytest.mark.parametrize( - ("source", "destination"), - [(1, 4), (4, 1)], - ids=["checkout-after-build", "build-before-checkout"], -) -def test_cache_writer_rejects_reordered_steps(source: int, destination: int): - writer = load_yaml(WRITER) - steps = writer["jobs"]["populate"]["steps"] - steps.insert(destination, steps.pop(source)) - - with pytest.raises(AssertionError): - check_cache_writer(writer) - - -def test_cache_writer_rejects_mutable_action_refs(): - writer = load_yaml(WRITER) - writer["jobs"]["populate"]["steps"][1]["uses"] = "actions/checkout@main" - - with pytest.raises(AssertionError): - check_cache_writer(writer) - - -def test_cache_writer_requires_its_environment_credential(): - step = load_yaml(WRITER)["jobs"]["populate"]["steps"][0] - assert step["env"] == {"CACHIX_WRITE_TOKEN": "${{ secrets.CACHIX_WRITE_TOKEN }}"} - for value, expected in (("", 1), ("local-test-sentinel", 0)): - result = subprocess.run( - ["bash", "--noprofile", "--norc", "-eu", "-c", step["run"]], - env={"PATH": os.defpath, "CACHIX_WRITE_TOKEN": value}, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == expected, result.stdout + result.stderr - assert "local-test-sentinel" not in result.stdout + result.stderr diff --git a/tasks/test.toml b/tasks/test.toml index 9a1bcd0ba7..c1df20262a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,7 +23,6 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", - "test:cachix-isolation", ] ["test:docs-website"] @@ -87,11 +86,6 @@ description = "Test public Codex Security diagnostics and sensitive-data filteri run = "uv run --no-project --with pytest pytest tasks/scripts/codex_security_diagnostics_test.py" hide = true -["test:cachix-isolation"] -description = "Verify the CI Cachix credential boundary" -run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/check_cachix_isolation_test.py" -hide = true - ["test:maintainer-approval"] description = "Test the maintainer approval gate and change-alert tools" run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/check_maintainer_approval_test.py tasks/scripts/alert_maintainer_change_test.py"