From 3c399df0a2c2f41d86e59b5489b94f81ba318677 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:00:45 +0000 Subject: [PATCH 01/21] ci: add ci.yml, the lane planner and the Agent PR gate Co-Authored-By: Claude Opus 5.5 --- .github/actions/setup-nix/action.yml | 15 ++ .github/workflows/agent-ci.yml | 8 +- .github/workflows/ci.yml | 184 ++++++++++++++++ apps/maple-agent/justfile | 13 ++ docs/repository-workflows.md | 19 +- flake.nix | 14 +- justfile | 4 + scripts/ci/lanes.py | 189 ++++++++++++++++ scripts/ci/plan.py | 315 +++++++++++++++++++++++++++ scripts/ci/test_agent_workflows.py | 4 +- scripts/ci/test_nix_workflows.py | 7 + scripts/ci/test_plan.py | 297 +++++++++++++++++++++++++ scripts/ci/test_select_xcode.py | 1 + 13 files changed, 1050 insertions(+), 20 deletions(-) create mode 100644 .github/actions/setup-nix/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/ci/lanes.py create mode 100644 scripts/ci/plan.py create mode 100644 scripts/ci/test_plan.py diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 000000000..0625e7a4b --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,15 @@ +name: Set up pinned Nix +description: >- + Install Determinate Nix 3.23.3 (pinned by commit) and verify the runtime, + exactly as the inline copies in the other workflows do. +runs: + using: composite + steps: + - name: Install pinned Nix environment + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + shell: bash + run: bash scripts/ci/verify-nix-runtime.sh diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index 64096499f..d9083cb80 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -1,10 +1,9 @@ name: Maple Agent CI +# Pull requests run the Agent lanes in ci.yml; this workflow verifies master. on: push: branches: [master] - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. workflow_dispatch: # PR code, including forks, executes only on fresh managed runners with a @@ -13,9 +12,10 @@ on: permissions: contents: read +# One group per commit, never cancelled: every master push gets a full verdict. concurrency: - group: maple-agent-ci-${{ github.ref }} - cancel-in-progress: true + group: maple-agent-ci-${{ github.sha }} + cancel-in-progress: false env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..f616eae42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,184 @@ +# CI: the single required check (ci-ok) for every pull request, on any base. +# Lane selection lives in scripts/ci/lanes.py and scripts/ci/plan.py. Generic +# Nix lanes run in the `nix` matrix; a special job exists only where a step list +# cannot be expressed generically (Windows toolchain, services, OIDC, artifacts). +# Never add `paths:` here: ci-ok must always report. +name: CI + +on: + pull_request: + merge_group: + types: [checks_requested] + push: + branches: [master] + workflow_dispatch: + inputs: + lanes: + description: "Lane ids (comma-separated) or 'all'" + type: string + default: all + +permissions: + contents: read + +concurrency: + # PRs replace superseded runs. Master, queue and dispatch runs are grouped per + # SHA and never cancelled, so every master commit gets a full verdict. + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + plan: + name: plan + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: read # live PR labels: add a label, then re-run + pull-requests: read + outputs: + lanes: ${{ steps.plan.outputs.lanes }} + nix: ${{ steps.plan.outputs.nix }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + filter: blob:none + persist-credentials: false + - name: Select lanes + id: plan + env: + GH_TOKEN: ${{ github.token }} + LANES_INPUT: ${{ inputs.lanes }} + CI_LINUX_RUNNER: ${{ vars.CI_LINUX_RUNNER }} + CI_LINUX_HEAVY_RUNNER: ${{ vars.CI_LINUX_HEAVY_RUNNER }} + CI_MACOS_RUNNER: ${{ vars.CI_MACOS_RUNNER }} + run: python3 scripts/ci/plan.py github + + nix: + name: ${{ matrix.id }} + needs: plan + if: needs.plan.outputs.nix != '[]' + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.nix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Select pinned Xcode + if: runner.os == 'macOS' + run: python3 scripts/ci/select-xcode.py + - name: Rust cache (restore everywhere, save only on master) + if: matrix.cache != '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + prefix-key: ci-v1 + # rust-cache ignores `key` when `shared-key` is set, so the lockfile + # hash (which also pins the flake's toolchain) goes into the shared key. + shared-key: ${{ matrix.cache }}-${{ matrix.cache_key }} + workspaces: ${{ matrix.workspaces }} + add-rust-environment-hash-key: "false" + # The action runs cargo from the workspace directory: use an absolute flake path. + cmd-format: nix develop --no-update-lock-file ${{ github.workspace }}/${{ matrix.flake }} -c {0} + save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') }} + - name: Run lane + env: + LANE: ${{ matrix.id }} + run: python3 scripts/ci/plan.py run "$LANE" + + agent-windows: + name: agent-windows + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['agent-windows'] + runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} + timeout-minutes: 45 + defaults: + run: + shell: bash + working-directory: apps/maple-agent + env: + RUSTFLAGS: -D warnings + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install pinned Windows Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below + with: + # Equal to AGENT_WINDOWS_RUST in scripts/ci/lanes.py; agent-lint checks + # that the Agent flake's rustc still reports this version. + toolchain: 1.98.0 + components: clippy + + - name: Select Windows MSVC and SDK + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 + with: + arch: x64 + + - name: Select native Windows Perl for the static crypto build + run: | + native_perl=/c/Strawberry/perl/bin/perl.exe + if [ ! -x "$native_perl" ]; then + echo 'Native Windows Perl is missing from the runner' >&2 + exit 1 + fi + "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' + # Pin the native executable across later Git Bash steps and Rust's + # process resolver instead of depending on their PATH ordering. + native_perl_windows="$(cygpath -m "$native_perl")" + printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" + # openssl-src uses NASM if available and otherwise selects no-asm. + + - name: Pin and smoke-test the selected Windows linker + run: python3 ../../scripts/ci/agent_windows.py + + - name: Rust cache (restore everywhere, save only on master) + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + prefix-key: ci-v1 + shared-key: agent-windows + workspaces: apps/maple-agent -> target + save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') }} + + - name: Clippy (Windows) + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + ci-ok: + name: ci-ok + if: always() + needs: [plan, nix, agent-windows] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Every selected lane succeeded + env: + NEEDS: ${{ toJSON(needs) }} + LANES: ${{ needs.plan.outputs.lanes }} + NIX: ${{ needs.plan.outputs.nix }} + run: | + set -euo pipefail + if [ "$(jq -r '.plan.result' <<<"$NEEDS")" != success ]; then + echo "::error::plan did not succeed"; exit 1 + fi + # Selected => success; an unselected job may only be skipped (or succeed). + jq -n -e --argjson needs "$NEEDS" --argjson lanes "$LANES" --argjson nix "$NIX" ' + def ok($job; $selected): + if $selected then $needs[$job].result == "success" + else ($needs[$job].result | IN("skipped", "success")) end; + ok("nix"; ($nix | length) > 0) + and ([$needs | keys[] | select(. != "plan" and . != "nix")] + | all(. as $job | ok($job; $lanes[$job] // false))) + ' >/dev/null || { echo "::error::a selected lane did not succeed"; exit 1; } + echo "ci-ok: every selected lane succeeded" diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 75e17e17b..7ffd4cf07 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -30,6 +30,19 @@ store-bench-ci: # Run all the checks that CI runs. ci: fmt-check git-metadata-test clippy test +# The ci.yml agent-lint lane: format, packaging tests, clippy and the dependency graph. +ci-lint: fmt-check git-metadata-test packaging-test + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy -p maple-agent-app --all-targets --locked {{headless}} -- -D warnings + metadata="$(mktemp)"; trap 'rm -f "$metadata"' EXIT; \ + cargo metadata --locked --format-version 1 > "$metadata"; \ + python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata"; \ + python3 scripts/verify-sqlcipher-build.py "$metadata" + +# The ci.yml agent-test lane; `cargo test` builds the examples, ci-lint's clippy checks the benches. +ci-test: + RUSTFLAGS="-D warnings" cargo test --workspace --locked + # Exercise the production Git metadata helper without compiling the app graph. git-metadata-test: python3 scripts/test_git_metadata.py diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 41d1ca6cc..260c14d28 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -126,11 +126,15 @@ remain separate measurements. ## Agent compilation and cache measurements -Agent CI runs the three-platform workspace matrix, three-platform upstream -SQLCipher provider suite, and plain Linux optimized binary as independent jobs. -The final `Agent CI required jobs` check rejects failed or unexpectedly skipped -selected jobs. All retain fail-closed change selection. The plain Linux binary -and Desktop Dev/Prod packages have separate profiles and caches. +Agent PR checks run in `ci.yml` (`agent-lint`, `agent-test`, `agent-macos`; +`agent-windows` on Windows paths or the `ci:windows` label), selected by +`scripts/ci/lanes.py` and reported by its `ci-ok` job. Full macOS and Windows +builds run on master: Agent CI (`agent-ci.yml`) runs on master pushes and +dispatch only, one never-cancelled run per commit, with the three-platform +workspace matrix, three-platform upstream SQLCipher provider suite, and plain +Linux optimized binary as independent jobs. The final `Agent CI required jobs` +check rejects failed or unexpectedly skipped selected jobs. The plain Linux +binary and Desktop Dev/Prod packages have separate profiles and caches. Before restoring targets, `scripts/ci/agent_cache_scope.py` wraps the existing `agent_cache.py` identity without changing its legacy digest. It fingerprints @@ -223,10 +227,7 @@ Agent CI also compares master cumulatively against the last successful ancestor whose three workspace jobs, three provider jobs and plain Linux release job actually passed. Desktop packaging retains its independent all-six-downloaded-verifier baseline. Selector-only, failed or cancelled runs -cannot consume outstanding changes. The existing master cancellation policy -can still replace a run, but a subsequent unrelated push reselects the -unverified component changes; lookup/history uncertainty selects a build. -PRs retain their merge-base selection and superseded-preview cancellation. +cannot consume outstanding changes; lookup/history uncertainty selects a build. Unsigned builders upload seven-day `maple-agent-*-metrics-*` artifacts with actual environment identity, phase durations, logical target sizes, and bounded diff --git a/flake.nix b/flake.nix index c0aaf4a05..ef72a581b 100644 --- a/flake.nix +++ b/flake.nix @@ -741,15 +741,20 @@ workflows = pkgs.runCommand "maple-github-workflows-check" { nativeBuildInputs = with pkgs; [ actionlint python3 yq-go ]; src = ./.github/workflows; + actions = ./.github/actions; tests = ./scripts/ci/test_codeql_workflow.py; nixTests = ./scripts/ci/test_nix_workflows.py; signingTests = ./scripts/ci/test_signing_workflows.py; } '' - cd "$src" + # Lint inside a repository-shaped tree so local actions resolve. + mkdir -p "$TMPDIR/repo/.github/workflows" "$TMPDIR/repo/scripts/ci" + cp -R "$src/." "$TMPDIR/repo/.github/workflows/" + cp -R "$actions" "$TMPDIR/repo/.github/actions" + cd "$TMPDIR/repo" # actionlint 1.7.10 predates GitHub's supported concurrency.queue key. # Release-gate tests assert that the only intended value is queue: max. - for workflow in ./*.yml; do - if [ "$workflow" = ./pages-publish.yml ] || [ "$workflow" = ./auth-pages-publish.yml ] || [ "$workflow" = ./auth-pages-dev-publish.yml ]; then + for workflow in .github/workflows/*.yml; do + if [ "$workflow" = .github/workflows/pages-publish.yml ] || [ "$workflow" = .github/workflows/auth-pages-publish.yml ] || [ "$workflow" = .github/workflows/auth-pages-dev-publish.yml ]; then # It also predates environment.deployment. Pages tests require # false on the publisher jobs; keep this exception file-scoped. actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "deployment" for "environment" section' "$workflow" @@ -757,8 +762,6 @@ actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "queue" for "concurrency" section' "$workflow" fi done - mkdir -p "$TMPDIR/repo/.github/workflows" "$TMPDIR/repo/scripts/ci" - cp -R . "$TMPDIR/repo/.github/workflows/" cp "$tests" "$TMPDIR/repo/scripts/ci/test_codeql_workflow.py" python3 "$TMPDIR/repo/scripts/ci/test_codeql_workflow.py" cp "$nixTests" "$TMPDIR/repo/scripts/ci/test_nix_workflows.py" @@ -787,6 +790,7 @@ python3 scripts/ci/test_agent_change_detection.py python3 scripts/ci/test_opensecret_change_detection.py python3 scripts/ci/test_hook_change_detection.py + python3 scripts/ci/test_plan.py touch "$out" ''; diff --git a/justfile b/justfile index 09a1079b5..e5e0437c2 100644 --- a/justfile +++ b/justfile @@ -211,6 +211,10 @@ release version: git tag -a "v{{version}}" -m "Release v{{version}}" echo "Release v{{version}} created! Don't forget to push tags: git push && git push --tags" +# Run one ci.yml lane exactly as CI does (lane ids are in scripts/ci/lanes.py). +lane NAME: + python3 scripts/ci/plan.py run {{NAME}} + # GPUI Agent uses its own pinned toolchain and component working directory. agent-check: cd apps/maple-agent && nix develop --no-update-lock-file -c just ci diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py new file mode 100644 index 000000000..acdd259b4 --- /dev/null +++ b/scripts/ci/lanes.py @@ -0,0 +1,189 @@ +"""The lane table: which ci.yml checks a change selects, and how each one runs. + +Read by scripts/ci/plan.py (the ci.yml plan job, `just lane`, and lint). Globs +are repository-relative: `**` spans directories, `*` stays within one path +segment, and a leading `!` excludes paths from the globs it is listed with. + +A lane with `legacy` set is still run by that workflow. Its paths are +classified here, so they never count as unknown, but ci.yml selects nothing for +it until its migration clears `legacy` and fills in how it runs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import FrozenSet, Optional, Tuple + + +@dataclass(frozen=True) +class Runner: + label: str + variable: Optional[str] = None # repository variable that overrides the label + + +# The override variables are unset; set one to fail over during an outage. +LINUX = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_RUNNER") +LINUX_HEAVY = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_HEAVY_RUNNER") +MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") +WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") # read by ci.yml itself +# ci.yml pins this toolchain for agent-windows; agent-lint checks that the Agent +# flake's rustc still reports it. +AGENT_WINDOWS_RUST = "1.98.0" + +PR, QUEUE, PUSH, NIGHTLY, DISPATCH = ( + "pull_request", "merge_group", "push", "schedule", "workflow_dispatch") +GATE: FrozenSet[str] = frozenset({PR, QUEUE, PUSH, NIGHTLY, DISPATCH}) +PR_EVENTS: FrozenSet[str] = frozenset({PR, QUEUE}) # events that use `pr_paths` + +# Never selects anything: notes, docs and hooks at the root or a component root +# (one or two levels down). Deeper files can be build inputs: the Agent compiles +# its bundled SKILL.md files and copies app/assets/dict/LICENSE into its notices. +INERT = ( + "README.md", "AGENTS.md", "LICENSE", ".gitignore", ".repo_ignore", "repo.meta.json", "docs/**", + "*/README.md", "*/LICENSE", "*/.gitignore", "*/docs/**", "*/.githooks/**", + "*/*/README.md", "*/*/AGENTS.md", "*/*/CLAUDE.md", "*/*/LICENSE", "*/*/.gitignore", + "*/*/docs/**", "*/*/.githooks/**", + ".agents/**", "!.agents/skills/release-maple/scripts/**", + "!.agents/skills/validate-opensecret/scripts/disposable_db_tests.sh", + # Local tools that no check consumes. + "proxy/docker-compose.yml", "scripts/testing/**", +) +# Editing the planner or ci.yml selects every active gate lane. +CI_INFRA = (".github/workflows/ci.yml", ".github/actions/**", "scripts/ci/plan.py", "scripts/ci/lanes.py") + +# The Agent and the Research shell build the Rust SDK and proxy from the tree. +RUST_SDK = ("sdk/rust/Cargo.toml", "sdk/rust/build.rs", "sdk/rust/src/**", "sdk/rust/assets/**") +PROXY_RUNTIME = ("proxy/Cargo.toml", "proxy/build.rs", "proxy/src/**") +# Build scripts Cargo would pick up if they were added; lint allows them to be absent. +NOT_YET_TRACKED = frozenset({"sdk/rust/build.rs", "proxy/build.rs"}) +SDK_TS = ("sdk/src/**", "sdk/package.json", "sdk/bun.lock", "sdk/bunfig.toml", + "sdk/tsconfig*.json", "sdk/vite.config.ts", "sdk/.npmrc", "sdk/flake.*") + +AGENT = ("apps/maple-agent/**",) +STORE = "apps/maple-agent/crates/maple-agent/src/store/" +AGENT_WINDOWS = ( + "apps/maple-agent/**/*windows*", "apps/maple-agent/app/build.rs", + STORE + "paths.rs", STORE + "keys.rs", STORE + "keys/**", STORE + "lifecycle.rs", STORE + "lifecycle/**", + "scripts/ci/agent_windows.py", +) +AGENT_PACKAGING = tuple("apps/maple-agent/" + glob for glob in ( + "scripts/build-*", "scripts/package-*", "scripts/verify-*", "release-profiles.json", + "release_profile.rs", "about.toml", "app/build.rs", "app/packaging/**", "flake.lock", +)) + (".github/workflows/agent-desktop-build.yml",) +RESEARCH = "apps/maple-research/frontend/" +TAURI = RESEARCH + "src-tauri/" + + +@dataclass(frozen=True) +class Lane: + id: str + paths: Tuple[str, ...] # own inputs + also: Tuple[str, ...] = () # CI-only fan-out to consumers + pr_paths: Optional[Tuple[str, ...]] = None # narrower selector for PRs and the merge queue + labels: Tuple[str, ...] = ("ci:full",) # PR labels that add the lane + events: FrozenSet[str] = GATE + job: str = "nix" # the generic matrix, or a special ci.yml job id + runner: Runner = LINUX + flake: Optional[str] = None # `nix develop` flake, from the repository root + workdir: str = "." + run: str = "" + env: Tuple[Tuple[str, str], ...] = () + cache: str = "" # rust-cache shared key under the ci-v1 prefix + workspaces: str = "" + cache_files: Tuple[str, ...] = () # hashed into the cache key + timeout: int = 15 + budget: float = 5.0 # p50 minutes, shown in the plan summary + gate: bool = True # selected by CI_INFRA edits + legacy: Optional[str] = None # the workflow that still runs this lane + + +AGENT_FLAKE = dict( + flake="./apps/maple-agent", workdir="apps/maple-agent", workspaces="apps/maple-agent -> target", + cache_files=("apps/maple-agent/flake.lock", "apps/maple-agent/Cargo.lock"), + env=(("RUSTFLAGS", "-D warnings"),), +) + +LANES: Tuple[Lane, ...] = ( + Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), + run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", + cache="agent-lint", timeout=15, budget=4, **AGENT_FLAKE), + Lane("agent-test", AGENT, also=RUST_SDK + PROXY_RUNTIME, runner=LINUX_HEAVY, + run="just ci-test", cache="agent-test", timeout=20, budget=6.5, **AGENT_FLAKE), + Lane("agent-macos", AGENT, + also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json"), + runner=MACOS, run="cargo clippy --workspace --all-targets --locked -- -D warnings", + cache="agent-macos", timeout=25, budget=5, **AGENT_FLAKE), + # PRs get Windows clippy only for Windows-specific paths or the ci:windows label. + Lane("agent-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=AGENT_WINDOWS, + labels=("ci:windows", "ci:full"), job="agent-windows", runner=WINDOWS, timeout=45, budget=8), + + # Legacy lanes. Lanes that differ only by OS (agent-package, research-desktop) + # are split when they move, and OpenSecret will route through + # opensecret_change_detection.py. + Lane("agent-full", AGENT, legacy="agent-ci.yml"), + Lane("agent-supply-chain", tuple("apps/maple-agent/" + glob for glob in ( + "Cargo.lock", "**/Cargo.toml", "deny.toml", "about.toml", "flake.lock", "dependencies/**", + "scripts/generate-third-party-notices.py", "scripts/audit-sqlcipher-dependencies.py", + )), legacy="agent-sqlcipher-supply-chain.yml"), + Lane("agent-provider-linux", tuple("apps/maple-agent/" + glob for glob in ( + "Cargo.lock", "dependencies/sqlcipher/**", "scripts/test-rusqlite-provider.py", + "scripts/test_rusqlite_provider.py", + )), legacy="agent-ci.yml"), + Lane("agent-package", AGENT_PACKAGING, labels=("ci:package", "ci:full"), legacy="agent-desktop-build.yml"), + Lane("agent-perf", (), labels=("ci:perf",), legacy="agent-store-performance.yml"), + Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", + "apps/maple-agent/release-profiles.json", "apps/maple-agent/scripts/release-info.py"), + legacy="agent-updates-tests.yml"), + Lane("repo", ( + ".github/**", "scripts/ci/**", ".githooks/**", "flake.nix", "flake.lock", "justfile", "setup-hooks.sh", + ".agents/skills/release-maple/scripts/**", "scripts/update-version.py", "apps/maple-research/zapstore.yaml", + RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", + ), legacy="repository-checks.yml, release-gates-tests.yml, pages-tests.yml"), + Lane("sdk-ts", SDK_TS + ("sdk/.prettierrc.json", "sdk/eslint.config.js", "sdk/justfile", "sdk/.env.example", + "sdk/LICENSE.md", "scripts/ci/build-sdk-publish.sh", + "scripts/ci/check-sdk-package-consumers.py"), + legacy="sdk-typescript.yml, sdk-publish-tests.yml"), + Lane("sdk-rust", ("sdk/rust/**", "sdk/rust-toolchain.toml", "sdk/flake.*", "scripts/ci/build-sdk-publish.sh", + "scripts/ci/sdk_crates_upload.py"), + legacy="sdk-rust.yml, sdk-publish-tests.yml"), + Lane("proxy", ("proxy/**", "!proxy/Dockerfile"), also=RUST_SDK, legacy="proxy-rust.yml"), + Lane("proxy-container", ("proxy/Dockerfile", ".dockerignore"), legacy="proxy-container.yml"), + Lane("deps", ("apps/maple-research/deny.toml", TAURI + "Cargo.*", "sdk/deny.toml", "sdk/rust/Cargo.*", + "proxy/deny.toml", "proxy/Cargo.*", "services/opensecret/deny.toml", + "services/opensecret/Cargo.*"), + legacy="supply-chain.yml, sdk-supply-chain.yml, proxy-supply-chain.yml, opensecret-ci.yml"), + Lane("research-web", (RESEARCH + "**", "!" + TAURI + "**", "scripts/prepare-*.sh", "scripts/ci/frontend.sh", + "scripts/ci/web.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), + legacy="frontend-tests.yml, web-build.yml"), + Lane("research-rust", (TAURI + "**", "!" + TAURI + "gen/**", "scripts/ci/rust.sh", + "scripts/ci/verify-local-rust-deps.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), + also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), legacy="rust-tests.yml"), + Lane("research-ios", (TAURI + "gen/apple/**", TAURI + "icons/ios/**", TAURI + "capabilities/mobile-ios.json", + TAURI + "tauri.ios-dev.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", + TAURI + "scripts/*ios*", TAURI + "scripts/canonicalize-static-archive.py", + TAURI + "scripts/onnxruntime-pins.sh", "scripts/ci/ios-*.sh", + "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"), + labels=("ci:ios", "ci:full"), legacy="mobile-pr-build.yml"), + Lane("research-android", (TAURI + "gen/android/**", TAURI + "icons/android/**", + TAURI + "capabilities/mobile-android.json", TAURI + "scripts/*android*", + "scripts/ci/android-*.sh"), + labels=("ci:android", "ci:full"), legacy="android-pr-build.yml"), + Lane("research-desktop", (TAURI + "tauri*.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", + TAURI + "build.rs", TAURI + "resources/windows/**", TAURI + "dmg/**", + TAURI + "scripts/provide-*-onnxruntime.sh", "scripts/ci/desktop-*.sh", + "scripts/ci/_common.sh", "scripts/ci/tauri_bundle_type.py", "scripts/ci/canonical-*.py", + "scripts/ci/dmg-settings.py", "scripts/ci/verify-release-artifacts.sh", + "scripts/ci/attestation-manifest.sh", "scripts/ci/*.ps1"), + labels=("ci:package", "ci:full"), legacy="desktop-pr-build.yml"), + Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", + "scripts/ci/pages_artifact.py", "flake.lock"), + legacy="auth-pages-ci.yml"), + Lane("updates", ("services/updates/**", "services/download-tracking/**", "scripts/ci/updates.sh"), + legacy="updates-tests.yml"), + Lane("os-rust", ("services/opensecret/**", ".gitmodules"), legacy="opensecret-ci.yml"), + Lane("os-sdk-e2e", ("sdk/test/**", ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh"), + also=RUST_SDK + SDK_TS, legacy="sdk-integration.yml"), + Lane("os-eif", ("scripts/ci/build_opensecret_eif.sh", "scripts/ci/check_opensecret_eif.sh", + "scripts/ci/verify_opensecret_eif_rootfs.py", "scripts/ci/fixtures/eif/**"), + legacy="opensecret-eif.yml"), +) diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py new file mode 100644 index 000000000..0b900d66e --- /dev/null +++ b/scripts/ci/plan.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Select and run the ci.yml lanes defined in scripts/ci/lanes.py. + + github ci.yml's plan job: write the `lanes` and `nix` outputs and a summary + run LANE run one lane's command in its flake, as CI does (`just lane LANE`) + explain show what changed paths select (stdin, or --base/--head) + check-rust the Agent flake's rustc must equal the agent-windows pin + lint the table classifies every tracked file and matches ci.yml +""" + +from __future__ import annotations + +import argparse +import functools +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +from typing import Dict, Iterable, List, Optional, Sequence + +from lanes import (AGENT_WINDOWS_RUST, CI_INFRA, DISPATCH, INERT, LANES, NIGHTLY, NOT_YET_TRACKED, PR, + PR_EVENTS, PUSH, QUEUE, Lane) + +ROOT = Path(__file__).resolve().parents[2] +CI_WORKFLOW = ROOT / ".github/workflows/ci.yml" +Selection = Dict[str, List[str]] # lane id -> why it was selected + + +@functools.lru_cache(maxsize=None) +def _pattern(glob: str) -> "re.Pattern[str]": + regex = re.escape(glob).replace(r"\*\*/", "(?:[^/]+/)*").replace(r"\*\*", ".*").replace(r"\*", "[^/]*") + return re.compile(regex) + + +def matches(path: str, globs: Iterable[str]) -> bool: + globs = tuple(globs) + return (any(_pattern(g).fullmatch(path) for g in globs if not g.startswith("!")) + and not any(_pattern(g[1:]).fullmatch(path) for g in globs if g.startswith("!"))) + + +def lane_globs(lane: Lane, event: Optional[str] = None) -> tuple: + if event in PR_EVENTS and lane.pr_paths is not None: + return lane.pr_paths + return lane.paths + lane.also + (lane.pr_paths or ()) + + +def malformed(path: str) -> bool: + return not path or path.startswith("/") or ".." in path.split("/") + + +def inert(path: str) -> bool: + return not malformed(path) and matches(path, INERT) + + +def classified(path: str) -> bool: + """Owned by some lane, active or legacy.""" + return not malformed(path) and ( + matches(path, CI_INFRA) or any(matches(path, lane_globs(lane)) for lane in LANES)) + + +def active(event: str) -> List[Lane]: + return [lane for lane in LANES if lane.legacy is None and event in lane.events] + + +def everything(event: str, why: str) -> Selection: + return {lane.id: [why] for lane in active(event)} + + +def select(event: str, paths: Iterable[str], labels: Sequence[str] = ()) -> Selection: + """Active lanes that the changed paths and PR labels select for `event`.""" + lanes = active(event) + chosen: Selection = {} + for path in paths: + if inert(path): + continue + if not classified(path): + # Fail safe: an unknown path may be an input of anything. + for lane in lanes: + chosen.setdefault(lane.id, []).append(f"unclassified {path}") + continue + infra = matches(path, CI_INFRA) + for lane in lanes: + if (infra and lane.gate) or matches(path, lane_globs(lane, event)): + chosen.setdefault(lane.id, []).append(path) + for label in labels: + for lane in lanes: + if label in lane.labels: + chosen.setdefault(lane.id, []).append(f"label {label}") + return chosen + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], cwd=ROOT, check=True, capture_output=True, text=True).stdout + + +def changed_paths(base: str, head: str, merge_base: bool) -> Optional[List[str]]: + """Paths changed from `base` (or its merge base with `head`) to `head`; None + when there is no usable base, such as a new branch or a force-push.""" + for sha in (base, head): + if not re.fullmatch(r"[0-9a-f]{40}", sha or "") or set(sha) == {"0"}: + return None + if subprocess.run(["git", "cat-file", "-e", f"{sha}^{{commit}}"], cwd=ROOT, + capture_output=True).returncode: + return None + if merge_base: + base = git("merge-base", base, head).strip() + return [p for p in git("diff", "--name-only", "--no-renames", "-z", base, head).split("\0") if p] + + +def pr_labels(repository: str, number: int) -> List[str]: + # Read live, so adding a label and re-running the job takes effect. + if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")): + return [] + result = subprocess.run(["gh", "api", "--paginate", f"repos/{repository}/issues/{number}/labels", + "--jq", ".[].name"], check=True, capture_output=True, text=True) + return result.stdout.splitlines() + + +def plan_event(event: str, payload: dict) -> Selection: + if event == DISPATCH: + names = [n.strip() for n in (os.environ.get("LANES_INPUT") or "all").split(",") if n.strip()] + if names in ([], ["all"]): + return everything(event, "dispatch: all") + unknown = sorted(set(names) - {lane.id for lane in active(event)}) + if unknown: + raise ValueError(f"not an active lane: {', '.join(unknown)}") + return {name: ["dispatch"] for name in names} + if event == NIGHTLY: + return everything(event, "nightly") + labels: List[str] = [] + if event == PR: + pull = payload["pull_request"] + base, head = pull["base"]["sha"], pull["head"]["sha"] + labels = pr_labels(os.environ["GITHUB_REPOSITORY"], pull["number"]) + elif event == QUEUE: + base, head = payload["merge_group"]["base_sha"], payload["merge_group"]["head_sha"] + elif event == PUSH: + base, head = payload["before"], payload["after"] + else: + raise ValueError(f"unsupported event: {event}") + paths = changed_paths(base, head, merge_base=event == PR) + if paths is None: + return everything(event, f"no usable diff for {base[:12]}..{head[:12]}") + return select(event, paths, labels) + + +def cache_key(lane: Lane) -> str: + digest = hashlib.sha256() + for name in lane.cache_files: + digest.update((ROOT / name).read_bytes()) + return digest.hexdigest()[:16] + + +def outputs(selected: Selection) -> tuple: + lanes = {lane.id: lane.id in selected for lane in LANES if lane.legacy is None} + nix = [{ + "id": lane.id, + "runner": os.environ.get(lane.runner.variable or "") or lane.runner.label, + "timeout": lane.timeout, + "flake": lane.flake or "", + "cache": lane.cache, + "cache_key": cache_key(lane) if lane.cache else "", + "workspaces": lane.workspaces, + } for lane in LANES if lane.id in selected and lane.job == "nix"] + return json.dumps(lanes, separators=(",", ":")), json.dumps(nix, separators=(",", ":")) + + +def summary(event: str, selected: Selection) -> str: + lines = [f"### Lanes for this {event}", ""] + if not selected: + return "\n".join(lines + ["No lane selected."]) + "\n" + lines += ["| Lane | Why | Budget (p50) |", "|---|---|---|"] + for lane in LANES: + reasons = selected.get(lane.id) + if reasons: + more = f" (+{len(reasons) - 1} more)" if len(reasons) > 1 else "" + lines.append(f"| `{lane.id}` | `{reasons[0]}`{more} | {lane.budget:g} min |") + return "\n".join(lines) + "\n" + + +def github() -> int: + event = os.environ["GITHUB_EVENT_NAME"] + try: + selected = plan_event(event, json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())) + except Exception as error: # noqa: BLE001 - every failure is handled the same way + if event in PR_EVENTS: + print(f"::error::lane selection failed: {error!r}") + return 1 + # Master, nightly and dispatch runs verify everything rather than nothing. + print(f"::warning::lane selection failed ({error!r}); selecting every lane") + selected = everything(event, "selection failed") + lanes, nix = outputs(selected) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"lanes={lanes}\nnix={nix}\n") + text = summary(event, selected) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as output: + output.write(text) + print(text, end="") + return 0 + + +def explain(event: str, base: Optional[str], head: Optional[str]) -> int: + if base and head: + paths = changed_paths(git("rev-parse", base).strip(), git("rev-parse", head).strip(), + merge_base=event == PR) + if paths is None: + print(f"no usable diff for {base}..{head}") + return 1 + else: + data = sys.stdin.read() + paths = [p for p in re.split("\0" if "\0" in data else "\n", data) if p.strip()] + selected = select(event, paths) + print(f"{event}: {len(paths)} changed paths") + for lane in LANES: + reasons = selected.get(lane.id) + if reasons: + more = f" (+{len(reasons) - 1} more)" if len(reasons) > 1 else "" + print(f" {lane.id}: {reasons[0]}{more}") + unknown = [p for p in paths if not inert(p) and not classified(p)] + if unknown: + print(" unclassified, so every lane runs: " + ", ".join(unknown)) + legacy = sorted({f"{lane.id} ({lane.legacy})" for path in paths for lane in LANES + if lane.legacy and not inert(path) and matches(path, lane_globs(lane))}) + if legacy: + print(" still run by their own workflows: " + "; ".join(legacy)) + if not selected: + print(" no ci.yml lane") + return 0 + + +def run(name: str) -> int: + lane = next((lane for lane in LANES if lane.id == name), None) + if lane is None or not lane.run: + print(f"{name}: no ci.yml command in scripts/ci/lanes.py", file=sys.stderr) + return 2 + command = ["bash", "-euo", "pipefail", "-c", f"cd {shlex.quote(lane.workdir)} && {lane.run}"] + if lane.flake: + command = ["nix", "develop", "--no-update-lock-file", lane.flake, "-c", *command] + print("+ " + " ".join(shlex.quote(part) for part in command), flush=True) + return subprocess.run(command, cwd=ROOT, env={**os.environ, **dict(lane.env)}).returncode + + +def check_rust(expected: str = AGENT_WINDOWS_RUST) -> int: + reported = subprocess.run(["rustc", "--version"], check=True, capture_output=True, text=True).stdout + version = reported.split()[1] + if version != expected: + print(f"::error::The Agent flake has rustc {version}, but ci.yml's agent-windows job pins {expected}. " + "Update AGENT_WINDOWS_RUST in scripts/ci/lanes.py and the ci.yml toolchain together.") + return 1 + print(f"rustc {version} matches the agent-windows pin") + return 0 + + +def tracked_files() -> List[str]: + if (ROOT / ".git").exists(): # tracked, plus new files not yet added + return [p for p in git("ls-files", "-z", "--cached", "--others", "--exclude-standard").split("\0") if p] + # A flake check sees a copy of exactly the tracked files, without .git. + return sorted(str(p.relative_to(ROOT)) for p in ROOT.rglob("*") if p.is_file()) + + +def lint_problems(files: Sequence[str]) -> List[str]: + problems = [] + globs = {g.lstrip("!") for g in INERT + CI_INFRA} | {g.lstrip("!") for lane in LANES for g in lane_globs(lane)} + problems += [f"matches no tracked file: {g}" for g in sorted(globs - NOT_YET_TRACKED) + if not any(_pattern(g).fullmatch(f) for f in files)] + problems += [f"unclassified: {f}" for f in files if not inert(f) and not classified(f)] + ids = [lane.id for lane in LANES] + problems += [f"duplicate lane: {i}" for i in sorted({i for i in ids if ids.count(i) > 1})] + workflow = CI_WORKFLOW.read_text() + jobs = set(re.findall(r"^ ([A-Za-z0-9_-]+):", workflow.split("\njobs:\n", 1)[1], re.M)) + special = {lane.job for lane in LANES if lane.legacy is None and lane.job != "nix"} + if jobs - {"plan", "nix", "ci-ok"} != special: + problems.append(f"ci.yml special jobs {sorted(jobs - {'plan', 'nix', 'ci-ok'})} != lanes {sorted(special)}") + if f"toolchain: {AGENT_WINDOWS_RUST}\n" not in workflow: + problems.append(f"ci.yml does not pin agent-windows to {AGENT_WINDOWS_RUST}") + declared = set(re.findall(r"^\s+- (\S+)$", (ROOT / ".github/actionlint.yaml").read_text(), re.M)) + for lane in LANES: + if lane.runner.label not in declared and not re.match(r"(ubuntu|macos|windows)-", lane.runner.label): + problems.append(f"{lane.id}: runner {lane.runner.label} is not in .github/actionlint.yaml") + return problems + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + modes = parser.add_subparsers(dest="mode", required=True) + modes.add_parser("github") + modes.add_parser("run").add_argument("lane") + explain_parser = modes.add_parser("explain") + explain_parser.add_argument("--event", default=PR, choices=(PR, QUEUE, PUSH)) + explain_parser.add_argument("--base") + explain_parser.add_argument("--head") + modes.add_parser("check-rust") + modes.add_parser("lint") + args = parser.parse_args(argv) + if args.mode == "github": + return github() + if args.mode == "run": + return run(args.lane) + if args.mode == "explain": + return explain(args.event, args.base, args.head) + if args.mode == "check-rust": + return check_rust() + files = tracked_files() + problems = lint_problems(files) + print("\n".join(problems) if problems else f"lanes.py classifies all {len(files)} tracked files") + return 1 if problems else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 936565b4b..6e6925a7b 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -165,9 +165,9 @@ def test_windows_crypto_pins_native_perl_only_after_required_module_probe(self): def test_contributor_build_has_no_credentials_or_privileged_events(self): config = workflow("agent-ci.yml") - self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) + # Pull requests run the Agent lanes in ci.yml. + self.assertEqual(set(config["on"]), {"push", "workflow_dispatch"}) self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertIsNone(config["on"]["pull_request"]) self.assertEqual(config["permissions"], {"contents": "read"}) for value in strings({key: value for key, value in config.items() if key != "jobs"}): self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") diff --git a/scripts/ci/test_nix_workflows.py b/scripts/ci/test_nix_workflows.py index 691126aa0..393e32406 100644 --- a/scripts/ci/test_nix_workflows.py +++ b/scripts/ci/test_nix_workflows.py @@ -78,6 +78,13 @@ def test_every_nix_installer_is_exactly_pinned_after_checkout(self): self.assertEqual(len(installs), EXPECTED_INSTALLS) + def test_setup_nix_action_installs_the_pinned_runtime_then_verifies_it(self): + steps = workflow(ROOT / ".github/actions/setup-nix/action.yml")["runs"]["steps"] + self.assertEqual(steps[0]["uses"], PINNED_ACTION) + self.assertEqual(steps[0]["with"], PINNED_INPUTS) + self.assertEqual({key: steps[1].get(key) for key in VERIFY_STEP}, VERIFY_STEP) + self.assertEqual(len(steps), 2) + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py new file mode 100644 index 000000000..aae1fcb10 --- /dev/null +++ b/scripts/ci/test_plan.py @@ -0,0 +1,297 @@ +"""Lane selection: the table, the planner's events, and the Agent selector it replaces.""" + +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +import lanes +import plan +from lanes import DISPATCH, NIGHTLY, PR, PUSH, QUEUE + + +ACTIVE = {lane.id for lane in lanes.LANES if lane.legacy is None} +AGENT_PR = {"agent-lint", "agent-test", "agent-macos"} +SHA_A, SHA_B = "a" * 40, "b" * 40 + + +def selected(paths, event=PR, labels=()): + return set(plan.select(event, paths, labels)) + + +def owners(path): + return {lane.id for lane in lanes.LANES if plan.matches(path, plan.lane_globs(lane))} + + +def agent(paths, event=PR): + """The decision agent_change_detection.py made: does any Agent check run?""" + return any(lane.startswith("agent-") for lane in selected(paths, event)) + + +class PortedAgentSelectorTests(unittest.TestCase): + """Every case of test_agent_change_detection.py, as an Agent lane decision.""" + + def test_agent_runtime_build_and_asset_inputs_select_agent(self): + for path in ( + "apps/maple-agent/app/src/main.rs", "apps/maple-agent/app/assets/fonts/Maple.ttf", + "apps/maple-agent/crates/maple-agent/src/agent.rs", "apps/maple-agent/Cargo.toml", + "apps/maple-agent/Cargo.lock", "apps/maple-agent/flake.nix", "apps/maple-agent/flake.lock", + "apps/maple-agent/rust-toolchain.toml", "apps/maple-agent/justfile", + "apps/maple-agent/scripts/macos-debug-app.sh", "apps/maple-agent/scripts/build-release.sh", + "apps/maple-agent/scripts/package-release.sh", "apps/maple-agent/scripts/verify-release.sh", + "apps/maple-agent/release_profile.rs", "apps/maple-agent/release-profiles.json", + "apps/maple-agent/app/packaging/macos-entitlements.plist", "apps/maple-agent/app/macos/Info.plist", + "apps/maple-agent/new-build-input", + # Compiled into the binary with include_str!, so nested notes are not inert. + "apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md", + ): + with self.subTest(path=path): + self.assertEqual(selected([path]), AGENT_PR) + + def test_rust_sdk_and_proxy_runtime_inputs_select_agent(self): + for path in ("proxy/Cargo.toml", "proxy/src/proxy.rs", "proxy/build.rs", "sdk/rust/Cargo.toml", + "sdk/rust/src/client.rs", "sdk/rust/build.rs", "sdk/rust/assets/aws_nitro_root.der"): + with self.subTest(path=path): + self.assertEqual(selected([path]), AGENT_PR) + self.assertEqual(selected([path], PUSH), ACTIVE) + + def test_research_typescript_and_independent_services_skip_agent(self): + for path in ( + "apps/maple-auth/src/main.tsx", "apps/maple-auth/package.json", "apps/maple-auth/bun.lock", + "apps/maple-auth/vite.config.ts", "apps/maple-research/frontend/src/main.tsx", + "apps/maple-research/frontend/src-tauri/src/lib.rs", "sdk/src/lib/index.ts", "sdk/package.json", + "sdk/flake.nix", "services/updates/src/index.ts", "services/opensecret/src/main.rs", + ".github/workflows/desktop-pr-build.yml", "scripts/ci/desktop-pr.sh", + ): + with self.subTest(path=path): + self.assertTrue(plan.classified(path)) + self.assertFalse(agent([path])) + + def test_docs_and_standalone_dependency_inputs_skip_agent(self): + for path in ( + "apps/maple-agent/README.md", "apps/maple-agent/AGENTS.md", "apps/maple-agent/CLAUDE.md", + "apps/maple-agent/LICENSE", "apps/maple-agent/docs/development.md", "README.md", + "sdk/rust/README.md", "sdk/rust/tests/client.rs", "sdk/rust/Cargo.lock", + "sdk/rust/examples/api_usage.rs", "sdk/src/lib/index.ts", "sdk/package.json", + "proxy/README.md", "proxy/tests/health.rs", "proxy/Cargo.lock", "proxy/Dockerfile", "proxy/flake.nix", + "apps/maple-agent/.githooks/pre-commit", + ): + with self.subTest(path=path): + self.assertFalse(agent([path])) + self.assertFalse(agent([path], PUSH)) + + def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): + self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint"}) + for path in ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"): + with self.subTest(path=path): + self.assertEqual(selected([path]), {"agent-macos"}) + self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows"}) + + def test_intentional_differences_select_only_the_legacy_repo_lane(self): + for path in ( + # The Agent flake has no root inputs. + "flake.nix", "flake.lock", + # These drive agent-ci.yml and agent-desktop-build.yml, which still own them. + ".github/workflows/agent-ci.yml", ".github/workflows/agent-desktop-build.yml", + "scripts/ci/agent_change_detection.py", "scripts/ci/agent_master_selection.py", + "scripts/ci/agent_cache.py", "scripts/ci/agent_cache_scope.py", "scripts/ci/agent_cache_command.py", + "scripts/ci/agent_dependency_archive.py", "scripts/ci/agent_measure.py", "scripts/ci/change_detection.py", + # Signing scripts: no Agent PR lane signs anything. + "scripts/ci/windows-artifact-sign.ps1", "scripts/ci/install-windows-artifact-signing.ps1", + ): + with self.subTest(path=path): + self.assertIn("repo", owners(path)) + self.assertEqual(selected([path]), set()) + self.assertEqual(selected([path], PUSH), set()) + + def test_unknown_roots_and_malformed_paths_select_every_lane(self): + for path in ("new-build-config.toml", "newroot/x", "", "/tmp/file", "../file", "docs/../flake.nix", + "apps/maple-agent/../maple-research/frontend/src/main.tsx"): + with self.subTest(path=path): + self.assertEqual(selected([path]), ACTIVE) + self.assertEqual(selected([path], PUSH), ACTIVE) + + def test_mixed_changes_and_empty_diff(self): + self.assertEqual(selected([]), set()) + self.assertEqual(selected(["README.md", "sdk/rust/README.md"]), set()) + self.assertEqual(selected(["README.md", "proxy/src/proxy.rs"]), AGENT_PR) + + def test_explain_reads_null_delimited_names(self): + for data, expected in ((b"README.md\0apps/maple-agent/app/assets/a\nspace name\0", "agent-lint"), + (b"README.md\0apps/maple-agent/docs/design notes.md\0", "no ci.yml lane"), + (b"", "no ci.yml lane")): + result = subprocess.run([sys.executable, str(Path(plan.__file__)), "explain"], input=data, + check=True, capture_output=True) + self.assertIn(expected, result.stdout.decode()) + + +class LaneTableTests(unittest.TestCase): + def test_typescript_sdk_never_selects_a_client_lane(self): + self.assertEqual(selected(["sdk/src/index.ts"], PUSH), set()) + self.assertFalse({o for o in owners("sdk/src/index.ts") if o.startswith(("agent-", "research-"))}) + + def test_rust_sdk_tests_and_proxy_container_select_no_agent_lane(self): + self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), set()) + self.assertEqual(selected(["proxy/src/main.rs"]), AGENT_PR) + self.assertEqual(selected(["proxy/Dockerfile"], PUSH), set()) + + def test_windows_clippy_runs_on_prs_only_for_windows_paths_or_labels(self): + windows = "apps/maple-agent/app/src/host/windows_spawn.rs" + other = "apps/maple-agent/app/src/ui/x.rs" + self.assertIn("agent-windows", selected([windows])) + self.assertIn("agent-windows", selected([windows], QUEUE)) + self.assertNotIn("agent-windows", selected([other])) + self.assertIn("agent-windows", selected([other], PUSH)) + self.assertIn("agent-windows", selected(["docs/x.md"], labels=["ci:windows"])) + self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), ACTIVE) + self.assertEqual(selected(["docs/x.md"], labels=["unrelated"]), set()) + + def test_inert_paths_select_nothing(self): + for path in ("apps/maple-agent/docs/x.md", "docs/x.md", ".agents/skills/x/SKILL.md"): + with self.subTest(path=path): + self.assertTrue(plan.inert(path)) + self.assertEqual(selected([path], PUSH), set()) + evidence = ".agents/skills/release-maple/scripts/ci_evidence.py" + self.assertFalse(plan.inert(evidence)) + self.assertEqual(owners(evidence), {"repo"}) + self.assertEqual(selected([evidence]), set()) + + def test_ci_infrastructure_selects_every_active_gate_lane(self): + for path in (".github/workflows/ci.yml", ".github/actions/setup-nix/action.yml", + "scripts/ci/plan.py", "scripts/ci/lanes.py"): + with self.subTest(path=path): + self.assertEqual(selected([path]), {lane.id for lane in lanes.LANES + if lane.legacy is None and lane.gate}) + + def test_legacy_components_are_classified_but_select_nothing(self): + for path in ("services/opensecret/src/main.rs", "apps/maple-research/frontend/src/App.tsx"): + with self.subTest(path=path): + self.assertTrue(plan.classified(path)) + self.assertEqual(selected([path], PUSH), set()) + + def test_every_tracked_file_is_classified_and_ci_yml_matches(self): + self.assertEqual(plan.lint_problems(plan.tracked_files()), []) + self.assertIn("unclassified: newroot/x", plan.lint_problems(["newroot/x"])) + + +class PlannerTests(unittest.TestCase): + def github(self, event, payload, **env): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "event.json").write_text(json.dumps(payload)) + env.update(GITHUB_EVENT_NAME=event, GITHUB_EVENT_PATH=str(root / "event.json"), + GITHUB_OUTPUT=str(root / "output"), GITHUB_REPOSITORY="MaplePrivacyLabs/Maple", + PATH=os.environ.get("PATH", "")) # no GH_TOKEN: labels read as none + with mock.patch.dict(os.environ, env, clear=True), contextlib.redirect_stdout(io.StringIO()): + code = plan.github() + output = (root / "output").read_text() if code == 0 else "" + return code, {key: json.loads(value) for key, value in + (line.split("=", 1) for line in output.splitlines())} + + def test_push_without_a_usable_base_selects_everything(self): + for before in ("0" * 40, SHA_A, ""): + with self.subTest(before=before): + code, outputs = self.github(PUSH, {"before": before, "after": SHA_B}) + self.assertEqual(code, 0) + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, ACTIVE) + + def test_schedule_and_dispatch(self): + _, outputs = self.github(NIGHTLY, {}) + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, + {lane.id for lane in lanes.LANES if lane.legacy is None and NIGHTLY in lane.events}) + _, outputs = self.github(DISPATCH, {}, LANES_INPUT="agent-test") + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, {"agent-test"}) + self.assertEqual([entry["id"] for entry in outputs["nix"]], ["agent-test"]) + + def test_malformed_event_fails_a_pr_and_selects_everything_on_push(self): + self.assertEqual(self.github(PR, {"pull_request": {}})[0], 1) + self.assertEqual(self.github(QUEUE, {})[0], 1) + code, outputs = self.github(PUSH, {}) + self.assertEqual(code, 0) + self.assertTrue(all(outputs["lanes"].values())) + code, outputs = self.github(DISPATCH, {}, LANES_INPUT="no-such-lane") + self.assertEqual((code, all(outputs["lanes"].values())), (0, True)) + + def test_outputs_empty_matrix_and_runner_overrides(self): + lanes_json, nix_json = plan.outputs({}) + self.assertEqual(nix_json, "[]") + self.assertEqual(json.loads(lanes_json), dict.fromkeys(sorted(ACTIVE), False)) + with mock.patch.dict(os.environ, {"CI_LINUX_HEAVY_RUNNER": "blacksmith-16vcpu-ubuntu-2404"}): + entries = json.loads(plan.outputs(dict.fromkeys(ACTIVE, ["x"]))[1]) + runners = {entry["id"]: entry["runner"] for entry in entries} + self.assertEqual(runners, {"agent-lint": "blacksmith-8vcpu-ubuntu-2404", + "agent-test": "blacksmith-16vcpu-ubuntu-2404", + "agent-macos": "blacksmith-6vcpu-macos-26"}) + self.assertTrue(all(len(entry["cache_key"]) == 16 for entry in entries)) + + def test_run_executes_the_lane_command_in_its_flake(self): + with mock.patch.object(plan.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + run.return_value.returncode = 0 + self.assertEqual(plan.run("agent-test"), 0) + self.assertEqual(plan.run("agent-windows"), 2) # a special ci.yml job + command, = run.call_args.args + self.assertEqual(command, ["nix", "develop", "--no-update-lock-file", "./apps/maple-agent", "-c", + "bash", "-euo", "pipefail", "-c", "cd apps/maple-agent && just ci-test"]) + self.assertEqual(run.call_args.kwargs["env"]["RUSTFLAGS"], "-D warnings") + + def test_check_rust_compares_the_flake_compiler_with_the_windows_pin(self): + for reported, expected in ((f"rustc {lanes.AGENT_WINDOWS_RUST} (abc 2026-01-01)\n", 0), + ("rustc 1.99.0 (abc 2026-01-01)\n", 1)): + with mock.patch.object(plan.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()): + run.return_value.stdout = reported + self.assertEqual(plan.check_rust(), expected) + + +class DiffTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + patcher = mock.patch.object(plan, "ROOT", self.root) + patcher.start() + self.addCleanup(patcher.stop) + self.git("init", "-q", "-b", "master") + self.base = self.commit("README.md") + + def git(self, *args): + env = {**os.environ, "GIT_AUTHOR_NAME": "CI", "GIT_AUTHOR_EMAIL": "ci@example.invalid", + "GIT_COMMITTER_NAME": "CI", "GIT_COMMITTER_EMAIL": "ci@example.invalid"} + return subprocess.run(["git", "-c", "core.hooksPath=/dev/null", *args], cwd=self.root, env=env, + check=True, capture_output=True, text=True).stdout.strip() + + def commit(self, path): + (self.root / path).parent.mkdir(parents=True, exist_ok=True) + (self.root / path).write_text(path) + self.git("add", "--", path) + self.git("commit", "-qm", path) + return self.git("rev-parse", "HEAD") + + def test_pull_requests_diff_from_the_merge_base(self): + master = self.commit("apps/maple-agent/app/src/main.rs") + self.git("checkout", "-qb", "topic", self.base) + docs = self.commit("apps/maple-agent/docs/design.md") + self.assertEqual(plan.changed_paths(master, docs, merge_base=True), ["apps/maple-agent/docs/design.md"]) + self.assertIn("apps/maple-agent/app/src/main.rs", plan.changed_paths(master, docs, merge_base=False)) + + def test_renames_out_of_a_component_keep_the_old_path(self): + runtime = self.commit("apps/maple-agent/app/src/main.rs") + self.git("mv", "apps/maple-agent/app/src/main.rs", "README.md.moved") + self.git("commit", "-qm", "rename") + paths = plan.changed_paths(runtime, self.git("rev-parse", "HEAD"), merge_base=False) + self.assertIn("apps/maple-agent/app/src/main.rs", paths) + + def test_missing_or_zero_base_has_no_usable_diff(self): + for base in ("0" * 40, SHA_A, "HEAD", ""): + with self.subTest(base=base): + self.assertIsNone(plan.changed_paths(base, self.base, merge_base=False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_select_xcode.py b/scripts/ci/test_select_xcode.py index fd6a0b0c4..3ec7a03c3 100644 --- a/scripts/ci/test_select_xcode.py +++ b/scripts/ci/test_select_xcode.py @@ -160,6 +160,7 @@ def test_no_workflow_uses_retired_mac_os_and_every_native_selector_has_checkout( ("agent-desktop-build.yml", "macos"), ("agent-desktop-build.yml", "macos-unsigned"), ("agent-desktop-build.yml", "verify-macos"), + ("ci.yml", "nix"), ("desktop-build.yml", "build-macos"), ("desktop-pr-build.yml", "build-macos"), ("desktop-pr-build.yml", "build-research-dev-macos"), From 6afc33f6ab45888f0f4d30cc561c3a5219ae4c3f Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:26:40 +0000 Subject: [PATCH 02/21] ci: replace workflow shape tests with policy and contract tests; activate the repo lane The manual Windows signature probe's `actions: read` moves from the workflow to its one job, as the read-only workflow-permissions policy requires; the job's token is unchanged. Co-Authored-By: Claude Opus 5.5 --- .githooks/repo-checks | 2 +- .../agent-windows-signature-probe.yml | 4 +- .github/workflows/auth-pages-ci.yml | 2 - .github/workflows/pages-tests.yml | 54 - .github/workflows/release-gates-tests.yml | 83 - .github/workflows/repository-checks.yml | 40 - docs/repository-workflows.md | 11 +- flake.nix | 108 +- scripts/ci/lanes.py | 23 +- scripts/ci/plan.py | 4 +- scripts/ci/test_agent_updates_workflows.py | 9 - scripts/ci/test_agent_workflows.py | 1216 -------------- scripts/ci/test_codeql_workflow.py | 55 - scripts/ci/test_contracts.py | 373 +++++ scripts/ci/test_nix_workflows.py | 90 -- .../ci/test_opensecret_change_detection.py | 135 ++ scripts/ci/test_opensecret_eif_scripts.py | 714 +++++++++ scripts/ci/test_opensecret_workflows.py | 1405 ----------------- scripts/ci/test_pages_auth_workflows.py | 26 +- scripts/ci/test_pages_workflows.py | 77 +- scripts/ci/test_plan.py | 32 +- scripts/ci/test_research_dev_profile.py | 76 - scripts/ci/test_sdk_publish_workflows.py | 23 +- scripts/ci/test_select_xcode.py | 21 - scripts/ci/test_signing_workflows.py | 314 ---- .../ci/test_windows_signature_reference.py | 3 +- scripts/ci/test_workflow_policy.py | 285 ++++ 27 files changed, 1582 insertions(+), 3603 deletions(-) delete mode 100644 .github/workflows/pages-tests.yml delete mode 100644 .github/workflows/release-gates-tests.yml delete mode 100644 .github/workflows/repository-checks.yml delete mode 100644 scripts/ci/test_agent_workflows.py delete mode 100644 scripts/ci/test_codeql_workflow.py create mode 100644 scripts/ci/test_contracts.py delete mode 100644 scripts/ci/test_nix_workflows.py create mode 100644 scripts/ci/test_opensecret_eif_scripts.py delete mode 100644 scripts/ci/test_opensecret_workflows.py delete mode 100644 scripts/ci/test_signing_workflows.py create mode 100644 scripts/ci/test_workflow_policy.py diff --git a/.githooks/repo-checks b/.githooks/repo-checks index 8fc5e81b3..337974fad 100755 --- a/.githooks/repo-checks +++ b/.githooks/repo-checks @@ -1,7 +1,7 @@ #!/bin/sh # Repository-level pre-commit checks: workflow lint and CI script unit tests. # Runs from the repository root inside the root flake's ci shell. -# CI equivalent: repository-checks.yml (nix flake check), minus the slow +# CI equivalent: the ci.yml repo lane (nix flake check), minus the slow # release-gates and toolchain checks. set -u diff --git a/.github/workflows/agent-windows-signature-probe.yml b/.github/workflows/agent-windows-signature-probe.yml index 984a4c0a0..7e1077430 100644 --- a/.github/workflows/agent-windows-signature-probe.yml +++ b/.github/workflows/agent-windows-signature-probe.yml @@ -2,7 +2,6 @@ name: Agent Windows Historical Signature Probe permissions: contents: read - actions: read on: workflow_dispatch: @@ -44,6 +43,9 @@ jobs: signature-probe: runs-on: windows-2025 timeout-minutes: 20 + permissions: + contents: read + actions: read env: CARGO_TERM_COLOR: never CARGO_INCREMENTAL: 0 diff --git a/.github/workflows/auth-pages-ci.yml b/.github/workflows/auth-pages-ci.yml index e2f009d85..cb7f4d377 100644 --- a/.github/workflows/auth-pages-ci.yml +++ b/.github/workflows/auth-pages-ci.yml @@ -9,7 +9,6 @@ on: # Include stacked PRs and forks; this job has no publishing authority. paths: - ".github/workflows/auth-pages-*.yml" - - ".github/workflows/pages-tests.yml" - "apps/maple-auth/**" - "scripts/ci/auth-*.sh" - "scripts/ci/pages_*.py" @@ -20,7 +19,6 @@ on: branches: [master] paths: - ".github/workflows/auth-pages-*.yml" - - ".github/workflows/pages-tests.yml" - "apps/maple-auth/**" - "scripts/ci/auth-*.sh" - "scripts/ci/pages_*.py" diff --git a/.github/workflows/pages-tests.yml b/.github/workflows/pages-tests.yml deleted file mode 100644 index 1e4cd0b92..000000000 --- a/.github/workflows/pages-tests.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Pages deployment tests - -permissions: - contents: read - -on: - pull_request: - paths: - - ".github/workflows/pages-*.yml" - - ".github/workflows/auth-pages-*.yml" - - "scripts/ci/pages_*.py" - - "scripts/ci/test_pages_*.py" - - "scripts/ci/_common.sh" - - "scripts/ci/web.sh" - - "scripts/ci/auth-*.sh" - - "services/updates/package.json" - - "services/updates/bun.lock" - - "flake.nix" - - "flake.lock" - push: - branches: [master] - paths: - - ".github/workflows/pages-*.yml" - - ".github/workflows/auth-pages-*.yml" - - "scripts/ci/pages_*.py" - - "scripts/ci/test_pages_*.py" - - "scripts/ci/_common.sh" - - "scripts/ci/web.sh" - - "scripts/ci/auth-*.sh" - - "services/updates/package.json" - - "services/updates/bun.lock" - - "flake.nix" - - "flake.lock" - -jobs: - pages: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Test Pages artifact and deployment boundaries - run: nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-linux.pages diff --git a/.github/workflows/release-gates-tests.yml b/.github/workflows/release-gates-tests.yml deleted file mode 100644 index 069b71a56..000000000 --- a/.github/workflows/release-gates-tests.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Release gates tests - -on: - pull_request: - paths: - - ".github/workflows/release.yml" - - ".github/workflows/release-gates-tests.yml" - - ".github/workflows/proxy-rust.yml" - - ".github/workflows/proxy-publish.yml" - - ".github/workflows/zapstore-publish.yml" - - "scripts/ci/classify-app-release.sh" - - "scripts/ci/proxy-release.sh" - - "scripts/ci/plan-proxy-container-publish.sh" - - "scripts/ci/proxy_registry_inventory.py" - - "scripts/ci/test_proxy_registry_inventory.py" - - "scripts/ci/inspect-proxy-container-manifest.sh" - - "scripts/ci/test-plan-proxy-container-publish.sh" - - "scripts/ci/test-proxy-release-artifacts.sh" - - "scripts/ci/test-release-gates.sh" - - "scripts/ci/test-windows-runtime-manifest.sh" - - "scripts/ci/validate-release-version.sh" - - "scripts/ci/verify-release-artifacts.sh" - - "proxy/Cargo.toml" - - "proxy/Cargo.lock" - - "proxy/justfile" - - "proxy/src/**" - - "apps/maple-research/frontend/package.json" - - "apps/maple-research/frontend/src-tauri/Cargo.toml" - - "apps/maple-research/frontend/src-tauri/tauri.conf.json" - - "flake.nix" - - "flake.lock" - push: - branches: [master] - paths: - - ".github/workflows/release.yml" - - ".github/workflows/release-gates-tests.yml" - - ".github/workflows/proxy-rust.yml" - - ".github/workflows/proxy-publish.yml" - - ".github/workflows/zapstore-publish.yml" - - "scripts/ci/classify-app-release.sh" - - "scripts/ci/proxy-release.sh" - - "scripts/ci/plan-proxy-container-publish.sh" - - "scripts/ci/proxy_registry_inventory.py" - - "scripts/ci/test_proxy_registry_inventory.py" - - "scripts/ci/inspect-proxy-container-manifest.sh" - - "scripts/ci/test-plan-proxy-container-publish.sh" - - "scripts/ci/test-proxy-release-artifacts.sh" - - "scripts/ci/test-release-gates.sh" - - "scripts/ci/test-windows-runtime-manifest.sh" - - "scripts/ci/validate-release-version.sh" - - "scripts/ci/verify-release-artifacts.sh" - - "proxy/Cargo.toml" - - "proxy/Cargo.lock" - - "proxy/justfile" - - "proxy/src/**" - - "apps/maple-research/frontend/package.json" - - "apps/maple-research/frontend/src-tauri/Cargo.toml" - - "apps/maple-research/frontend/src-tauri/tauri.conf.json" - - "flake.nix" - - "flake.lock" - -permissions: - contents: read - -jobs: - release-gates: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Test release gates - run: nix build --no-link --print-build-logs .#checks.x86_64-linux.release-gates diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml deleted file mode 100644 index dc9aebeda..000000000 --- a/.github/workflows/repository-checks.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Repository CI checks - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/**" - - "scripts/ci/**" - - "flake.nix" - - "flake.lock" - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. - paths: - - ".github/workflows/**" - - "scripts/ci/**" - - "flake.nix" - - "flake.lock" - -jobs: - repository-checks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Validate repository CI configuration - run: nix flake check --no-update-lock-file --print-build-logs diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 260c14d28..604add80d 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -78,12 +78,21 @@ artifacts to TestFlight. Creating a GitHub Release starts release builds and downstream publication. Use `$release-maple` only for explicitly requested release work, and report the tag and commit before publishing. +## Workflow checks + +The `repo` lane of `ci.yml` runs `nix flake check` when workflows, `scripts/ci/`, +hooks, the root flake, the release-preflight scripts, the Agent update publisher, +or the Research version files change. Besides actionlint and the release gates, +it runs `scripts/ci/test_workflow_policy.py` (invariants every workflow keeps) +and `scripts/ci/test_contracts.py` (what the release, the publishers, signing and +OpenSecret rely on). A contract failure means fix the workflow, not the contract. + ## Nix runtime GitHub Actions jobs use Determinate Nix 3.23.3 through an immutable `DeterminateSystems/determinate-nix-action` commit and an explicit `source-tag: v3.23.3`. Keep the action revision and source tag synchronized when -upgrading. `scripts/ci/test_nix_workflows.py` rejects the floating installer, +upgrading. `scripts/ci/test_workflow_policy.py` rejects the floating installer, an unexpected action revision or tag, installation before checkout, or a missing runtime verification step. Each job checks the installed Determinate and upstream Nix versions before doing build work. The workflows pass no GitHub diff --git a/flake.nix b/flake.nix index ef72a581b..894815056 100644 --- a/flake.nix +++ b/flake.nix @@ -739,67 +739,34 @@ ''; workflows = pkgs.runCommand "maple-github-workflows-check" { - nativeBuildInputs = with pkgs; [ actionlint python3 yq-go ]; - src = ./.github/workflows; - actions = ./.github/actions; - tests = ./scripts/ci/test_codeql_workflow.py; - nixTests = ./scripts/ci/test_nix_workflows.py; - signingTests = ./scripts/ci/test_signing_workflows.py; + nativeBuildInputs = [ pkgs.actionlint ]; + src = ./.; } '' - # Lint inside a repository-shaped tree so local actions resolve. - mkdir -p "$TMPDIR/repo/.github/workflows" "$TMPDIR/repo/scripts/ci" - cp -R "$src/." "$TMPDIR/repo/.github/workflows/" - cp -R "$actions" "$TMPDIR/repo/.github/actions" - cd "$TMPDIR/repo" + # The whole tree, so local actions resolve. + cd "$src" # actionlint 1.7.10 predates GitHub's supported concurrency.queue key. # Release-gate tests assert that the only intended value is queue: max. for workflow in .github/workflows/*.yml; do if [ "$workflow" = .github/workflows/pages-publish.yml ] || [ "$workflow" = .github/workflows/auth-pages-publish.yml ] || [ "$workflow" = .github/workflows/auth-pages-dev-publish.yml ]; then # It also predates environment.deployment. Pages tests require # false on the publisher jobs; keep this exception file-scoped. - actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "deployment" for "environment" section' "$workflow" + actionlint -config-file .github/actionlint.yaml -ignore 'unexpected key "deployment" for "environment" section' "$workflow" else - actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "queue" for "concurrency" section' "$workflow" + actionlint -config-file .github/actionlint.yaml -ignore 'unexpected key "queue" for "concurrency" section' "$workflow" fi done - cp "$tests" "$TMPDIR/repo/scripts/ci/test_codeql_workflow.py" - python3 "$TMPDIR/repo/scripts/ci/test_codeql_workflow.py" - cp "$nixTests" "$TMPDIR/repo/scripts/ci/test_nix_workflows.py" - python3 "$TMPDIR/repo/scripts/ci/test_nix_workflows.py" - cp "$signingTests" "$TMPDIR/repo/scripts/ci/test_signing_workflows.py" - python3 "$TMPDIR/repo/scripts/ci/test_signing_workflows.py" - touch "$out" - ''; - - apple-toolchain = pkgs.runCommand "maple-apple-toolchain-check" { - nativeBuildInputs = with pkgs; [ mapleBash python3 yq-go ]; - src = ./.; - } '' - cd "$src" - python3 scripts/ci/test_select_xcode.py - python3 scripts/ci/test_research_dev_profile.py touch "$out" ''; - change-detection = pkgs.runCommand "maple-ci-change-detection-check" { - nativeBuildInputs = with pkgs; [ bash git python3 ]; + # Every scripts/ci test (workflow policy, contracts, the planner, the + # release and publication scripts) and the Agent update publisher. + ci-scripts = pkgs.runCommand "maple-ci-scripts-check" { + nativeBuildInputs = with pkgs; [ mapleBash git jq minisign python3 unzip yq-go ]; src = ./.; } '' cd "$src" - python3 scripts/ci/test_change_detection.py - python3 scripts/ci/test_agent_change_detection.py - python3 scripts/ci/test_opensecret_change_detection.py - python3 scripts/ci/test_hook_change_detection.py - python3 scripts/ci/test_plan.py - touch "$out" - ''; - - opensecret-workflows = pkgs.runCommand "maple-opensecret-workflow-check" { - nativeBuildInputs = with pkgs; [ mapleBash git jq python3 yq-go ]; - src = ./.; - } '' - cd "$src" - python3 scripts/ci/test_opensecret_workflows.py + python3 -I -m unittest discover -s scripts/ci -p 'test_*.py' + python3 -I -m unittest discover -s services/agent-updates/publisher -p 'test_*.py' touch "$out" ''; @@ -813,56 +780,6 @@ touch "$out" ''; - agent-workflows = pkgs.runCommand "maple-agent-workflow-check" { - nativeBuildInputs = with pkgs; [ mapleBash git python3 yq-go ]; - src = ./.; - } '' - cd "$src" - python3 scripts/ci/test_agent_workflows.py - python3 scripts/ci/test_agent_cache.py - python3 scripts/ci/test_agent_cache_scope.py - python3 scripts/ci/test_agent_dependency_archive.py - python3 scripts/ci/test_agent_windows.py - python3 scripts/ci/test_agent_measure.py - python3 apps/maple-agent/scripts/test_rusqlite_provider.py - python3 scripts/ci/test_agent_master_selection.py - python3 scripts/ci/test_agent_rust_deps.py - python3 scripts/ci/test_agent_debug_plist.py - python3 apps/maple-agent/scripts/test_third_party_notices.py - python3 apps/maple-agent/scripts/test_release_packaging.py - python3 apps/maple-agent/scripts/test_linux_release_appimage.py - python3 apps/maple-agent/scripts/test_linux_release_bundle.py - python3 apps/maple-agent/scripts/test_linux_install.py - python3 apps/maple-agent/scripts/test_macos_release_dylibs.py - python3 apps/maple-agent/scripts/test_macos_build_info.py - python3 apps/maple-agent/scripts/test_windows_build_info.py - python3 apps/maple-agent/scripts/test_windows_host_spawn.py - python3 apps/maple-agent/scripts/test_windows_release.py - python3 apps/maple-agent/scripts/test_smoke_macos_release.py - python3 apps/maple-agent/scripts/test_smoke_linux_bundle_host.py - touch "$out" - ''; - - agent-updates = pkgs.runCommand "maple-agent-updates-check" { - nativeBuildInputs = with pkgs; [ git minisign python3 yq-go ]; - src = ./.; - } '' - cd "$src" - python3 scripts/ci/test_agent_updates_workflows.py - python3 -I -m unittest discover -s services/agent-updates/publisher -p 'test_*.py' - touch "$out" - ''; - - ios-build-profile = pkgs.runCommand "maple-ios-build-profile-check" { - nativeBuildInputs = with pkgs; [ mapleBash python3 unzip ]; - src = ./.; - } '' - cd "$src" - python3 scripts/ci/test_ios_build_profile.py - python3 scripts/ci/test_ios_release_signing.py - touch "$out" - ''; - pages = pkgs.runCommand "maple-pages-deployment-check" { nativeBuildInputs = with pkgs; [ mapleBash python3 yq-go ]; src = ./.; @@ -872,6 +789,7 @@ touch "$out" ''; + # Built by sdk-publish-tests.yml; ci-scripts runs the same tests. sdk-publishing = pkgs.runCommand "maple-sdk-publishing-check" { nativeBuildInputs = with pkgs; [ mapleBash git python3 yq-go ]; src = ./.; diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index acdd259b4..1af3317aa 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -26,6 +26,7 @@ class Runner: LINUX_HEAVY = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_HEAVY_RUNNER") MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") # read by ci.yml itself +GITHUB = Runner("ubuntu-latest") # free GitHub runner for lanes that compile nothing heavy # ci.yml pins this toolchain for agent-windows; agent-lint checks that the Agent # flake's rustc still reports it. AGENT_WINDOWS_RUST = "1.98.0" @@ -92,7 +93,6 @@ class Lane: workspaces: str = "" cache_files: Tuple[str, ...] = () # hashed into the cache key timeout: int = 15 - budget: float = 5.0 # p50 minutes, shown in the plan summary gate: bool = True # selected by CI_INFRA edits legacy: Optional[str] = None # the workflow that still runs this lane @@ -106,16 +106,24 @@ class Lane: LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", - cache="agent-lint", timeout=15, budget=4, **AGENT_FLAKE), + cache="agent-lint", timeout=15, **AGENT_FLAKE), Lane("agent-test", AGENT, also=RUST_SDK + PROXY_RUNTIME, runner=LINUX_HEAVY, - run="just ci-test", cache="agent-test", timeout=20, budget=6.5, **AGENT_FLAKE), + run="just ci-test", cache="agent-test", timeout=20, **AGENT_FLAKE), Lane("agent-macos", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json"), runner=MACOS, run="cargo clippy --workspace --all-targets --locked -- -D warnings", - cache="agent-macos", timeout=25, budget=5, **AGENT_FLAKE), + cache="agent-macos", timeout=25, **AGENT_FLAKE), # PRs get Windows clippy only for Windows-specific paths or the ci:windows label. Lane("agent-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=AGENT_WINDOWS, - labels=("ci:windows", "ci:full"), job="agent-windows", runner=WINDOWS, timeout=45, budget=8), + labels=("ci:windows", "ci:full"), job="agent-windows", runner=WINDOWS, timeout=45), + # Every root flake check: actionlint, workflow policy and contracts, CI script + # tests, the release gates, and release metadata (read from the version files). + Lane("repo", ( + ".github/**", "scripts/ci/**", ".githooks/**", "flake.nix", "flake.lock", "justfile", "setup-hooks.sh", + ".agents/skills/release-maple/scripts/**", "services/agent-updates/publisher/**", + "scripts/update-version.py", "apps/maple-research/zapstore.yaml", "proxy/Cargo.toml", "proxy/justfile", + RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", + ), runner=GITHUB, run="nix flake check --no-update-lock-file --print-build-logs", timeout=15), # Legacy lanes. Lanes that differ only by OS (agent-package, research-desktop) # are split when they move, and OpenSecret will route through @@ -134,11 +142,6 @@ class Lane: Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", "apps/maple-agent/release-profiles.json", "apps/maple-agent/scripts/release-info.py"), legacy="agent-updates-tests.yml"), - Lane("repo", ( - ".github/**", "scripts/ci/**", ".githooks/**", "flake.nix", "flake.lock", "justfile", "setup-hooks.sh", - ".agents/skills/release-maple/scripts/**", "scripts/update-version.py", "apps/maple-research/zapstore.yaml", - RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", - ), legacy="repository-checks.yml, release-gates-tests.yml, pages-tests.yml"), Lane("sdk-ts", SDK_TS + ("sdk/.prettierrc.json", "sdk/eslint.config.js", "sdk/justfile", "sdk/.env.example", "sdk/LICENSE.md", "scripts/ci/build-sdk-publish.sh", "scripts/ci/check-sdk-package-consumers.py"), diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index 0b900d66e..96e8fc9ed 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -173,12 +173,12 @@ def summary(event: str, selected: Selection) -> str: lines = [f"### Lanes for this {event}", ""] if not selected: return "\n".join(lines + ["No lane selected."]) + "\n" - lines += ["| Lane | Why | Budget (p50) |", "|---|---|---|"] + lines += ["| Lane | Why |", "|---|---|"] for lane in LANES: reasons = selected.get(lane.id) if reasons: more = f" (+{len(reasons) - 1} more)" if len(reasons) > 1 else "" - lines.append(f"| `{lane.id}` | `{reasons[0]}`{more} | {lane.budget:g} min |") + lines.append(f"| `{lane.id}` | `{reasons[0]}`{more} |") return "\n".join(lines) + "\n" diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index 915657fe9..35bf40eea 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -20,15 +20,6 @@ def workflow(name): class AgentUpdatesWorkflows(unittest.TestCase): - def test_delivery_checks_follow_imported_profile_and_package_contracts(self): - config = workflow("agent-updates-tests.yml") - for event in ("push", "pull_request"): - self.assertTrue({ - "apps/maple-agent/scripts/release-info.py", - "apps/maple-agent/release-profiles.json", - "services/agent-updates/**", - } <= set(config["on"][event]["paths"])) - def test_manual_master_only_and_separate_authority(self): config = workflow("agent-updates-publish.yml") self.assertEqual(set(config["on"]), {"workflow_dispatch"}) diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py deleted file mode 100644 index 6e6925a7b..000000000 --- a/scripts/ci/test_agent_workflows.py +++ /dev/null @@ -1,1216 +0,0 @@ -"""Security, component selection, and Research release isolation for Agent CI.""" - -import functools -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import sys -import tempfile -import unittest - -import agent_master_selection - - -ROOT = Path(__file__).resolve().parents[2] -RUST_CACHE_COMMAND = 'python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0}' -TRUSTED_DEPENDENCY_WRITE = "${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }}" -TRUSTED_DEPENDENCY_UPLOAD = "${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }}" -DEPENDENCY_BUILDERS = ( - ("agent-ci.yml", "desktop"), ("agent-ci.yml", "provider"), ("agent-ci.yml", "linux-release"), - ("agent-desktop-build.yml", "macos-build"), ("agent-desktop-build.yml", "linux"), - ("agent-desktop-build.yml", "windows-build"), -) - - -@functools.cache -def workflow(name): - result = subprocess.run( - ["yq", "-o=json", ".", str(ROOT / ".github" / "workflows" / name)], - check=True, capture_output=True, text=True, - ) - return json.loads(result.stdout) - - -def strings(value): - if isinstance(value, str): - yield value - elif isinstance(value, dict): - for key, child in value.items(): - yield str(key) - yield from strings(child) - elif isinstance(value, list): - for child in value: - yield from strings(child) - - -def variants_for(job, event): - value = job["strategy"]["matrix"]["variant"] - if isinstance(value, list): - return value - # Interpret only the event-based JSON choice used by this workflow. Reject - # other contexts so fork/base/selector state cannot change the PR profiles. - choice = re.fullmatch( - r"\$\{\{ fromJSON\(github\.event_name == '([^']+)' && '([^']+)' \|\| '([^']+)'\) \}\}", - value, - ) - if choice is None: - raise AssertionError(f"unsupported variant matrix: {value}") - return json.loads(choice[2] if event == choice[1] else choice[3]) - - -class AgentWorkflowBoundaryTests(unittest.TestCase): - def test_native_workspace_commands_keep_the_declared_cache_profile(self): - steps = workflow("agent-ci.yml")["jobs"]["desktop"]["steps"] - expected = { - "Build macOS workspace and test targets": ( - "runner.os == 'macOS'", - "nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase macos-build --target target -- cargo build --workspace --all-targets --locked --timings", - ), - "Test macOS workspace": ( - "runner.os == 'macOS'", - "nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase macos-test --target target -- cargo test --workspace --locked --timings", - ), - "Build Windows workspace and test targets": ( - "runner.os == 'Windows'", - "python3 ../../scripts/ci/agent_measure.py --phase windows-build --target target -- cargo build --workspace --all-targets --locked --timings", - ), - "Test Windows workspace": ( - "runner.os == 'Windows'", - "python3 ../../scripts/ci/agent_measure.py --phase windows-test --target target -- cargo test --workspace --locked --timings", - ), - } - for name, (condition, command) in expected.items(): - with self.subTest(step=name): - selected = [step for step in steps if step.get("name") == name] - self.assertEqual(len(selected), 1) - self.assertEqual(selected[0]["if"], condition) - self.assertEqual(selected[0]["run"].strip(), command) - self.assertNotIn("env", selected[0]) - self.assertEqual(workflow("agent-ci.yml")["defaults"]["run"]["working-directory"], "apps/maple-agent") - - def test_git_metadata_freshness_is_checked_with_each_platform_toolchain(self): - steps = workflow("agent-ci.yml")["jobs"]["desktop"]["steps"] - freshness = next(step for step in steps if step.get("name") == "Verify Git metadata freshness on native workspace builds") - self.assertEqual(freshness["if"], "runner.os != 'Linux'") - self.assertEqual(freshness["run"].strip(), - 'if [ "$RUNNER_OS" = Windows ]; then\n' - ' python3 scripts/test_git_metadata.py\n' - 'else\n' - ' nix develop --no-update-lock-file . -c python3 scripts/test_git_metadata.py\n' - 'fi') - self.assertNotIn("continue-on-error", freshness) - recipes = (ROOT / "apps/maple-agent/justfile").read_text() - self.assertIn("ci: fmt-check git-metadata-test clippy test", recipes) - self.assertIn("git-metadata-test:\n python3 scripts/test_git_metadata.py", recipes) - hook = (ROOT / "apps/maple-agent/.githooks/pre-commit").read_text() - self.assertIn("hook_run python3 scripts/test_git_metadata.py", hook) - - def test_windows_linker_is_pinned_and_smoked_before_fingerprint_or_cache(self): - jobs = workflow("agent-ci.yml")["jobs"] - for name in ("desktop", "provider"): - with self.subTest(job=name): - steps = jobs[name]["steps"] - native = next(i for i, step in enumerate(steps) - if step.get("uses", "").startswith("ilammy/msvc-dev-cmd@")) - smoke = next(i for i, step in enumerate(steps) - if step.get("run") == "python3 ../../scripts/ci/agent_windows.py") - fingerprint = next(i for i, step in enumerate(steps) if step.get("id") == "environment") - cache = next(i for i, step in enumerate(steps) - if "rust-cache@" in step.get("uses", "") - or "actions/cache/restore@" in step.get("uses", "")) - self.assertLess(native, smoke) - self.assertLess(smoke, fingerprint) - self.assertLess(fingerprint, cache) - self.assertEqual(steps[smoke]["if"], "runner.os == 'Windows'") - self.assertNotIn("continue-on-error", steps[smoke]) - - def test_windows_crypto_pins_native_perl_only_after_required_module_probe(self): - step = next(step for step in workflow("agent-ci.yml")["jobs"]["desktop"]["steps"] - if step.get("name") == "Select native Windows Perl for the static crypto build") - self.assertEqual(step["if"], "runner.os == 'Windows'") - with tempfile.TemporaryDirectory(prefix="native perl ") as temporary: - folder = Path(temporary) - native_perl = folder / "native perl.exe" - native_perl.write_text("#!/bin/sh\n" - 'test "$1" = "-MLocale::Maketext::Simple" || exit 41\n' - 'test "$2" = "-MIPC::Cmd" || exit 42\n' - 'test "$3" = "-e" || exit 43\n' - 'case "$4" in *MSWin32*) ;; *) exit 44 ;; esac\n' - 'exit "$PERL_PROBE_EXIT"\n') - native_perl.chmod(0o755) - cygpath = folder / "cygpath" - cygpath.write_text('#!/bin/sh\ntest "$1" = -m || exit 45\n' - 'test "$2" = "$PERL_PROBE_PATH" || exit 46\n' - 'printf "%s\\n" "C:/Native Perl/perl.exe"\n') - cygpath.chmod(0o755) - script = step["run"].replace("/c/Strawberry/perl/bin/perl.exe", '"' + str(native_perl) + '"') - destination = folder / "github-env" - for probe_exit in (0, 2): - with self.subTest(probe_exit=probe_exit): - destination.unlink(missing_ok=True) - environment = {**os.environ, "PATH": str(folder) + os.pathsep + os.environ["PATH"], - "GITHUB_ENV": str(destination), "PERL_PROBE_PATH": str(native_perl), - "PERL_PROBE_EXIT": str(probe_exit)} - result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], - env=environment, capture_output=True, text=True) - self.assertEqual(result.returncode, probe_exit, result.stderr) - if probe_exit == 0: - self.assertEqual(destination.read_text(), - "OPENSSL_SRC_PERL=C:/Native Perl/perl.exe\n") - else: - self.assertFalse(destination.exists(), "Failed probes cannot select a Perl executable") - - def test_contributor_build_has_no_credentials_or_privileged_events(self): - config = workflow("agent-ci.yml") - # Pull requests run the Agent lanes in ci.yml. - self.assertEqual(set(config["on"]), {"push", "workflow_dispatch"}) - self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertEqual(config["permissions"], {"contents": "read"}) - for value in strings({key: value for key, value in config.items() if key != "jobs"}): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - for name, job in config["jobs"].items(): - self.assertNotIn("environment", job) - self.assertNotIn("uses", job) - if name in {"changes", "desktop", "provider", "linux-release"}: - self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) - else: - self.assertIn(job.get("permissions"), (None, {"contents": "read"})) - for step in job["steps"]: - self.assertNotIn("${{", step.get("run", "")) - if name == "changes" and step.get("id") == "master": - self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) - self.assertIn("agent_master_selection.py --lane ci", step["run"]) - self.assertEqual(step["if"], "${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'push' }}") - elif name in {"desktop", "provider", "linux-release"} and step.get("id") == "dependency-archive": - expected = {"GH_TOKEN": "${{ github.token }}"} - if name == "provider": - expected["PROVIDER_DEPENDENCY_FINGERPRINT"] = "${{ steps.provider.outputs.dependency-fingerprint }}" - self.assertEqual(step["env"], expected) - self.assertIn("agent_dependency_archive.py restore", step["run"]) - else: - for value in strings(step): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - action = step.get("uses", "") - if action: - self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@[0-9a-f]{40}$") - if action.startswith("actions/checkout@"): - self.assertIs(step["with"]["persist-credentials"], False) - if action.startswith("DeterminateSystems/determinate-nix-action@"): - self.assertEqual(step["with"]["github-token"], "") - - def test_agent_cache_and_artifacts_do_not_share_research_publication(self): - steps = workflow("agent-ci.yml")["jobs"]["desktop"]["steps"] - caches = [step["with"] for step in steps if "rust-cache@" in step.get("uses", "")] - self.assertTrue(caches) - for cache in caches: - self.assertEqual(cache["workspaces"], "apps/maple-agent -> target") - self.assertTrue(cache["key"].startswith("maple-agent-")) - self.assertEqual(cache["save-if"], "false") - for step in steps: - action = step.get("uses", "") - self.assertNotIn("release", action.lower()) - self.assertNotIn("download-artifact", action) - if "upload-artifact@" in action: - if step.get("name") == "Upload durable dependency archive": - self.assertEqual(step["with"]["name"], "${{ steps.dependency-seed.outputs.artifact-name }}") - else: - self.assertTrue(step["with"]["name"].startswith("maple-agent-")) - self.assertTrue(step["with"]["path"].startswith("apps/maple-agent/target/")) - self.assertFalse(any((ROOT / "apps/maple-agent/.github/workflows").glob("*.yml"))) - - def test_failed_or_missing_selection_cannot_skip_the_desktop_matrix(self): - condition = workflow("agent-ci.yml")["jobs"]["desktop"]["if"] - self.assertIn("always() && !cancelled()", condition) - self.assertIn("needs.changes.result != 'success'", condition) - self.assertIn("needs.changes.outputs.agent != 'false'", condition) - self.assertNotIn("head.repo", condition) - - def test_parallel_jobs_retain_selection_platforms_and_required_gate(self): - jobs = workflow("agent-ci.yml")["jobs"] - for name in ("desktop", "provider", "linux-release"): - with self.subTest(job=name): - job = jobs[name] - self.assertEqual(job["needs"], "changes") - self.assertEqual(job["if"], jobs["desktop"]["if"]) - self.assertFalse(any(step.get("continue-on-error") for step in job["steps"])) - self.assertEqual(jobs["provider"]["strategy"], jobs["desktop"]["strategy"]) - self.assertEqual(jobs["provider"]["runs-on"], jobs["desktop"]["runs-on"]) - self.assertEqual(jobs["linux-release"]["runs-on"], "blacksmith-8vcpu-ubuntu-2404") - self.assertEqual(jobs["required"]["needs"], ["changes", "desktop", "provider", "linux-release"]) - self.assertEqual(jobs["required"]["if"], "${{ always() && !cancelled() }}") - gate = jobs["required"]["steps"][0]["run"] - success = {"SELECTOR_RESULT": "success", "AGENT_SELECTED": "true", - "DESKTOP_RESULT": "success", "PROVIDER_RESULT": "success", "RELEASE_RESULT": "success"} - for selected in ("true", "", "false"): - for selector in ("success", "failure", "skipped"): - for key in ("DESKTOP_RESULT", "PROVIDER_RESULT", "RELEASE_RESULT"): - for result in ("success", "failure", "skipped", "cancelled"): - env = {**success, "SELECTOR_RESULT": selector, "AGENT_SELECTED": selected, key: result} - omitted = selector == "success" and selected == "false" - if omitted: - env.update({name: "skipped" for name in success if name.endswith("_RESULT") and name != "SELECTOR_RESULT"}) - env[key] = result - command = subprocess.run(["bash", "-c", gate], env={**os.environ, **env}, capture_output=True) - expected = result == ("skipped" if omitted else "success") - self.assertEqual(command.returncode == 0, expected, env) - - def test_master_selection_ci_verifiers_match_actual_platform_job_names(self): - jobs = workflow("agent-ci.yml")["jobs"] - actual = {jobs["linux-release"]["name"]} - for name in ("desktop", "provider"): - job = jobs[name] - self.assertEqual(set(job["strategy"]["matrix"]), {"os"}) - actual.update(job["name"].replace("${{ matrix.os }}", runner) - for runner in job["strategy"]["matrix"]["os"]) - self.assertEqual(agent_master_selection.CI_VERIFIERS, actual) - - def test_dependency_cache_restore_prefix_uses_actual_environment_without_lock_hash(self): - for filename, name in DEPENDENCY_BUILDERS: - if name == "provider": - continue - with self.subTest(workflow=filename, job=name): - steps = workflow(filename)["jobs"][name]["steps"] - fingerprint = next(i for i, step in enumerate(steps) if step.get("id") == "environment") - artifact = next(i for i, step in enumerate(steps) if step.get("id") == "dependency-archive") - cache_index = next(i for i, step in enumerate(steps) if "rust-cache@" in step.get("uses", "")) - self.assertLess(fingerprint, artifact) - self.assertLess(artifact, cache_index) - cache = steps[cache_index]["with"] - self.assertEqual(steps[cache_index]["if"], "${{ steps.dependency-archive.outputs.restored != 'true' }}") - self.assertEqual(cache["add-rust-environment-hash-key"], "true") - self.assertIn("steps.environment.outputs.compatibility", cache["key"]) - self.assertNotIn("hashFiles", cache["key"]) - self.assertEqual(cache["cmd-format"], RUST_CACHE_COMMAND) - self.assertEqual(cache["save-if"], "false") - self.assertIn("python3 ../../scripts/ci/agent_cache_scope.py", steps[fingerprint]["run"]) - if name != "windows-build": - self.assertIn("nix develop --no-update-lock-file . -c", steps[fingerprint]["run"]) - - def test_legacy_cache_restore_preserves_abi_scope_and_never_writes(self): - for filename, name in DEPENDENCY_BUILDERS: - if name == "provider": - continue - with self.subTest(workflow=filename, job=name): - steps = workflow(filename)["jobs"][name]["steps"] - primary_index = next(i for i, step in enumerate(steps) if step.get("id") == "rust-cache") - marker_index = next(i for i, step in enumerate(steps) if step.get("id") == "restored-target") - caches = [(i, step) for i, step in enumerate(steps) if "rust-cache@" in step.get("uses", "")] - self.assertEqual(len(caches), 2) - fallback_index, fallback = caches[1] - primary = steps[primary_index] - self.assertLess(primary_index, marker_index) - self.assertLess(marker_index, fallback_index) - self.assertEqual(fallback["uses"], primary["uses"]) - self.assertEqual(fallback["if"], "${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }}") - self.assertEqual(fallback["with"], {**primary["with"], - "add-rust-environment-hash-key": "false", - "key": primary["with"]["key"].replace("outputs.compatibility", "outputs.legacy_compatibility")}) - self.assertEqual(primary["with"]["add-rust-environment-hash-key"], "true") - self.assertEqual(primary["with"]["save-if"], "false") - self.assertNotIn("continue-on-error", fallback) - - def test_primary_prefix_restore_does_not_download_a_second_archive(self): - # Metadata and a restored CACHEDIR.TAG do not prove useful dependencies. - # Require real target fingerprints and a Rust library/metadata output. - for filename, name in DEPENDENCY_BUILDERS: - if name == "provider": - continue - marker = next(step for step in workflow(filename)["jobs"][name]["steps"] - if step.get("id") == "restored-target") - for case, expected in (("empty", False), ("tag_only", False), - ("fingerprint_only", False), ("library_only", False), ("empty_library", False), - ("rlib", True), ("rmeta", True), ("nested", True)): - with self.subTest(workflow=filename, job=name, case=case), tempfile.TemporaryDirectory(prefix="cache restore ") as directory: - root = Path(directory) - target = root / "apps/maple-agent/target" - profile = target / ("aarch64-apple-darwin/release" if case == "nested" else "debug") - profile.mkdir(parents=True) - if case != "empty": - (target / "CACHEDIR.TAG").write_text("Signature: 8a477f597d28d172789f06886806bc55\n") - if case in {"fingerprint_only", "empty_library", "rlib", "rmeta", "nested"}: - unit = profile / ".fingerprint/public-123" - unit.mkdir(parents=True) - (unit / "lib-public").write_text("fingerprint") - if case in {"library_only", "empty_library", "rlib", "rmeta", "nested"}: - deps = profile / "deps" - deps.mkdir() - extension = "rmeta" if case == "rmeta" else "rlib" - (deps / f"libpublic-123.{extension}").write_bytes(b"" if case == "empty_library" else b"actual dependency") - output = root / "outputs" - subprocess.run(["bash", "-eu", "-c", marker["run"]], check=True, - env={**os.environ, "GITHUB_WORKSPACE": str(root), "GITHUB_OUTPUT": str(output)}, - capture_output=True) - self.assertEqual(output.read_text(), f"present={str(expected).lower()}\n") - - def test_provider_restores_only_after_verified_graph_with_no_compatible_fallback(self): - steps = workflow("agent-ci.yml")["jobs"]["provider"]["steps"] - indices = {step.get("id"): i for i, step in enumerate(steps) if step.get("id")} - test = next(i for i, step in enumerate(steps) if step.get("name") == "Test released rusqlite against the static provider") - self.assertLess(indices["provider"], indices["environment"]) - self.assertLess(indices["environment"], indices["dependency-archive"]) - self.assertLess(indices["dependency-archive"], indices["provider-cache"]) - self.assertLess(indices["provider-cache"], test) - self.assertLess(test, indices["dependency-seed"]) - self.assertIn('--cargo-config-dir "$PROVIDER_WORK"', steps[indices["environment"]]["run"]) - native = steps[indices["provider-cache"]] - self.assertEqual(native["if"], "${{ steps.dependency-archive.outputs.restored != 'true' }}") - self.assertNotIn("restore-keys", native["with"]) - self.assertEqual(native["with"]["path"], "${{ env.PROVIDER_TARGET }}") - self.assertIn("steps.environment.outputs.legacy_compatibility", native["with"]["key"]) - self.assertIn("steps.provider.outputs.dependency-fingerprint", native["with"]["key"]) - self.assertIn("--phase prepare", steps[indices["provider"]]["run"]) - self.assertIn("--phase run", steps[test]["run"]) - for ident in ("dependency-archive", "dependency-seed"): - self.assertIn('--dependency-fingerprint "$PROVIDER_DEPENDENCY_FINGERPRINT"', steps[indices[ident]]["run"]) - self.assertEqual(steps[indices[ident]]["env"]["PROVIDER_DEPENDENCY_FINGERPRINT"], "${{ steps.provider.outputs.dependency-fingerprint }}") - self.assertIn('--metadata "$PROVIDER_WORK/maple-provider-metadata.json"', steps[indices["dependency-seed"]]["run"]) - self.assertFalse(any("actions/cache/save@" in step.get("uses", "") for step in steps)) - - def test_durable_archive_restore_identity_and_trusted_fail_closed_upload(self): - for filename, name in DEPENDENCY_BUILDERS: - with self.subTest(workflow=filename, job=name): - job = workflow(filename)["jobs"][name] - self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) - self.assertEqual(job["env"]["DEPENDENCY_CACHE_JOB"], job["name"]) - steps = job["steps"] - restore = next(step for step in steps if step.get("id") == "dependency-archive") - pack = next(step for step in steps if step.get("id") == "dependency-seed") - upload = next(step for step in steps if step.get("name") == "Upload durable dependency archive") - self.assertIn("agent_dependency_archive.py restore", restore["run"]) - self.assertIn("agent_dependency_archive.py pack", pack["run"]) - for step in (restore, pack): - self.assertIn(f"--workflow {filename}", step["run"]) - self.assertIn('--job-name "$DEPENDENCY_CACHE_JOB"', step["run"]) - self.assertIn('--receipt "$RUNNER_TEMP/agent-dependency-receipt.json"', step["run"]) - self.assertNotIn("continue-on-error", step) - self.assertEqual(restore["env"]["GH_TOKEN"], "${{ github.token }}") - self.assertNotIn("GH_TOKEN", pack.get("env", {})) - self.assertEqual(pack["if"], TRUSTED_DEPENDENCY_WRITE) - # A verified <24h receipt can suppress compression/upload. Only - # the helper's positive needs-upload result permits a write. - self.assertEqual(upload["if"], TRUSTED_DEPENDENCY_UPLOAD) - self.assertEqual(upload["uses"], "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02") - self.assertEqual(upload["with"], { - "name": "${{ steps.dependency-seed.outputs.artifact-name }}", - "path": "${{ runner.temp }}/agent-dependency-seed/manifest.json\n${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz\n", - "if-no-files-found": "error", "compression-level": 0, - "overwrite": True, "retention-days": 3, - }) - self.assertNotIn("continue-on-error", upload) - self.assertLess(steps.index(restore), steps.index(pack)) - self.assertLess(steps.index(pack), steps.index(upload)) - if name != "provider": - graph = next(step for step in steps if step.get("name") == "Verify the canonical SDK and proxy dependency graph") - self.assertIn('metadata="$RUNNER_TEMP/agent-workspace-metadata.json"', graph["run"]) - self.assertNotIn("rm -f", graph["run"]) - self.assertIn('--metadata "$RUNNER_TEMP/agent-workspace-metadata.json"', pack["run"]) - # Every earlier validation must succeed before pack/upload; - # diagnostics remain the only explicitly always-run steps. - for step in steps[:steps.index(pack)]: - self.assertNotIn("continue-on-error", step) - for step in steps[steps.index(pack) + 1:]: - if "run" in step: - self.assertEqual(step.get("if"), "always()") - self.assertIn("agent_measure.py --summarize", step["run"]) - self.assertNotIn("always()", pack["if"]) - self.assertNotIn("always()", upload["if"]) - - def test_dependency_archives_are_absent_from_protected_and_consumer_jobs(self): - for name, job in workflow("agent-desktop-build.yml")["jobs"].items(): - if name in {"macos-build", "linux", "windows-build"}: - continue - for step in job["steps"]: - self.assertNotIn("agent_dependency_archive.py", step.get("run", "")) - self.assertNotIn(step.get("id"), {"dependency-archive", "dependency-seed"}) - - def test_workspace_checks_and_plain_release_keep_original_coverage(self): - jobs = workflow("agent-ci.yml")["jobs"] - workspace = "\n".join(step.get("run", "") for step in jobs["desktop"]["steps"]) - for command in ("-- just ci", "cargo build --workspace --all-targets --locked --timings", - "cargo test --workspace --locked --timings", "./scripts/macos-debug-app.sh", - "scripts/audit-sqlcipher-dependencies.py", "dependencies/sqlcipher/verify_sqlcipher.py", - "scripts/verify-sqlcipher-build.py", "verify-agent-rust-deps.py"): - self.assertIn(command, workspace) - self.assertNotIn("test-rusqlite-provider.py", workspace) - self.assertNotIn("cargo build --release", workspace) - release = "\n".join(step.get("run", "") for step in jobs["linux-release"]["steps"]) - for command in ("cargo build --release -p maple-agent-app --locked --timings", - "generate-third-party-notices.py --check", "scripts/verify-sqlcipher-build.py", - "verify-agent-rust-deps.py"): - self.assertIn(command, release) - - def test_namespaced_agent_releases_do_not_enter_research_jobs(self): - release = workflow("release.yml") - classifier = release["jobs"]["classify-app-release"] - self.assertEqual(classifier["if"], "startsWith(github.event.release.tag_name, 'v')") - # All release work remains downstream of the classifier, with GitHub's - # default success gate. Skipping it skips builds and publishers together. - for name, job in release["jobs"].items(): - if name == "classify-app-release": - continue - needs = job["needs"] - if isinstance(needs, str): - needs = [needs] - self.assertIn("classify-app-release", needs) - self.assertNotIn("if", job) - for name, job_name in ( - ("pages-publish.yml", "production"), - ("updates-publish.yml", "publish"), - ("proxy-publish.yml", "prepare"), - ("zapstore-publish.yml", "publish"), - ): - with self.subTest(workflow=name): - condition = workflow(name)["jobs"][job_name]["if"] - self.assertIn("startsWith(github.event.workflow_run.head_branch, 'v') &&", condition) - self.assertIn("github.event.workflow_run.conclusion == 'success'", condition) - self.assertIn("github.event.workflow_run.event == 'release'", condition) - - -class AgentDesktopPackagingBoundaryTests(unittest.TestCase): - WORKFLOW = "agent-desktop-build.yml" - MASTER_GUARD = ( - "github.repository == 'MaplePrivacyLabs/Maple' && " - "github.ref == 'refs/heads/master' && " - "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')" - ) - APPLE_SECRETS = { - "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", - "APPLE_ID_PASSWORD", "APPLE_TEAM_ID", - } - ATTEST_ACTION = "actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb" - - def test_windows_reuses_existing_environment_only_for_trusted_master_signing(self): - jobs = workflow(self.WORKFLOW)["jobs"] - signer = jobs["windows"] - self.assertEqual(signer["environment"], "windows-signing") - self.assertEqual(signer["runs-on"], "windows-2025") - self.assertEqual(signer["needs"], "windows-build") - self.assertIn(self.MASTER_GUARD, signer["if"]) - self.assertIn("needs.windows-build.result == 'success'", signer["if"]) - steps = signer["steps"] - preflight = next(i for i, step in enumerate(steps) if "windows-release.py prepare" in step.get("run", "")) - login = next(i for i, step in enumerate(steps) if step.get("uses", "").startswith("azure/login@")) - package = next(i for i, step in enumerate(steps) if "windows-release.py package" in step.get("run", "")) - self.assertLess(preflight, login) - self.assertLess(login, package) - self.assertEqual(steps[login]["with"], { - "client-id": "${{ secrets.AZURE_CLIENT_ID }}", - "tenant-id": "${{ secrets.AZURE_TENANT_ID }}", - "subscription-id": "${{ secrets.AZURE_SUBSCRIPTION_ID }}", - }) - self.assertEqual(steps[package]["env"], { - "MAPLE_WINDOWS_ARTIFACT_SIGNING_" + name: "${{ secrets.AZURE_ARTIFACT_SIGNING_" + name + " }}" - for name in ("ENDPOINT", "ACCOUNT_NAME", "CERTIFICATE_PROFILE_NAME") - }) - self.assertNotIn("--unsigned", steps[package]["run"]) - for step in steps: - self.assertNotRegex(step.get("run", ""), r"cargo\b|--smoke|maple-agent\.exe\s+--") - self.assertNotIn("rust-cache@", step.get("uses", "")) - self.assertNotIn("rust-toolchain@", step.get("uses", "")) - self.assertFalse(any("TAURI_SIGNING" in value for value in strings(signer))) - - def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): - jobs = workflow(self.WORKFLOW)["jobs"] - build = jobs["windows-build"] - self.assertIn("target-feature=+crt-static", build["env"]["RUSTFLAGS"]) - steps = build["steps"] - linker = next(i for i, step in enumerate(steps) if "agent_windows.py" in step.get("run", "")) - fingerprint = next(i for i, step in enumerate(steps) if step.get("id") == "environment") - self.assertLess(linker, fingerprint) - self.assertTrue(any("test_windows_build_info.py" in step.get("run", "") for step in steps)) - release = next(step["run"] for step in steps - if step.get("name") == "Build Windows release binary and notices without signing credentials") - # Native Windows CreateProcess cannot launch a Unix shebang script. - self.assertIn('git_bash="$(cygpath -w "$BASH")"', release) - self.assertIn('--target target -- "$git_bash" ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE"', release) - self.assertNotIn('-- bash ', release) - shell_probe = next(i for i, step in enumerate(steps) - if step.get("name") == "Smoke native Git Bash process launch") - graph = next(i for i, step in enumerate(steps) - if step.get("name") == "Verify the canonical SDK and proxy dependency graph") - self.assertLess(shell_probe, graph) - host_probe = next(i for i, step in enumerate(steps) - if step.get("run") == "python3 scripts/test_windows_host_spawn.py") - self.assertLess(host_probe, graph) - upload = next(step["with"] for step in steps - if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertEqual(upload["path"].splitlines(), [ - "apps/maple-agent/target/release/maple-agent.exe", - "apps/maple-agent/target/third-party-notices", - ]) - for name in ("windows-unsigned", "windows"): - self.assertEqual(jobs[name]["env"]["MAPLE_PACKAGE_BUILD_NUMBER"], "${{ github.run_number }}") - download = next(step["with"] for step in jobs[name]["steps"] - if step.get("uses", "").startswith("actions/download-artifact@")) - self.assertEqual(download, {"name": upload["name"], "path": "apps/maple-agent/target"}) - preview = jobs["windows-unsigned"] - self.assertEqual(preview["strategy"]["matrix"], {"variant": ["dev"]}) - self.assertIn("github.event_name == 'pull_request'", preview["if"]) - self.assertNotIn("github.event_name == 'push'", preview["if"]) - self.assertTrue(any('package "$MAPLE_RELEASE_PROFILE" --unsigned' in step.get("run", "") - for step in preview["steps"])) - verify = jobs["verify-windows"] - self.assertEqual(verify["env"]["MAPLE_PACKAGE_BUILD_NUMBER"], "${{ github.run_number }}") - self.assertEqual(verify["needs"], ["windows", "windows-unsigned"]) - self.assertIn("always() && !cancelled()", verify["if"]) - self.assertIn("needs.windows-unsigned.result == 'success'", verify["if"]) - self.assertIn("needs.windows.result == 'success'", verify["if"]) - smoke = next(step["run"] for step in verify["steps"] if "windows-release.py verify" in step.get("run", "")) - self.assertIn("--smoke", smoke) - self.assertIn("--unsigned", smoke) - self.assertIn('"$GITHUB_EVENT_NAME" = pull_request', smoke) - for name in ("windows-build", "windows-unsigned", "verify-windows"): - self.assertNotIn("environment", jobs[name]) - self.assertFalse(any("secrets." in value for value in strings(jobs[name]))) - - def test_windows_verifier_clears_runner_paths_without_hiding_signing_configuration(self): - steps = workflow(self.WORKFLOW)["jobs"]["verify-windows"]["steps"] - smoke = next(step["run"] for step in steps if "windows-release.py verify" in step.get("run", "")) - paths = {"AZURE_CONFIG_DIR", "AZURE_DEVOPS_CACHE_DIR", "AZURE_EXTENSION_DIR"} - credentials = {"AZURE_CLIENT_ID", "AZURE_FUTURE_TOKEN", "MAPLE_WINDOWS_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME"} - with tempfile.TemporaryDirectory() as temporary: - output = Path(temporary) / "environment-names.json" - # Execute the real workflow script with a harmless Python probe in - # place of the installer verifier. Record names, never values. - probe = '''python3() { - "$PROBE_PYTHON" -c 'import json, os; print(json.dumps(sorted(os.environ)))' > "$PROBE_OUTPUT" - } - ''' - subprocess.run(["bash", "-euo", "pipefail", "-c", probe + smoke], check=True, - env={**os.environ, **dict.fromkeys(paths | credentials, "fixture"), - "GITHUB_EVENT_NAME": "pull_request", "MAPLE_RELEASE_PROFILE": "dev", - "PROBE_PYTHON": sys.executable, "PROBE_OUTPUT": str(output)}, - capture_output=True) - remaining = set(json.loads(output.read_text())) - self.assertFalse(paths & remaining) - self.assertTrue(credentials <= remaining) - - def test_only_pr_updates_can_replace_an_in_progress_or_pending_build(self): - concurrency = workflow(self.WORKFLOW)["concurrency"] - self.assertEqual(concurrency, { - "group": "maple-agent-desktop-builds-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}", - "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", - }) - - def test_signing_recipes_and_embedded_profile_inputs_require_release_owner_review(self): - owners = set((ROOT / ".github/CODEOWNERS").read_text().splitlines()) - for path in ( - "/apps/maple-agent/scripts/", - "/apps/maple-agent/app/packaging/", - "/apps/maple-agent/release_profile.rs", - "/apps/maple-agent/release-profiles.json", - ): - with self.subTest(path=path): - self.assertIn(path + " @AnthonyRonning", owners) - - def test_only_trusted_master_can_receive_apple_signing_credentials(self): - config = workflow(self.WORKFLOW) - self.assertEqual(config["name"], "Maple Agent Desktop Builds") - self.assertEqual(config["on"], { - "push": {"branches": ["master"]}, - "pull_request": None, - "workflow_dispatch": None, - }) - self.assertEqual(config["permissions"], {"contents": "read"}) - for value in strings({key: value for key, value in config.items() if key != "jobs"}): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - jobs = config["jobs"] - signed = jobs["macos"] - self.assertEqual(signed["environment"], "desktop-signing") - self.assertEqual(signed["runs-on"], "macos-26") - condition = " ".join(signed["if"].split()) - self.assertIn(self.MASTER_GUARD, condition) - self.assertIn("always() && !cancelled()", condition) - self.assertNotIn("needs.changes.outputs.agent", condition) - secret_steps = [step for step in signed["steps"] - if any("secrets." in value for value in strings(step))] - self.assertEqual(len(secret_steps), 1) - package = secret_steps[0] - self.assertIn("./scripts/package-release.sh", package["run"]) - self.assertNotIn("--unsigned", package["run"]) - self.assertEqual(package["env"], { - name: "${{ secrets." + name + " }}" for name in self.APPLE_SECRETS - }) - for name, job in jobs.items(): - with self.subTest(job=name): - self.assertNotIn("uses", job) - if name in ("macos", "attest-linux", "windows"): - self.assertEqual(job["permissions"], { - "contents": "read", "id-token": "write", "attestations": "write", - }) - self.assertIn(self.MASTER_GUARD, " ".join(job["if"].split())) - elif name in ("verify-macos", "verify-linux", "verify-windows"): - self.assertEqual(job["permissions"], { - "contents": "read", "attestations": "read", - }) - elif name in ("changes", "macos-build", "linux", "windows-build"): - self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) - else: - self.assertIn(job.get("permissions"), (None, {"contents": "read"})) - if name not in ("macos", "windows"): - self.assertNotIn("environment", job) - for step in job["steps"]: - if "gh attestation verify" in step.get("run", ""): - self.assertIn(name, ("verify-macos", "verify-linux", "verify-windows")) - self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) - if name in ("verify-linux", "verify-windows"): - self.assertIn(self.MASTER_GUARD, " ".join(step["if"].split())) - elif name in ("macos-build", "linux", "windows-build") and step.get("id") == "dependency-archive": - self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) - self.assertIn("agent_dependency_archive.py restore", step["run"]) - elif name == "changes" and step.get("id") == "master": - self.assertEqual(step["if"], - "${{ github.repository == 'MaplePrivacyLabs/Maple' && " - "github.ref == 'refs/heads/master' && github.event_name == 'push' }}") - self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) - self.assertIn("agent_master_selection.py", step["run"]) - else: - for value in strings(step): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - self.assertFalse(any(step.get("continue-on-error") for step in job["steps"])) - - def test_actions_and_checkouts_do_not_grant_publication_or_persist_credentials(self): - for job in workflow(self.WORKFLOW)["jobs"].values(): - for step in job["steps"]: - self.assertNotIn("${{", step.get("run", "")) - action = step.get("uses", "") - if action: - self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@[0-9a-f]{40}$") - self.assertNotIn("release", action.lower()) - if "attest" in action.lower(): - self.assertEqual(action, self.ATTEST_ACTION) - if action.startswith("actions/checkout@"): - self.assertEqual(step["with"]["ref"], "${{ github.sha }}") - self.assertIs(step["with"]["persist-credentials"], False) - if action.startswith("DeterminateSystems/determinate-nix-action@"): - self.assertEqual(step["with"]["github-token"], "") - if action.startswith("Swatinem/rust-cache@"): - cache = step["with"] - self.assertEqual(cache["workspaces"], "apps/maple-agent -> target") - self.assertTrue(cache["key"].startswith("maple-agent-release-")) - self.assertIn("${{ matrix.variant }}", cache["key"]) - self.assertEqual(cache["save-if"], "false") - - def test_unsigned_pr_jobs_are_fail_safe_and_never_use_signed_macos_artifact_names(self): - jobs = workflow(self.WORKFLOW)["jobs"] - macos = " ".join(jobs["macos-unsigned"]["if"].split()) - self.assertIn("github.event_name == 'pull_request'", macos) - self.assertNotIn("github.event_name == 'push'", macos) - for name in ("macos-unsigned", "linux"): - condition = " ".join(jobs[name]["if"].split()) - self.assertIn("always() && !cancelled()", condition) - self.assertIn("needs.changes.result != 'success'", condition) - self.assertIn("needs.changes.outputs.agent != 'false'", condition) - self.assertIn(self.MASTER_GUARD, " ".join(jobs["linux"]["if"].split())) - package = next(step for step in jobs["macos-unsigned"]["steps"] - if "./scripts/package-release.sh" in step.get("run", "")) - self.assertIn("--unsigned", package["run"]) - verify = next(step for step in jobs["macos-unsigned"]["steps"] - if "./scripts/verify-release.sh" in step.get("run", "")) - self.assertIn("--unsigned", verify["run"]) - - def test_master_selection_and_manual_force_fall_back_to_building(self): - classify = next(step for step in workflow(self.WORKFLOW)["jobs"]["changes"]["steps"] - if step.get("id") == "classify") - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "outputs" - for event, selected, expected in ( - ("push", "", "true"), ("push", "true", "true"), - ("push", "unknown", "true"), ("push", "false", "false"), - ("workflow_dispatch", "", "true"), ("workflow_dispatch", "false", "true"), - ): - with self.subTest(event=event, selected=selected): - output.unlink(missing_ok=True) - subprocess.run(["bash", "-c", classify["run"]], check=True, - capture_output=True, cwd=directory, - env={**os.environ, "GITHUB_EVENT_NAME": event, - "GITHUB_OUTPUT": str(output), "BASE_SHA": "", "HEAD_SHA": "", - "MASTER_AGENT": selected}) - self.assertEqual(output.read_text(), f"agent={expected}\n") - - def test_pr_profiles_match_producers_and_consumers_while_master_keeps_both(self): - jobs = workflow(self.WORKFLOW)["jobs"] - for name in ("macos-build", "linux", "verify-linux", "windows-build", "verify-windows"): - self.assertEqual(set(jobs[name]["strategy"]["matrix"]), {"variant"}) - for event, expected in (("pull_request", ["dev"]), - ("push", ["dev", "prod"]), - ("workflow_dispatch", ["dev", "prod"])): - with self.subTest(event=event): - for name in ("macos-build", "linux", "verify-linux", "windows-build", "verify-windows"): - self.assertEqual(variants_for(jobs[name], event), expected, name) - macos_consumer = "macos-unsigned" if event == "pull_request" else "macos" - self.assertEqual(variants_for(jobs[macos_consumer], event), expected) - for producer, consumer in (("macos-build", macos_consumer), - ("linux", "verify-linux"), ("windows-build", "verify-windows")): - self.assertEqual(variants_for(jobs[producer], event), - variants_for(jobs[consumer], event)) - self.assertEqual(jobs["macos-unsigned"]["strategy"]["matrix"], {"variant": ["dev"]}) - for name in ("macos", "attest-linux", "verify-macos", "windows"): - self.assertEqual(jobs[name]["strategy"]["matrix"], {"variant": ["dev", "prod"]}) - - def test_profiles_and_artifacts_keep_platform_variant_and_run_identity(self): - jobs = workflow(self.WORKFLOW)["jobs"] - for name, platform in ( - ("macos-build", "macos-aarch64-prebuilt"), - ("macos", "macos-aarch64"), - ("macos-unsigned", "macos-aarch64-unsigned"), - ("linux", "linux-x86_64"), - ): - with self.subTest(job=name): - job = jobs[name] - self.assertIs(job["strategy"]["fail-fast"], False) - self.assertEqual(job["env"]["MAPLE_RELEASE_PROFILE"], "${{ matrix.variant }}") - script = "build-release.sh" if name in ("macos-build", "linux") else "verify-prebuilt-release.sh" - profile_step = next(step for step in job["steps"] - if "./scripts/" + script in step.get("run", "")) - self.assertIn('"$MAPLE_RELEASE_PROFILE"', profile_step["run"]) - uploads = [step for step in job["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@") - and step.get("with", {}).get("path") != "apps/maple-agent/target/ci-metrics/" - and step.get("name") != "Upload durable dependency archive"] - self.assertEqual(len(uploads), 1) - upload = uploads[0] - self.assertNotIn("if", upload) - self.assertEqual(upload["with"]["name"], - "maple-agent-${{ matrix.variant }}-" + platform + - "-${{ github.run_id }}") - self.assertIs(upload["with"]["overwrite"], True) - expected_paths = (["apps/maple-agent/target/release/maple-agent", - "apps/maple-agent/target/third-party-notices"] if name == "macos-build" - else ["apps/maple-agent/dist/${{ matrix.variant }}/"]) - self.assertEqual(upload["with"]["path"].splitlines(), expected_paths) - self.assertEqual(upload["with"]["if-no-files-found"], "error") - self.assertEqual(upload["with"]["retention-days"], 7 if name == "macos-build" else 30) - - def test_provenance_is_mandatory_and_only_minted_by_trusted_master_jobs(self): - jobs = workflow(self.WORKFLOW)["jobs"] - for name, job in jobs.items(): - attests = [i for i, step in enumerate(job["steps"]) - if step.get("uses") == self.ATTEST_ACTION] - with self.subTest(job=name): - if name not in ("macos", "attest-linux", "windows"): - self.assertEqual(attests, []) - self.assertNotIn("id-token", job.get("permissions", {})) - self.assertIn(job.get("permissions", {}).get("attestations"), (None, "read")) - continue - self.assertEqual(len(attests), 1) - self.assertIn(self.MASTER_GUARD, " ".join(job["if"].split())) - attest = job["steps"][attests[0]] - self.assertNotIn("if", attest) - self.assertNotIn("continue-on-error", attest) - if name in ("macos", "windows"): - package = next(i for i, step in enumerate(job["steps"]) - if ("./scripts/package-release.sh" if name == "macos" else "windows-release.py package") in step.get("run", "")) - upload = next(i for i, step in enumerate(job["steps"]) - if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertGreater(attests[0], package) - self.assertLess(attests[0], upload) - self.assertEqual(attest["with"], { - "subject-path": "apps/maple-agent/dist/${{ matrix.variant }}/*", - }) - else: - # PR compilation and packaging have no OIDC permission. - # A separate job attests downloaded trusted-master outputs. - self.assertEqual(job["needs"], "linux") - self.assertIn("needs.linux.result == 'success'", job["if"]) - self.assertEqual(attest["with"], {"subject-path": "artifacts/*"}) - linux_upload = next(step["with"] for step in jobs["linux"]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")) - download = next(i for i, step in enumerate(job["steps"]) - if step.get("uses", "").startswith("actions/download-artifact@")) - self.assertLess(download, attests[0]) - self.assertEqual(job["steps"][download]["with"], { - "name": linux_upload["name"], "path": "artifacts", - }) - scanner_build = next(i for i, step in enumerate(job["steps"]) - if "cargo build --locked -p maple-agent-updater --example validate-release-archive" - in step.get("run", "")) - metadata = next(i for i, step in enumerate(job["steps"]) - if "release-info.py verify" in step.get("run", "")) - scanner = next(i for i, step in enumerate(job["steps"]) - if "./scripts/verify-updater-archive.sh" in step.get("run", "")) - self.assertLess(scanner_build, scanner) - self.assertLess(download, metadata) - self.assertLess(metadata, scanner) - self.assertLess(scanner, attests[0]) - self.assertNotIn("if", job["steps"][scanner_build]) - self.assertNotIn("if", job["steps"][scanner]) - self.assertIn('--profile "$MAPLE_RELEASE_PROFILE"', job["steps"][scanner]["run"]) - self.assertIn("--platform linux-x86_64", job["steps"][scanner]["run"]) - self.assertFalse(any("verify-release.sh" in step.get("run", "") - or "AppRun" in step.get("run", "") - for step in job["steps"]), - "attestation inspects data without executing artifact code") - - def test_linux_official_bundle_reuses_one_profile_build_and_has_no_appimage_runtime(self): - job = workflow(self.WORKFLOW)["jobs"]["linux"] - self.assertEqual(job["name"], "Linux bundle (${{ matrix.variant }})") - builds = [step for step in job["steps"] - if "./scripts/build-release.sh" in step.get("run", "")] - self.assertEqual(len(builds), 1) - self.assertFalse(any("cargo build" in step.get("run", "") for step in job["steps"])) - package = (ROOT / "apps/maple-agent/scripts/package-release.sh").read_text() - self.assertIn('scripts/linux-release-bundle.sh" "$binary" "$staging/${name}.tar.gz"', package) - self.assertIn('export MAPLE_PACKAGE_SOURCE_SHA="$source_sha"', package) - self.assertNotIn("linux-release-appimage", package) - - def test_macos_packaging_uses_verified_prebuilt_binaries_on_fresh_runners(self): - jobs = workflow(self.WORKFLOW)["jobs"] - build = jobs["macos-build"] - self.assertNotIn("environment", build) - build_condition = " ".join(build["if"].split()) - self.assertIn(self.MASTER_GUARD, build_condition) - self.assertIn("needs.changes.result != 'success'", build_condition) - self.assertIn("needs.changes.outputs.agent != 'false'", build_condition) - self.assertTrue(any(step.get("uses", "").startswith("Swatinem/rust-cache@") - for step in build["steps"])) - upload = next(step["with"] for step in build["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")) - for name in ("macos", "macos-unsigned"): - with self.subTest(job=name): - job = jobs[name] - self.assertIn("macos-build", job["needs"]) - self.assertIn("needs.macos-build.result == 'success'", job["if"]) - steps = job["steps"] - self.assertFalse(any("rust-cache@" in step.get("uses", "") for step in steps)) - self.assertFalse(any("build-release.sh" in step.get("run", "") for step in steps)) - scanner_builds = [i for i, step in enumerate(steps) if "cargo" in step.get("run", "")] - self.assertEqual(len(scanner_builds), 1) - scanner_build = scanner_builds[0] - self.assertEqual(steps[scanner_build]["run"], "nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive") - self.assertEqual(steps[scanner_build]["if"], "${{ matrix.variant == 'prod' }}") - self.assertNotIn("env", steps[scanner_build], "scanner compilation must receive no signing credentials") - download = next(i for i, step in enumerate(steps) - if step.get("uses", "").startswith("actions/download-artifact@")) - self.assertEqual(steps[download]["with"], { - "name": upload["name"], "path": "apps/maple-agent/target", - }) - verify = next(i for i, step in enumerate(steps) - if "./scripts/verify-prebuilt-release.sh" in step.get("run", "")) - package = next(i for i, step in enumerate(steps) - if "./scripts/package-release.sh" in step.get("run", "")) - scanner = next(i for i, step in enumerate(steps) - if "./scripts/verify-updater-archive.sh" in step.get("run", "")) - self.assertLess(scanner_build, package) - self.assertGreater(scanner, package) - self.assertEqual(steps[scanner]["if"], "${{ matrix.variant == 'prod' }}") - self.assertNotIn("env", steps[scanner], "archive data inspection must receive no signing credentials") - self.assertIn('"dist/$MAPLE_RELEASE_PROFILE"', steps[scanner]["run"]) - self.assertEqual("--unsigned" in steps[scanner]["run"], name == "macos-unsigned") - for index, step in enumerate(steps): - if step.get("uses", "").startswith(("actions/upload-artifact@", "actions/attest@")): - self.assertLess(scanner, index, "actual archive must pass the installer parser before publication") - self.assertGreater(verify, download) - self.assertGreater(package, verify) - self.assertNotIn("env", steps[verify]) - self.assertIn('"$MAPLE_RELEASE_PROFILE"', steps[verify]["run"]) - - def test_prebuilt_notice_payload_preserves_binary_and_target_graph_layout(self): - jobs = workflow(self.WORKFLOW)["jobs"] - build_steps = jobs["macos-build"]["steps"] - build = next(i for i, step in enumerate(build_steps) - if "./scripts/build-release.sh" in step.get("run", "")) - upload = next(i for i, step in enumerate(build_steps) - if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertLess(build, upload) - uploaded_paths = [Path(path) for path in build_steps[upload]["with"]["path"].splitlines()] - # upload-artifact preserves paths relative to their least common ancestor. - # Adding a notices directory must not move the downloaded executable out - # of target/release or disconnect target-specific inventories from it. - common_root = Path(os.path.commonpath(uploaded_paths)) - self.assertEqual(common_root, Path("apps/maple-agent/target")) - binary = Path("apps/maple-agent/target/release/maple-agent") - notices = Path("apps/maple-agent/target/third-party-notices/aarch64-apple-darwin") - payload = [binary, *(notices / name for name in - ("manifest.json", "THIRD_PARTY_NOTICES.html", "THIRD_PARTY_NOTICES.txt"))] - self.assertIn(binary, uploaded_paths) - self.assertIn(notices.parent, uploaded_paths) - for name in ("macos", "macos-unsigned"): - with self.subTest(job=name): - download = next(step["with"] for step in jobs[name]["steps"] - if step.get("uses", "").startswith("actions/download-artifact@")) - destination = Path(download["path"]) - reconstructed = [destination / path.relative_to(common_root) for path in payload] - self.assertEqual(reconstructed, payload) - - def test_partial_producer_and_verifier_only_retries_reuse_successful_artifacts(self): - jobs = workflow(self.WORKFLOW)["jobs"] - - def artifact_name(template, variant, attempt): - for expression, value in ( - ("matrix.variant", variant), - ("github.run_id", "12345"), - ("github.run_attempt", str(attempt)), - ): - template = template.replace("${{ " + expression + " }}", value) - self.assertNotIn("${{", template) - return template - - for producer, verifier in ( - ("macos-build", "macos"), ("macos-build", "macos-unsigned"), - ("macos", "verify-macos"), ("linux", "verify-linux"), - ): - with self.subTest(producer=producer): - upload = next(step["with"] for step in jobs[producer]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")) - download = next(step["with"] for step in jobs[verifier]["steps"] - if step.get("uses", "").startswith("actions/download-artifact@")) - artifacts = {} - - def publish(variant, attempt): - name = artifact_name(upload["name"], variant, attempt) - if name in artifacts: - self.assertIs(upload.get("overwrite"), True) - artifacts[name] = (variant, attempt) - - # Attempt 1 retained Dev; Prod failed before publishing. Retry - # runs only Prod and both dependent verification variants. - publish("dev", 1) - publish("prod", 2) - for attempt in (2, 3): - # Attempt 3 retries verification alone, without rebuilding. - for variant, producer_attempt in (("dev", 1), ("prod", 2)): - name = artifact_name(download["name"], variant, attempt) - self.assertIn(name, artifacts) - self.assertEqual(artifacts[name], (variant, producer_attempt)) - - # A subsequent producer retry replaces only its own artifact. - publish("prod", 4) - self.assertEqual(len(artifacts), 2) - self.assertEqual(artifacts[artifact_name(download["name"], "prod", 4)], ("prod", 4)) - self.assertEqual(artifacts[artifact_name(download["name"], "dev", 4)], ("dev", 1)) - - def test_downloaded_packages_are_verified_without_signing_credentials(self): - jobs = workflow(self.WORKFLOW)["jobs"] - for name, build in (("verify-macos", "macos"), ("verify-linux", "linux")): - with self.subTest(job=name): - job = jobs[name] - if name == "verify-macos": - self.assertEqual(job["needs"], build) - self.assertNotIn("if", job) # Failed/skipped builds cannot enter verification. - else: - self.assertEqual(job["needs"], ["linux", "attest-linux"]) - condition = " ".join(job["if"].split()) - self.assertIn("always() && !cancelled()", condition) - self.assertIn("needs.linux.result == 'success'", condition) - self.assertIn("github.event_name == 'pull_request' ||", condition) - self.assertIn("needs.attest-linux.result == 'success'", condition) - self.assertIn(self.MASTER_GUARD, condition) - self.assertNotIn("environment", job) - upload = next(step for step in jobs[build]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")) - download = next(i for i, step in enumerate(job["steps"]) - if step.get("uses", "").startswith("actions/download-artifact@")) - self.assertEqual(job["steps"][download]["with"], { - "name": upload["with"]["name"], "path": "artifacts", - }) - verify = next(i for i, step in enumerate(job["steps"]) - if "./scripts/verify-release.sh" in step.get("run", "")) - self.assertGreater(verify, download) - provenance = next(i for i, step in enumerate(job["steps"]) - if "gh attestation verify" in step.get("run", "")) - self.assertGreater(provenance, download) - self.assertLess(provenance, verify) - if name == "verify-macos": - self.assertNotIn("--unsigned", job["steps"][verify]["run"]) - else: - scanner_build = next(i for i, step in enumerate(job["steps"]) - if "cargo build --locked -p maple-agent-updater --example validate-release-archive" - in step.get("run", "")) - scanner = next(i for i, step in enumerate(job["steps"]) - if "./scripts/verify-updater-archive.sh" in step.get("run", "")) - self.assertLess(provenance, scanner_build) - self.assertLess(scanner_build, scanner) - self.assertLess(download, scanner) - self.assertLess(scanner, verify) - self.assertNotIn("if", job["steps"][scanner_build]) - self.assertNotIn("if", job["steps"][scanner]) - smoke = next(step for step in jobs["verify-linux"]["steps"] - if "docker run" in step.get("run", ""))["run"] - self.assertRegex(smoke, r"ubuntu@sha256:[0-9a-f]{64}") - self.assertIn("--platform linux/amd64", smoke) - self.assertIn("--user 65534:65534", smoke) - self.assertIn("--network none", smoke) - self.assertIn("--read-only", smoke) - self.assertRegex(smoke, r"--tmpfs /tmp:[^\s]*mode=1777") - self.assertIn("--build-info", smoke) - self.assertIn("actual != expected", smoke) - self.assertIn("tar -xzpf /artifacts/*-linux-x86_64.tar.gz", smoke) - self.assertIn("./maple-agent*.app/AppRun", smoke) - self.assertIn("/bin/sh /checks/smoke-linux-bundle-host.sh ./maple-agent*.app/AppRun >&2", smoke) - self.assertIn("--volume \"$PWD/apps/maple-agent/scripts/smoke-linux-bundle-host.sh:/checks/smoke-linux-bundle-host.sh:ro\"", smoke) - self.assertIn("test -d /etc/ssl/certs", smoke) - self.assertIn("--volume /etc/ssl/certs:/etc/ssl/certs:ro", smoke) - self.assertNotIn("--appimage-extract", smoke) - - def test_downloaded_provenance_enforces_workflow_ref_commit_and_every_file(self): - for name in ("verify-macos", "verify-linux", "verify-windows"): - step = next(step for step in workflow(self.WORKFLOW)["jobs"][name]["steps"] - if "gh attestation verify" in step.get("run", "")) - with self.subTest(job=name), tempfile.TemporaryDirectory() as directory: - root = Path(directory) - artifacts = root / "artifacts" - artifacts.mkdir() - files = ("package with spaces.AppImage", "release-manifest.json", "SHA256SUMS") - for file in files: - (artifacts / file).write_text("fixture\n") - calls = root / "calls" - command = r'''gh() { printf '%s\0' "$@" >> "$CALLS"; return "${GH_RESULT:-0}"; } -''' + step["run"] - env = {**os.environ, "CALLS": str(calls), "GITHUB_REPOSITORY": "MaplePrivacyLabs/Maple", - "GITHUB_SHA": "a" * 40} - subprocess.run(["bash", "-c", command], check=True, capture_output=True, - cwd=root, env=env) - arguments = [value.decode() for value in calls.read_bytes().split(b"\0")[:-1]] - expected = [] - for file in sorted(files): - expected.extend([ - "attestation", "verify", "artifacts/" + file, - "--repo", "MaplePrivacyLabs/Maple", - "--signer-workflow", "MaplePrivacyLabs/Maple/.github/workflows/agent-desktop-build.yml", - "--source-ref", "refs/heads/master", "--source-digest", "a" * 40, - "--deny-self-hosted-runners", - ]) - self.assertEqual(arguments, expected) - result = subprocess.run(["bash", "-c", command], capture_output=True, - cwd=root, env={**env, "GH_RESULT": "42"}) - self.assertEqual(result.returncode, 42) - for file in artifacts.iterdir(): - file.unlink() - result = subprocess.run(["bash", "-c", command], capture_output=True, cwd=root, env=env) - self.assertNotEqual(result.returncode, 0) # Empty downloads cannot pass. - - def test_linux_pr_packages_and_both_verifiers_use_unsigned_mode_only_for_pr_events(self): - jobs = workflow(self.WORKFLOW)["jobs"] - for job_name, script, directory in ( - ("linux", "package-release.sh", None), - ("linux", "verify-release.sh", "dist/{profile}"), - ("verify-linux", "verify-release.sh", "../../artifacts"), - ): - step = next(step for step in jobs[job_name]["steps"] - if "./scripts/" + script in step.get("run", "")) - for profile in ("dev", "prod"): - for event in ("pull_request", "push", "workflow_dispatch"): - with self.subTest(job=job_name, script=script, profile=profile, event=event): - # Capture Nix's arguments without executing package or signing commands. - command = r'''nix() { printf '%s\0' "$@"; } -''' + step["run"] - result = subprocess.run(["bash", "-c", command], check=True, - capture_output=True, - env={**os.environ, "GITHUB_EVENT_NAME": event, - "MAPLE_RELEASE_PROFILE": profile}) - arguments = [value.decode() for value in result.stdout.split(b"\0")[:-1]] - script_index = arguments.index("./scripts/" + script) - expected = [profile] - if directory is not None: - expected.append(directory.format(profile=profile)) - if event == "pull_request": - expected.append("--unsigned") - if script == "verify-release.sh": - expected.extend(["--platform", "linux-x86_64"]) - self.assertEqual(arguments[script_index + 1:], expected) - - scanner = next(step for step in jobs["verify-linux"]["steps"] - if "./scripts/verify-updater-archive.sh" in step.get("run", "")) - for profile in ("dev", "prod"): - for event in ("pull_request", "push", "workflow_dispatch"): - with self.subTest(scanner_profile=profile, event=event): - command = r'''nix() { printf '%s\0' "$@"; } -''' + scanner["run"] - result = subprocess.run(["bash", "-c", command], check=True, capture_output=True, - env={**os.environ, "GITHUB_EVENT_NAME": event, - "MAPLE_RELEASE_PROFILE": profile}) - arguments = [value.decode() for value in result.stdout.split(b"\0")[:-1]] - script_index = arguments.index("./scripts/verify-updater-archive.sh") - expected = ["../../artifacts", "--profile", profile, "--platform", "linux-x86_64"] - if event == "pull_request": - expected.append("--unsigned") - self.assertEqual(arguments[script_index + 1:], expected) - - -class AgentDiffSelectionTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.git("init", "-q", "-b", "master") - self.git("config", "user.email", "ci@example.invalid") - self.git("config", "user.name", "CI fixture") - self.git("config", "core.hooksPath", "/dev/null") - scripts = self.root / "scripts/ci" - scripts.mkdir(parents=True) - for name in ("agent_change_detection.py", "change_detection.py"): - shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) - self.base = self.commit_file("README.md", "initial\n") - - def git(self, *arguments): - result = subprocess.run(["git", *arguments], cwd=self.root, check=True, - capture_output=True, text=True) - return result.stdout.strip() - - def commit_file(self, path, text): - file = self.root / path - file.parent.mkdir(parents=True, exist_ok=True) - file.write_text(text) - self.git("add", "--", path) - self.git("commit", "-qm", "fixture change") - return self.git("rev-parse", "HEAD") - - def select(self, event, base, head, *, repository="fork/Maple", ref="refs/heads/topic", master=""): - step = next(step for step in workflow("agent-ci.yml")["jobs"]["changes"]["steps"] - if step.get("id") == "classify") - output = self.root / "output" - output.unlink(missing_ok=True) - env = {**os.environ, "GITHUB_EVENT_NAME": event, "BASE_SHA": base, "HEAD_SHA": head, - "GITHUB_REPOSITORY": repository, "GITHUB_REF": ref, "MASTER_AGENT": master, - "GITHUB_OUTPUT": str(output)} - result = subprocess.run(["bash", "-c", step["run"]], cwd=self.root, env=env, - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr) - return output.read_text() - - def test_docs_only_push_and_agent_runtime_push(self): - docs = self.commit_file("apps/maple-agent/docs/design.md", "design\n") - self.assertEqual(self.select("push", self.base, docs), "agent=false\n") - runtime = self.commit_file("apps/maple-agent/app/src/main.rs", "fn main() {}\n") - self.assertEqual(self.select("push", docs, runtime), "agent=true\n") - - def test_canonical_master_preserves_unvalidated_work_and_omits_only_proven_history(self): - docs = self.commit_file("apps/maple-agent/docs/design.md", "design\n") - for selected, expected in (("", "true"), ("true", "true"), - ("unknown", "true"), ("false", "false")): - with self.subTest(selected=selected): - self.assertEqual(self.select("push", self.base, docs, - repository="MaplePrivacyLabs/Maple", - ref="refs/heads/master", master=selected), - f"agent={expected}\n") - - def test_pull_request_uses_merge_base_instead_of_unrelated_base_changes(self): - master = self.commit_file("apps/maple-agent/app/src/main.rs", "fn main() {}\n") - self.git("checkout", "-qb", "contributor", self.base) - docs = self.commit_file("apps/maple-agent/docs/design.md", "design\n") - self.assertEqual(self.select("pull_request", master, docs), "agent=false\n") - - def test_deletion_or_rename_out_of_component_still_builds(self): - runtime = self.commit_file("apps/maple-agent/app/src/main.rs", "fn main() {}\n") - self.git("mv", "apps/maple-agent/app/src/main.rs", "README.md.moved") - self.git("commit", "-qm", "rename fixture") - self.assertEqual(self.select("push", runtime, self.git("rev-parse", "HEAD")), "agent=true\n") - - def test_missing_history_manual_event_and_classifier_failure_select_build(self): - for event, base, head in ( - ("push", "0" * 40, self.base), - ("push", "a" * 40, self.base), - ("workflow_dispatch", "", ""), - ): - with self.subTest(event=event, base=base): - self.assertEqual(self.select(event, base, head), "agent=true\n") - (self.root / "scripts/ci/agent_change_detection.py").write_text("raise RuntimeError('fixture')\n") - self.assertEqual(self.select("push", self.base, self.base), "agent=true\n") - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_codeql_workflow.py b/scripts/ci/test_codeql_workflow.py deleted file mode 100644 index 3da39cf6b..000000000 --- a/scripts/ci/test_codeql_workflow.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Keep full CodeQL scans nightly and manual, outside ordinary merge CI.""" - -import functools -import json -from pathlib import Path -import re -import subprocess -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -SHA = re.compile(r"^[0-9a-f]{40}$") - - -@functools.cache -def workflow(): - result = subprocess.run( - ["yq", "-o=json", ".", str(ROOT / ".github/workflows/codeql.yml")], - check=True, capture_output=True, text=True, - ) - return json.loads(result.stdout) - - -class CodeQLWorkflowTests(unittest.TestCase): - def test_scans_nightly_and_manually_not_pushes_or_pull_requests(self): - config = workflow() - self.assertEqual(set(config["on"]), {"schedule", "workflow_dispatch"}) - self.assertEqual(config["on"]["schedule"], [{"cron": "28 5 * * *"}]) - self.assertEqual(config["permissions"], {"contents": "read"}) - - def test_analyzes_default_setup_languages_without_a_build(self): - job = workflow()["jobs"]["analyze"] - self.assertGreaterEqual(job["timeout-minutes"], 90) - self.assertEqual(job["permissions"]["security-events"], "write") - languages = [row["language"] for row in job["strategy"]["matrix"]["include"]] - self.assertEqual( - languages, - ["actions", "javascript-typescript", "python", "rust"], - ) - self.assertTrue(all(row["build-mode"] == "none" for row in job["strategy"]["matrix"]["include"])) - - def test_actions_are_pinned_and_checkout_is_unprivileged(self): - steps = workflow()["jobs"]["analyze"]["steps"] - self.assertEqual(len(steps), 3) - for step in steps: - action = step["uses"] - self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@[0-9a-f]{40}$") - self.assertRegex(action.rsplit("@", 1)[1], SHA) - checkout = steps[0] - self.assertTrue(checkout["uses"].startswith("actions/checkout@")) - self.assertIs(checkout["with"]["persist-credentials"], False) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py new file mode 100644 index 000000000..c0776667b --- /dev/null +++ b/scripts/ci/test_contracts.py @@ -0,0 +1,373 @@ +"""Contracts between CI and the release, publication and signing machinery. + +Each test imports the code on the other side of its contract (the publishers, +the release preflight) instead of copying it. A failure means a release, update +feed or signed build would break: fix the workflow, not the contract. +""" + +from __future__ import annotations + +import importlib.util +import itertools +import json +from pathlib import Path +import re +import unittest +from unittest import mock + +import plan +from test_workflow_policy import (ENVIRONMENTS, GITHUB_HOSTED, LISTENERS, ROOT, cache_writer, environment, jobs, + load, permissions, runner_labels, triggers, workflows) + + +def module(name: str, path: str): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +publisher = module("agent_publisher", "services/agent-updates/publisher/publisher.py") +ci_evidence = module("ci_evidence", ".agents/skills/release-maple/scripts/ci_evidence.py") + +# Master workflows whose caches release.yml restores by prefix. +RELEASE_FEEDING = frozenset({"release.yml", "desktop-build.yml", "android-build.yml", "mobile-build.yml", + "ios-dev-testflight.yml"}) +RESERVED_PREFIXES = ( + "Linux-sccache-", "macOS-sccache-", "Windows-sccache-", "${{ runner.os }}-sccache-", "xcode-cache-", + "maple-dev-xcode", "release-onnxruntime-ios-", "${{ runner.os }}-bun-", "${{ runner.os }}-gradle-", +) +ONNX_CACHE = "apps/maple-research/frontend/src-tauri/onnxruntime-ios" +# Jobs that may mint an OIDC token: attestation, signing identity, FlakeHub, registries. +OIDC_JOBS = frozenset({ + ("agent-desktop-build.yml", "macos"), ("agent-desktop-build.yml", "windows"), + ("agent-desktop-build.yml", "attest-linux"), ("android-build.yml", "build-android"), + ("desktop-build.yml", "build-macos"), ("desktop-build.yml", "build-linux"), ("desktop-build.yml", "build-windows"), + ("dev-credential-requester-image.yml", "build"), ("ios-dev-testflight.yml", "build-ios-dev"), + ("mobile-build.yml", "build-ios"), ("opensecret-eif-release.yml", "build"), + ("opensecret-eif.yml", "eif-candidate"), ("opensecret-eif.yml", "eif-trusted"), + *(("release.yml", job) for job in ("build-tauri", "build-windows", "build-android", "build-ios", "build-web", + "publish-proxy-release-artifacts", "update-latest-json", + "publish-release-verification-guide")), + ("sdk-publish-npm.yml", "publish"), ("sdk-publish-rust.yml", "publish"), ("web-build.yml", "build-web-master"), +}) +# The EIF builders that warm FlakeHub for opensecret-eif-release.yml. +FLAKEHUB_JOBS = frozenset({("opensecret-eif.yml", "eif-candidate"), ("opensecret-eif.yml", "eif-trusted"), + ("opensecret-eif-release.yml", "build")}) +# Every job that holds a protected environment. +CONSUMERS = { + ("agent-desktop-build.yml", "macos"): "desktop-signing", + ("agent-desktop-build.yml", "windows"): "windows-signing", + **{("agent-updates-publish.yml", job): "updates-production" for job in ("reserve", "sign", "publish")}, + ("android-build.yml", "build-android"): "android-signing", + ("auth-pages-dev-publish.yml", "development"): "auth-pages-development", + ("auth-pages-dev-publish.yml", "preview"): "auth-pages-development", + ("auth-pages-publish.yml", "production"): "auth-pages-production", + ("desktop-build.yml", "build-macos"): "desktop-signing", + ("desktop-build.yml", "build-linux"): "desktop-signing", + ("desktop-build.yml", "build-windows"): "windows-signing", + ("ios-dev-testflight.yml", "build-ios-dev"): "apple-signing", + ("ios-dev-testflight.yml", "submit-ios-dev-testflight"): "apple-signing", + ("mobile-build.yml", "build-ios"): "apple-signing", + ("mobile-build.yml", "submit-ios-testflight"): "apple-signing", + ("opensecret-eif-release.yml", "sign"): "pcr-signing", + ("pages-publish.yml", "preview"): "pages-preview", + ("pages-publish.yml", "production"): "pages-production", + ("release.yml", "build-tauri"): "desktop-signing", + ("release.yml", "build-windows"): "windows-signing", + ("release.yml", "build-android"): "android-signing", + ("release.yml", "build-ios"): "apple-signing", + ("sdk-publish-npm.yml", "publish"): "sdk-npm", + ("sdk-publish-rust.yml", "publish"): "sdk-crates", + **{("signing-credentials-check.yml", name): name for name in ( + "desktop-signing", "apple-signing", "android-signing", "windows-signing", "zapstore-publishing")}, + ("updates-publish.yml", "publish"): "updates-production", + ("zapstore-publish.yml", "publish"): "zapstore-publishing", +} +DOCS = ("docs/**", "*/docs/**", "*/*/docs/**", ".agents/**", "**/README.md", "**/AGENTS.md", "**/CLAUDE.md") + +TEMPORARY = { + # Signed-lane hygiene: CodeQL runs on every master push, never cancelled. + "evidence": frozenset({"codeql.yml"}), + # The client lanes delete the PR builds and drop the evidence twins' caches. + "reserved": frozenset({ + ("android-pr-build.yml", "build-android"), ("desktop-pr-build.yml", "build-macos"), + ("desktop-pr-build.yml", "build-research-dev-macos"), ("desktop-pr-build.yml", "build-linux"), + ("mobile-pr-build.yml", "build-ios"), ("frontend-tests.yml", "test-frontend"), + ("rust-tests.yml", "rust-tests"), ("web-build.yml", "build-web"), ("web-build.yml", "build-web-master"), + }), + # Signed-lane hygiene moves id-token from the workflow level to the attesting jobs. + "oidc": frozenset({("android-build.yml", "changes"), ("desktop-build.yml", "changes"), + ("desktop-build.yml", "verify-desktop-artifacts")}), +} + + +def condition(job: dict) -> str: + return " ".join(str(job.get("if", "")).split()) + + +def variants(job: dict) -> list: + matrix = job.get("strategy", {}).get("matrix", {}).get("variant") + if matrix is None or isinstance(matrix, list): + return matrix or [None] + return sorted({value for array in re.findall(r"\[[^\]]*\]", matrix) for value in json.loads(array)}) + + +def cache_steps(): + for name, job_id, job in jobs(): + for step in job.get("steps", []): + if step.get("uses", "").startswith(("actions/cache", "irgaly/xcode-cache@")): + yield name, job_id, step + + +def cancels(concurrency) -> bool: + if not isinstance(concurrency, dict): + return False + value = concurrency.get("cancel-in-progress", False) + return value is True or (isinstance(value, str) and value != "${{ github.event_name == 'pull_request' }}") + + +class ContractTests(unittest.TestCase): + def test_c1_release_is_unique_and_listeners_prove_their_producer(self): + configs = workflows() + names = [config.get("name") for config in configs.values()] + self.assertEqual([name for name, config in configs.items() if config.get("name") == "Release"], + ["release.yml"]) + self.assertEqual([name for name, config in configs.items() if "release" in triggers(config)], + ["release.yml"]) + for name in sorted(LISTENERS): + watched = configs[name]["on"]["workflow_run"]["workflows"] + self.assertTrue(all(names.count(producer) == 1 for producer in watched), name) + for job_id, job in configs[name]["jobs"].items(): + gate = condition(job) + with self.subTest(workflow=name, job=job_id): + if job.get("needs"): + continue + if "workflow_run" not in gate: + self.assertIn("github.event_name == 'workflow_dispatch'", gate) + continue + for control in ("github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.head_repository.full_name == github.repository", + "github.event.workflow_run.event =="): + self.assertIn(control, gate) + paths = re.findall(r"workflow_run\.path == '\.github/workflows/([^']+)'", gate) + self.assertTrue(all(configs[path]["name"] in watched for path in paths)) + if not paths: # only release.yml runs on `release` + self.assertIn("github.event.workflow_run.event == 'release'", gate) + + def test_c1_research_releases_gate_every_release_job_and_publisher(self): + release = workflows()["release.yml"] + self.assertEqual(release["jobs"]["classify-app-release"]["if"], + "startsWith(github.event.release.tag_name, 'v')") + for job_id, job in release["jobs"].items(): + if job_id != "classify-app-release": + needs = job["needs"] if isinstance(job["needs"], list) else [job["needs"]] + self.assertIn("classify-app-release", needs, job_id) + self.assertNotIn("if", job, job_id) + for name, job_id in (("pages-publish.yml", "production"), ("updates-publish.yml", "publish"), + ("proxy-publish.yml", "prepare"), ("zapstore-publish.yml", "publish")): + gate = condition(workflows()[name]["jobs"][job_id]) + for control in ("startsWith(github.event.workflow_run.head_branch, 'v') &&", + "github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.event == 'release'"): + self.assertIn(control, gate, name) + + def test_c2_agent_publisher_accepts_this_workflows_jobs_and_artifacts(self): + config = load(ROOT / publisher.WORKFLOW) + self.assertIn("master", config["on"]["push"]["branches"]) + self.assertIn("workflow_dispatch", config["on"]) + run_id = 4242 + + def expand(text, variant): + return text.replace("${{ matrix.variant }}", str(variant)).replace("${{ github.run_id }}", str(run_id)) + + inventory, artifacts = [], [] + for job_id, job in config["jobs"].items(): + for variant in variants(job): + inventory.append({"name": expand(job.get("name", job_id), variant), + "status": "completed", "conclusion": "success"}) + artifacts += [{"name": expand(step["with"]["name"], variant), "expired": False} + for step in job.get("steps", []) + if step.get("uses", "").startswith("actions/upload-artifact@")] + attests = any(step.get("uses", "").startswith("actions/attest@") for step in job.get("steps", [])) + if attests: # the publisher verifies with --deny-self-hosted-runners + self.assertTrue(GITHUB_HOSTED.fullmatch(job["runs-on"]), job_id) + if attests or environment(job) or job.get("name", "").startswith("Verify"): + self.assertFalse([step for step in job.get("steps", []) if step.get("uses", "").startswith( + ("actions/cache", "Swatinem/rust-cache@"))], f"{job_id} must restore no cache") + run = {"id": run_id, "run_attempt": 1, "run_number": 77, "path": publisher.WORKFLOW, + "head_branch": "master", "event": "push", "status": "completed", "head_sha": "a" * 40, + "created_at": "2026-01-01T00:00:00Z", "repository": {"full_name": publisher.REPOSITORY}, + "head_repository": {"full_name": publisher.REPOSITORY}} + responses = {"": run, "jobs": {"total_count": len(inventory), "jobs": inventory}, + "artifacts": {"total_count": len(artifacts), "artifacts": artifacts}} + with mock.patch.object(publisher, "gh_json", + lambda path: responses[re.sub(r".*/runs/\d+/?([a-z]*).*", r"\1", path)]): + proof = publisher.trust_run(run_id, linux=True) + self.assertEqual(proof["build"], "77") # run_number is the Agent build number + + def test_c3_release_preflight_finds_its_evidence_jobs_on_master_pushes(self): + broken = set() + for name, required in ci_evidence.WORKFLOWS.items(): + config = workflows().get(name) + self.assertIsNotNone(config, name) + push = config["on"].get("push") if isinstance(config["on"], dict) else None + names = {job.get("name", job_id) for job_id, job in config["jobs"].items()} + found = set(required) <= names if required else any(n.startswith("Analyze (") for n in names) + self.assertTrue(found, name) + if (not isinstance(push, dict) or "master" not in push.get("branches", ["master"]) + or cancels(config.get("concurrency")) + or any(cancels(job.get("concurrency")) for job in config["jobs"].values())): + broken.add(name) + self.assertEqual(broken, TEMPORARY["evidence"]) + + def test_c4_signed_lanes_own_the_release_cache_keys(self): + steps = list(cache_steps()) + writers = {(name, job_id) for name, job_id, step in steps if cache_writer(step) + and name not in RELEASE_FEEDING and step["with"].get("key", "").startswith(RESERVED_PREFIXES)} + self.assertEqual(writers, TEMPORARY["reserved"]) + feeders = [step["with"]["key"] for name, _, step in steps + if name in RELEASE_FEEDING - {"release.yml"} and cache_writer(step)] + for job_id, job in workflows()["release.yml"]["jobs"].items(): + for step in job.get("steps", []): + if step.get("uses", "").startswith(("actions/cache", "irgaly/xcode-cache@")): + prefixes = [step["with"]["key"]] + [ + line.strip() for line in step["with"].get("restore-keys", "").splitlines() if line.strip()] + with self.subTest(job=job_id, key=step["with"]["key"]): + self.assertTrue(any(key.startswith(tuple(prefixes)) for key in feeders)) + + def test_c4_onnx_cache_text_matches_release_and_only_signed_lanes_write_it(self): + onnx = [(name, step) for name, _, step in cache_steps() + if step["with"].get("path", "").strip() == ONNX_CACHE] + self.assertLessEqual({"release.yml", "mobile-build.yml", "ios-dev-testflight.yml"}, {n for n, _ in onnx}) + release = next(step["with"] for name, step in onnx if name == "release.yml") + for name, step in onnx: + with self.subTest(workflow=name): + self.assertEqual((step["with"]["key"], step["with"]["restore-keys"]), + (release["key"], release["restore-keys"])) + if cache_writer(step): + self.assertIn(name, RELEASE_FEEDING) + + def test_c4_dev_testflight_build_number_is_the_run_number(self): + job = workflows()["ios-dev-testflight.yml"]["jobs"]["build-ios-dev"] + identity = next(step for step in job["steps"] if step.get("id") == "identity") + self.assertEqual(identity["env"]["RUN_NUMBER"], "${{ github.run_number }}") + + def test_c5_eif_event_gate_matches_the_approval_policy(self): + # Evaluate the real GitHub expressions with equivalent Python semantics. + expressions = {} + for job_id in ("eif-candidate", "eif", "eif-trusted"): + text = workflows()["opensecret-eif.yml"]["jobs"][job_id]["if"].strip() + text = text.removeprefix("${{").removesuffix("}}").replace("&&", " and ").replace("||", " or ") + expressions[job_id] = " ".join(text.replace("!cancelled()", "NOT_CANCELLED") + .replace("always()", "True").split()) + cases = ( + ("pull_request", "refs/pull/1/merge", "success", "true", "false", False), + ("pull_request", "refs/pull/1/merge", "success", "true", "true", True), + ("pull_request", "refs/pull/1/merge", "success", "false", "true", True), + ("pull_request", "refs/pull/1/merge", "failure", "", "", False), + ("pull_request", "refs/pull/1/merge", "failure", "true", "true", False), + ("pull_request", "refs/pull/1/merge", "skipped", "", "", False), + ("pull_request", "refs/pull/1/merge", "cancelled", "", "", False), + ("pull_request", "refs/pull/1/merge", "success", "", "", False), + ("pull_request", "refs/heads/master", "success", "true", "true", True), + ("push", "refs/heads/master", "success", "true", "false", True), + ("push", "refs/heads/master", "success", "false", "false", False), + ("push", "refs/heads/master", "failure", "", "", True), + ("push", "refs/heads/master", "skipped", "", "", True), + ("push", "refs/heads/master", "success", "", "", True), + ("push", "refs/heads/feature", "success", "true", "true", False), + ("push", "refs/tags/master", "success", "true", "true", False), + ("workflow_dispatch", "refs/heads/master", "success", "true", "false", True), + ("workflow_dispatch", "refs/heads/master", "failure", "", "", True), + ("workflow_dispatch", "refs/heads/feature", "success", "true", "false", True), + ("workflow_dispatch", "refs/tags/review", "success", "true", "false", True), + ("workflow_dispatch", "refs/tags/master", "failure", "", "", True), + ("schedule", "refs/heads/master", "success", "true", "true", False), + ("pull_request_target", "refs/heads/master", "success", "true", "true", False), + ("workflow_run", "refs/heads/master", "success", "true", "true", False), + ) + heads = (("MaplePrivacyLabs/Maple", True), ("contributor/Maple", False), + ("MaplePrivacyLabs/Maple-fork", False), ("", False), (None, False)) + for case, (head, same_repo), cancelled in itertools.product(cases, heads, (False, True)): + event, ref, result, eif, approvals, expected = case + context = { + "github.event_name": event, "github.ref": ref, + "github.event.pull_request.head.repo.full_name": head, + "github.repository": "MaplePrivacyLabs/Maple", "needs.changes.result": result, + "needs.changes.outputs.eif": eif, "needs.changes.outputs.pcr_approvals": approvals, + "NOT_CANCELLED": not cancelled, + } + selected = [] + for job_id, expression in expressions.items(): + for key, value in context.items(): + expression = expression.replace(key, repr(value)) + if eval(expression, {"__builtins__": {}}, {}): # noqa: S307 - fixed workflow text + selected.append(job_id) + trusted = ((event == "pull_request" and same_repo) + or (event in ("push", "workflow_dispatch") and ref == "refs/heads/master")) + wanted = [] + if (not cancelled and event == "pull_request" and same_repo and result == "success" + and eif == "true" and approvals == "false"): + wanted.append("eif-candidate") + if expected and not cancelled: + wanted.append("eif-trusted" if trusted else "eif") + self.assertEqual(selected, wanted, (case, head, cancelled)) + + def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): + minted = {(name, job_id) for name, job_id, job in jobs() + if permissions(workflows()[name], job).get("id-token") == "write"} + self.assertEqual(minted - OIDC_JOBS, TEMPORARY["oidc"]) + builder = workflows()["opensecret-eif-release.yml"]["jobs"]["build"]["runs-on"] + flakehub = {(name, job_id) for name, job_id, job in jobs() for step in job.get("steps", []) + if "flakehub-cache-action@" in step.get("uses", "")} + self.assertEqual(flakehub, FLAKEHUB_JOBS) + for name, job_id in flakehub: + self.assertEqual(workflows()[name]["jobs"][job_id]["runs-on"], builder, job_id) + + def test_c5_eif_release_is_manual_reviewed_and_never_tags(self): + config = workflows()["opensecret-eif-release.yml"] + self.assertEqual(set(config["on"]), {"workflow_dispatch"}) + self.assertIs(config["on"]["workflow_dispatch"]["inputs"]["dry_run"]["default"], True) + self.assertNotRegex(json.dumps(config), r"gh release|git tag|refs/tags|releases/|latest\.json") + build, sign = config["jobs"]["build"], config["jobs"]["sign"] + self.assertEqual(build["if"], "github.ref == 'refs/heads/master'") + self.assertEqual(build["permissions"], {"contents": "read", "id-token": "write", "attestations": "write"}) + self.assertNotIn("environment", build) + self.assertFalse(re.search(r"\bsecrets\b|github\.token|\bGH_TOKEN\b", json.dumps(build))) + self.assertEqual(sign["needs"], "build") + self.assertEqual(sign["if"], "inputs.dry_run == false && needs.build.outputs.approval_needed == 'true'") + self.assertEqual(sign["permissions"], {"contents": "write"}) + self.assertNotRegex(json.dumps(sign), r"id-token|flakehub-cache") + key = [step for step in sign["steps"] if "outputs.SIGNING_PRIVATE_KEY" in json.dumps(step)] + self.assertEqual(len(key), 1) # the signing key reaches exactly one step + self.assertEqual(key[0]["env"], {"SIGNING_PRIVATE_KEY": "${{ steps.signing-key.outputs.SIGNING_PRIVATE_KEY }}"}) + + def test_c6_protected_environments_have_fixed_consumers(self): + self.assertEqual({(name, job_id): environment(job) for name, job_id, job in jobs() if environment(job)}, + CONSUMERS) + self.assertLessEqual(set(CONSUMERS.values()), set(ENVIRONMENTS)) + for (name, job_id), granted in CONSUMERS.items(): + if granted == "windows-signing": # the Azure OIDC subject is a GitHub-hosted runner + labels = runner_labels(name, job_id, workflows()[name]["jobs"][job_id]) + self.assertTrue(labels and all(GITHUB_HOSTED.fullmatch(label) for label in labels), job_id) + + def test_c7_docs_name_only_existing_workflows_and_files(self): + files = plan.tracked_files() + names = {Path(path).name for path in files} + dangling = [] + for path in files: + if not (path.endswith(".md") and plan.matches(path, DOCS)): + continue + text = (ROOT / path).read_text(encoding="utf-8", errors="replace") + for match in re.finditer(r"(?> \"$GITHUB_STEP_SUMMARY\"\n" - 'bash scripts/ci/build_opensecret_eif.sh "$EIF_MODE" ' - '"$RUNNER_TEMP/eif-candidate-$EIF_MODE"\n' -) -PR_ROOTFS_CHECK = ( - 'python3 scripts/ci/verify_opensecret_eif_rootfs.py "$EIF_MODE" ' - '"$RUNNER_TEMP/eif-candidate-$EIF_MODE" "$GITHUB_SHA"' -) -VERIFY_NIX = "bash scripts/ci/verify-nix-runtime.sh" - - -@functools.cache -def workflow(name): - result = subprocess.run(["yq", "-o=json", ".", str(ROOT / ".github/workflows" / name)], - check=True, capture_output=True, text=True) - return json.loads(result.stdout) - - -def strings(value): - if isinstance(value, str): - yield value - elif isinstance(value, dict): - for key, child in value.items(): - yield str(key) - yield from strings(child) - elif isinstance(value, list): - for child in value: - yield from strings(child) - - -class OpenSecretWorkflowBoundaryTests(unittest.TestCase): - def test_nix_jobs_fetch_complete_backend_and_submodule_history(self): - # checkout's fetch-depth applies to submodule update too. Nix's Git - # fetcher cannot calculate revCount for a shallow recursive input. - for workflow_name, job_names in ( - ("opensecret-ci.yml", ("rust", "nix")), - ("opensecret-eif.yml", ("eif-candidate", "eif", "eif-trusted")), - ("sdk-integration.yml", ("sdk-integration",)), - ): - for job_name in job_names: - with self.subTest(workflow=workflow_name, job=job_name): - steps = workflow(workflow_name)["jobs"][job_name]["steps"] - checkouts = [step["with"] for step in steps - if step.get("uses", "").startswith("actions/checkout@")] - self.assertEqual(len(checkouts), 1) - self.assertEqual(checkouts[0]["submodules"], "recursive") - self.assertEqual(checkouts[0].get("fetch-depth"), 0) - - def test_jobs_are_hosted_with_read_only_contents_and_scoped_oidc(self): - for name in ("opensecret-ci.yml", "opensecret-eif.yml", - "opensecret-change-detection.yml", "sdk-integration.yml"): - config = workflow(name) - with self.subTest(workflow=name): - self.assertEqual(config["permissions"], {"contents": "read"}) - self.assertNotIn("pull_request_target", config["on"]) - self.assertNotIn("workflow_run", config["on"]) - for value in strings(config): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - for job_name, job in config["jobs"].items(): - self.assertNotIn("environment", job) - if name == "opensecret-eif.yml" and job_name in ("eif-candidate", "eif-trusted"): - self.assertEqual(job["permissions"], {"contents": "read", "id-token": "write"}) - else: - self.assertIn(job.get("permissions"), (None, {"contents": "read"})) - for value in strings(job): - self.assertNotRegex(value, r"\bid-token\b|flakehub-cache-action") - if "uses" in job: - self.assertEqual(job["uses"], "./.github/workflows/opensecret-change-detection.yml") - self.assertNotIn("secrets", job) - continue - expected_runner = "ubuntu-latest" - if name == "opensecret-eif.yml": - expected_runner = { - "eif-candidate": "blacksmith-8vcpu-ubuntu-2404-arm", - "eif": "ubuntu-24.04-arm", - "eif-trusted": "blacksmith-8vcpu-ubuntu-2404-arm", - }[job_name] - elif (name, job_name) == ("opensecret-ci.yml", "helpers"): - expected_runner = "ubuntu-24.04-arm" - self.assertEqual(job["runs-on"], expected_runner) - for step in job["steps"]: - self.assertNotIn("${{", step.get("run", "")) - action = step.get("uses", "") - if action: - self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") - if action.startswith("actions/checkout@"): - self.assertIs(step["with"]["persist-credentials"], False) - self.assertNotIn("repository", step["with"]) - self.assertNotIn("ref", step["with"]) - if action.startswith("DeterminateSystems/determinate-nix-action@"): - self.assertEqual(step["with"], { - "source-tag": "v3.23.3", "github-token": "", - }) - - def test_custom_hosted_runner_is_registered_for_workflow_lint(self): - result = subprocess.run( - ["yq", "-o=json", ".", str(ROOT / ".github/actionlint.yaml")], - check=True, capture_output=True, text=True, - ) - labels = json.loads(result.stdout)["self-hosted-runner"]["labels"] - for workflow_name, job_names in ( - ("opensecret-eif.yml", ("eif-candidate", "eif-trusted")), - ("opensecret-eif-release.yml", ("build",)), - ): - for job_name in job_names: - self.assertIn(workflow(workflow_name)["jobs"][job_name]["runs-on"], labels) - - def test_ordinary_backend_ci_does_not_publish_or_build_eifs(self): - config = workflow("opensecret-ci.yml") - self.assertEqual(set(config["on"]), {"push", "pull_request", "schedule", "workflow_dispatch"}) - self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertEqual(config["on"]["pull_request"]["branches"], ["master"]) - self.assertFalse(any((ROOT / "services/opensecret/.github/workflows").glob("*.yml"))) - for value in strings(config["jobs"]): - self.assertNotRegex(value, r"eif-|deploy-|stage-|scp-|update-pcr|append-pcr|verify-pcr") - self.assertNotRegex(value, r"upload-artifact|download-artifact|flakehub-cache|gh release") - cache = next(step["with"] for step in config["jobs"]["rust"]["steps"] - if "rust-cache@" in step.get("uses", "")) - self.assertEqual(cache["workspaces"], "services/opensecret -> target") - self.assertEqual(cache["save-if"], - "${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}") - - def test_eif_workflow_preserves_approvals_and_keeps_both_environments_independent(self): - config = workflow("opensecret-eif.yml") - self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) - for event in ("push", "pull_request"): - self.assertEqual(config["on"][event]["branches"], ["master"]) - self.assertNotIn("paths", config["on"][event]) - self.assertEqual(config["concurrency"]["group"], - "opensecret-eif-${{ github.event_name }}-${{ github.ref }}") - self.assertEqual(set(config["jobs"]), {"changes", "eif-candidate", "eif", "eif-trusted"}) - for job_name in ("eif-candidate", "eif", "eif-trusted"): - job = config["jobs"][job_name] - self.assertEqual(job["needs"], "changes") - self.assertEqual(job["strategy"]["matrix"], {"mode": ["dev", "prod"]}) - self.assertIs(job["strategy"]["fail-fast"], False) - self.assertEqual(job["env"]["EIF_MODE"], "${{ matrix.mode }}") - self.assertEqual(job["timeout-minutes"], 90 if job_name == "eif" else 180) - for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): - self.assertEqual(job["env"][key], "0") - commands = [step["run"] for step in job["steps"] if "run" in step] - expected_command = PR_CANDIDATE_BUILD if job_name == "eif-candidate" else ( - 'bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"') - self.assertEqual(commands, [VERIFY_NIX, expected_command] + ( - [PR_ROOTFS_CHECK] if job_name == "eif-candidate" else [] - )) - for value in strings(config["jobs"]): - self.assertNotRegex(value, r"deploy-|stage-|scp-|update-pcr|append-pcr|generate-keys") - self.assertNotRegex(value, r"upload-artifact|download-artifact|gh release") - candidate = config["jobs"]["eif-candidate"] - self.assertNotIn("environment", candidate) - self.assertNotIn("outputs", candidate) - self.assertNotIn("continue-on-error", candidate["steps"][-1]) - - def test_eif_cache_setup_precedes_build_and_warms_fork_compatible_cache(self): - jobs = workflow("opensecret-eif.yml")["jobs"] - installer_action = "DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28" - for job_name, cache_action, cache_inputs in ( - ("eif", "DeterminateSystems/magic-nix-cache-action@3c034b51a9deec0a09ef1df8b436ac5db50fae94", { - "source-revision": "4cc363589df8090801c098cdcde1bdd42562318a", - "use-flakehub": "disabled", - "use-gha-cache": "enabled", - }), - ("eif-trusted", "DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea", { - "diff-store": True, - }), - ("eif-candidate", "DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea", { - "diff-store": True, - }), - ): - with self.subTest(job=job_name): - steps = jobs[job_name]["steps"] - self.assertEqual(len(steps), 6 if job_name == "eif-candidate" else 5) - self.assertTrue(steps[0]["uses"].startswith("actions/checkout@")) - self.assertEqual(steps[1]["uses"], installer_action) - self.assertEqual(steps[1]["with"], { - "source-tag": "v3.23.3", "github-token": "", - }) - self.assertEqual(steps[2]["run"], VERIFY_NIX) - self.assertEqual(steps[3]["uses"], cache_action) - self.assertEqual(steps[3]["with"], cache_inputs) - self.assertNotIn("if", steps[3]) - self.assertNotIn("continue-on-error", steps[3]) - expected_command = PR_CANDIDATE_BUILD if job_name == "eif-candidate" else ( - 'bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"') - self.assertEqual(steps[4]["run"], expected_command) - if job_name == "eif-candidate": - self.assertEqual(steps[5]["run"], PR_ROOTFS_CHECK) - self.assertEqual(steps[5]["shell"], "bash") - self.assertNotIn("continue-on-error", steps[5]) - - def test_eif_event_gate_matches_the_approval_policy(self): - # Exercise the actual GitHub boolean expression with a restricted, - # equivalent local representation, not a separately implemented policy. - expressions = {} - for job_name in ("eif-candidate", "eif", "eif-trusted"): - expression = workflow("opensecret-eif.yml")["jobs"][job_name]["if"] - expression = expression.strip().removeprefix("${{").removesuffix("}}").strip() - expression = expression.replace("&&", " and ").replace("||", " or ") - expression = expression.replace("!cancelled()", "NOT_CANCELLED").replace("always()", "True") - expressions[job_name] = " ".join(expression.split()) - cases = ( - ("pull_request", "refs/pull/1/merge", "success", "true", "false", False), - ("pull_request", "refs/pull/1/merge", "success", "true", "true", True), - ("pull_request", "refs/pull/1/merge", "success", "false", "true", True), - ("pull_request", "refs/pull/1/merge", "failure", "", "", False), - ("pull_request", "refs/pull/1/merge", "failure", "true", "true", False), - ("pull_request", "refs/pull/1/merge", "skipped", "", "", False), - ("pull_request", "refs/pull/1/merge", "cancelled", "", "", False), - ("pull_request", "refs/pull/1/merge", "success", "", "", False), - ("pull_request", "refs/heads/master", "success", "true", "true", True), - ("push", "refs/heads/master", "success", "true", "false", True), - ("push", "refs/heads/master", "success", "false", "false", False), - ("push", "refs/heads/master", "failure", "", "", True), - ("push", "refs/heads/master", "skipped", "", "", True), - ("push", "refs/heads/master", "success", "", "", True), - ("push", "refs/heads/feature", "success", "true", "true", False), - ("push", "refs/tags/master", "success", "true", "true", False), - ("workflow_dispatch", "refs/heads/master", "success", "true", "false", True), - ("workflow_dispatch", "refs/heads/master", "failure", "", "", True), - ("workflow_dispatch", "refs/heads/feature", "success", "true", "false", True), - ("workflow_dispatch", "refs/tags/review", "success", "true", "false", True), - ("workflow_dispatch", "refs/tags/master", "failure", "", "", True), - ("schedule", "refs/heads/master", "success", "true", "true", False), - ("pull_request_target", "refs/heads/master", "success", "true", "true", False), - ("workflow_run", "refs/heads/master", "success", "true", "true", False), - ) - head_repositories = ( - ("MaplePrivacyLabs/Maple", True), - ("contributor/Maple", False), - ("MaplePrivacyLabs/Maple-fork", False), - ("", False), # Missing nested properties evaluate to an empty string. - (None, False), - ) - for case, (head_repo, same_repo), cancelled in itertools.product( - cases, head_repositories, (False, True) - ): - event, ref, result, eif, approvals, expected = case - with self.subTest(event=event, ref=ref, result=result, eif=eif, - approvals=approvals, head_repo=head_repo, cancelled=cancelled): - trusted = ( - (event == "pull_request" and same_repo) or - (event in ("push", "workflow_dispatch") and ref == "refs/heads/master") - ) - selected = [] - for job_name, expression in expressions.items(): - condition = expression - for key, value in { - "github.event_name": event, "github.ref": ref, - "github.event.pull_request.head.repo.full_name": head_repo, - "github.repository": "MaplePrivacyLabs/Maple", - "needs.changes.result": result, - "needs.changes.outputs.eif": eif, - "needs.changes.outputs.pcr_approvals": approvals, - "NOT_CANCELLED": not cancelled, - }.items(): - condition = condition.replace(key, repr(value)) - if eval(condition, {"__builtins__": {}}, {}): - selected.append(job_name) - expected_jobs = [] - if not cancelled and event == "pull_request" and same_repo and result == "success" and eif == "true" and approvals == "false": - expected_jobs.append("eif-candidate") - if expected and not cancelled: - expected_jobs.append("eif-trusted" if trusted else "eif") - self.assertEqual(selected, expected_jobs) - - def test_backend_retains_exact_rust_gates_and_disabled_stateful_shell_hooks(self): - config = workflow("opensecret-ci.yml") - self.assertEqual(config["defaults"]["run"]["working-directory"], "services/opensecret") - self.assertEqual(config["env"]["RUSTFLAGS"], "-D warnings") - for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): - self.assertEqual(config["env"][key], "0") - commands = [step["run"] for step in config["jobs"]["rust"]["steps"] if "run" in step] - self.assertEqual(commands, [ - VERIFY_NIX, - "nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo fmt --all -- --check", - "nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo clippy --locked --all-targets --all-features -- -D warnings", - "nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo test --locked --all-features", - ]) - nix_commands = [step["run"] for step in config["jobs"]["nix"]["steps"] if "run" in step] - self.assertEqual(nix_commands, [ - VERIFY_NIX, - "nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'", - "nix build --no-link --no-update-lock-file '.?submodules=1#default'", - ]) - audit = config["jobs"]["audit"]["steps"][-1]["with"] - toolchain = tomllib.loads((ROOT / "services/opensecret/rust-toolchain.toml").read_text()) - self.assertEqual(audit["rust-version"], toolchain["toolchain"]["channel"]) - self.assertEqual(audit["manifest-path"], "services/opensecret/Cargo.toml") - self.assertEqual(audit["command"], "check advisories bans") - self.assertEqual(audit["arguments"], "--config services/opensecret/deny.toml --all-features --locked") - - def test_backend_ci_has_no_standalone_signed_pcr_job(self): - jobs = workflow("opensecret-ci.yml")["jobs"] - self.assertEqual(set(jobs), {"changes", "rust", "nix", "audit", "helpers"}) - for value in strings(jobs): - self.assertNotIn("pcr_compatibility.py", value) - - def test_parent_helper_job_builds_both_arm64_images_without_publishing(self): - job = workflow("opensecret-ci.yml")["jobs"]["helpers"] - self.assertEqual(job["runs-on"], "ubuntu-24.04-arm") - self.assertEqual(job["strategy"]["matrix"]["include"], [ - {"name": "credential-requester", "context": "nitro-toolkit/credential_requester"}, - {"name": "enclave-logging", "context": "nitro-toolkit/logging"}, - ]) - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertIn("docker build --platform linux/arm64", commands) - self.assertIn("docker image inspect", commands) - self.assertIn('importlib.util.find_spec("pip") is None', commands) - self.assertIn('importlib.util.find_spec("ensurepip") is None', commands) - self.assertIn("--network none", commands) - self.assertIn("-m unittest discover -s /tests -p 'test_*.py' -v", commands) - self.assertNotRegex(commands, r"docker (?:push|save|load)|(?:scp|aws |gh release)") - scan = job["steps"][-1] - self.assertTrue(scan["uses"].startswith("aquasecurity/trivy-action@")) - self.assertEqual(scan["with"]["version"], "v0.75.0") - self.assertEqual(scan["with"]["severity"], "HIGH,CRITICAL") - self.assertEqual(scan["with"]["scanners"], "vuln") - self.assertEqual(scan["with"]["exit-code"], "1") - for component in ("credential_requester", "logging"): - dockerfile = (ROOT / "services/opensecret/nitro-toolkit" / component / "Dockerfile").read_text() - self.assertIn("--only-upgrade", dockerfile) - for package, version in ( - ("libpcre2-8-0", "10.42-1+deb12u2"), - ("perl-base", "5.36.0-7+deb12u4"), - ("libssl3", "3.0.22-1~deb12u2"), - ): - self.assertIn(f"{package}={version}", dockerfile) - self.assertIn(f"dpkg-query -W -f='${{Version}}' {package})\" = '{version}'", dockerfile) - self.assertIn("--only-binary=:all: --require-hashes", dockerfile) - self.assertIn("python -m pip check", dockerfile) - self.assertIn("python -m pip uninstall -y pip", dockerfile) - self.assertIn("rm -r /usr/local/lib/python3.13/ensurepip", dockerfile) - - def test_selector_exports_only_active_checks_and_the_approval_signal(self): - expected = {"rust", "nix", "integration", "audit", "eif", "helpers", "pcr_approvals"} - config = workflow("opensecret-change-detection.yml") - self.assertEqual(set(OUTPUTS), expected) - self.assertEqual(set(config["on"]["workflow_call"]["outputs"]), expected) - self.assertEqual(set(config["jobs"]["detect"]["outputs"]), expected) - - def test_sdk_integration_uses_checked_out_backend_and_disposable_services(self): - config = workflow("sdk-integration.yml") - self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) - job = config["jobs"]["sdk-integration"] - checkouts = [step for step in job["steps"] if "checkout@" in step.get("uses", "")] - self.assertEqual(len(checkouts), 1) - self.assertEqual(checkouts[0]["with"]["submodules"], "recursive") - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertNotIn(".ci/opensecret", commands) - self.assertNotIn("opensecret-integration-revision", commands) - self.assertFalse((ROOT / "sdk/opensecret-integration-revision").exists()) - self.assertIn("cd services/opensecret", commands) - self.assertEqual(commands.count("'./services/opensecret?submodules=1#ci'"), 4) - self.assertNotIn("'./services/opensecret?submodules=1'", commands) - self.assertIn("services/opensecret/target/debug/opensecret", commands) - self.assertIn("diesel migration run", commands) - self.assertIn("bash ../../.agents/skills/validate-opensecret/scripts/disposable_db_tests.sh", commands) - self.assertIn("sdk/test/integration/bootstrap.sql", commands) - self.assertIn("bun test src/lib/test/integration/api.test.ts", commands) - self.assertIn("cargo test --locked --all-features --tests", commands) - self.assertIn("client::tests::native_handoff_full_stack_round_trip", commands) - for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): - self.assertEqual(config["env"][key], "0") - self.assertEqual(config["env"]["APP_MODE"], "local") - self.assertEqual(config["env"]["OPENAI_API_BASE"], "http://127.0.0.1:9") - self.assertEqual(job["services"]["postgres"]["env"]["POSTGRES_DB"], "opensecret") - - def test_selector_failures_or_missing_outputs_cannot_skip_validation(self): - for workflow_name, lanes in (("opensecret-ci.yml", {name: name for name in ("rust", "nix", "audit", "helpers")}), - ("sdk-integration.yml", {"sdk-integration": "integration"})): - config = workflow(workflow_name) - self.assertNotIn("paths", config["on"]["pull_request"]) - self.assertNotIn("paths", config["on"]["push"]) - for job_name, output in lanes.items(): - condition = config["jobs"][job_name]["if"] - self.assertIn("always() && !cancelled()", condition) - self.assertIn("needs.changes.result != 'success'", condition) - self.assertIn(f"needs.changes.outputs.{output} != 'false'", condition) - - -class EifReleaseWorkflowTests(unittest.TestCase): - NAME = "opensecret-eif-release.yml" - CHECKOUT = "actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" - INSTALLER = "DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28" - - def test_release_is_manual_and_never_creates_a_release_or_tag(self): - config = workflow(self.NAME) - self.assertEqual(set(config["on"]), {"workflow_dispatch"}) - inputs = config["on"]["workflow_dispatch"]["inputs"] - self.assertEqual(set(inputs), {"environment", "dry_run"}) - self.assertEqual(inputs["environment"]["options"], ["dev", "prod"]) - self.assertIs(inputs["dry_run"]["default"], True) - self.assertEqual(config["permissions"], {"contents": "read"}) - self.assertEqual(config["concurrency"], { - "group": "opensecret-eif-release-${{ inputs.environment }}", "cancel-in-progress": False, - }) - self.assertEqual(config["env"]["EIF_MODE"], "${{ inputs.environment }}") - for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): - self.assertEqual(config["env"][key], "0") - # /releases/latest and updater metadata belong to the Maple desktop app. - for value in strings(config): - self.assertNotRegex(value, r"gh release|git tag|refs/tags|releases/|latest\.json") - self.assertNotRegex(value, r"pull_request_target|workflow_run|deploy-|stage-|scp-|update-pcr") - self.assertEqual(list(config["jobs"]), ["build", "sign"]) - for job in config["jobs"].values(): - for step in job["steps"]: - self.assertNotIn("${{", step.get("run", "")) - action = step.get("uses", "") - if action: - self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") - if action.startswith("actions/checkout@"): - self.assertEqual(step["with"], { - "persist-credentials": False, "submodules": "recursive", "fetch-depth": 0, - }) - if action.startswith("DeterminateSystems/determinate-nix-action@"): - self.assertEqual(step["with"], { - "source-tag": "v3.23.3", "github-token": "", - }) - - def test_build_job_is_the_trusted_read_only_builder(self): - job = workflow(self.NAME)["jobs"]["build"] - self.assertEqual(job["if"], "github.ref == 'refs/heads/master'") - self.assertEqual(job["runs-on"], "blacksmith-8vcpu-ubuntu-2404-arm") - self.assertEqual(job["timeout-minutes"], 180) - self.assertEqual(job["permissions"], {"contents": "read", "id-token": "write", "attestations": "write"}) - self.assertNotIn("environment", job) - for value in strings(job): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") - self.assertEqual(set(job["outputs"]), {"approval_needed", "pcr0", "eif_sha256", "artifact"}) - steps = job["steps"] - self.assertEqual([step.get("uses", "run") for step in steps], [ - self.CHECKOUT, self.INSTALLER, "run", - "DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea", - "run", - "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", - "actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb", - ]) - self.assertEqual(steps[1]["with"], {"source-tag": "v3.23.3", "github-token": ""}) - self.assertEqual(steps[2]["run"], VERIFY_NIX) - self.assertEqual(steps[3]["with"], {"diff-store": True}) - self.assertEqual(steps[4]["run"], 'bash scripts/ci/build_opensecret_eif.sh "$EIF_MODE" eif-artifact') - self.assertEqual(steps[5]["with"]["path"], "eif-artifact") - self.assertEqual(steps[5]["with"]["if-no-files-found"], "error") - self.assertEqual(steps[6]["with"], {"subject-checksums": "eif-artifact/SHA256SUMS"}) - self.assertNotIn("continue-on-error", steps[6]) - - def test_sign_job_is_reviewer_gated_and_exposes_the_key_to_one_step(self): - job = workflow(self.NAME)["jobs"]["sign"] - self.assertEqual(job["needs"], "build") - self.assertEqual(job["if"], "inputs.dry_run == false && needs.build.outputs.approval_needed == 'true'") - self.assertEqual(job["environment"], "pcr-signing") - self.assertEqual(job["permissions"], {"contents": "write"}) - self.assertEqual(job["runs-on"], "ubuntu-latest") - self.assertFalse(any("id-token" in value or "flakehub-cache" in value for value in strings(job))) - steps = job["steps"] - self.assertEqual([step.get("uses", "run") for step in steps], [ - self.CHECKOUT, self.INSTALLER, "run", - "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093", - "bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793", - "run", "run", - ]) - self.assertEqual(steps[1]["with"], {"source-tag": "v3.23.3", "github-token": ""}) - self.assertEqual(steps[2]["run"], VERIFY_NIX) - self.assertEqual(steps[3]["with"], {"name": "${{ env.APPROVAL_ARTIFACT }}", "path": "eif-artifact"}) - bitwarden = steps[4] - self.assertEqual(bitwarden["id"], "signing-key") - self.assertEqual(bitwarden["with"]["access_token"], - "${{ secrets.OPENSECRET_PCR_SIGNING_BWS_ACCESS_TOKEN }}") - self.assertEqual(bitwarden["with"]["secrets"].strip(), - "${{ vars.OPENSECRET_PCR_SIGNING_KEY_ID }} > SIGNING_PRIVATE_KEY") - self.assertIs(bitwarden["with"]["set_env"], False) - sign = steps[5] - self.assertEqual(sign["env"], {"SIGNING_PRIVATE_KEY": "${{ steps.signing-key.outputs.SIGNING_PRIVATE_KEY }}"}) - self.assertEqual(sign["run"], "nix develop --no-update-lock-file './services/opensecret?submodules=1#signing'" - ' -c bash services/opensecret/scripts/ci_sign_pcr.sh "$EIF_MODE" eif-artifact') - publish = steps[6] - self.assertEqual(publish["env"], {"GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"}) - self.assertEqual(publish["run"], 'bash scripts/ci/publish_opensecret_approval.sh "$EIF_MODE" eif-artifact') - references = [value for value in strings(job) if "outputs.SIGNING_PRIVATE_KEY" in value] - self.assertEqual(references, [sign["env"]["SIGNING_PRIVATE_KEY"]]) - secrets = sorted({name for value in strings(job) for name in re.findall(r"secrets\.([A-Za-z0-9_]+)", value)}) - self.assertEqual(secrets, ["GITHUB_TOKEN", "OPENSECRET_PCR_SIGNING_BWS_ACCESS_TOKEN"]) - - -VALID_MEASUREMENTS = {"HashAlgorithm": "Sha384 { ... }", "PCR0": "a" * 96, "PCR1": "b" * 96, "PCR2": "c" * 96} - - -class EifReleaseBuildCommandTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.repo = self.root / "repo" - self.component = self.repo / "services/opensecret" - self.component.mkdir(parents=True) - scripts = self.repo / "scripts/ci" - scripts.mkdir(parents=True) - self.script = scripts / "build_opensecret_eif.sh" - shutil.copyfile(ROOT / "scripts/ci/build_opensecret_eif.sh", self.script) - self.binaries = self.root / "bin" - self.binaries.mkdir() - self.scratch = self.root / "scratch" - self.scratch.mkdir() - for tool in ("dirname", "mktemp", "rm", "ls", "mkdir", "cp", "chmod", "jq", "sha256sum", "cut", "cmp"): - (self.binaries / tool).symlink_to(shutil.which(tool)) - uname = self.binaries / "uname" - uname.write_text( - f"#!{sys.executable}\n" - "import os, sys\n" - "print('Linux' if sys.argv[1] == '-s' else os.environ.get('TEST_ARCH', 'aarch64'))\n" - ) - uname.chmod(0o755) - nix = self.binaries / "nix" - nix.write_text( - f"#!{sys.executable}\n" - "import json, os, sys\n" - "from pathlib import Path\n" - "args = sys.argv[1:]\n" - "Path(os.environ['TEST_TRACE']).write_text(json.dumps({'args': args, 'cwd': os.getcwd()}))\n" - "output = Path(args[args.index('--out-link') + 1])\n" - "output.mkdir()\n" - "(output / 'image.eif').write_bytes(b'fixture EIF, not a real image')\n" - "(output / 'pcr.json').write_text(os.environ['TEST_MEASUREMENTS'])\n" - ) - nix.chmod(0o755) - self.measurements = json.dumps(VALID_MEASUREMENTS) + "\n" - (self.component / "pcrDev.json").write_text(json.dumps({**VALID_MEASUREMENTS, "PCR0": "d" * 96}) + "\n") - (self.component / "pcrProd.json").write_text(self.measurements) - for name in ("pcrDevHistory.json", "pcrProdHistory.json"): - (self.component / name).write_text("untouched history fixture\n") - self.existing_result = self.component / "result" - self.existing_result.symlink_to(self.root / "operator-owned-output") - self.sentinel = self.root / "dotenv-was-loaded" - (self.component / ".env").write_text(f"touch '{self.sentinel}'\n") - self.before_files = {p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file()} - self.github_output = self.root / "github-output" - - def run_build(self, mode="dev", output="candidate", **extra_env): - env = { - "PATH": str(self.binaries), "HOME": str(self.root), "TMPDIR": str(self.scratch), - "TEST_TRACE": str(self.root / "trace"), "TEST_MEASUREMENTS": self.measurements, - "GITHUB_SHA": "1" * 40, "GITHUB_OUTPUT": str(self.github_output), **extra_env, - } - result = subprocess.run( - [shutil.which("bash"), "--noprofile", "--norc", str(self.script), mode, output], - cwd=self.root, env=env, capture_output=True, text=True, - ) - self.assertFalse(self.sentinel.exists()) - self.assertEqual(os.readlink(self.existing_result), str(self.root / "operator-owned-output")) - self.assertEqual({p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file()}, - self.before_files) - self.assertEqual(list(self.scratch.iterdir()), []) - return result - - def outputs(self): - return dict(line.split("=", 1) for line in self.github_output.read_text().splitlines()) - - def test_new_measurements_produce_an_attestable_candidate_and_request_approval(self): - result = self.run_build() - self.assertEqual(result.returncode, 0, result.stderr) - candidate = self.root / "candidate" - self.assertEqual(sorted(p.name for p in candidate.iterdir()), - ["SHA256SUMS", "handoff.json", "image.eif", "pcr.json"]) - for name in ("image.eif", "pcr.json"): - self.assertFalse((candidate / name).is_symlink()) - self.assertEqual((candidate / name).stat().st_mode & 0o777, 0o644) - digest = hashlib.sha256(b"fixture EIF, not a real image").hexdigest() - self.assertEqual((candidate / "SHA256SUMS").read_text().splitlines()[0], f"{digest} image.eif") - handoff = json.loads((candidate / "handoff.json").read_text()) - self.assertEqual(handoff, { - "environment": "dev", "source_sha": "1" * 40, "measurements": VALID_MEASUREMENTS, - "eif_sha256": digest, "approval_needed": True, - }) - self.assertEqual(self.outputs(), { - "approval_needed": "true", "pcr0": "a" * 96, "eif_sha256": digest, - "artifact": "opensecret-eif-dev-111111111111", - }) - trace = json.loads((self.root / "trace").read_text()) - self.assertIn(".?submodules=1#eif-dev", trace["args"]) - self.assertEqual(trace["cwd"], str(self.component)) - - def test_already_approved_measurements_need_no_signing(self): - result = self.run_build("prod") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.outputs()["approval_needed"], "false") - self.assertEqual(self.outputs()["artifact"], "opensecret-eif-prod-111111111111") - - def test_malformed_measurements_and_unsupported_hosts_fail_before_output(self): - bad = json.dumps({**VALID_MEASUREMENTS, "PCR0": "0" * 96}) - result = self.run_build(TEST_MEASUREMENTS=bad) - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "candidate/SHA256SUMS").exists()) - self.assertFalse((self.root / "candidate/handoff.json").exists()) - self.assertFalse(self.github_output.exists()) - (self.root / "trace").unlink() - result = self.run_build(output="candidate-x86", TEST_ARCH="x86_64") - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "trace").exists()) - - def test_refuses_a_nonempty_output_directory(self): - (self.root / "candidate").mkdir() - (self.root / "candidate/stale").write_text("") - result = self.run_build() - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "trace").exists()) - - -class EifRootfsDiagnosticTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.entrypoint_store = "/nix/store/" + "a" * 32 + "-entrypoint" - self.ca_store = "/nix/store/" + "b" * 32 + "-nss-cacert-3.131.1" - self.ca_bundle = b"synthetic certificate bundle" - self.archive = self.root / "user-initramfs.cpio.gz" - self.script = ( - "#!/bin/bash\n" - f"ln -sf {self.ca_store}/etc/ssl/certs/ca-bundle.crt " - "/etc/ssl/certs/ca-bundle.crt\n" - "export SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt\n" - "export AWS_CA_BUNDLE=/etc/ssl/certs/ca-bundle.crt\n" - ).encode() - self.entries = [ - ("cmd", stat.S_IFREG | 0o444, b"/bin/entrypoint"), - ("rootfs/bin/entrypoint", stat.S_IFLNK | 0o777, - f"{self.entrypoint_store}/bin/entrypoint".encode()), - (f"rootfs{self.entrypoint_store}/bin/entrypoint", stat.S_IFREG | 0o755, - self.script), - (f"rootfs{self.ca_store}/etc/ssl/certs/ca-bundle.crt", stat.S_IFREG | 0o444, - self.ca_bundle), - ] - self.write_archive() - - @staticmethod - def newc_entry(name, mode, data): - name = name.encode() + b"\0" - fields = (1, mode, 0, 0, 1, 0, len(data), 0, 0, 0, 0, len(name), 0) - header = b"070701" + b"".join(f"{field:08x}".encode() for field in fields) - return (header + name + b"\0" * (-(len(header) + len(name)) % 4) - + data + b"\0" * (-len(data) % 4)) - - def write_archive(self): - with gzip.open(self.archive, "wb") as archive: - for entry in self.entries: - archive.write(self.newc_entry(*entry)) - archive.write(self.newc_entry("TRAILER!!!", 0, b"")) - - def candidate(self): - candidate = self.root / "candidate" - output = self.root / "nix-output" - candidate.mkdir() - output.mkdir() - measurements = { - "HashAlgorithm": "Sha384 { ... }", "PCR0": "a" * 96, - "PCR1": "b" * 96, "PCR2": "c" * 96, - } - image = b"synthetic EIF bytes" - pcr = (json.dumps(measurements) + "\n").encode() - for directory in (candidate, output): - (directory / "image.eif").write_bytes(image) - (directory / "pcr.json").write_bytes(pcr) - eif_sha = hashlib.sha256(image).hexdigest() - pcr_sha = hashlib.sha256(pcr).hexdigest() - (candidate / "SHA256SUMS").write_text( - f"{eif_sha} image.eif\n{pcr_sha} pcr.json\n" - ) - (candidate / "handoff.json").write_text(json.dumps({ - "environment": "dev", "source_sha": "d" * 40, - "eif_sha256": eif_sha, "measurements": measurements, - "approval_needed": True, - })) - ca_file = self.root / self.ca_store.lstrip("/") / "etc/ssl/certs/ca-bundle.crt" - ca_file.parent.mkdir(parents=True) - ca_file.write_bytes(self.ca_bundle) - return candidate, output - - def test_exact_user_ramdisk_entrypoint_resolves_to_its_archived_ca_bundle(self): - store, bundle = rootfs_diagnostic.inspect_archive(self.archive) - self.assertEqual(store, self.ca_store) - self.assertEqual(bundle, self.ca_bundle) - candidate, output = self.candidate() - user_drv = Path("/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz.drv") - with mock.patch.object(rootfs_diagnostic, "eif_derivation", - return_value=(output, self.archive, user_drv)), mock.patch.object( - rootfs_diagnostic, "realize_user_ramdisk" - ), mock.patch.object( - rootfs_diagnostic, "fingerprints", return_value={"F" * 64} - ), mock.patch.object( - rootfs_diagnostic, "verify_aaa_tls_policy", - return_value="retained_rejected_for_openssl_tls" - ): - report = rootfs_diagnostic.verify_candidate( - "dev", candidate, "d" * 40, "openssl", store_root=self.root - ) - self.assertEqual(report["kind"], "PR_DIAGNOSTIC_UNAPPROVED") - self.assertEqual(report["ca_store_path"], self.ca_store) - self.assertEqual(report["ca_bundle_sha256"], hashlib.sha256(self.ca_bundle).hexdigest()) - self.assertEqual(report["ca_store_comparison"], "matched") - self.assertEqual(report["ca_certificate_count"], 1) - self.assertEqual(report["comodo_aaa_tls_policy"], "retained_rejected_for_openssl_tls") - - def test_archived_ca_remains_inspectable_when_cached_store_input_is_absent(self): - candidate, output = self.candidate() - ca_file = self.root / self.ca_store.lstrip("/") / "etc/ssl/certs/ca-bundle.crt" - ca_file.unlink() - user_drv = Path("/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz.drv") - with mock.patch.object(rootfs_diagnostic, "eif_derivation", - return_value=(output, self.archive, user_drv)), mock.patch.object( - rootfs_diagnostic, "realize_user_ramdisk" - ), mock.patch.object( - rootfs_diagnostic, "fingerprints", return_value={"F" * 64} - ), mock.patch.object( - rootfs_diagnostic, "verify_aaa_tls_policy", return_value="absent" - ): - report = rootfs_diagnostic.verify_candidate( - "dev", candidate, "d" * 40, "openssl", store_root=self.root - ) - self.assertEqual(report["ca_store_comparison"], "not_materialized") - self.assertEqual(report["ca_bundle_sha256"], hashlib.sha256(self.ca_bundle).hexdigest()) - - def test_affected_root_and_candidate_byte_drift_fail_closed(self): - candidate, output = self.candidate() - user_drv = Path("/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz.drv") - with mock.patch.object(rootfs_diagnostic, "eif_derivation", - return_value=(output, self.archive, user_drv)), mock.patch.object( - rootfs_diagnostic, "realize_user_ramdisk" - ), mock.patch.object( - rootfs_diagnostic, "fingerprints", - return_value={rootfs_diagnostic.EMSIGN_G3} - ), mock.patch.object( - rootfs_diagnostic, "verify_aaa_tls_policy", - return_value="retained_rejected_for_openssl_tls" - ): - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "affected emSign root"): - rootfs_diagnostic.verify_candidate( - "dev", candidate, "d" * 40, "openssl", store_root=self.root - ) - (candidate / "image.eif").write_bytes((output / "image.eif").read_bytes()) - ca_file = self.root / self.ca_store.lstrip("/") / "etc/ssl/certs/ca-bundle.crt" - ca_file.write_bytes(b"different Nix CA bundle") - with mock.patch.object(rootfs_diagnostic, "eif_derivation", - return_value=(output, self.archive, user_drv)), mock.patch.object( - rootfs_diagnostic, "realize_user_ramdisk" - ): - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "archived CA bundle differs"): - rootfs_diagnostic.verify_candidate( - "dev", candidate, "d" * 40, "openssl", store_root=self.root - ) - (candidate / "image.eif").write_bytes(b"different candidate") - with mock.patch.object(rootfs_diagnostic, "eif_derivation", - return_value=(output, self.archive, user_drv)), mock.patch.object( - rootfs_diagnostic, "realize_user_ramdisk" - ): - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "differs from derivation"): - rootfs_diagnostic.verify_candidate( - "dev", candidate, "d" * 40, "openssl", store_root=self.root - ) - - def test_missing_effective_ca_and_unsafe_entrypoint_symlink_fail_closed(self): - self.entries.pop() - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "bundle missing"): - rootfs_diagnostic.inspect_archive(self.archive) - self.entries[1] = ("rootfs/bin/entrypoint", stat.S_IFLNK | 0o777, - b"../../outside/entrypoint") - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "leaves reviewed store path"): - rootfs_diagnostic.inspect_archive(self.archive) - - def test_malformed_and_duplicate_cpio_entries_fail_closed(self): - self.archive.write_bytes(b"not a gzip archive") - with self.assertRaises(OSError): - rootfs_diagnostic.inspect_archive(self.archive) - self.entries.append(self.entries[0]) - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "duplicate"): - rootfs_diagnostic.inspect_archive(self.archive) - - def test_entrypoint_must_export_both_ca_variables(self): - self.entries[2] = ( - self.entries[2][0], self.entries[2][1], - self.script.replace(b"export AWS_CA_BUNDLE=", b"export DISABLED_CA_BUNDLE="), - ) - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "AWS_CA_BUNDLE"): - rootfs_diagnostic.inspect_archive(self.archive) - - def test_nitro_init_command_must_select_reviewed_entrypoint(self): - self.entries.pop(0) - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "Nitro init command missing"): - rootfs_diagnostic.inspect_archive(self.archive) - self.entries.insert(0, ("cmd", stat.S_IFREG | 0o444, b"/bin/unused")) - self.write_archive() - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "does not select"): - rootfs_diagnostic.inspect_archive(self.archive) - - def test_retained_aaa_requires_explicit_openssl_tls_rejection_and_no_ordinary_pem(self): - bundle = self.root / "ca-bundle.crt" - ordinary = b"-----BEGIN CERTIFICATE-----\n" + base64.b64encode(b"synthetic root") + b"\n-----END CERTIFICATE-----\n" - bundle.write_bytes(ordinary * 100) - self.assertEqual(rootfs_diagnostic.verify_aaa_tls_policy("openssl", bundle, set()), "absent") - rejected = subprocess.CompletedProcess([], 2, b"", b"error 28 at 0 depth lookup: certificate rejected") - with mock.patch.object(rootfs_diagnostic.subprocess, "run", return_value=rejected): - self.assertEqual(rootfs_diagnostic.verify_aaa_tls_policy( - "openssl", bundle, {rootfs_diagnostic.COMODO_AAA} - ), "retained_rejected_for_openssl_tls") - unsuitable = subprocess.CompletedProcess([], 2, b"", b"error 26: unsuitable certificate purpose") - with mock.patch.object(rootfs_diagnostic.subprocess, "run", return_value=unsuitable): - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "did not explicitly reject"): - rootfs_diagnostic.verify_aaa_tls_policy( - "openssl", bundle, {rootfs_diagnostic.COMODO_AAA} - ) - bundle.write_bytes(bundle.read_bytes() + rootfs_diagnostic.AAA_ROOT.read_bytes()) - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "entered the ordinary TLS root"): - rootfs_diagnostic.verify_aaa_tls_policy( - "openssl", bundle, {rootfs_diagnostic.COMODO_AAA} - ) - - def test_eif_derivation_selects_one_user_ramdisk_input(self): - eif = "/nix/store/" + "c" * 32 + "-opensecret-eif-dev" - system = "/nix/store/" + "d" * 32 + "-bootstrap-initramfs.cpio.gz" - user = "/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz" - user_drv_name = "f" * 32 + "-user-initramfs.cpio.gz.drv" - user_drv = Path("/nix/store") / user_drv_name - derivation = {"derivations": {"candidate.drv": {"env": { - "out": eif, "ramdisksArgs": f"--ramdisk {system} --ramdisk {user} ", - }, "inputs": {"drvs": {user_drv_name: {"outputs": ["out"]}}}}}} - with mock.patch.object(rootfs_diagnostic.subprocess, "run") as run: - run.return_value.stdout = json.dumps(derivation) - self.assertEqual(rootfs_diagnostic.eif_derivation("dev"), (Path(eif), Path(user), user_drv)) - self.assertEqual(run.call_args.kwargs["cwd"], rootfs_diagnostic.COMPONENT) - derivation["derivations"]["candidate.drv"]["inputs"]["drvs"] = { - str(user_drv): {"outputs": ["out"]} - } - run.return_value.stdout = json.dumps(derivation) - self.assertEqual(rootfs_diagnostic.eif_derivation("dev"), (Path(eif), Path(user), user_drv)) - derivation["derivations"]["candidate.drv"]["env"]["ramdisksArgs"] += f"--ramdisk {user}" - run.return_value.stdout = json.dumps(derivation) - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "ramdisk inputs"): - rootfs_diagnostic.eif_derivation("dev") - - def test_explicit_realization_binds_direct_drv_to_selected_archive(self): - user_drv = Path("/nix/store/" + "f" * 32 + "-user-initramfs.cpio.gz.drv") - selected = Path("/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz") - derivation = {"derivations": {user_drv.name: { - "env": {"out": str(selected)}, "outputs": {"out": {"path": selected.name}}, - }}} - with mock.patch.object(rootfs_diagnostic.subprocess, "run") as run, mock.patch.object( - rootfs_diagnostic, "regular_file", return_value=True - ): - run.side_effect = [ - subprocess.CompletedProcess([], 0, json.dumps(derivation), ""), - subprocess.CompletedProcess([], 0, str(selected) + "\n", ""), - ] - rootfs_diagnostic.realize_user_ramdisk(selected, user_drv) - self.assertEqual(run.call_count, 2) - self.assertEqual(run.call_args.args[0], [ - "nix", "build", "--no-link", "--no-update-lock-file", "--print-out-paths", - f"{user_drv}^out", - ]) - derivation["derivations"][user_drv.name]["env"]["out"] = "/nix/store/other" - run.side_effect = [subprocess.CompletedProcess([], 0, json.dumps(derivation), "")] - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "does not produce"): - rootfs_diagnostic.realize_user_ramdisk(selected, user_drv) - self.assertEqual(run.call_count, 3) - - def test_explicit_realization_rejects_wrong_reported_output(self): - user_drv = Path("/nix/store/" + "f" * 32 + "-user-initramfs.cpio.gz.drv") - selected = Path("/nix/store/" + "e" * 32 + "-user-initramfs.cpio.gz") - derivation = {"derivations": {user_drv.name: { - "env": {"out": str(selected)}, "outputs": {"out": {"path": selected.name}}, - }}} - with mock.patch.object(rootfs_diagnostic.subprocess, "run") as run: - run.side_effect = [ - subprocess.CompletedProcess([], 0, json.dumps(derivation), ""), - subprocess.CompletedProcess([], 0, "/nix/store/wrong\n", ""), - ] - with self.assertRaisesRegex(rootfs_diagnostic.VerificationError, "different user ramdisk"): - rootfs_diagnostic.realize_user_ramdisk(selected, user_drv) - - -class ApprovalPublishCommandTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.origin = self.root / "origin.git" - self.git("init", "-q", "--bare", "--initial-branch=master", str(self.origin)) - self.repo = self.root / "repo" - self.git("clone", "-q", str(self.origin), str(self.repo)) - component = self.repo / "services/opensecret" - component.mkdir(parents=True) - for name in ("pcrDev.json", "pcrProd.json", "pcrDevHistory.json", "pcrProdHistory.json"): - (component / name).write_text(f"{name} fixture\n") - (component / "other.rs").write_text("fn main() {}\n") - scripts = self.repo / "scripts/ci" - scripts.mkdir(parents=True) - shutil.copyfile(ROOT / "scripts/ci/publish_opensecret_approval.sh", scripts / "publish_opensecret_approval.sh") - self.git("-C", str(self.repo), "add", "-A") - self.git("-C", str(self.repo), "commit", "-q", "-m", "fixture") - self.git("-C", str(self.repo), "push", "-q", "origin", "HEAD:refs/heads/master") - self.source_sha = self.git("-C", str(self.repo), "rev-parse", "HEAD") - self.artifact = self.root / "artifact" - self.artifact.mkdir() - self.write_handoff() - self.summary = self.root / "summary.md" - - def git(self, *arguments): - return subprocess.run( - ["git", "-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", - "-c", "commit.gpgsign=false", *arguments], - check=True, capture_output=True, text=True, - ).stdout.strip() - - def write_handoff(self, **overrides): - handoff = {"environment": "dev", "source_sha": self.source_sha, - "measurements": VALID_MEASUREMENTS, "eif_sha256": "f" * 64, **overrides} - (self.artifact / "handoff.json").write_text(json.dumps(handoff)) - - def change_approvals(self, mode="dev"): - prefix = "pcrDev" if mode == "dev" else "pcrProd" - for name in (f"{prefix}.json", f"{prefix}History.json"): - (self.repo / "services/opensecret" / name).write_text(f"{name} approved\n") - - def branch(self): - return f"opensecret/pcr-approval-{self.source_sha[:12]}" - - def run_publish(self, mode="dev", path=None): - env = {"PATH": path or os.environ["PATH"], "HOME": str(self.root), "GITHUB_TOKEN": "fixture-token", - "GITHUB_REPOSITORY": "fixture/repo", "GITHUB_RUN_ID": "7", "GITHUB_STEP_SUMMARY": str(self.summary)} - return subprocess.run( - [shutil.which("bash"), "--noprofile", "--norc", - str(self.repo / "scripts/ci/publish_opensecret_approval.sh"), mode, str(self.artifact)], - cwd=self.repo, env=env, capture_output=True, text=True, - ) - - def origin_branches(self): - return self.git("-C", str(self.origin), "for-each-ref", "--format=%(refname:short)", "refs/heads") - - def test_pushes_only_the_approval_files_to_a_review_branch(self): - self.change_approvals() - result = self.run_publish() - self.assertEqual(result.returncode, 0, result.stderr) - branch = self.branch() - self.assertEqual(sorted(self.origin_branches().split()), sorted(["master", branch])) - self.assertEqual(self.git("-C", str(self.origin), "rev-parse", "master"), self.source_sha) - changed = self.git("-C", str(self.origin), "diff", "--name-only", "master", branch) - self.assertEqual(changed.split(), ["services/opensecret/pcrDev.json", "services/opensecret/pcrDevHistory.json"]) - message = self.git("-C", str(self.origin), "log", "-1", "--format=%an%n%B", branch) - self.assertIn("github-actions[bot]", message) - self.assertIn(self.source_sha, message) - self.assertIn("a" * 96, message) - self.assertIn(f"compare/master...{branch}", self.summary.read_text()) - self.assertNotIn("fixture-token", (self.repo / ".git/config").read_text()) - self.assertNotIn("fixture-token", result.stdout + result.stderr) - - def test_refuses_unexpected_changes_and_a_repeated_environment(self): - self.change_approvals() - (self.repo / "services/opensecret/other.rs").write_text("fn main() { changed }\n") - result = self.run_publish() - self.assertNotEqual(result.returncode, 0) - self.assertEqual(self.origin_branches(), "master") - self.git("-C", str(self.repo), "checkout", "-q", "--", "services/opensecret/other.rs") - self.assertEqual(self.run_publish().returncode, 0) - self.git("-C", str(self.repo), "reset", "-q", "--hard", self.source_sha) - self.change_approvals() - result = self.run_publish() - self.assertNotEqual(result.returncode, 0) - self.assertIn("already carries the dev approval", result.stderr) - self.assertEqual(len(self.git("-C", str(self.origin), "rev-list", f"master..{self.branch()}").split()), 1) - - def test_second_environment_lands_on_the_same_branch(self): - self.change_approvals("dev") - self.assertEqual(self.run_publish("dev").returncode, 0) - self.git("-C", str(self.repo), "reset", "-q", "--hard", self.source_sha) - self.change_approvals("prod") - self.write_handoff(environment="prod") - result = self.run_publish("prod") - self.assertEqual(result.returncode, 0, result.stderr) - branch = self.branch() - self.assertEqual(sorted(self.origin_branches().split()), sorted(["master", branch])) - self.assertEqual(self.git("-C", str(self.origin), "rev-parse", "master"), self.source_sha) - self.assertEqual(len(self.git("-C", str(self.origin), "rev-list", f"master..{branch}").split()), 2) - changed = self.git("-C", str(self.origin), "diff", "--name-only", "master", branch) - self.assertEqual(changed.split(), ["services/opensecret/pcrDev.json", "services/opensecret/pcrDevHistory.json", - "services/opensecret/pcrProd.json", "services/opensecret/pcrProdHistory.json"]) - subjects = self.git("-C", str(self.origin), "log", "--format=%s", f"master..{branch}") - self.assertEqual(subjects.splitlines(), [f"Approve prod OpenSecret EIF measurements from {self.source_sha[:12]}", - f"Approve dev OpenSecret EIF measurements from {self.source_sha[:12]}"]) - - def test_lost_push_race_is_replayed_on_top_of_the_other_environment(self): - # A git shim lets the prod run push to the branch after the dev run has - # fetched and just before it pushes, so the first push is rejected. - rival = self.root / "rival" - self.git("clone", "-q", str(self.origin), str(rival)) - for name in ("pcrProd.json", "pcrProdHistory.json"): - (rival / "services/opensecret" / name).write_text(f"{name} approved\n") - self.git("-C", str(rival), "commit", "-q", "-am", f"Approve prod OpenSecret EIF measurements from {self.source_sha[:12]}") - shim_dir = self.root / "shim" - shim_dir.mkdir() - marker = self.root / "raced" - real_git = shutil.which("git") - (shim_dir / "git").write_text( - f"#!{shutil.which('bash')}\n" - "for arg in \"$@\"; do\n" - f" if [ \"$arg\" = push ] && [ ! -e '{marker}' ]; then touch '{marker}'; " - f"'{real_git}' -C '{rival}' push -q origin HEAD:refs/heads/{self.branch()}; fi\n" - "done\n" - f"exec '{real_git}' \"$@\"\n") - (shim_dir / "git").chmod(0o755) - self.change_approvals("dev") - result = self.run_publish("dev", path=f"{shim_dir}:{os.environ['PATH']}") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("rejected (attempt 1)", result.stderr) - branch = self.branch() - self.assertEqual(len(self.git("-C", str(self.origin), "rev-list", f"master..{branch}").split()), 2) - changed = self.git("-C", str(self.origin), "diff", "--name-only", "master", branch) - self.assertEqual(changed.split(), ["services/opensecret/pcrDev.json", "services/opensecret/pcrDevHistory.json", - "services/opensecret/pcrProd.json", "services/opensecret/pcrProdHistory.json"]) - subjects = self.git("-C", str(self.origin), "log", "--format=%s", f"master..{branch}") - self.assertEqual(subjects.splitlines()[0], f"Approve dev OpenSecret EIF measurements from {self.source_sha[:12]}") - - def test_nothing_to_publish_and_handoff_mismatches(self): - result = self.run_publish() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.origin_branches(), "master") - self.change_approvals() - self.write_handoff(source_sha="0" * 40) - self.assertNotEqual(self.run_publish().returncode, 0) - self.write_handoff(environment="prod") - self.assertNotEqual(self.run_publish().returncode, 0) - self.assertEqual(self.origin_branches(), "master") - - -class SdkWorkflowFailurePropagationTests(unittest.TestCase): - CHECKS = ( - ("sdk-typescript.yml", "sdk-typescript", ( - "bun install", "bun audit", "bun run format:check", "bun run build", "bun test", - )), - ("sdk-rust.yml", "sdk-rust", ( - "cargo fmt", "cargo clippy", "cargo test", "cargo doc", - )), - ) - - def run_checks(self, name, job, failure=""): - steps = [ - step["run"] for step in workflow(name)["jobs"][job]["steps"] - if "run" in step and step["run"] != VERIFY_NIX - ] - # Execute the actual nested shell body, without letting an outer -e or - # host login profile accidentally supply the workflow's missing guard. - self.assertEqual(len(steps), 1) - command = shlex.split(steps[0]) - self.assertEqual(command[:-1], [ - "nix", "develop", "--no-update-lock-file", "./sdk", "-c", "bash", "-lc", - ]) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "sdk/rust").mkdir(parents=True) - binaries = root / "bin" - binaries.mkdir() - trace = root / "commands" - for tool in ("bun", "cargo"): - stub = binaries / tool - stub.write_text( - f"#!{sys.executable}\n" - "import os, sys\n" - "from pathlib import Path\n" - "command = ' '.join([Path(sys.argv[0]).name, *sys.argv[1:]])\n" - "with open(os.environ['SDK_CHECK_TRACE'], 'a') as trace:\n" - " trace.write(command + '\\n')\n" - "failure = os.environ['SDK_CHECK_FAILURE']\n" - "sys.exit(42 if failure and command.startswith(failure) else 0)\n" - ) - stub.chmod(0o755) - result = subprocess.run( - [shutil.which("bash"), "--noprofile", "--norc", "-c", command[-1]], - cwd=root, - env={"PATH": str(binaries), "HOME": temporary, - "SDK_CHECK_TRACE": str(trace), "SDK_CHECK_FAILURE": failure}, - capture_output=True, text=True, - ) - return result, trace.read_text().splitlines() if trace.exists() else [] - - def test_each_failed_sdk_check_stops_the_workflow(self): - for name, job, checks in self.CHECKS: - for index, failure in enumerate(checks): - with self.subTest(workflow=name, failure=failure): - result, commands = self.run_checks(name, job, failure) - self.assertEqual(result.returncode, 42, result.stderr) - self.assertEqual(len(commands), index + 1, commands) - for command, expected in zip(commands, checks): - self.assertTrue(command.startswith(expected), command) - - def test_successful_sdk_checks_all_execute(self): - for name, job, checks in self.CHECKS: - with self.subTest(workflow=name): - result, commands = self.run_checks(name, job) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(len(commands), len(checks), commands) - for command, expected in zip(commands, checks): - self.assertTrue(command.startswith(expected), command) - - -class EifComparisonCommandTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.repo = self.root / "repo" - self.component = self.repo / "services/opensecret" - self.component.mkdir(parents=True) - scripts = self.repo / "scripts/ci" - scripts.mkdir(parents=True) - self.script = scripts / "check_opensecret_eif.sh" - shutil.copyfile(ROOT / "scripts/ci/check_opensecret_eif.sh", self.script) - self.binaries = self.root / "bin" - self.binaries.mkdir() - self.scratch = self.root / "scratch" - self.scratch.mkdir() - for tool in ("dirname", "mktemp", "rm", "diff"): - (self.binaries / tool).symlink_to(shutil.which(tool)) - uname = self.binaries / "uname" - uname.write_text( - f"#!{sys.executable}\n" - "import os, sys\n" - "print('Linux' if sys.argv[1] == '-s' else os.environ.get('TEST_ARCH', 'aarch64'))\n" - ) - uname.chmod(0o755) - nix = self.binaries / "nix" - nix.write_text( - f"#!{sys.executable}\n" - "import json, os, sys\n" - "from pathlib import Path\n" - "args = sys.argv[1:]\n" - "Path(os.environ['TEST_TRACE']).write_text(json.dumps({'args': args, 'cwd': os.getcwd()}))\n" - "if os.environ.get('TEST_FAIL_BUILD'):\n" - " sys.exit(42)\n" - "output = Path(args[args.index('--out-link') + 1])\n" - "output.mkdir()\n" - "if not os.environ.get('TEST_MISSING_IMAGE'):\n" - " (output / 'image.eif').write_bytes(b'fixture EIF, not a real image')\n" - "if os.environ.get('TEST_PCR_DIRECTORY'):\n" - " (output / 'pcr.json').mkdir()\n" - "elif not os.environ.get('TEST_MISSING_PCR'):\n" - " (output / 'pcr.json').write_text(os.environ['TEST_MEASUREMENTS'])\n" - ) - nix.chmod(0o755) - self.measurements = { - mode: json.dumps({"HashAlgorithm": "fixture", "PCR0": f"reviewed-{mode}"}) + "\n" - for mode in ("dev", "prod") - } - for mode, name in (("dev", "pcrDev.json"), ("prod", "pcrProd.json")): - (self.component / name).write_text(self.measurements[mode]) - for name in ("pcrDevHistory.json", "pcrProdHistory.json"): - (self.component / name).write_text("untouched history fixture\n") - self.existing_result = self.component / "result" - self.existing_result.symlink_to(self.root / "operator-owned-output") - self.sentinel = self.root / "dotenv-was-loaded" - (self.component / ".env").write_text(f"touch '{self.sentinel}'\n") - self.before_files = { - p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file() - } - - def run_check(self, mode="dev", **extra_env): - env = { - "PATH": str(self.binaries), "HOME": str(self.root), - "TMPDIR": str(self.scratch), "TEST_TRACE": str(self.root / "trace"), - "TEST_MEASUREMENTS": self.measurements.get(mode, self.measurements["dev"]), **extra_env, - } - result = subprocess.run( - [shutil.which("bash"), "--noprofile", "--norc", str(self.script), mode], - cwd=self.root, env=env, capture_output=True, text=True, - ) - self.assertFalse(self.sentinel.exists()) - self.assertEqual(os.readlink(self.existing_result), str(self.root / "operator-owned-output")) - self.assertEqual( - {p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file()}, - self.before_files, - ) - self.assertEqual(list(self.scratch.iterdir()), []) - return result - - def test_each_environment_builds_the_pinned_component_without_loading_dotenv(self): - for mode in ("dev", "prod"): - with self.subTest(mode=mode): - result = self.run_check(mode) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn(f"EIF/PCR approval match ({mode})", result.stdout) - trace = json.loads((self.root / "trace").read_text()) - # macOS resolves /tmp to /private/tmp; compare canonical paths. - self.assertEqual(Path(trace["cwd"]).resolve(), self.component.resolve()) - self.assertEqual(trace["args"][:4], [ - "build", "--no-update-lock-file", "--print-build-logs", "--out-link", - ]) - self.assertEqual(trace["args"][-1], f".?submodules=1#eif-{mode}") - - def test_measurement_mismatch_fails_without_rewriting_approvals(self): - result = self.run_check(TEST_MEASUREMENTS='{"PCR0":"not approved"}\n') - self.assertNotEqual(result.returncode, 0) - self.assertIn("EIF/PCR approval mismatch", result.stderr) - - def test_build_failure_and_missing_generated_measurements_fail(self): - result = self.run_check(TEST_FAIL_BUILD="1") - self.assertEqual(result.returncode, 42) - for failure in ("TEST_MISSING_PCR", "TEST_MISSING_IMAGE", "TEST_PCR_DIRECTORY"): - with self.subTest(failure=failure): - result = self.run_check(**{failure: "1"}) - self.assertNotEqual(result.returncode, 0) - self.assertIn("did not produce", result.stderr) - - def test_invalid_mode_and_wrong_architecture_do_not_build(self): - for mode, extra_env in (("preview", {}), ("dev", {"TEST_ARCH": "x86_64"})): - with self.subTest(mode=mode, extra_env=extra_env): - result = self.run_check(mode, **extra_env) - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "trace").exists()) - - def test_missing_or_symlinked_approved_measurements_do_not_build(self): - reference = self.component / "pcrDev.json" - reference.unlink() - self.before_files.pop(reference.name) - result = self.run_check() - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "trace").exists()) - reference.symlink_to(self.component / "pcrProd.json") - self.before_files[reference.name] = self.measurements["prod"].encode() - result = self.run_check() - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.root / "trace").exists()) - - def test_directory_instead_of_approved_measurements_does_not_build(self): - reference = self.component / "pcrDev.json" - reference.unlink() - self.before_files.pop(reference.name) - reference.mkdir() - result = self.run_check() - self.assertNotEqual(result.returncode, 0) - self.assertIn("Missing regular approved measurement file", result.stderr) - self.assertFalse((self.root / "trace").exists()) - - -class OpenSecretDiffSelectionTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.git("init", "-q", "-b", "master") - self.git("config", "user.email", "ci@example.invalid") - self.git("config", "user.name", "CI fixture") - self.git("config", "core.hooksPath", "/dev/null") - scripts = self.root / "scripts/ci" - scripts.mkdir(parents=True) - shutil.copyfile(ROOT / "scripts/ci/opensecret_change_detection.py", scripts / "opensecret_change_detection.py") - self.base = self.commit_file("README.md", "initial\n") - - def git(self, *arguments): - result = subprocess.run(["git", *arguments], cwd=self.root, check=True, - capture_output=True, text=True) - return result.stdout.strip() - - def commit_file(self, path, text): - file = self.root / path - file.parent.mkdir(parents=True, exist_ok=True) - file.write_text(text) - self.git("add", "--", path) - self.git("commit", "-qm", "fixture change") - return self.git("rev-parse", "HEAD") - - def select(self, event, base, head, *, succeeds=True): - step = next(step for step in workflow("opensecret-change-detection.yml")["jobs"]["detect"]["steps"] - if step.get("id") == "classify") - output = self.root / "output" - output.unlink(missing_ok=True) - env = {**os.environ, "GITHUB_EVENT_NAME": event, "BASE_SHA": base, "HEAD_SHA": head, - "GITHUB_OUTPUT": str(output)} - result = subprocess.run(["bash", "-c", step["run"]], cwd=self.root, env=env, - capture_output=True, text=True) - if succeeds: - self.assertEqual(result.returncode, 0, result.stderr) - else: - self.assertNotEqual(result.returncode, 0) - self.assertIn("Cannot determine the PR's approved-PCR changes", result.stdout) - return dict(line.split("=", 1) for line in output.read_text().splitlines()) if output.exists() else {} - - def expected(self, *selected): - return {name: "true" if name in selected else "false" for name in OUTPUTS} - - def test_docs_only_push_backend_runtime_push_and_signed_pcr_push(self): - docs = self.commit_file("services/opensecret/docs/design.md", "design\n") - self.assertEqual(self.select("push", self.base, docs), self.expected()) - runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - self.assertEqual(self.select("push", docs, runtime), self.expected("rust", "nix", "integration", "eif")) - pcr = self.commit_file("services/opensecret/pcrDevHistory.json", "[]\n") - self.assertEqual(self.select("push", runtime, pcr), self.expected("eif", "pcr_approvals")) - - def test_parent_helper_only_diff_skips_eif_while_enclave_input_retains_it(self): - helper = self.commit_file("services/opensecret/nitro-toolkit/logging/Dockerfile", "FROM scratch\n") - for event in ("push", "pull_request"): - with self.subTest(event=event): - self.assertEqual(self.select(event, self.base, helper), self.expected("helpers")) - docs = self.commit_file("services/opensecret/nitro-toolkit/README.md", "helper docs\n") - self.assertEqual(self.select("push", helper, docs), self.expected()) - enclave = self.commit_file("services/opensecret/nitro-toolkit/vsock_helper.py", "# enclave input\n") - self.assertEqual(self.select("push", docs, enclave), self.expected("nix", "eif")) - - def test_pull_request_uses_merge_base_instead_of_unrelated_base_changes(self): - master = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - self.git("checkout", "-qb", "contributor", self.base) - docs = self.commit_file("services/opensecret/docs/design.md", "design\n") - self.assertEqual(self.select("pull_request", master, docs), self.expected()) - - def test_unrelated_master_approvals_do_not_count_as_pr_approval_edits(self): - master = self.commit_file("services/opensecret/pcrDev.json", "approved on master\n") - self.git("checkout", "-qb", "contributor", self.base) - runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - self.assertEqual(self.select("pull_request", master, runtime), - self.expected("rust", "nix", "integration", "eif")) - - def test_pr_approval_edit_selects_comparison_even_with_backend_changes(self): - runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - approvals = self.commit_file("services/opensecret/pcrProdHistory.json", "[]\n") - self.assertEqual(self.select("pull_request", self.base, approvals), - self.expected("rust", "nix", "integration", "eif", "pcr_approvals")) - self.assertEqual(self.select("pull_request", runtime, approvals), - self.expected("eif", "pcr_approvals")) - - def test_deletion_or_rename_out_of_backend_still_selects_contract_checks(self): - runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - self.git("mv", "services/opensecret/src/main.rs", "LICENSE") - self.git("commit", "-qam", "rename fixture") - self.assertEqual(self.select("push", runtime, self.git("rev-parse", "HEAD")), - self.expected("rust", "nix", "integration", "eif")) - - def test_deletion_or_rename_of_an_approval_file_is_an_explicit_edit(self): - for rename in (False, True): - with self.subTest(rename=rename): - before = self.commit_file("services/opensecret/pcrProd.json", "approval\n") - if rename: - (self.root / "services/opensecret/docs").mkdir(exist_ok=True) - self.git("mv", "services/opensecret/pcrProd.json", "services/opensecret/docs/old-approval.json") - else: - self.git("rm", "services/opensecret/pcrProd.json") - self.git("commit", "-qam", "remove approval fixture") - self.assertEqual(self.select("pull_request", before, self.git("rev-parse", "HEAD")), - self.expected("eif", "pcr_approvals")) - - def test_missing_history_manual_event_classifier_failure_and_partial_output_fail_safe(self): - for event, base, head in (("push", "0" * 40, self.base), ("push", "a" * 40, self.base), - ("workflow_dispatch", "", "")): - with self.subTest(event=event, base=base): - self.assertEqual(self.select(event, base, head), self.expected(*CHECK_OUTPUTS)) - classifier = self.root / "scripts/ci/opensecret_change_detection.py" - classifier.write_text("print('rust=false')\nraise RuntimeError('fixture')\n") - self.assertEqual(self.select("push", self.base, self.base), self.expected(*CHECK_OUTPUTS)) - self.assertEqual(self.select("pull_request", self.base, self.base, succeeds=False), {}) - - def test_pr_missing_history_fails_routing_without_inventing_approval_changes(self): - self.assertEqual(self.select("pull_request", "a" * 40, self.base, succeeds=False), {}) - - def test_successful_but_incomplete_classifier_output_is_rejected(self): - classifier = self.root / "scripts/ci/opensecret_change_detection.py" - classifier.write_text( - "OUTPUTS = " + repr(OUTPUTS) + "\nprint('rust=false')\n" - ) - self.assertEqual(self.select("pull_request", self.base, self.base, succeeds=False), {}) - self.assertEqual(self.select("push", self.base, self.base), self.expected(*CHECK_OUTPUTS)) - - def test_schedule_selects_only_advisory_audit(self): - self.assertEqual(self.select("schedule", "", ""), self.expected("audit")) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_pages_auth_workflows.py b/scripts/ci/test_pages_auth_workflows.py index 3998c09d0..655c81115 100644 --- a/scripts/ci/test_pages_auth_workflows.py +++ b/scripts/ci/test_pages_auth_workflows.py @@ -10,10 +10,6 @@ BUILD = "auth-pages-build.yml" PUBLISH = "auth-pages-publish.yml" DEV_PUBLISH = "auth-pages-dev-publish.yml" -CHECK_PAGES = ( - "nix build --no-update-lock-file --no-link --print-build-logs " - ".#checks.x86_64-linux.pages" -) BUILD_AUTH = "nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh" CHECK_AUTH = "nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-ci.sh" ARTIFACT_DIRECTORY = "apps/maple-auth/target/reproducibility" @@ -66,19 +62,6 @@ def test_ci_checks_out_exact_pr_head_and_labels_that_same_source(self): self.assertIn('--sha "$SOURCE_SHA"', describe["run"]) self.assertNotIn('--sha "$GITHUB_SHA"', describe["run"]) - def test_ci_covers_only_auth_and_shared_build_tooling(self): - events = workflow(CI)["on"] - self.assertEqual(events["pull_request"]["paths"], events["push"]["paths"]) - self.assertEqual(set(events["pull_request"]["paths"]), { - ".github/workflows/auth-pages-*.yml", ".github/workflows/pages-tests.yml", - "apps/maple-auth/**", "scripts/ci/auth-*.sh", - "scripts/ci/pages_*.py", "scripts/ci/test_pages_*.py", "flake.nix", "flake.lock", - }) - for event in ("pull_request", "push"): - paths = workflow("pages-tests.yml")["on"][event]["paths"] - self.assertIn(".github/workflows/auth-pages-*.yml", paths) - self.assertIn("scripts/ci/auth-*.sh", paths) - def test_builds_are_unprivileged_and_test_before_building_fixed_profiles(self): for name, job_name, profile in ((CI, "auth", "dev"), (BUILD, "build", "release")): with self.subTest(workflow=name): @@ -96,11 +79,10 @@ def test_builds_are_unprivileged_and_test_before_building_fixed_profiles(self): self.assertEqual(len(builds), 1) self.assertEqual(builds[0]["env"], {"MAPLE_AUTH_ENVIRONMENT": profile}) self.assertNotIn("if", builds[0]) - for command in (CHECK_PAGES, CHECK_AUTH): - checks = [step for step in steps if step.get("run") == command] - self.assertEqual(len(checks), 1) - self.assertNotIn("if", checks[0]) - self.assertLess(steps.index(checks[0]), steps.index(builds[0])) + checks = [step for step in steps if step.get("run") == CHECK_AUTH] + self.assertEqual(len(checks), 1) + self.assertNotIn("if", checks[0]) + self.assertLess(steps.index(checks[0]), steps.index(builds[0])) for step in steps: self.assertNotIn("GH_TOKEN", step.get("env", {})) self.assertNotIn("github.token", " ".join(strings(step.get("env", {})))) diff --git a/scripts/ci/test_pages_workflows.py b/scripts/ci/test_pages_workflows.py index 2349f253e..4c716d726 100644 --- a/scripts/ci/test_pages_workflows.py +++ b/scripts/ci/test_pages_workflows.py @@ -6,7 +6,6 @@ import os from pathlib import Path import subprocess -import tempfile import unittest @@ -93,12 +92,8 @@ def test_build_is_unprivileged_and_skips_forks(self): job = build["jobs"]["build-preview"] self.assertNotIn("permissions", job) self.assertNotIn("environment", job) - self.assertEqual( - normalized(job["if"]), - "github.event_name == 'push' || " - "(github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository)", - ) + # Fork PRs never produce a preview artifact. + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", normalized(job["if"])) self.assertEqual( job["env"]["SOURCE_SHA"], "${{ github.event_name == 'pull_request' && " @@ -118,30 +113,6 @@ def test_build_is_unprivileged_and_skips_forks(self): {"ref": "${{ github.sha }}", "path": ".pages-tools", "persist-credentials": False}, ) - def test_master_preview_uses_the_same_development_profile_as_prs(self): - preview = workflow("pages-preview-build.yml") - old_build = workflow("web-build.yml") - for event in ("push", "pull_request"): - self.assertEqual(preview["on"][event]["branches"], ["master"]) - self.assertTrue( - set(old_build["on"][event]["paths"]) - <= set(preview["on"][event]["paths"]) - ) - legacy_paths = preview["on"]["pull_request"]["paths"] - self.assertIn("frontend/**", legacy_paths) - self.assertIn("!frontend/src-tauri/**", legacy_paths) - self.assertLess(legacy_paths.index("frontend/**"), legacy_paths.index("!frontend/src-tauri/**")) - build_steps = [ - step for step in preview["jobs"]["build-preview"]["steps"] - if "./scripts/ci/web.sh" in step.get("run", "") - ] - self.assertEqual(len(build_steps), 1) - self.assertEqual(build_steps[0]["env"], { - "MAPLE_WEB_ENVIRONMENT": "pr", - "MAPLE_IOS_DEV_AUTH_ORIGIN": "${{ vars.MAPLE_IOS_DEV_AUTH_ORIGIN }}", - }) - self.assertNotIn("if", build_steps[0]) - def test_optional_native_auth_origin_is_only_exported_in_development(self): origin = "https://dev-auth.example.test" for profile in ("pr", "release"): @@ -192,38 +163,6 @@ def test_preview_artifact_is_bound_to_one_run_and_attempt(self): ): self.assertIn(argument, descriptions[0]) - def test_preview_artifact_selection_supports_both_layouts_and_rejects_ambiguity(self): - steps = workflow("pages-preview-build.yml")["jobs"]["build-preview"]["steps"] - description = next(step for step in steps if step.get("id") == "artifact") - modern = "apps/maple-research/frontend/src-tauri/target/reproducibility" - legacy = "frontend/src-tauri/target/reproducibility" - for directories, expected in (((modern,), modern), ((legacy,), legacy), - ((modern, legacy), None), ((), None)): - with self.subTest(directories=directories), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - for directory in directories: - archive = root / directory / "maple-web-dist.tar.gz" - archive.parent.mkdir(parents=True) - archive.write_bytes(b"fixture") - # The selection is shell logic; artifact validation has its own tests. - binaries = root / "bin" - binaries.mkdir() - nix = binaries / "nix" - nix.write_text("#!/bin/sh\nexit 0\n") - nix.chmod(0o755) - output = root / "output" - environment = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"], - "GITHUB_WORKSPACE": tmp, "GITHUB_OUTPUT": str(output), - "SOURCE_SHA": "a" * 40, "GITHUB_RUN_ID": "100", "GITHUB_RUN_ATTEMPT": "2"} - result = subprocess.run(["bash", "-c", description["run"]], cwd=root, - env=environment, capture_output=True, text=True) - if expected is None: - self.assertNotEqual(result.returncode, 0) - self.assertFalse(output.exists()) - else: - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(output.read_text(), f"directory={expected}\n") - def test_publisher_uses_only_trusted_checkout_and_dependencies(self): publish = workflow("pages-publish.yml") self.assertEqual(set(publish["on"]), {"workflow_run", "workflow_dispatch"}) @@ -346,17 +285,11 @@ def test_production_has_one_explicitly_enabled_publisher_and_distinct_lock(self) {"group": "pages-preview-${{ github.event.workflow_run.head_branch }}", "cancel-in-progress": False}, ) - def test_actions_are_immutable_and_nix_gets_no_github_token(self): - for name in ("pages-preview-build.yml", "pages-production-build.yml", "pages-publish.yml", "pages-tests.yml"): + def test_producers_and_publisher_use_no_caches(self): + for name in ("pages-preview-build.yml", "pages-production-build.yml", "pages-publish.yml"): for job in workflow(name)["jobs"].values(): for step in job["steps"]: - if "uses" not in step: - continue - with self.subTest(workflow=name, action=step["uses"]): - self.assertRegex(step["uses"], r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") - self.assertNotIn("actions/cache@", step["uses"]) - if step["uses"].startswith("DeterminateSystems/determinate-nix-action@"): - self.assertEqual(step["with"]["github-token"], "") + self.assertNotIn("cache", step.get("uses", "").lower(), name) if __name__ == "__main__": diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index aae1fcb10..db42ee968 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -58,8 +58,9 @@ def test_rust_sdk_and_proxy_runtime_inputs_select_agent(self): for path in ("proxy/Cargo.toml", "proxy/src/proxy.rs", "proxy/build.rs", "sdk/rust/Cargo.toml", "sdk/rust/src/client.rs", "sdk/rust/build.rs", "sdk/rust/assets/aws_nitro_root.der"): with self.subTest(path=path): - self.assertEqual(selected([path]), AGENT_PR) - self.assertEqual(selected([path], PUSH), ACTIVE) + self.assertEqual(selected([path]) - {"repo"}, AGENT_PR) # proxy/Cargo.toml feeds the release gates + self.assertEqual({lane for lane in selected([path], PUSH) if lane.startswith("agent-")}, + {lane for lane in ACTIVE if lane.startswith("agent-")}) def test_research_typescript_and_independent_services_skip_agent(self): for path in ( @@ -87,13 +88,13 @@ def test_docs_and_standalone_dependency_inputs_skip_agent(self): self.assertFalse(agent([path], PUSH)) def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): - self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint"}) + self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint", "repo"}) for path in ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"): with self.subTest(path=path): - self.assertEqual(selected([path]), {"agent-macos"}) - self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows"}) + self.assertEqual(selected([path]), {"agent-macos", "repo"}) + self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "repo"}) - def test_intentional_differences_select_only_the_legacy_repo_lane(self): + def test_intentional_differences_select_only_the_repo_lane(self): for path in ( # The Agent flake has no root inputs. "flake.nix", "flake.lock", @@ -106,9 +107,8 @@ def test_intentional_differences_select_only_the_legacy_repo_lane(self): "scripts/ci/windows-artifact-sign.ps1", "scripts/ci/install-windows-artifact-signing.ps1", ): with self.subTest(path=path): - self.assertIn("repo", owners(path)) - self.assertEqual(selected([path]), set()) - self.assertEqual(selected([path], PUSH), set()) + self.assertEqual(selected([path]), {"repo"}) + self.assertEqual(selected([path], PUSH), {"repo"}) def test_unknown_roots_and_malformed_paths_select_every_lane(self): for path in ("new-build-config.toml", "newroot/x", "", "/tmp/file", "../file", "docs/../flake.nix", @@ -136,6 +136,13 @@ def test_typescript_sdk_never_selects_a_client_lane(self): self.assertEqual(selected(["sdk/src/index.ts"], PUSH), set()) self.assertFalse({o for o in owners("sdk/src/index.ts") if o.startswith(("agent-", "research-"))}) + def test_release_metadata_and_publisher_inputs_select_the_repo_lane(self): + for path in ("apps/maple-research/frontend/package.json", "apps/maple-research/frontend/src-tauri/Cargo.toml", + "apps/maple-research/frontend/src-tauri/tauri.conf.json", "proxy/justfile", ".github/CODEOWNERS", + ".github/actionlint.yaml", "services/agent-updates/publisher/publisher.py"): + with self.subTest(path=path): + self.assertIn("repo", selected([path])) + def test_rust_sdk_tests_and_proxy_container_select_no_agent_lane(self): self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), set()) self.assertEqual(selected(["proxy/src/main.rs"]), AGENT_PR) @@ -159,8 +166,7 @@ def test_inert_paths_select_nothing(self): self.assertEqual(selected([path], PUSH), set()) evidence = ".agents/skills/release-maple/scripts/ci_evidence.py" self.assertFalse(plan.inert(evidence)) - self.assertEqual(owners(evidence), {"repo"}) - self.assertEqual(selected([evidence]), set()) + self.assertEqual(selected([evidence]), {"repo"}) def test_ci_infrastructure_selects_every_active_gate_lane(self): for path in (".github/workflows/ci.yml", ".github/actions/setup-nix/action.yml", @@ -227,8 +233,8 @@ def test_outputs_empty_matrix_and_runner_overrides(self): runners = {entry["id"]: entry["runner"] for entry in entries} self.assertEqual(runners, {"agent-lint": "blacksmith-8vcpu-ubuntu-2404", "agent-test": "blacksmith-16vcpu-ubuntu-2404", - "agent-macos": "blacksmith-6vcpu-macos-26"}) - self.assertTrue(all(len(entry["cache_key"]) == 16 for entry in entries)) + "agent-macos": "blacksmith-6vcpu-macos-26", "repo": "ubuntu-latest"}) + self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) def test_run_executes_the_lane_command_in_its_flake(self): with mock.patch.object(plan.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()), \ diff --git a/scripts/ci/test_research_dev_profile.py b/scripts/ci/test_research_dev_profile.py index cfbd947ac..44fa564dd 100644 --- a/scripts/ci/test_research_dev_profile.py +++ b/scripts/ci/test_research_dev_profile.py @@ -3,7 +3,6 @@ import json from pathlib import Path import plistlib -import subprocess import tempfile import unittest from unittest.mock import patch @@ -49,81 +48,6 @@ def test_checks_actual_package_identity_and_scheme(self): with self.assertRaises(ValueError): profile_module.verify_bundle(bundle, self.profile) - def test_pr_artifact_job_has_no_publication_or_signing_authority(self): - workflow = json.loads(subprocess.check_output([ - "yq", "-o=json", ".", str(profile_module.ROOT / ".github/workflows/desktop-pr-build.yml") - ], text=True)) - self.assertEqual(set(workflow["on"]), {"pull_request"}) - self.assertEqual(workflow["permissions"], {"contents": "read"}) - job = workflow["jobs"]["build-research-dev-macos"] - self.assertEqual(job["needs"], "changes") - self.assertIn("needs.changes.outputs.macos", job["if"]) - self.assertNotIn("environment", job) - self.assertNotIn("secrets.", json.dumps(job)) - self.assertTrue(any("./scripts/ci/research-dev-desktop.sh" in step.get("run", "") for step in job["steps"])) - artifact = next(step["with"] for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertEqual(artifact["name"], "maple-research-dev-macos-pr") - self.assertEqual(artifact["if-no-files-found"], "error") - self.assertIn("build-profile.json", artifact["path"]) - self.assertIn("maple-research-dev-macos.tar.gz", artifact["path"]) - - def test_pr_dev_opt_in_event_selection_and_concurrency(self): - workflow = json.loads(subprocess.check_output([ - "yq", "-o=json", ".", str(profile_module.ROOT / ".github/workflows/desktop-pr-build.yml") - ], text=True)) - self.assertEqual(workflow["on"]["pull_request"]["types"], ["opened", "synchronize", "reopened", "labeled"]) - jobs = workflow["jobs"] - for name, job in jobs.items(): - self.assertEqual(job["name"], name + "${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }}") - dev = "build-research-dev-macos" - native = {"build-macos", "build-linux", "build-windows", dev} - label = "ci:research-dev-macos" - - def selected(action, added_label="", labels=(), result="success", changed="false", cancelled=False): - # Evaluate the actual job expressions with the same boolean semantics. - context = { - "github.event.action": action, - "github.event.label.name": added_label, - "github.event.pull_request.labels.*.name": list(labels), - "needs.changes.result": result, - **{f"needs.changes.outputs.{platform}": changed for platform in ("macos", "linux", "windows")}, - } - selected_jobs = set() - for name in native | {"changes"}: - expression = jobs[name]["if"].strip().removeprefix("${{").removesuffix("}}").strip() - expression = expression.replace("&&", " and ").replace("||", " or ") - expression = expression.replace("!cancelled()", str(not cancelled)).replace("always()", "True") - for key, value in context.items(): - expression = expression.replace(key, repr(value)) - if eval(" ".join(expression.split()), {"__builtins__": {}}, - {"contains": lambda items, value: value in items}): - selected_jobs.add(name) - return selected_jobs - - for action in ("opened", "synchronize", "reopened"): - with self.subTest(action=action): - self.assertEqual(selected(action), {"changes"}) - self.assertEqual(selected(action, labels=[label]), {"changes", dev}) - self.assertEqual(selected(action, changed="true"), {"changes"} | native) - self.assertEqual(selected(action, result="failure"), {"changes"} | native) - self.assertEqual(selected(action, changed="true", cancelled=True), {"changes"}) - self.assertEqual(selected("labeled", label, [label], "skipped"), {dev}) - self.assertEqual(selected("labeled", "unrelated", [label], "skipped", "true"), set()) - self.assertEqual(selected("labeled", label, [label], "skipped", cancelled=True), set()) - - # Metadata runs cannot cancel source runs or runs for other labels. - self.assertEqual(workflow["concurrency"]["group"], - "${{ github.workflow }}-pr-${{ github.event.pull_request.number }}" - "${{ github.event.action == 'labeled' && format('-label-{0}', github.event.label.name) || '' }}") - self.assertTrue(workflow["concurrency"]["cancel-in-progress"]) - self.assertEqual(jobs[dev]["concurrency"], { - "group": "research-dev-macos-pr-${{ github.event.pull_request.number }}", - "cancel-in-progress": True, - }) - checkout = next(step for step in jobs[dev]["steps"] if step.get("uses", "").startswith("actions/checkout@")) - self.assertEqual(checkout["with"]["ref"], "${{ github.event.pull_request.head.sha }}") - self.assertFalse(checkout["with"]["persist-credentials"]) - def test_runtime_audit_rejects_host_dependencies_and_search_paths(self): system_loads = "maple:\n\t/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)\n" bundle_rpath = "Load command 0\n cmd LC_RPATH\n path @executable_path/../Frameworks (offset 12)\n" diff --git a/scripts/ci/test_sdk_publish_workflows.py b/scripts/ci/test_sdk_publish_workflows.py index f42d1a329..bf8fdbc25 100644 --- a/scripts/ci/test_sdk_publish_workflows.py +++ b/scripts/ci/test_sdk_publish_workflows.py @@ -81,7 +81,7 @@ def test_builders_cannot_receive_registry_or_oidc_credentials(self): self.assertEqual(job["outputs"]["artifact_id"], "${{ steps.bundle.outputs.artifact-id }}") def test_all_actions_are_pinned_and_checkout_cannot_change_source(self): - for kind in ("npm", "rust", "tests"): + for kind in ("npm", "rust"): for job in workflow(kind)["jobs"].values(): for step in job["steps"]: self.assertNotIn("${{", step.get("run", "")) @@ -93,8 +93,7 @@ def test_all_actions_are_pinned_and_checkout_cannot_change_source(self): if action.startswith("actions/checkout@"): self.assertIs(step["with"]["persist-credentials"], False) self.assertNotIn("submodules", step["with"]) - if kind != "tests": - self.assertEqual(step["with"]["ref"], "${{ github.sha }}") + self.assertEqual(step["with"]["ref"], "${{ github.sha }}") if action.startswith("DeterminateSystems/determinate-nix-action@"): self.assertEqual(step["with"]["github-token"], "") @@ -132,28 +131,12 @@ def test_publishers_only_upload_validated_same_run_artifacts(self): self.assertNotIn("secrets.", text) def test_no_workflow_creates_releases_tags_or_write_contents(self): - for kind in ("npm", "rust", "tests"): + for kind in ("npm", "rust"): config = workflow(kind) for text in strings(config): self.assertNotRegex(text, r"contents: write|gh release|git (?:push|tag)|releases/latest|softprops/action-gh-release") self.assertEqual(config["permissions"], {"contents": "read"}) - def test_pr_boundary_tests_cannot_publish(self): - config = workflow("tests") - self.assertEqual(set(config["on"]), {"push", "pull_request"}) - self.assertEqual(config["on"]["push"]["branches"], ["master"]) - for job in config["jobs"].values(): - self.assertNotIn("environment", job) - self.assertNotIn("permissions", job) - for text in strings(job): - self.assertNotRegex(text, r"\bsecrets\b|id-token|sdk_publish.py (?:source|verify|confirm)|npm publish") - for step in job["steps"]: - if "sdk_crates_upload.py" in step.get("run", ""): - self.assertIn("--validate-only", step["run"]) - packages = config["jobs"]["packages"] - self.assertEqual(packages["strategy"]["matrix"]["kind"], ["npm", "rust"]) - self.assertTrue(any("build-sdk-publish.sh" in step.get("run", "") for step in packages["steps"])) - class NPMPublishCommandTests(unittest.TestCase): def run_publish(self, mode, token=""): diff --git a/scripts/ci/test_select_xcode.py b/scripts/ci/test_select_xcode.py index 3ec7a03c3..e2115b194 100644 --- a/scripts/ci/test_select_xcode.py +++ b/scripts/ci/test_select_xcode.py @@ -139,7 +139,6 @@ def test_local_resolution_skips_same_version_wrong_build_and_fails_if_no_match(s self.assertIn("17F113", result.stderr) def test_no_workflow_uses_retired_mac_os_and_every_native_selector_has_checkout(self): - selectors = [] for path in sorted((ROOT / ".github/workflows").glob("*.yml")): workflow = json.loads(subprocess.check_output(["yq", "-o=json", ".", str(path)], text=True)) self.assertNotRegex(json.dumps(workflow), r"macos-14(?:-large|-xlarge)?") @@ -149,31 +148,11 @@ def test_no_workflow_uses_retired_mac_os_and_every_native_selector_has_checkout( if "scripts/ci/select-xcode.py" not in step.get("run", ""): self.assertNotIn("Xcode_26.5_beta", step.get("run", "")) continue - selectors.append((path.name, job_id)) if "if" in step: self.assertEqual(step["if"], "runner.os == 'macOS'", (path.name, job_id)) checkout = [s for s in steps[:index] if s.get("uses", "").startswith("actions/checkout@")] self.assertEqual(len(checkout), 1, (path.name, job_id)) self.assertIs(checkout[0]["with"]["persist-credentials"], False) - self.assertCountEqual(selectors, [ - ("agent-desktop-build.yml", "macos-build"), - ("agent-desktop-build.yml", "macos"), - ("agent-desktop-build.yml", "macos-unsigned"), - ("agent-desktop-build.yml", "verify-macos"), - ("ci.yml", "nix"), - ("desktop-build.yml", "build-macos"), - ("desktop-pr-build.yml", "build-macos"), - ("desktop-pr-build.yml", "build-research-dev-macos"), - ("ios-dev-testflight.yml", "build-ios-dev"), - ("ios-dev-testflight.yml", "submit-ios-dev-testflight"), - ("mobile-build.yml", "build-ios"), - ("mobile-build.yml", "submit-ios-testflight"), - ("mobile-pr-build.yml", "build-ios"), - ("release.yml", "build-tauri"), - ("release.yml", "build-ios"), - ("release.yml", "verify-macos-desktop-release-artifacts"), - ("release.yml", "verify-release-artifacts"), - ]) if __name__ == "__main__": diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py deleted file mode 100644 index 41f5b325c..000000000 --- a/scripts/ci/test_signing_workflows.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Keep app signing credentials behind GitHub's protected environments. - -The server-side branch/tag policies are configured by repository administrators; -these tests prevent a workflow change from accidentally bypassing that boundary. -""" - -import functools -import json -import os -from pathlib import Path -import re -import subprocess -import tempfile -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -ENVIRONMENTS = { - "desktop-signing": { - "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", - "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", - "APPLE_ID_PASSWORD", "APPLE_TEAM_ID", "KEYCHAIN_PASSWORD", - }, - "apple-signing": { - "APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_PRIVATE_KEY", "APPLE_TEAM_ID", - }, - "android-signing": { - "ANDROID_KEYSTORE_BASE64", "ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD", - }, - "windows-signing": { - "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", - "AZURE_ACCOUNT", "AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME", - "AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", - "AZURE_ARTIFACT_SIGNING_ENDPOINT", "AZURE_ARTIFACT_SIGNING_EXPECTED_SUBJECT", - "AZURE_CLIENT_ID", "AZURE_ENDPOINT", "AZURE_PROFILE", - "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", - }, - "zapstore-publishing": {"ZAPSTORE_SIGN_WITH"}, - "updates-production": {"AGENT_UPDATES_BWS_ACCESS_TOKEN"}, -} -SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) -CONSUMERS = { - "agent-desktop-build.yml": {"macos": "desktop-signing", "windows": "windows-signing"}, - "agent-updates-publish.yml": { - "reserve": "updates-production", "sign": "updates-production", - "publish": "updates-production", - }, - "desktop-build.yml": { - "build-macos": "desktop-signing", "build-linux": "desktop-signing", - "build-windows": "windows-signing", - }, - "mobile-build.yml": { - "build-ios": "apple-signing", "submit-ios-testflight": "apple-signing", - }, - "ios-dev-testflight.yml": { - "build-ios-dev": "apple-signing", "submit-ios-dev-testflight": "apple-signing", - }, - "android-build.yml": {"build-android": "android-signing"}, - "release.yml": { - "build-tauri": "desktop-signing", "build-windows": "windows-signing", - "build-android": "android-signing", "build-ios": "apple-signing", - }, - "zapstore-publish.yml": {"publish": "zapstore-publishing"}, - # Preserve the original credential diagnostics and Research environments. - "signing-credentials-check.yml": { - name: name for name in ( - "desktop-signing", "apple-signing", "android-signing", - "windows-signing", "zapstore-publishing", - ) - }, -} - - -@functools.cache -def workflows(): - return { - p.name: json.loads(subprocess.check_output(["yq", "-o=json", ".", str(p)], text=True)) - for p in sorted((ROOT / ".github/workflows").glob("*.yml")) - } - - -def signing_references(value): - return set(re.findall(r"secrets\.([A-Z_0-9]+)", json.dumps(value))) & SIGNING_SECRETS - - -def check_boundary(job): - used = signing_references(job) - if used: - environment = job.get("environment") - if not isinstance(environment, str) or environment not in ENVIRONMENTS: - raise ValueError("signing credentials require a fixed protected environment") - if not used <= ENVIRONMENTS[environment]: - raise ValueError("signing credentials are not configured in this environment") - - -class SigningWorkflowTests(unittest.TestCase): - def test_every_signing_reference_is_inside_the_correct_environment_job(self): - for name, config in workflows().items(): - with self.subTest(workflow=name): - self.assertFalse(signing_references({k: v for k, v in config.items() if k != "jobs"})) - for job in config["jobs"].values(): - check_boundary(job) - - def test_existing_builds_keep_their_signing_environments(self): - for name, jobs in CONSUMERS.items(): - for job, environment in jobs.items(): - with self.subTest(workflow=name, job=job): - self.assertEqual(workflows()[name]["jobs"][job]["environment"], environment) - - def test_master_release_and_unsigned_pr_triggers_are_preserved(self): - for name in ("desktop-build.yml", "android-build.yml"): - self.assertEqual(workflows()[name]["on"], {"push": {"branches": ["master"]}}) - self.assertEqual(workflows()["release.yml"]["on"], {"release": {"types": ["created"]}}) - for name in ("desktop-pr-build.yml", "mobile-pr-build.yml", "android-pr-build.yml"): - self.assertFalse(signing_references(workflows()[name])) - for job in workflows()[name]["jobs"].values(): - self.assertNotIn(job.get("environment", ""), ENVIRONMENTS) - - def test_production_testflight_serializes_export_through_upload_on_trusted_master(self): - config = workflows()["mobile-build.yml"] - self.assertEqual(config["on"], {"push": {"branches": ["master"]}, "workflow_dispatch": None}) - self.assertEqual(config["concurrency"], { - "group": "maple-ios-production-testflight", "cancel-in-progress": False, "queue": "max", - }) - self.assertNotEqual(config["concurrency"]["group"], workflows()["ios-dev-testflight.yml"]["concurrency"]["group"]) - guard = ( - "github.repository == 'MaplePrivacyLabs/Maple' && " - "github.ref == 'refs/heads/master' && " - "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')" - ) - jobs = config["jobs"] - for name in ("changes", "build-ios", "submit-ios-testflight"): - self.assertIn(guard, " ".join(jobs[name]["if"].split())) - self.assertEqual(jobs["build-ios"]["needs"], "changes") - self.assertEqual(jobs["verify-ios-artifacts"]["needs"], "build-ios") - self.assertEqual(jobs["submit-ios-testflight"]["needs"], "verify-ios-artifacts") - for job in jobs.values(): - self.assertNotIn("concurrency", job) - self.assertIn("always() && !cancelled()", jobs["build-ios"]["if"]) - self.assertIn("needs.changes.result != 'success'", jobs["build-ios"]["if"]) - - def test_manual_testflight_dispatch_forces_a_fresh_build_without_a_path_diff(self): - steps = workflows()["app-change-detection.yml"]["jobs"]["detect"]["steps"] - classify = next(step for step in steps if step.get("id") == "classify") - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "outputs" - subprocess.run(["bash", "-c", classify["run"]], check=True, capture_output=True, - cwd=directory, env={**os.environ, "GITHUB_EVENT_NAME": "workflow_dispatch", - "GITHUB_OUTPUT": str(output), "BASE_SHA": "", "HEAD_SHA": ""}) - self.assertEqual(dict(line.split("=", 1) for line in output.read_text().splitlines()), { - name: "true" for name in ("frontend", "macos", "linux", "windows", "ios", "android") - }) - - def test_windows_oidc_keeps_its_federated_environment_identity(self): - for name in ("desktop-build.yml", "release.yml"): - job = workflows()[name]["jobs"]["build-windows"] - self.assertEqual(job["environment"], "windows-signing") - self.assertEqual(job.get("permissions", workflows()[name]["permissions"])["id-token"], "write") - - def test_desktop_release_linux_build_subprocess_has_no_apple_environment(self): - # Step env is evaluated on the runner. This checks subprocess hygiene, - # not isolation from the job's protected-environment secret context. - job = workflows()["release.yml"]["jobs"]["build-tauri"] - build = next(step for step in job["steps"] - if step.get("name") == "Build Tauri desktop app") - for name in ("APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", - "APPLE_PASSWORD", "APPLE_TEAM_ID"): - self.assertTrue(build["env"][name].startswith("${{ runner.os == 'macOS' && secrets.")) - self.assertTrue(build["env"][name].endswith(" || '' }}")) - for name in ("TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"): - self.assertEqual(build["env"][name], "${{ secrets." + name + " }}") - - def test_dev_testflight_runs_for_every_master_push_and_only_trusted_manual_runs(self): - config = workflows()["ios-dev-testflight.yml"] - self.assertEqual(config["on"], {"push": {"branches": ["master"]}, "workflow_dispatch": None}) - self.assertEqual(config["permissions"], {"contents": "read"}) - self.assertEqual(config["concurrency"], { - "group": "maple-ios-dev-testflight", "cancel-in-progress": False, "queue": "max", - }) - expected_guard = ( - "github.repository == 'MaplePrivacyLabs/Maple' && " - "github.ref == 'refs/heads/master' && " - "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')" - ) - for job in config["jobs"].values(): - self.assertEqual(" ".join(job["if"].split()), expected_guard) - self.assertNotIn("strategy", job) - checkout = job["steps"][0] - self.assertTrue(checkout["uses"].startswith("actions/checkout@")) - self.assertEqual(checkout["with"], { - "ref": "${{ github.sha }}", "persist-credentials": False, - }) - - def test_dev_testflight_build_variant_and_artifacts_cannot_use_production_defaults(self): - job = workflows()["ios-dev-testflight.yml"]["jobs"]["build-ios-dev"] - build = next(step for step in job["steps"] if "ios-release.sh" in step.get("run", "")) - self.assertEqual(build["env"]["MAPLE_IOS_VARIANT"], "dev") - self.assertEqual(build["env"]["MAPLE_IOS_BUILD_NUMBER"], "${{ steps.identity.outputs.build_number }}") - self.assertEqual(build["env"]["MAPLE_IOS_DEV_AUTH_ORIGIN"], "${{ vars.MAPLE_IOS_DEV_AUTH_ORIGIN }}") - self.assertEqual(build["env"]["MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY"], "1") - upload = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertEqual(upload["with"]["name"], job["outputs"]["artifact_name"]) - self.assertEqual(upload["with"]["if-no-files-found"], "error") - paths = upload["with"]["path"].splitlines() - self.assertIn("apps/maple-research/frontend/src-tauri/target/ios-dev/Maple-Dev.ipa", paths) - self.assertIn("apps/maple-research/frontend/src-tauri/target/ios-dev/ios-build-profile.json", paths) - self.assertTrue(all("/ios-dev/" in path for path in paths)) - for step in job["steps"]: - if step.get("uses", "").startswith("irgaly/xcode-cache@"): - self.assertTrue(step["with"]["key"].startswith("maple-dev-")) - self.assertTrue(all(key.startswith("maple-dev-") for key in step["with"]["restore-keys"].splitlines())) - - def test_unsigned_onnx_cache_has_trusted_writers_and_restore_only_prs(self): - caches = {} - for name, job_id in ( - ("mobile-build.yml", "build-ios"), - ("ios-dev-testflight.yml", "build-ios-dev"), - ("release.yml", "build-ios"), - ("mobile-pr-build.yml", "build-ios"), - ): - job = workflows()[name]["jobs"][job_id] - cache = next(step for step in job["steps"] - if step.get("with", {}).get("path", "").strip() - == "apps/maple-research/frontend/src-tauri/onnxruntime-ios") - caches[name] = cache - action = "actions/cache/restore@" if name == "mobile-pr-build.yml" else "actions/cache@" - self.assertTrue(cache["uses"].startswith(action), name) - prepare = next(step for step in job["steps"] - if "./scripts/ci/ios-onnxruntime.sh" in step.get("run", "")) - self.assertLess(job["steps"].index(cache), job["steps"].index(prepare)) - master = caches["mobile-build.yml"]["with"] - for name, cache in caches.items(): - self.assertEqual(cache["with"]["key"], master["key"], name) - self.assertEqual(cache["with"]["restore-keys"], master["restore-keys"], name) - - def test_dev_testflight_verifies_exact_download_before_exposing_upload_credentials(self): - job = workflows()["ios-dev-testflight.yml"]["jobs"]["submit-ios-dev-testflight"] - self.assertEqual(job["needs"], "build-ios-dev") - self.assertEqual(job["permissions"], {"contents": "read"}) - self.assertEqual(job["env"]["MAPLE_IOS_BUILD_NUMBER"], "${{ needs.build-ios-dev.outputs.build_number }}") - self.assertEqual(job["env"]["MAPLE_IOS_DEV_AUTH_ORIGIN"], "${{ vars.MAPLE_IOS_DEV_AUTH_ORIGIN }}") - steps = job["steps"] - download = next(i for i, step in enumerate(steps) if step.get("uses", "").startswith("actions/download-artifact@")) - self.assertEqual(steps[download]["with"], { - "name": "${{ needs.build-ios-dev.outputs.artifact_name }}", "path": "artifacts", - }) - proof = next(i for i, step in enumerate(steps) if "verify-release-artifacts.sh" in step.get("run", "")) - profile = next(i for i, step in enumerate(steps) if "ios-build-profile.py" in step.get("run", "")) - upload = next(i for i, step in enumerate(steps) if "altool --upload-app" in step.get("run", "")) - self.assertLess(download, proof) - self.assertLess(proof, profile) - self.assertLess(profile, upload) - self.assertEqual(steps[proof]["env"]["MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY"], "1") - for index in (proof, profile, upload): - self.assertNotIn("continue-on-error", steps[index]) - self.assertNotIn("if", steps[index]) - for step in steps[:upload]: - self.assertFalse(signing_references(step)) - verification = steps[profile]["run"] - for required in ( - "verify-ipa artifacts/ios-dev/Maple-Dev.ipa --variant dev", - '--source-sha "${GITHUB_SHA}"', '--build-number "${MAPLE_IOS_BUILD_NUMBER}"', - '--report "${RUNNER_TEMP}/verified-ios-build-profile.json"', - 'cmp artifacts/ios-dev/ios-build-profile.json "${RUNNER_TEMP}/verified-ios-build-profile.json"', - ): - self.assertIn(required, verification) - self.assertIn("--file artifacts/ios-dev/Maple-Dev.ipa", steps[upload]["run"]) - self.assertIn("trap 'rm -f", steps[upload]["run"]) - - def test_dev_testflight_has_no_release_or_external_tester_distribution_action(self): - config = workflows()["ios-dev-testflight.yml"] - for job in config["jobs"].values(): - for step in job["steps"]: - self.assertNotRegex(step.get("run", ""), r"gh release|betaGroups|betaTesters|appStoreVersions") - if "uses" in step: - self.assertRegex(step["uses"], r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") - - def test_credential_check_is_manual_and_cannot_sign_or_publish(self): - config = workflows()["signing-credentials-check.yml"] - self.assertEqual(set(config["on"]), {"workflow_dispatch"}) - self.assertEqual(config["permissions"], {"contents": "read"}) - for job in config["jobs"].values(): - self.assertNotIn("permissions", job) - self.assertEqual(len(job["steps"]), 1) - step = job["steps"][0] - self.assertEqual(step["shell"], "python") - self.assertEqual(step["run"].splitlines()[0], "import os") - self.assertNotRegex(step["run"], r"subprocess|os\.system|open\(|socket|urllib|requests") - - def test_missing_wrong_and_dynamic_environments_fail_closed(self): - reference = {"steps": [{"env": {"KEY": "${{ secrets.APPLE_API_PRIVATE_KEY }}"}}]} - for environment in (None, "pages-preview", "${{ inputs.environment }}", "android-signing"): - with self.subTest(environment=environment), self.assertRaises(ValueError): - check_boundary({**reference, "environment": environment}) - check_boundary({**reference, "environment": "apple-signing"}) - - def test_agent_update_bws_access_is_exclusive_to_its_publisher_jobs(self): - consumers = [] - for name, config in workflows().items(): - for job_name, job in config["jobs"].items(): - if "AGENT_UPDATES_BWS_ACCESS_TOKEN" in signing_references(job): - consumers.append((name, job_name)) - self.assertEqual(consumers, [("agent-updates-publish.yml", job) - for job in ("reserve", "sign", "publish")]) - reference = {"steps": [{"with": {"access_token": "${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }}"}}]} - for environment in (None, "desktop-signing", "agent-updates-production", - "${{ inputs.environment }}"): - with self.subTest(environment=environment), self.assertRaises(ValueError): - check_boundary({**reference, "environment": environment}) - check_boundary({**reference, "environment": "updates-production"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_windows_signature_reference.py b/scripts/ci/test_windows_signature_reference.py index 6d7795af3..01f47a9e7 100644 --- a/scripts/ci/test_windows_signature_reference.py +++ b/scripts/ci/test_windows_signature_reference.py @@ -114,11 +114,12 @@ def test_probe_workflow_is_manual_read_only_and_has_no_implicit_reference(self): "yq", "-o=json", ".", str(root / ".github/workflows/agent-windows-signature-probe.yml") ], text=True)) self.assertEqual(set(workflow["on"]), {"workflow_dispatch"}) - self.assertEqual(workflow["permissions"], {"contents": "read", "actions": "read"}) + self.assertEqual(workflow["permissions"], {"contents": "read"}) inputs = workflow["on"]["workflow_dispatch"]["inputs"] self.assertEqual(len(inputs), 8) self.assertTrue(all(value["required"] and "default" not in value for value in inputs.values())) job = workflow["jobs"]["signature-probe"] + self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) self.assertEqual(job["runs-on"], "windows-2025") self.assertNotIn("environment", job) self.assertNotIn("secrets", json.dumps(workflow)) diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py new file mode 100644 index 000000000..1a5cf19dc --- /dev/null +++ b/scripts/ci/test_workflow_policy.py @@ -0,0 +1,285 @@ +"""Invariants every GitHub Actions workflow keeps (docs/repository-workflows.md). + +TEMPORARY lists the violations that later commits in this change remove. Each +list must equal the current violations exactly, so an entry is deleted as soon +as its violation is fixed and no new violation can hide behind it. +""" + +from __future__ import annotations + +import functools +import json +from pathlib import Path +import re +import subprocess +import unittest + +import lanes + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / ".github/workflows" +# Release and publication workflows that stay byte-identical; owner review only. +SACRED = frozenset({ + "release.yml", "pages-publish.yml", "proxy-publish.yml", "updates-publish.yml", "zapstore-publish.yml", + "sdk-publish-npm.yml", "sdk-publish-rust.yml", "agent-updates-publish.yml", "auth-pages-publish.yml", + "opensecret-eif-release.yml", +}) +LISTENERS = frozenset({ + "auth-pages-dev-publish.yml", "pages-publish.yml", "proxy-publish.yml", "updates-publish.yml", + "zapstore-publish.yml", +}) +NIX_ACTION = "DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28" +NIX_INPUTS = {"source-tag": "v3.23.3", "github-token": ""} +VERIFY_NIX = "bash scripts/ci/verify-nix-runtime.sh" +SETUP_NIX = "./.github/actions/setup-nix" +PINNED = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}") +GITHUB_HOSTED = re.compile(r"(?:ubuntu|macos|windows)-[A-Za-z0-9.-]+") +CLOUDFLARE = frozenset({"CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"}) +# Every protected environment and the secrets configured in it. +ENVIRONMENTS = { + "desktop-signing": frozenset({ + "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "APPLE_CERTIFICATE", + "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", "APPLE_ID_PASSWORD", "APPLE_TEAM_ID", "KEYCHAIN_PASSWORD", + }), + "apple-signing": frozenset({"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_PRIVATE_KEY", "APPLE_TEAM_ID"}), + "android-signing": frozenset({"ANDROID_KEYSTORE_BASE64", "ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD"}), + "windows-signing": frozenset({ + "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "AZURE_ACCOUNT", + "AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME", "AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", + "AZURE_ARTIFACT_SIGNING_ENDPOINT", "AZURE_ARTIFACT_SIGNING_EXPECTED_SUBJECT", "AZURE_CLIENT_ID", + "AZURE_ENDPOINT", "AZURE_PROFILE", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", + }), + "zapstore-publishing": frozenset({"ZAPSTORE_SIGN_WITH"}), + "updates-production": frozenset({"AGENT_UPDATES_BWS_ACCESS_TOKEN"}) | CLOUDFLARE, + "pages-preview": CLOUDFLARE, + "pages-production": CLOUDFLARE, + "auth-pages-development": CLOUDFLARE, + "auth-pages-production": CLOUDFLARE, + "pcr-signing": frozenset({"OPENSECRET_PCR_SIGNING_BWS_ACCESS_TOKEN"}), + "sdk-npm": frozenset({"NPM_BOOTSTRAP_TOKEN"}), + "sdk-crates": frozenset({"CRATES_BOOTSTRAP_TOKEN"}), +} +# Actions-cache writers; a key under these prefixes feeds release.yml's fallbacks. +CACHE_WRITERS = ("actions/cache@", "actions/cache/save@", "irgaly/xcode-cache@", "Swatinem/rust-cache@") + +TEMPORARY = { + # Signed-lane hygiene adds the timeouts and moves OIDC to the attesting jobs. + "timeout": frozenset({ + ("android-build.yml", "build-android"), ("android-build.yml", "verify-android-artifacts"), + ("app-change-detection.yml", "detect"), + *(("desktop-build.yml", job) for job in ( + "build-macos", "build-linux", "verify-linux-desktop-artifacts", "verify-macos-desktop-artifacts", + "build-windows", "verify-windows-desktop-artifacts", "verify-desktop-artifacts")), + ("mobile-build.yml", "build-ios"), ("mobile-build.yml", "verify-ios-artifacts"), + ("mobile-build.yml", "submit-ios-testflight"), + *(("signing-credentials-check.yml", job) for job in ( + "desktop-signing", "apple-signing", "android-signing", "windows-signing", "zapstore-publishing")), + ("web-build.yml", "build-web"), ("web-build.yml", "build-web-master"), + ("web-build.yml", "verify-web-master-artifact"), + # Deleted, or made push-only, by the client lanes. + ("android-pr-build.yml", "build-android"), ("mobile-pr-build.yml", "build-ios"), + *(("desktop-pr-build.yml", job) for job in ( + "build-macos", "build-research-dev-macos", "build-linux", "build-windows", "verify-windows-artifacts")), + ("frontend-tests.yml", "test-frontend"), ("rust-tests.yml", "rust-tests"), + }), + "permissions": frozenset({"desktop-build.yml", "android-build.yml"}), + # The client lanes delete these PR workflows or make them push-only. + "pr-cache-writes": frozenset({ + ("android-pr-build.yml", "build-android"), ("desktop-pr-build.yml", "build-macos"), + ("desktop-pr-build.yml", "build-research-dev-macos"), ("desktop-pr-build.yml", "build-linux"), + ("mobile-pr-build.yml", "build-ios"), ("frontend-tests.yml", "test-frontend"), + ("rust-tests.yml", "rust-tests"), ("web-build.yml", "build-web"), ("proxy-rust.yml", "proxy-rust"), + ("proxy-rust.yml", "proxy-native-release"), ("sdk-rust.yml", "sdk-rust"), + ("sdk-typescript.yml", "sdk-typescript"), ("updates-tests.yml", "test-updates-worker"), + }), +} + + +@functools.lru_cache(maxsize=None) +def load(path: Path) -> dict: + return json.loads(subprocess.run(["yq", "-o=json", ".", str(path)], check=True, + capture_output=True, text=True).stdout) + + +def workflows() -> dict: + return {path.name: load(path) for path in sorted(WORKFLOWS.glob("*.yml"))} + + +def jobs(): + for name, config in workflows().items(): + for job_id, job in config["jobs"].items(): + yield name, job_id, job + + +def triggers(config: dict) -> set: + on = config["on"] + return set(on) if isinstance(on, dict) else set([on] if isinstance(on, str) else on) + + +def environment(job: dict): + value = job.get("environment") + return value.get("name") if isinstance(value, dict) else value + + +def secrets(value) -> set: + return set(re.findall(r"secrets\.([A-Za-z0-9_]+)", json.dumps(value))) - {"GITHUB_TOKEN"} + + +def check_secret_boundary(job: dict) -> None: + used = secrets(job) + if used and not (isinstance(environment(job), str) and used <= ENVIRONMENTS.get(environment(job), set())): + raise ValueError(f"secrets {sorted(used)} need a fixed protected environment that holds them") + + +def permissions(config: dict, job: dict) -> dict: + value = job.get("permissions", config.get("permissions", {})) + return value if isinstance(value, dict) else {} + + +def matrix_values(matrix, key: str) -> set: + if isinstance(matrix, str): + return set(re.findall(rf'"{re.escape(key)}"\s*:\s*"([^"]+)"', matrix)) + values = matrix.get(key, []) + found = set(values) if isinstance(values, list) else matrix_values(values, key) + include = matrix.get("include", []) + if isinstance(include, str): + return found | matrix_values(include, key) + return found | {row[key] for row in include if key in row} + + +def runner_labels(name: str, job_id: str, job: dict) -> set: + if (name, job_id) == ("ci.yml", "nix"): # the planner fills this matrix from lanes.py + return {lane.runner.label for lane in lanes.LANES} + values = job.get("runs-on", []) + labels = set() + for value in values if isinstance(values, list) else [values]: + if "${{" not in value: + labels.add(value) + continue + labels |= set(re.findall(r"'([^']+)'", value)) + for key in re.findall(r"matrix\.([A-Za-z0-9_-]+)", value): + labels |= matrix_values(job.get("strategy", {}).get("matrix", {}), key) + return labels + + +def cache_writer(step: dict) -> bool: + uses = step.get("uses", "") + return uses.startswith(CACHE_WRITERS) and str(step.get("with", {}).get("save-if", "true")) != "false" + + +class WorkflowPolicyTests(unittest.TestCase): + def test_1_no_privileged_pull_request_or_unlisted_workflow_run_triggers(self): + for name, config in workflows().items(): + with self.subTest(workflow=name): + self.assertNotIn("pull_request_target", triggers(config)) + if "workflow_run" in triggers(config): + self.assertIn(name, LISTENERS) + + def test_2_every_action_is_local_or_pinned_to_a_commit(self): + files = sorted(WORKFLOWS.glob("*.yml")) + sorted((ROOT / ".github/actions").glob("*/action.yml")) + for path in files: + config = load(path) + units = list(config.get("jobs", {}).values()) + [config.get("runs", {})] + for unit in units: + for uses in [unit.get("uses")] + [step.get("uses") for step in unit.get("steps", [])]: + if uses: + with self.subTest(file=path.name, uses=uses): + self.assertTrue(uses.startswith("./") or PINNED.fullmatch(uses)) + + def test_3_checkouts_never_persist_credentials(self): + for name, job_id, job in jobs(): + for step in job.get("steps", []): + if step.get("uses", "").startswith("actions/checkout@"): + with self.subTest(workflow=name, job=job_id): + self.assertIs(step.get("with", {}).get("persist-credentials"), False) + + def test_4_workflow_permissions_are_read_only(self): + broad = set() + for name, config in workflows().items(): + granted = config.get("permissions") + with self.subTest(workflow=name): + self.assertIsInstance(granted, dict) + if name in SACRED: + self.assertNotIn("write", granted.values()) + elif granted != {"contents": "read"}: + broad.add(name) + self.assertEqual(broad, TEMPORARY["permissions"]) + + def test_5_secrets_are_used_only_inside_their_protected_environment(self): + for name, config in workflows().items(): + with self.subTest(workflow=name): + self.assertFalse(secrets({key: value for key, value in config.items() if key != "jobs"})) + for job_id, job in config["jobs"].items(): + self.assertNotEqual(job.get("secrets"), "inherit", job_id) + check_secret_boundary(job) + reference = {"steps": [{"env": {"KEY": "${{ secrets.APPLE_API_PRIVATE_KEY }}"}}]} + for wrong in (None, "pages-preview", "${{ inputs.environment }}", "android-signing"): + with self.subTest(environment=wrong), self.assertRaises(ValueError): + check_secret_boundary({**reference, "environment": wrong}) + check_secret_boundary({**reference, "environment": "apple-signing"}) + + def test_6_every_nix_install_is_pinned_and_verified(self): + composite = load(ROOT / ".github/actions/setup-nix/action.yml")["runs"]["steps"] + self.assertEqual([(step.get("uses"), step.get("with"), step.get("run")) for step in composite], + [(NIX_ACTION, NIX_INPUTS, None), (None, None, VERIFY_NIX)]) + for name, config in workflows().items(): + root = config.get("defaults", {}).get("run", {}).get("working-directory", ".") + for job_id, job in config["jobs"].items(): + directory = job.get("defaults", {}).get("run", {}).get("working-directory", root) + steps = job.get("steps", []) + for index, step in enumerate(steps): + uses = step.get("uses", "") + if not uses.startswith(("DeterminateSystems/determinate-nix-action@", + "DeterminateSystems/nix-installer-action@", SETUP_NIX)): + continue + with self.subTest(workflow=name, job=job_id): + self.assertTrue(any(s.get("uses", "").startswith("actions/checkout@") for s in steps[:index])) + if uses == SETUP_NIX: + continue + self.assertEqual((uses, step.get("with")), (NIX_ACTION, NIX_INPUTS)) + verify = steps[index + 1] if index + 1 < len(steps) else {} + self.assertEqual(verify.get("run"), VERIFY_NIX) + self.assertEqual(verify.get("if"), step.get("if")) + self.assertEqual(verify.get("working-directory", directory), ".") + + def test_7_every_job_has_a_timeout(self): + missing = {(name, job_id) for name, job_id, job in jobs() + if name not in SACRED and "uses" not in job and "timeout-minutes" not in job} + self.assertEqual(missing, TEMPORARY["timeout"]) + + def test_8_ci_always_reports_one_verdict(self): + config = workflows()["ci.yml"] + for event, settings in config["on"].items(): + self.assertFalse({"paths", "paths-ignore"} & set(settings or {}), event) + verdict = config["jobs"]["ci-ok"] + self.assertEqual(verdict["if"], "always()") + self.assertEqual(set(verdict["needs"]), set(config["jobs"]) - {"ci-ok"}) + + def test_9_runner_labels_are_github_hosted_or_declared(self): + declared = set(load(ROOT / ".github/actionlint.yaml")["self-hosted-runner"]["labels"]) + for name, job_id, job in jobs(): + if "uses" in job: + continue + labels = runner_labels(name, job_id, job) + with self.subTest(workflow=name, job=job_id): + self.assertTrue(labels, "runs-on must name its labels") + for label in labels: + self.assertTrue(GITHUB_HOSTED.fullmatch(label) or label in declared, label) + + def test_pull_requests_never_save_caches_or_read_other_runs_artifacts(self): + writers = set() + for name, config in workflows().items(): + pr = bool({"pull_request", "merge_group"} & triggers(config)) + for job_id, job in config["jobs"].items(): + for step in job.get("steps", []): + self.assertNotIn("run-id", step.get("with", {}), (name, job_id)) + guard = " ".join(str(value) for value in ( + job.get("if", ""), step.get("if", ""), step.get("with", {}).get("save-if", ""))) + if pr and cache_writer(step) and not re.search( + r"refs/heads/master|github\.event_name == 'push'", guard): + writers.add((name, job_id)) + self.assertEqual(writers, TEMPORARY["pr-cache-writes"]) + + +if __name__ == "__main__": + unittest.main() From 532ab2f9ca350a554db85799a1282ad02c9a4995 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:00:27 +0000 Subject: [PATCH 03/21] ci: run Agent verification, packaging rehearsal and nightly checks from ci.yml Activate the remaining Agent lanes (full Linux/macOS/Windows suites, supply chain, provider, store bench, PR packaging rehearsals), add nightly.yml with one ci-nightly issue, make agent-desktop-build.yml master-only with exact-key release caches and a Blacksmith Windows builder, and delete agent-ci.yml, the SQLCipher and store workflows and the dependency archive, cache-scope, measurement and selector scripts. test_windows_updater_workflow.py reads the Windows Rust pin from setup-agent-windows, which windows-build now uses. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/develop-maple-agent/SKILL.md | 14 +- .../actions/setup-agent-windows/action.yml | 70 ++ .github/workflows/agent-ci.yml | 680 --------------- .github/workflows/agent-desktop-build.yml | 599 ++----------- .../agent-sqlcipher-supply-chain.yml | 92 -- .github/workflows/agent-store-performance.yml | 89 -- .github/workflows/ci.yml | 186 ++-- .github/workflows/nightly.yml | 153 ++++ apps/maple-agent/.githooks/pre-commit | 2 +- apps/maple-agent/CLAUDE.md | 5 +- apps/maple-agent/README.md | 13 +- apps/maple-agent/crates/store-bench/README.md | 9 +- apps/maple-agent/docs/desktop-builds.md | 57 +- apps/maple-agent/justfile | 35 + .../scripts/verify-linux-bundle-docker.sh | 35 + docs/agent-ci-performance.md | 376 -------- docs/repository-workflows.md | 134 +-- scripts/ci/agent_cache.py | 339 -------- scripts/ci/agent_cache_command.py | 45 - scripts/ci/agent_cache_scope.py | 262 ------ scripts/ci/agent_change_detection.py | 103 --- scripts/ci/agent_dependency_archive.py | 822 ------------------ scripts/ci/agent_master_selection.py | 132 --- scripts/ci/agent_measure.py | 258 ------ scripts/ci/lanes.py | 91 +- scripts/ci/nightly_report.py | 74 ++ scripts/ci/plan.py | 89 +- scripts/ci/test_agent_cache.py | 373 -------- scripts/ci/test_agent_cache_command.py | 161 ---- scripts/ci/test_agent_cache_scope.py | 248 ------ scripts/ci/test_agent_change_detection.py | 139 --- scripts/ci/test_agent_dependency_archive.py | 575 ------------ scripts/ci/test_agent_master_selection.py | 323 ------- scripts/ci/test_agent_measure.py | 136 --- scripts/ci/test_contracts.py | 19 + scripts/ci/test_nightly_report.py | 57 ++ .../ci/test_opensecret_change_detection.py | 8 +- scripts/ci/test_plan.py | 140 ++- scripts/ci/test_windows_updater_workflow.py | 6 +- scripts/ci/test_workflow_policy.py | 6 +- 40 files changed, 1023 insertions(+), 5932 deletions(-) create mode 100644 .github/actions/setup-agent-windows/action.yml delete mode 100644 .github/workflows/agent-ci.yml delete mode 100644 .github/workflows/agent-sqlcipher-supply-chain.yml delete mode 100644 .github/workflows/agent-store-performance.yml create mode 100644 .github/workflows/nightly.yml create mode 100755 apps/maple-agent/scripts/verify-linux-bundle-docker.sh delete mode 100644 docs/agent-ci-performance.md delete mode 100644 scripts/ci/agent_cache.py delete mode 100644 scripts/ci/agent_cache_command.py delete mode 100644 scripts/ci/agent_cache_scope.py delete mode 100644 scripts/ci/agent_change_detection.py delete mode 100644 scripts/ci/agent_dependency_archive.py delete mode 100644 scripts/ci/agent_master_selection.py delete mode 100644 scripts/ci/agent_measure.py create mode 100644 scripts/ci/nightly_report.py delete mode 100644 scripts/ci/test_agent_cache.py delete mode 100644 scripts/ci/test_agent_cache_command.py delete mode 100644 scripts/ci/test_agent_cache_scope.py delete mode 100644 scripts/ci/test_agent_change_detection.py delete mode 100644 scripts/ci/test_agent_dependency_archive.py delete mode 100644 scripts/ci/test_agent_master_selection.py delete mode 100644 scripts/ci/test_agent_measure.py create mode 100644 scripts/ci/test_nightly_report.py diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 387c4be66..1c6334427 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -42,11 +42,15 @@ contracts when CI, Nix, or routing changes. Agent has its own Cargo and Nix lockfiles. CI selects Agent for proxy runtime changes and for Rust SDK build inputs, both path dependencies it builds from the tree, but not for TypeScript SDK changes. Component-only -changes should not unnecessarily select Research packaging. Maintain the root -selectors, their tests, `.github/workflows/agent-ci.yml`, and unsigned PR -selection in `.github/workflows/agent-desktop-build.yml` together. The desktop -workflow compares `master` with the last fully verified ancestor package run; -unrelated pushes skip packages, while manual master dispatch forces both profiles. +changes should not unnecessarily select Research packaging. Agent lanes live in +`scripts/ci/lanes.py`, with fixtures in `scripts/ci/test_plan.py`; root +`just lane ` runs one locally. PRs run `agent-lint`, `agent-test` and +`agent-macos`, plus Windows clippy for Windows paths, `agent-supply-chain` and +`agent-provider` for dependency inputs, and an unsigned Dev package rehearsal for +packaging inputs. Labels `ci:windows`, `ci:package`, `ci:perf` and `ci:full` add +lanes; master adds the full macOS and Windows suites. On master, +`.github/workflows/agent-desktop-build.yml` builds signed packages for pushes +that change `AGENT_DESKTOP` inputs and on every manual dispatch. Linux Nix packages use a pure source fileset rooted at the monorepo, including the sibling SDK/proxy source and SDK assets. Validate it when adding a new local dependency diff --git a/.github/actions/setup-agent-windows/action.yml b/.github/actions/setup-agent-windows/action.yml new file mode 100644 index 000000000..255526a8d --- /dev/null +++ b/.github/actions/setup-agent-windows/action.yml @@ -0,0 +1,70 @@ +name: Set up the Agent's Windows toolchain +description: >- + Windows has no Nix: pinned Rust and MSVC, native Perl for the static crypto + build and a smoke-tested linker. Release builds also select the GPUI shader + compiler and install the pinned notice generator. +inputs: + release: + description: Also select fxc and install cargo-about, for release packages + default: "false" +runs: + using: composite + steps: + - name: Install pinned Windows Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below + with: + # Equal to AGENT_WINDOWS_RUST in scripts/ci/lanes.py; agent-lint checks + # that the Agent flake's rustc still reports this version. + toolchain: 1.98.0 + components: clippy + + - name: Select Windows MSVC and SDK + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 + with: + arch: x64 + + - name: Select native Windows Perl for the static crypto build + shell: bash + run: | + native_perl=/c/Strawberry/perl/bin/perl.exe + if [ ! -x "$native_perl" ]; then + echo 'Native Windows Perl is missing from the runner' >&2 + exit 1 + fi + "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' + # Pin the native executable across later Git Bash steps and Rust's + # process resolver instead of depending on their PATH ordering. + native_perl_windows="$(cygpath -m "$native_perl")" + printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" + # openssl-src uses NASM if available and otherwise selects no-asm. + + - name: Pin and smoke-test the selected Windows linker + shell: bash + working-directory: apps/maple-agent + run: python3 ../../scripts/ci/agent_windows.py + + - name: Select the SDK shader compiler for release GPUI + if: inputs.release == 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:WindowsSdkVerBinPath) { throw 'Selected Windows SDK binary path is missing' } + $fxc = Join-Path $env:WindowsSdkVerBinPath 'x64/fxc.exe' + if (-not (Test-Path -LiteralPath $fxc -PathType Leaf)) { throw "Windows SDK shader compiler is missing: $fxc" } + Write-Host "Selected HLSL compiler: $fxc" + "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Install pinned Windows notice generator + if: inputs.release == 'true' + shell: bash + run: | + set -euo pipefail + ( + cd "$RUNNER_TEMP" + curl --fail --location --show-error --silent \ + https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-x86_64-pc-windows-msvc.tar.gz \ + --output cargo-about-0.9.0.tar.gz + printf '%s %s\n' 87daca52ad79980b42b2de6acdec84e143be41e2d4bbebedba0d0c90c49b055a cargo-about-0.9.0.tar.gz | sha256sum --check - + tar -xzf cargo-about-0.9.0.tar.gz + ) + printf '%s\n' "$RUNNER_TEMP/cargo-about-0.9.0-x86_64-pc-windows-msvc" >> "$GITHUB_PATH" diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml deleted file mode 100644 index d9083cb80..000000000 --- a/.github/workflows/agent-ci.yml +++ /dev/null @@ -1,680 +0,0 @@ -name: Maple Agent CI - -# Pull requests run the Agent lanes in ci.yml; this workflow verifies master. -on: - push: - branches: [master] - workflow_dispatch: - -# PR code, including forks, executes only on fresh managed runners with a -# read-only token. No signing, release publication, environment, or deployment -# credentials belong in this workflow. -permissions: - contents: read - -# One group per commit, never cancelled: every master push gets a full verdict. -concurrency: - group: maple-agent-ci-${{ github.sha }} - cancel-in-progress: false - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - CARGO_PROFILE_DEV_DEBUG: 0 - RUSTFLAGS: -D warnings - -defaults: - run: - shell: bash - working-directory: apps/maple-agent - -jobs: - changes: - name: Select Agent checks - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - actions: read - outputs: - agent: ${{ steps.classify.outputs.agent }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Retain Agent changes since the last validated master run - id: master - if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'push' }} - working-directory: . - env: - GH_TOKEN: ${{ github.token }} - run: python3 scripts/ci/agent_master_selection.py --lane ci >> "$GITHUB_OUTPUT" - - - name: Classify changed paths - id: classify - working-directory: . - env: - BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - MASTER_AGENT: ${{ steps.master.outputs.agent }} - run: | - set -euo pipefail - enable_agent() { echo 'agent=true' >> "$GITHUB_OUTPUT"; } - case "$GITHUB_EVENT_NAME" in - pull_request) separator='...' ;; - push) - # A later unrelated push must not consume Agent work cancelled - # before every workspace, provider and release check completed. - if [ "$GITHUB_REPOSITORY" = MaplePrivacyLabs/Maple ] && [ "$GITHUB_REF" = refs/heads/master ]; then - if [ "${MASTER_AGENT:-}" = false ]; then - echo 'agent=false' >> "$GITHUB_OUTPUT" - else - enable_agent - fi - exit 0 - fi - separator='..' - if [[ "$BASE_SHA" =~ ^0+$ ]]; then - enable_agent - exit 0 - fi - ;; - *) enable_agent; exit 0 ;; - esac - if ! git cat-file -e "${BASE_SHA}^{commit}" || ! git cat-file -e "${HEAD_SHA}^{commit}"; then - enable_agent - exit 0 - fi - changed_paths="$(mktemp)" - trap 'rm -f "$changed_paths"' EXIT - if ! git diff --no-renames --name-only -z "${BASE_SHA}${separator}${HEAD_SHA}" > "$changed_paths"; then - enable_agent - elif ! python3 scripts/ci/agent_change_detection.py < "$changed_paths" >> "$GITHUB_OUTPUT"; then - enable_agent - fi - - desktop: - name: Agent (${{ matrix.os }}) - needs: changes - # A failed selector must never silently omit validation. - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} - runs-on: ${{ matrix.os }} - permissions: - contents: read - actions: read - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - os: [blacksmith-8vcpu-ubuntu-2404, macos-26, windows-latest] - env: - DEPENDENCY_CACHE_JOB: Agent (${{ matrix.os }}) - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install pinned Nix environment - if: runner.os != 'Windows' - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - if: runner.os != 'Windows' - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Install pinned Windows Rust - if: runner.os == 'Windows' - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below - with: - # Matches the component's frozen rust-overlay input. - toolchain: 1.98.0 - - - name: Select Windows MSVC and SDK - if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 - with: - arch: x64 - - - name: Select native Windows Perl for the static crypto build - if: runner.os == 'Windows' - run: | - native_perl=/c/Strawberry/perl/bin/perl.exe - if [ ! -x "$native_perl" ]; then - echo 'Native Windows Perl is missing from the runner' >&2 - exit 1 - fi - "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' - # Pin the native executable across later Git Bash steps and Rust's - # process resolver instead of depending on their PATH ordering. - native_perl_windows="$(cygpath -m "$native_perl")" - printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" - # openssl-src uses NASM if available and otherwise selects no-asm. - - - name: Pin and smoke-test the selected Windows linker - if: runner.os == 'Windows' - run: python3 ../../scripts/ci/agent_windows.py - - - name: Fingerprint the actual build environment - id: environment - run: | - if [ "$RUNNER_OS" = Windows ]; then - python3 ../../scripts/ci/agent_cache_scope.py --profile ci --output "$RUNNER_TEMP/agent-environment.json" - else - nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache_scope.py --profile ci --output "$RUNNER_TEMP/agent-environment.json" - fi - - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "$RUNNER_TEMP/agent-environment.json" --target "target" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Cache Agent Rust dependencies - id: rust-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "true" - # Dependency hashes belong in the action's exact suffix, not its restore prefix. - key: maple-agent-ci-v2-${{ steps.environment.outputs.compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - - name: Check whether Cargo targets were restored - id: restored-target - run: | - python3 - <<'PYTHON' - import os - from pathlib import Path - target = Path(os.environ["GITHUB_WORKSPACE"]) / "apps/maple-agent/target" - present = any( - any(fingerprint.iterdir()) and any( - output.is_file() and output.stat().st_size > 0 - for pattern in ("*.rlib", "*.rmeta") - for output in (fingerprint.parent / "deps").glob(pattern) - ) - for fingerprint in target.rglob(".fingerprint") if fingerprint.is_dir() - ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - output.write(f"present={str(present).lower()}\n") - PYTHON - - - name: Restore compatible legacy Cargo targets on a cache miss - if: ${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: apps/maple-agent -> target - # Read only within the existing actual compiler/native ABI prefix. - # Native bootstrap is read-only; durable artifacts own cache writes. - add-rust-environment-hash-key: "false" - key: maple-agent-ci-v2-${{ steps.environment.outputs.legacy_compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - - name: Record the current build environment after restore - run: | - mkdir -p target/ci-metrics - cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json - - - name: Verify Git metadata freshness on native workspace builds - if: runner.os != 'Linux' - run: | - if [ "$RUNNER_OS" = Windows ]; then - python3 scripts/test_git_metadata.py - else - nix develop --no-update-lock-file . -c python3 scripts/test_git_metadata.py - fi - - - name: Verify the canonical SDK and proxy dependency graph - run: | - metadata="$RUNNER_TEMP/agent-workspace-metadata.json" - if [ "$RUNNER_OS" = Windows ]; then - cargo metadata --locked --format-version 1 > "$metadata" - else - # shellcheck disable=SC2016 # The child shell expands its own positional argument. - nix develop --no-update-lock-file . -c bash -c 'cargo metadata --locked --format-version 1 > "$1"' -- "$metadata" - fi - python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" - if [ "$RUNNER_OS" = Windows ]; then - python3 scripts/verify-sqlcipher-build.py "$metadata" - else - nix develop --no-update-lock-file . -c python3 scripts/verify-sqlcipher-build.py "$metadata" - fi - - - name: Format, lint, build, and test Linux feature matrix - if: runner.os == 'Linux' - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase linux-ci --target target -- just ci - - - name: Build macOS workspace and test targets - if: runner.os == 'macOS' - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase macos-build --target target -- cargo build --workspace --all-targets --locked --timings - - - name: Package and smoke macOS debug app - if: runner.os == 'macOS' - run: nix develop --no-update-lock-file . -c ./scripts/macos-debug-app.sh - - - name: Test macOS workspace - if: runner.os == 'macOS' - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase macos-test --target target -- cargo test --workspace --locked --timings - - - name: Build Windows workspace and test targets - if: runner.os == 'Windows' - run: python3 ../../scripts/ci/agent_measure.py --phase windows-build --target target -- cargo build --workspace --all-targets --locked --timings - - - name: Test Windows workspace - if: runner.os == 'Windows' - run: python3 ../../scripts/ci/agent_measure.py --phase windows-test --target target -- cargo test --workspace --locked --timings - - - name: Audit Agent dependencies - if: runner.os == 'Linux' - run: nix develop --no-update-lock-file . -c python3 scripts/audit-sqlcipher-dependencies.py - - - name: Regenerate the signed SQLCipher source - if: runner.os == 'Linux' - run: | - metadata="$(mktemp)" - trap 'rm -f "$metadata"' EXIT - # shellcheck disable=SC2016 # The child shell expands its own positional argument. - nix develop --no-update-lock-file . -c bash -c 'cargo metadata --locked --format-version 1 > "$1"' -- "$metadata" - nix develop --no-update-lock-file . -c python3 dependencies/sqlcipher/verify_sqlcipher.py --metadata "$metadata" - - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "$RUNNER_TEMP/agent-environment.json" --metadata "$RUNNER_TEMP/agent-workspace-metadata.json" \ - --target "target" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} - path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize target - - - name: Upload bounded CI measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: maple-agent-ci-metrics-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 - - provider: - name: Agent provider (${{ matrix.os }}) - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} - runs-on: ${{ matrix.os }} - permissions: - contents: read - actions: read - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - os: [blacksmith-8vcpu-ubuntu-2404, macos-26, windows-latest] - env: - DEPENDENCY_CACHE_JOB: Agent provider (${{ matrix.os }}) - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install pinned Nix environment - if: runner.os != 'Windows' - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - if: runner.os != 'Windows' - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Install pinned Windows Rust - if: runner.os == 'Windows' - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below - with: - # Matches the component's frozen rust-overlay input. - toolchain: 1.98.0 - - - name: Select Windows MSVC and SDK - if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 - with: - arch: x64 - - - name: Select native Windows Perl for the static crypto build - if: runner.os == 'Windows' - run: | - native_perl=/c/Strawberry/perl/bin/perl.exe - if [ ! -x "$native_perl" ]; then - echo 'Native Windows Perl is missing from the runner' >&2 - exit 1 - fi - "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' - # Pin the native executable across later Git Bash steps and Rust's - # process resolver instead of depending on their PATH ordering. - native_perl_windows="$(cygpath -m "$native_perl")" - printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" - # openssl-src uses NASM if available and otherwise selects no-asm. - - - name: Pin and smoke-test the selected Windows linker - if: runner.os == 'Windows' - run: python3 ../../scripts/ci/agent_windows.py - - - name: Select isolated provider paths - run: | - printf 'PROVIDER_WORK=%s/rusqlite-provider\nPROVIDER_TARGET=%s/rusqlite-provider-target\n' "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV" - - - name: Prepare and verify the resolved upstream provider test graph - id: provider - run: | - metadata="$RUNNER_TEMP/provider-workspace-metadata.json" - if [ "$RUNNER_OS" = Windows ]; then - cargo metadata --locked --format-version 1 > "$metadata" - python3 scripts/test-rusqlite-provider.py "$metadata" --phase prepare --work-dir "$PROVIDER_WORK" --target-dir "$PROVIDER_TARGET" --github-output "$GITHUB_OUTPUT" - else - # shellcheck disable=SC2016 - nix develop --no-update-lock-file . -c bash -c 'cargo metadata --locked --format-version 1 > "$1"' -- "$metadata" - nix develop --no-update-lock-file . -c python3 scripts/test-rusqlite-provider.py "$metadata" --phase prepare --work-dir "$PROVIDER_WORK" --target-dir "$PROVIDER_TARGET" --github-output "$GITHUB_OUTPUT" - fi - - - name: Fingerprint the actual build environment - id: environment - run: | - if [ "$RUNNER_OS" = Windows ]; then - python3 ../../scripts/ci/agent_cache_scope.py --profile provider --cargo-config-dir "$PROVIDER_WORK" --output target/ci-metrics/environment.json - else - nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache_scope.py --profile provider --cargo-config-dir "$PROVIDER_WORK" --output target/ci-metrics/environment.json - fi - - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - PROVIDER_DEPENDENCY_FINGERPRINT: ${{ steps.provider.outputs.dependency-fingerprint }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "target/ci-metrics/environment.json" --target "$PROVIDER_TARGET" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" \ - --dependency-fingerprint "$PROVIDER_DEPENDENCY_FINGERPRINT" - - - name: Restore only matching verified provider artifacts - id: provider-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ${{ env.PROVIDER_TARGET }} - key: maple-agent-provider-v2-${{ steps.environment.outputs.legacy_compatibility }}-${{ steps.provider.outputs.dependency-fingerprint }} - - - name: Test released rusqlite against the static provider - run: | - metadata="$RUNNER_TEMP/provider-workspace-metadata.json" - if [ "$RUNNER_OS" = Windows ]; then - python3 ../../scripts/ci/agent_measure.py --phase provider-test --target "$PROVIDER_TARGET" -- python3 scripts/test-rusqlite-provider.py "$metadata" --phase run --work-dir "$PROVIDER_WORK" --target-dir "$PROVIDER_TARGET" - else - nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase provider-test --target "$PROVIDER_TARGET" -- python3 scripts/test-rusqlite-provider.py "$metadata" --phase run --work-dir "$PROVIDER_WORK" --target-dir "$PROVIDER_TARGET" - fi - - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - env: - PROVIDER_DEPENDENCY_FINGERPRINT: ${{ steps.provider.outputs.dependency-fingerprint }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "target/ci-metrics/environment.json" --metadata "$PROVIDER_WORK/maple-provider-metadata.json" \ - --target "$PROVIDER_TARGET" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" \ - --dependency-fingerprint "$PROVIDER_DEPENDENCY_FINGERPRINT" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} - path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize "$PROVIDER_TARGET" - - - name: Upload bounded CI measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: maple-agent-provider-metrics-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 - - linux-release: - name: Agent Linux release binary - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - permissions: - contents: read - actions: read - timeout-minutes: 90 - env: - DEPENDENCY_CACHE_JOB: Agent Linux release binary - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install pinned Nix environment - if: runner.os != 'Windows' - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - if: runner.os != 'Windows' - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Fingerprint the actual build environment - id: environment - run: | - if [ "$RUNNER_OS" = Windows ]; then - python3 ../../scripts/ci/agent_cache_scope.py --profile release --output "$RUNNER_TEMP/agent-environment.json" - else - nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache_scope.py --profile release --output "$RUNNER_TEMP/agent-environment.json" - fi - - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "$RUNNER_TEMP/agent-environment.json" --target "target" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Cache Agent release dependencies - id: rust-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "true" - # Dependency hashes belong in the action's exact suffix, not its restore prefix. - key: maple-agent-release-v2-${{ steps.environment.outputs.compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - - name: Check whether Cargo targets were restored - id: restored-target - run: | - python3 - <<'PYTHON' - import os - from pathlib import Path - target = Path(os.environ["GITHUB_WORKSPACE"]) / "apps/maple-agent/target" - present = any( - any(fingerprint.iterdir()) and any( - output.is_file() and output.stat().st_size > 0 - for pattern in ("*.rlib", "*.rmeta") - for output in (fingerprint.parent / "deps").glob(pattern) - ) - for fingerprint in target.rglob(".fingerprint") if fingerprint.is_dir() - ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - output.write(f"present={str(present).lower()}\n") - PYTHON - - - name: Restore compatible legacy Cargo targets on a cache miss - if: ${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "false" - key: maple-agent-release-v2-${{ steps.environment.outputs.legacy_compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - - name: Record the current build environment after restore - run: | - mkdir -p target/ci-metrics - cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json - - - name: Verify the canonical SDK and proxy dependency graph - run: | - metadata="$RUNNER_TEMP/agent-workspace-metadata.json" - if [ "$RUNNER_OS" = Windows ]; then - cargo metadata --locked --format-version 1 > "$metadata" - else - # shellcheck disable=SC2016 # The child shell expands its own positional argument. - nix develop --no-update-lock-file . -c bash -c 'cargo metadata --locked --format-version 1 > "$1"' -- "$metadata" - fi - python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" - if [ "$RUNNER_OS" = Windows ]; then - python3 scripts/verify-sqlcipher-build.py "$metadata" - else - nix develop --no-update-lock-file . -c python3 scripts/verify-sqlcipher-build.py "$metadata" - fi - - - name: Generate and validate Linux third-party notices - if: runner.os == 'Linux' - run: | - nix develop --no-update-lock-file . -c python3 scripts/generate-third-party-notices.py - nix develop --no-update-lock-file . -c python3 scripts/generate-third-party-notices.py --check - - - name: Build Linux release binary - if: runner.os == 'Linux' - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase linux-release --target target -- cargo build --release -p maple-agent-app --locked --timings - - # This is an unsigned CI build, never a GitHub Release or an updater feed. - - name: Upload Linux CI binary - if: runner.os == 'Linux' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: maple-agent-linux-x86_64-ci-${{ github.run_id }}-${{ github.run_attempt }} - path: | - apps/maple-agent/target/release/maple-agent - apps/maple-agent/target/third-party-notices - if-no-files-found: error - retention-days: 5 - - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "$RUNNER_TEMP/agent-environment.json" --metadata "$RUNNER_TEMP/agent-workspace-metadata.json" \ - --target "target" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-ci.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} - path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize target - - - name: Upload bounded CI measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: maple-agent-release-metrics-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 - - required: - name: Agent CI required jobs - needs: [changes, desktop, provider, linux-release] - if: ${{ always() && !cancelled() }} - runs-on: ubuntu-latest - timeout-minutes: 5 - defaults: - run: - working-directory: . - steps: - - name: Require every selected platform and independent check - env: - SELECTOR_RESULT: ${{ needs.changes.result }} - AGENT_SELECTED: ${{ needs.changes.outputs.agent }} - DESKTOP_RESULT: ${{ needs.desktop.result }} - PROVIDER_RESULT: ${{ needs.provider.result }} - RELEASE_RESULT: ${{ needs.linux-release.result }} - run: | - set -euo pipefail - if [ "$SELECTOR_RESULT" = success ] && [ "$AGENT_SELECTED" = false ]; then - test "$DESKTOP_RESULT" = skipped - test "$PROVIDER_RESULT" = skipped - test "$RELEASE_RESULT" = skipped - else - test "$DESKTOP_RESULT" = success - test "$PROVIDER_RESULT" = success - test "$RELEASE_RESULT" = success - fi diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index ced5a7ee4..be7085e7a 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -3,20 +3,19 @@ name: Maple Agent Desktop Builds on: push: branches: [master] - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. workflow_dispatch: -# Signing is confined to protected master jobs. PR packaging runs with a -# read-only token and no environment credentials, including fork PRs. +# Signing is confined to protected master jobs; builders have a read-only token +# and no environment credentials. Pull requests rehearse unsigned Dev packages +# in ci.yml (agent-package-*). permissions: contents: read concurrency: - # PR updates replace their previews. Each master run keeps its own group, - # so neither running nor pending packages are discarded by a later push. - group: maple-agent-desktop-builds-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Each run keeps its own group, so neither running nor pending packages are + # discarded by a later push. + group: maple-agent-desktop-builds-${{ github.run_id }} + cancel-in-progress: false env: CARGO_TERM_COLOR: always @@ -32,66 +31,34 @@ jobs: changes: runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read - actions: read outputs: - agent: ${{ steps.classify.outputs.agent }} + agent: ${{ steps.classify.outputs.selected }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.sha }} persist-credentials: false fetch-depth: 0 - - name: Select master packaging since last verified build - id: master - if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'push' }} - working-directory: . - env: - GH_TOKEN: ${{ github.token }} - run: python3 scripts/ci/agent_master_selection.py >> "$GITHUB_OUTPUT" - name: Select Agent packaging id: classify working-directory: . - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - MASTER_AGENT: ${{ steps.master.outputs.agent }} - run: | - set -euo pipefail - if [ "$GITHUB_EVENT_NAME" != pull_request ]; then - if [ "$GITHUB_EVENT_NAME" = push ] && [ "${MASTER_AGENT:-}" = false ]; then - echo 'agent=false' >> "$GITHUB_OUTPUT" - else - echo 'agent=true' >> "$GITHUB_OUTPUT" - fi - exit 0 - fi - git fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA" - paths="$(mktemp)" - trap 'rm -f "$paths"' EXIT - if ! git diff --no-renames --name-only -z "${BASE_SHA}...${HEAD_SHA}" > "$paths"; then - echo 'agent=true' >> "$GITHUB_OUTPUT" - elif ! python3 scripts/ci/agent_change_detection.py < "$paths" >> "$GITHUB_OUTPUT"; then - echo 'agent=true' >> "$GITHUB_OUTPUT" - fi + # Dispatch, an unusable push base and selector errors all build. + run: python3 scripts/ci/plan.py only agent-desktop >> "$GITHUB_OUTPUT" macos-build: name: Build macOS binary (${{ matrix.variant }}) needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} runs-on: blacksmith-6vcpu-macos-26 permissions: contents: read - actions: read timeout-minutes: 120 strategy: fail-fast: false matrix: - variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} - DEPENDENCY_CACHE_JOB: Build macOS binary (${{ matrix.variant }}) steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -109,61 +76,18 @@ jobs: - name: Select pinned Xcode working-directory: . run: python3 scripts/ci/select-xcode.py - - name: Fingerprint the actual release environment - id: environment - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache_scope.py --profile release --output "$RUNNER_TEMP/agent-environment.json" - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "$RUNNER_TEMP/agent-environment.json" --target "target" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Cache Agent release dependencies - id: rust-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "true" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Check whether Cargo targets were restored - id: restored-target - run: | - python3 - <<'PYTHON' - import os - from pathlib import Path - target = Path(os.environ["GITHUB_WORKSPACE"]) / "apps/maple-agent/target" - present = any( - any(fingerprint.iterdir()) and any( - output.is_file() and output.stat().st_size > 0 - for pattern in ("*.rlib", "*.rmeta") - for output in (fingerprint.parent / "deps").glob(pattern) - ) - for fingerprint in target.rglob(".fingerprint") if fingerprint.is_dir() - ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - output.write(f"present={str(present).lower()}\n") - PYTHON - - - name: Restore compatible legacy Cargo targets on a cache miss - if: ${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "false" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.legacy_compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Record the current release environment after restore - run: | - mkdir -p target/ci-metrics - cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json + - name: Restore the exact-key Agent release cache + id: cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Lockfiles only, and no restore-keys: a master build never starts from + # another dependency graph's artifacts. ci.yml's agent-package-* read it. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target - name: Verify the canonical SDK and proxy dependency graph run: | metadata="$RUNNER_TEMP/agent-workspace-metadata.json" @@ -172,7 +96,7 @@ jobs: python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" nix develop --no-update-lock-file . -c python3 scripts/verify-sqlcipher-build.py "$metadata" - name: Build optimized Agent with embedded profile - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase release-build --target target -- ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + run: nix develop --no-update-lock-file . -c ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - name: Upload prebuilt Agent binary and notices uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: @@ -184,40 +108,16 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "$RUNNER_TEMP/agent-environment.json" --metadata "$RUNNER_TEMP/agent-workspace-metadata.json" \ - --target "target" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - name: Save the Agent release cache (master pushes only) + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} + key: ${{ steps.cache.outputs.cache-primary-key }} path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize release phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize target - - name: Upload bounded release measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: maple-agent-release-metrics-${{ matrix.variant }}-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target macos: name: Signed macOS (${{ matrix.variant }}) @@ -287,77 +187,20 @@ jobs: if-no-files-found: error retention-days: 30 - macos-unsigned: - name: Unsigned macOS packaging (${{ matrix.variant }}) - needs: [changes, macos-build] - if: ${{ always() && !cancelled() && needs.macos-build.result == 'success' && github.event_name == 'pull_request' && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} - runs-on: macos-26 - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - variant: [dev] - env: - MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Install pinned Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - name: Select pinned Xcode - working-directory: . - run: python3 scripts/ci/select-xcode.py - - name: Download exact prebuilt Agent binary and notices - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: maple-agent-${{ matrix.variant }}-macos-aarch64-prebuilt-${{ github.run_id }} - path: apps/maple-agent/target - - name: Verify prebuilt profile and source - run: nix develop --no-update-lock-file . -c ./scripts/verify-prebuilt-release.sh "$MAPLE_RELEASE_PROFILE" - - name: Build trusted installer archive scanner - if: ${{ matrix.variant == 'prod' }} - run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive - - name: Package unsigned Agent preview - run: nix develop --no-update-lock-file . -c ./scripts/package-release.sh "$MAPLE_RELEASE_PROFILE" --unsigned - - name: Scan actual Prod updater archive with installer parser - if: ${{ matrix.variant == 'prod' }} - run: nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "dist/$MAPLE_RELEASE_PROFILE" --unsigned - - name: Verify unsigned package profile and hashes - run: nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" --unsigned - - name: Upload Agent packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: maple-agent-${{ matrix.variant }}-macos-aarch64-unsigned-${{ github.run_id }} - overwrite: true - path: apps/maple-agent/dist/${{ matrix.variant }}/ - if-no-files-found: error - retention-days: 30 - windows-build: name: Build Windows binary (${{ matrix.variant }}) needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} - runs-on: windows-2025 + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + runs-on: blacksmith-8vcpu-windows-2025 permissions: contents: read - actions: read timeout-minutes: 120 strategy: fail-fast: false matrix: - variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} - DEPENDENCY_CACHE_JOB: Build Windows binary (${{ matrix.variant }}) # Ship the CRT with the executable rather than requiring a machine-wide # redistributable. Native verification rejects non-system DLL imports. RUSTFLAGS: -D warnings -C target-feature=+crt-static @@ -366,35 +209,10 @@ jobs: with: ref: ${{ github.sha }} persist-credentials: false - - name: Install pinned Windows Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 - with: - toolchain: 1.98.0 - - name: Select Windows MSVC and SDK - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c + - name: Set up the pinned Windows toolchain, shader compiler and notice generator + uses: ./.github/actions/setup-agent-windows with: - arch: x64 - - name: Select native Windows Perl for the static crypto build - run: | - native_perl=/c/Strawberry/perl/bin/perl.exe - if [ ! -x "$native_perl" ]; then - echo 'Native Windows Perl is missing from the runner' >&2 - exit 1 - fi - "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' - native_perl_windows="$(cygpath -m "$native_perl")" - printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" - - name: Pin and smoke-test the selected Windows linker - run: python3 ../../scripts/ci/agent_windows.py - - name: Select the SDK shader compiler for release GPUI - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if (-not $env:WindowsSdkVerBinPath) { throw 'Selected Windows SDK binary path is missing' } - $fxc = Join-Path $env:WindowsSdkVerBinPath 'x64/fxc.exe' - if (-not (Test-Path -LiteralPath $fxc -PathType Leaf)) { throw "Windows SDK shader compiler is missing: $fxc" } - Write-Host "Selected HLSL compiler: $fxc" - "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + release: "true" - name: Exercise Windows metadata parser and native linker retention run: python3 scripts/test_windows_build_info.py - name: Exercise Windows host launch in a restrictive job @@ -403,84 +221,26 @@ jobs: run: | git_bash="$(cygpath -w "$BASH")" python3 -c 'import subprocess, sys; host = subprocess.check_output([sys.argv[1], "-c", "uname -s"], text=True).strip(); assert host.startswith(("MINGW", "MSYS")), host; print("Verified native Git Bash: " + host)' "$git_bash" - - name: Fingerprint the actual release environment - id: environment - run: python3 ../../scripts/ci/agent_cache_scope.py --profile release --output "$RUNNER_TEMP/agent-environment.json" - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "$RUNNER_TEMP/agent-environment.json" --target "target" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Cache Agent release dependencies - id: rust-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "true" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Check whether Cargo targets were restored - id: restored-target - run: | - python3 - <<'PYTHON' - import os - from pathlib import Path - target = Path(os.environ["GITHUB_WORKSPACE"]) / "apps/maple-agent/target" - present = any( - any(fingerprint.iterdir()) and any( - output.is_file() and output.stat().st_size > 0 - for pattern in ("*.rlib", "*.rmeta") - for output in (fingerprint.parent / "deps").glob(pattern) - ) - for fingerprint in target.rglob(".fingerprint") if fingerprint.is_dir() - ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - output.write(f"present={str(present).lower()}\n") - PYTHON - - - name: Restore compatible legacy Cargo targets on a cache miss - if: ${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "false" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.legacy_compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Record the current build environment after restore - run: | - mkdir -p target/ci-metrics - cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json + - name: Restore the exact-key Agent release cache + id: cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Lockfiles only, and no restore-keys: a master build never starts from + # another dependency graph's artifacts. ci.yml's agent-package-* read it. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target - name: Verify the canonical SDK and proxy dependency graph run: | metadata="$RUNNER_TEMP/agent-workspace-metadata.json" cargo metadata --locked --format-version 1 > "$metadata" python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" python3 scripts/verify-sqlcipher-build.py "$metadata" - - name: Install pinned Windows notice generator - run: | - set -euo pipefail - ( - cd "$RUNNER_TEMP" - curl --fail --location --show-error --silent \ - https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-x86_64-pc-windows-msvc.tar.gz \ - --output cargo-about-0.9.0.tar.gz - printf '%s %s\n' 87daca52ad79980b42b2de6acdec84e143be41e2d4bbebedba0d0c90c49b055a cargo-about-0.9.0.tar.gz | sha256sum --check - - tar -xzf cargo-about-0.9.0.tar.gz - ) - printf '%s\n' "$RUNNER_TEMP/cargo-about-0.9.0-x86_64-pc-windows-msvc" >> "$GITHUB_PATH" - name: Build Windows release binary and notices without signing credentials - run: | - # Native Python otherwise finds System32's WSL bash launcher first. - git_bash="$(cygpath -w "$BASH")" - python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- "$git_bash" ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + run: ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - name: Inspect immutable Windows profile and source run: python3 scripts/windows-release.py prepare "$MAPLE_RELEASE_PROFILE" - name: Upload prebuilt Windows binary and notices @@ -494,40 +254,16 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "$RUNNER_TEMP/agent-environment.json" --metadata "$RUNNER_TEMP/agent-workspace-metadata.json" \ - --target "target" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - name: Save the Agent release cache (master pushes only) + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} + key: ${{ steps.cache.outputs.cache-primary-key }} path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize target - - name: Upload bounded release measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: maple-agent-release-metrics-${{ matrix.variant }}-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target windows: name: Signed Windows (${{ matrix.variant }}) @@ -587,44 +323,10 @@ jobs: if-no-files-found: error retention-days: 30 - windows-unsigned: - name: Unsigned Windows packaging (${{ matrix.variant }}) - needs: windows-build - if: ${{ always() && !cancelled() && needs.windows-build.result == 'success' && github.event_name == 'pull_request' }} - runs-on: windows-2025 - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - variant: [dev] - env: - MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} - MAPLE_PACKAGE_BUILD_NUMBER: ${{ github.run_number }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Download exact prebuilt Windows binary and notices - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: maple-agent-${{ matrix.variant }}-windows-x86_64-prebuilt-${{ github.run_id }} - path: apps/maple-agent/target - - name: Package unsigned Windows Agent preview - run: python3 scripts/windows-release.py package "$MAPLE_RELEASE_PROFILE" --unsigned - - name: Upload Agent packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: maple-agent-${{ matrix.variant }}-windows-x86_64-unsigned-${{ github.run_id }} - overwrite: true - path: apps/maple-agent/dist/${{ matrix.variant }}/ - if-no-files-found: error - retention-days: 30 - verify-windows: name: Verify downloaded Windows (${{ matrix.variant }}) - needs: [windows, windows-unsigned] - if: ${{ always() && !cancelled() && ((github.event_name == 'pull_request' && needs.windows-unsigned.result == 'success') || (needs.windows.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + needs: windows + if: ${{ always() && !cancelled() && needs.windows.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} permissions: contents: read attestations: read @@ -633,7 +335,7 @@ jobs: strategy: fail-fast: false matrix: - variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} MAPLE_PACKAGE_BUILD_NUMBER: ${{ github.run_number }} @@ -645,10 +347,9 @@ jobs: - name: Download exact Windows packages uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: maple-agent-${{ matrix.variant }}-windows-x86_64${{ github.event_name == 'pull_request' && '-unsigned' || '' }}-${{ github.run_id }} + name: maple-agent-${{ matrix.variant }}-windows-x86_64-${{ github.run_id }} path: artifacts - name: Verify downloaded package provenance - if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} working-directory: . run: | set -euo pipefail @@ -669,26 +370,22 @@ jobs: # The stock image exports these Azure CLI tooling/cache paths. # Keep other Azure/signing variables visible to the fail-closed guard. unset AZURE_CONFIG_DIR AZURE_DEVOPS_CACHE_DIR AZURE_EXTENSION_DIR - args=() - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - python3 scripts/windows-release.py verify "$MAPLE_RELEASE_PROFILE" ../../artifacts "${args[@]}" --smoke + python3 scripts/windows-release.py verify "$MAPLE_RELEASE_PROFILE" ../../artifacts --smoke linux: name: Linux bundle (${{ matrix.variant }}) needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read - actions: read timeout-minutes: 120 strategy: fail-fast: false matrix: - variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} - DEPENDENCY_CACHE_JOB: Linux bundle (${{ matrix.variant }}) steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -703,61 +400,18 @@ jobs: - name: Verify pinned Nix runtime working-directory: . run: bash scripts/ci/verify-nix-runtime.sh - - name: Fingerprint the actual release environment - id: environment - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache_scope.py --profile release --output "$RUNNER_TEMP/agent-environment.json" - - name: Restore validated master dependency archive - id: dependency-archive - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py restore \ - --environment "$RUNNER_TEMP/agent-environment.json" --target "target" \ - --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Cache Agent release dependencies - id: rust-cache - if: ${{ steps.dependency-archive.outputs.restored != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "true" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Check whether Cargo targets were restored - id: restored-target - run: | - python3 - <<'PYTHON' - import os - from pathlib import Path - target = Path(os.environ["GITHUB_WORKSPACE"]) / "apps/maple-agent/target" - present = any( - any(fingerprint.iterdir()) and any( - output.is_file() and output.stat().st_size > 0 - for pattern in ("*.rlib", "*.rmeta") - for output in (fingerprint.parent / "deps").glob(pattern) - ) - for fingerprint in target.rglob(".fingerprint") if fingerprint.is_dir() - ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: - output.write(f"present={str(present).lower()}\n") - PYTHON - - - name: Restore compatible legacy Cargo targets on a cache miss - if: ${{ steps.dependency-archive.outputs.restored != 'true' && steps.restored-target.outputs.present != 'true' }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "false" - key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.legacy_compatibility }} - cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} - save-if: "false" - - name: Record the current release environment after restore - run: | - mkdir -p target/ci-metrics - cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json + - name: Restore the exact-key Agent release cache + id: cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Lockfiles only, and no restore-keys: a master build never starts from + # another dependency graph's artifacts. ci.yml's agent-package-* read it. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target - name: Verify the canonical SDK and proxy dependency graph run: | metadata="$RUNNER_TEMP/agent-workspace-metadata.json" @@ -766,17 +420,11 @@ jobs: python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" nix develop --no-update-lock-file . -c python3 scripts/verify-sqlcipher-build.py "$metadata" - name: Build optimized Agent with embedded profile - run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase release-build --target target -- ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + run: nix develop --no-update-lock-file . -c ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - name: Package Linux installed bundle - run: | - args=() - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/package-release.sh "$MAPLE_RELEASE_PROFILE" "${args[@]}" + run: nix develop --no-update-lock-file . -c ./scripts/package-release.sh "$MAPLE_RELEASE_PROFILE" - name: Verify Linux package profile and hashes - run: | - args=() - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" "${args[@]}" --platform linux-x86_64 + run: nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" --platform linux-x86_64 - name: Upload Agent packages uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: @@ -786,40 +434,16 @@ jobs: if-no-files-found: error retention-days: 30 - - name: Prepare trusted dependency archive after validation - id: dependency-seed - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} - run: | - python3 ../../scripts/ci/agent_dependency_archive.py pack \ - --environment "$RUNNER_TEMP/agent-environment.json" --metadata "$RUNNER_TEMP/agent-workspace-metadata.json" \ - --target "target" --job-name "$DEPENDENCY_CACHE_JOB" --workflow agent-desktop-build.yml \ - --output-dir "$RUNNER_TEMP/agent-dependency-seed" \ - --receipt "$RUNNER_TEMP/agent-dependency-receipt.json" - - - name: Upload durable dependency archive - if: ${{ success() && github.repository == 'MaplePrivacyLabs/Maple' && github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.dependency-seed.outputs.needs-upload == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - name: Save the Agent release cache (master pushes only) + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - name: ${{ steps.dependency-seed.outputs.artifact-name }} + key: ${{ steps.cache.outputs.cache-primary-key }} path: | - ${{ runner.temp }}/agent-dependency-seed/manifest.json - ${{ runner.temp }}/agent-dependency-seed/dependencies.tar.gz - if-no-files-found: error - compression-level: 0 - overwrite: true - retention-days: 3 - - - name: Summarize release phase timings and target size - if: always() - run: python3 ../../scripts/ci/agent_measure.py --summarize target - - name: Upload bounded release measurements - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: maple-agent-release-metrics-${{ matrix.variant }}-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/ci-metrics/ - if-no-files-found: warn - retention-days: 7 + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target attest-linux: name: Attest Linux packages (${{ matrix.variant }}) @@ -923,7 +547,7 @@ jobs: verify-linux: name: Verify downloaded Linux (${{ matrix.variant }}) needs: [linux, attest-linux] - if: ${{ always() && !cancelled() && needs.linux.result == 'success' && (github.event_name == 'pull_request' || (needs.attest-linux.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + if: ${{ always() && !cancelled() && needs.linux.result == 'success' && needs.attest-linux.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} permissions: contents: read attestations: read @@ -932,7 +556,7 @@ jobs: strategy: fail-fast: false matrix: - variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} steps: @@ -955,7 +579,6 @@ jobs: name: maple-agent-${{ matrix.variant }}-linux-x86_64-${{ github.run_id }} path: artifacts - name: Verify downloaded package provenance - if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} working-directory: . run: | set -euo pipefail @@ -974,41 +597,9 @@ jobs: - name: Build trusted installer archive scanner run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive - name: Scan downloaded Linux bundle with installer parser - run: | - args=() - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "../../artifacts" --profile "$MAPLE_RELEASE_PROFILE" --platform linux-x86_64 "${args[@]}" + run: nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "../../artifacts" --profile "$MAPLE_RELEASE_PROFILE" --platform linux-x86_64 - name: Verify downloaded hashes and profile - run: | - args=() - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "../../artifacts" "${args[@]}" --platform linux-x86_64 + run: nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "../../artifacts" --platform linux-x86_64 - name: Smoke downloaded Linux bundle without Nix working-directory: . - run: | - set -euo pipefail - shopt -s nullglob - archives=(artifacts/*-linux-x86_64.tar.gz) - test "${#archives[@]}" -eq 1 - test -d /etc/ssl/certs - docker run --rm --platform linux/amd64 --user 65534:65534 \ - --network none --read-only --tmpfs /tmp:exec,mode=1777 \ - --volume "$PWD/artifacts:/artifacts:ro" \ - --volume "$PWD/apps/maple-agent/scripts/smoke-linux-bundle-host.sh:/checks/smoke-linux-bundle-host.sh:ro" \ - --volume /etc/ssl/certs:/etc/ssl/certs:ro \ - ubuntu@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b /bin/sh -ec ' - cd /tmp - tar -xzpf /artifacts/*-linux-x86_64.tar.gz - ./maple-agent*.app/AppRun --version >&2 - ./maple-agent*.app/AppRun --build-info - MAPLE_AGENT_SMOKE_DIAGNOSTICS=1 /bin/sh /checks/smoke-linux-bundle-host.sh ./maple-agent*.app/AppRun >&2 - ' > "$RUNNER_TEMP/portable-build-info.json" - python3 - "$MAPLE_RELEASE_PROFILE" "$RUNNER_TEMP/portable-build-info.json" artifacts/build-info.json <<'PY' - import json - import sys - actual = json.load(open(sys.argv[2])) - expected = json.load(open(sys.argv[3])) - if actual != expected or actual.get("profile") != sys.argv[1]: - raise SystemExit("portable Linux bundle build profile differs from verified package") - print("Verified Agent launch and embedded profile without a Nix installation") - PY + run: bash apps/maple-agent/scripts/verify-linux-bundle-docker.sh "$MAPLE_RELEASE_PROFILE" artifacts diff --git a/.github/workflows/agent-sqlcipher-supply-chain.yml b/.github/workflows/agent-sqlcipher-supply-chain.yml deleted file mode 100644 index 62bd1cf58..000000000 --- a/.github/workflows/agent-sqlcipher-supply-chain.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Agent SQLCipher source verification - -on: - pull_request: - paths: - - apps/maple-agent/Cargo.lock - - apps/maple-agent/flake.nix - - apps/maple-agent/flake.lock - - apps/maple-agent/dependencies/sqlcipher/** - - apps/maple-agent/dependencies/licenses/** - - apps/maple-agent/about.toml - - apps/maple-agent/scripts/*third*party*notices* - - apps/maple-agent/app/assets/dict/LICENSE - - apps/maple-agent/crates/maple-agent/src/store/keys/SOURCES.md - - apps/maple-agent/crates/maple-agent/src/store/keys/eff-large-wordlist.txt - - apps/maple-agent/Cargo.toml - - apps/maple-agent/**/Cargo.toml - - apps/maple-agent/scripts/verify-sqlcipher-build.py - - apps/maple-agent/scripts/test-rusqlite-provider.py - - apps/maple-agent/scripts/audit-sqlcipher-dependencies.py - - .github/workflows/agent-sqlcipher-supply-chain.yml - push: - branches: [master] - paths: - - apps/maple-agent/Cargo.lock - - apps/maple-agent/flake.nix - - apps/maple-agent/flake.lock - - apps/maple-agent/dependencies/sqlcipher/** - - apps/maple-agent/dependencies/licenses/** - - apps/maple-agent/about.toml - - apps/maple-agent/scripts/*third*party*notices* - - apps/maple-agent/app/assets/dict/LICENSE - - apps/maple-agent/crates/maple-agent/src/store/keys/SOURCES.md - - apps/maple-agent/crates/maple-agent/src/store/keys/eff-large-wordlist.txt - - apps/maple-agent/Cargo.toml - - apps/maple-agent/**/Cargo.toml - - apps/maple-agent/scripts/verify-sqlcipher-build.py - - apps/maple-agent/scripts/test-rusqlite-provider.py - - apps/maple-agent/scripts/audit-sqlcipher-dependencies.py - - .github/workflows/agent-sqlcipher-supply-chain.yml - schedule: - - cron: '28 8 * * 1' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: agent-sqlcipher-source-${{ github.ref }} - cancel-in-progress: true - -jobs: - source: - runs-on: ubuntu-latest - timeout-minutes: 30 - defaults: - run: - shell: bash - working-directory: apps/maple-agent - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - persist-credentials: false - - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 - with: - source-tag: v3.23.3 - github-token: "" - - name: Verify pinned Nix runtime - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - name: Test provider source selection - run: >- - nix develop --no-update-lock-file . -c python3 -m unittest - discover -s dependencies/sqlcipher -p 'test_*.py' - - name: Fetch the pinned Git and registry sources and check their hashes - run: | - set -euo pipefail - metadata="$RUNNER_TEMP/maple-sqlcipher-metadata.json" - # shellcheck disable=SC2016 # The child shell expands its own positional argument. - nix develop --no-update-lock-file . -c bash -c 'cargo metadata --locked --format-version 1 > "$1"' -- "$metadata" - nix develop --no-update-lock-file . -c python3 dependencies/sqlcipher/verify_sources.py --metadata "$metadata" - - name: Verify the signed tag and regenerate the SQLCipher amalgamation - run: >- - nix develop --no-update-lock-file . -c python3 - dependencies/sqlcipher/verify_sqlcipher.py - --metadata "$RUNNER_TEMP/maple-sqlcipher-metadata.json" - - name: Audit supported-target dependency graphs - run: nix develop --no-update-lock-file . -c python3 scripts/audit-sqlcipher-dependencies.py - - name: Generate and validate Linux third-party notices - run: | - nix develop --no-update-lock-file . -c python3 scripts/generate-third-party-notices.py - nix develop --no-update-lock-file . -c python3 scripts/generate-third-party-notices.py --check diff --git a/.github/workflows/agent-store-performance.yml b/.github/workflows/agent-store-performance.yml deleted file mode 100644 index 20658021c..000000000 --- a/.github/workflows/agent-store-performance.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Maple Agent store performance - -on: - pull_request: - paths: - - 'apps/maple-agent/**' - - 'sdk/rust/**' - - 'proxy/**' - - '.github/workflows/agent-store-performance.yml' - push: - branches: [master] - paths: - - 'apps/maple-agent/**' - - 'sdk/rust/**' - - 'proxy/**' - - '.github/workflows/agent-store-performance.yml' - schedule: - - cron: '0 12 * * *' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: agent-store-performance-${{ github.ref }}-${{ github.event_name }} - cancel-in-progress: true - -defaults: - run: - shell: bash - working-directory: apps/maple-agent - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUSTFLAGS: -D warnings - -jobs: - performance: - name: Store (${{ matrix.profile }}) - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '[{"os":"blacksmith-8vcpu-ubuntu-2404","profile":"linux-ci","scale":"--ci"},{"os":"macos-26","profile":"macos-full","scale":"--full"}]' || '[{"os":"blacksmith-8vcpu-ubuntu-2404","profile":"linux-ci","scale":"--ci"}]') }} - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' - runs-on: ${{ matrix.os }} - timeout-minutes: 240 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install pinned Nix environment - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache benchmark dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: apps/maple-agent -> target - add-rust-environment-hash-key: "false" - key: maple-store-bench-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/maple-agent/Cargo.lock', 'apps/maple-agent/flake.lock') }} - save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} - - - name: Test the evidence evaluator - run: python3 -m unittest discover -s crates/store-bench/tests -p 'test_*.py' - - - name: Measure the actual durable store - run: nix develop --no-update-lock-file . -c cargo run --release --locked -p store-bench -- ${{ matrix.scale }} --output target/store-bench-${{ matrix.profile }}.json - - - name: Compare the recorded runner reference - run: python3 crates/store-bench/scripts/evaluate.py target/store-bench-${{ matrix.profile }}.json --profile ${{ matrix.profile }} - - - name: Save measurements and their digest - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: store-bench-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }} - path: | - apps/maple-agent/target/store-bench-${{ matrix.profile }}.json - apps/maple-agent/target/store-bench-${{ matrix.profile }}.json.sha256 - if-no-files-found: error - retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f616eae42..2baab0f65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # CI: the single required check (ci-ok) for every pull request, on any base. # Lane selection lives in scripts/ci/lanes.py and scripts/ci/plan.py. Generic -# Nix lanes run in the `nix` matrix; a special job exists only where a step list -# cannot be expressed generically (Windows toolchain, services, OIDC, artifacts). +# lanes run in the `nix` and `windows` matrices; a special job exists only where +# a step list cannot be expressed generically (services, OIDC, artifacts). # Never add `paths:` here: ci-ok must always report. name: CI @@ -11,6 +11,9 @@ on: types: [checks_requested] push: branches: [master] + schedule: + # Every lane on the master tip: refreshes the caches and catches misroutes. + - cron: "17 4 * * *" workflow_dispatch: inputs: lanes: @@ -45,6 +48,7 @@ jobs: outputs: lanes: ${{ steps.plan.outputs.lanes }} nix: ${{ steps.plan.outputs.nix }} + windows: ${{ steps.plan.outputs.windows }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -59,6 +63,7 @@ jobs: CI_LINUX_RUNNER: ${{ vars.CI_LINUX_RUNNER }} CI_LINUX_HEAVY_RUNNER: ${{ vars.CI_LINUX_HEAVY_RUNNER }} CI_MACOS_RUNNER: ${{ vars.CI_MACOS_RUNNER }} + CI_WINDOWS_RUNNER: ${{ vars.CI_WINDOWS_RUNNER }} run: python3 scripts/ci/plan.py github nix: @@ -79,7 +84,7 @@ jobs: - name: Select pinned Xcode if: runner.os == 'macOS' run: python3 scripts/ci/select-xcode.py - - name: Rust cache (restore everywhere, save only on master) + - name: Rust cache (restore everywhere, save only on master and on Blacksmith) if: matrix.cache != '' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -91,74 +96,152 @@ jobs: add-rust-environment-hash-key: "false" # The action runs cargo from the workspace directory: use an absolute flake path. cmd-format: nix develop --no-update-lock-file ${{ github.workspace }}/${{ matrix.flake }} -c {0} - save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') }} + # A failover to a GitHub-hosted runner must not fill GitHub's small cache store. + save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') && startsWith(matrix.runner, 'blacksmith-') }} - name: Run lane env: LANE: ${{ matrix.id }} run: python3 scripts/ci/plan.py run "$LANE" - agent-windows: - name: agent-windows + windows: + name: ${{ matrix.id }} needs: plan - if: fromJSON(needs.plan.outputs.lanes)['agent-windows'] - runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} - timeout-minutes: 45 + if: needs.plan.outputs.windows != '[]' + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.windows) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} defaults: run: shell: bash - working-directory: apps/maple-agent - env: - RUSTFLAGS: -D warnings steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - - - name: Install pinned Windows Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below + - uses: ./.github/actions/setup-agent-windows + - name: Rust cache (restore everywhere, save only on master and on Blacksmith) + if: matrix.cache != '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - # Equal to AGENT_WINDOWS_RUST in scripts/ci/lanes.py; agent-lint checks - # that the Agent flake's rustc still reports this version. - toolchain: 1.98.0 - components: clippy + prefix-key: ci-v1 + shared-key: ${{ matrix.cache }}-${{ matrix.cache_key }} + workspaces: ${{ matrix.workspaces }} + add-rust-environment-hash-key: "false" + # A failover to a GitHub-hosted runner must not fill GitHub's small cache store. + save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') && startsWith(matrix.runner, 'blacksmith-') }} + - name: Run lane + env: + LANE: ${{ matrix.id }} + run: python3 scripts/ci/plan.py run "$LANE" - - name: Select Windows MSVC and SDK - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 + # Unsigned Dev packages built like agent-desktop-build.yml's master builds, from + # their master-written cache. Required when the packaging selector fires. + agent-package-linux: + name: agent-package-linux + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['agent-package-linux'] + runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - arch: x64 - - - name: Select native Windows Perl for the static crypto build - run: | - native_perl=/c/Strawberry/perl/bin/perl.exe - if [ ! -x "$native_perl" ]; then - echo 'Native Windows Perl is missing from the runner' >&2 - exit 1 - fi - "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' - # Pin the native executable across later Git Bash steps and Rust's - # process resolver instead of depending on their PATH ordering. - native_perl_windows="$(cygpath -m "$native_perl")" - printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" - # openssl-src uses NASM if available and otherwise selects no-asm. - - - name: Pin and smoke-test the selected Windows linker - run: python3 ../../scripts/ci/agent_windows.py + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Restore master's Agent release cache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Exactly agent-desktop-build.yml's builder key; it is the only writer. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target + - name: Build, package, verify and smoke-test an unsigned Dev bundle + run: python3 scripts/ci/plan.py run agent-package-linux + - name: Upload the unsigned package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-agent-dev-linux-x86_64-unsigned-${{ github.run_id }} + path: apps/maple-agent/dist/dev/ + if-no-files-found: error + retention-days: 7 - - name: Rust cache (restore everywhere, save only on master) - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + agent-package-macos: + name: agent-package-macos + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['agent-package-macos'] + runs-on: ${{ vars.CI_MACOS_RUNNER || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - prefix-key: ci-v1 - shared-key: agent-windows - workspaces: apps/maple-agent -> target - save-if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') }} + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Select pinned Xcode + run: python3 scripts/ci/select-xcode.py + - name: Restore master's Agent release cache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Exactly agent-desktop-build.yml's builder key; it is the only writer. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target + - name: Build, package and verify an unsigned Dev app + run: python3 scripts/ci/plan.py run agent-package-macos + - name: Upload the unsigned package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-agent-dev-macos-aarch64-unsigned-${{ github.run_id }} + path: apps/maple-agent/dist/dev/ + if-no-files-found: error + retention-days: 7 - - name: Clippy (Windows) - run: cargo clippy --workspace --all-targets --locked -- -D warnings + agent-package-windows: + name: agent-package-windows + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['agent-package-windows'] + runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-agent-windows + with: + release: "true" + - name: Restore master's Agent release cache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Exactly agent-desktop-build.yml's builder key; it is the only writer. + key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target + - name: Build, package and verify an unsigned Dev installer + run: python3 scripts/ci/plan.py run agent-package-windows + - name: Upload the unsigned package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-agent-dev-windows-x86_64-unsigned-${{ github.run_id }} + path: apps/maple-agent/dist/dev/ + if-no-files-found: error + retention-days: 7 ci-ok: name: ci-ok if: always() - needs: [plan, nix, agent-windows] + needs: [plan, nix, windows, agent-package-linux, agent-package-macos, agent-package-windows] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -167,18 +250,19 @@ jobs: NEEDS: ${{ toJSON(needs) }} LANES: ${{ needs.plan.outputs.lanes }} NIX: ${{ needs.plan.outputs.nix }} + WINDOWS: ${{ needs.plan.outputs.windows }} run: | set -euo pipefail if [ "$(jq -r '.plan.result' <<<"$NEEDS")" != success ]; then echo "::error::plan did not succeed"; exit 1 fi # Selected => success; an unselected job may only be skipped (or succeed). - jq -n -e --argjson needs "$NEEDS" --argjson lanes "$LANES" --argjson nix "$NIX" ' + jq -n -e --argjson needs "$NEEDS" --argjson lanes "$LANES" --argjson nix "$NIX" --argjson windows "$WINDOWS" ' def ok($job; $selected): if $selected then $needs[$job].result == "success" else ($needs[$job].result | IN("skipped", "success")) end; - ok("nix"; ($nix | length) > 0) - and ([$needs | keys[] | select(. != "plan" and . != "nix")] + ok("nix"; ($nix | length) > 0) and ok("windows"; ($windows | length) > 0) + and ([$needs | keys[] | select(IN("plan", "nix", "windows") | not)] | all(. as $job | ok($job; $lanes[$job] // false))) ' >/dev/null || { echo "::error::a selected lane did not succeed"; exit 1; } echo "ci-ok: every selected lane succeeded" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..9af2fb95c --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,153 @@ +# Nightly checks of what drifts without a commit: advisories, pinned upstream +# sources, the SQLCipher provider suite on every OS and the store benchmark. +# One `ci-nightly` issue reports failures here and in ci.yml's scheduled run; +# a green night closes it. Nothing here gates a pull request. +name: Nightly + +on: + schedule: + - cron: "47 5 * * *" # after ci.yml's 04:17 run, so the report includes it + workflow_dispatch: # a dry run: the report is printed, not written to the issue + +permissions: + contents: read + +concurrency: + group: nightly + cancel-in-progress: false + +jobs: + advisories: + name: Advisories (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: research + manifest: apps/maple-research/frontend/src-tauri/Cargo.toml + config: apps/maple-research/deny.toml + - name: sdk + manifest: sdk/rust/Cargo.toml + config: sdk/deny.toml + - name: proxy + manifest: proxy/Cargo.toml + config: proxy/deny.toml + - name: opensecret + manifest: services/opensecret/Cargo.toml + config: services/opensecret/deny.toml + rust: 1.91.1 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Check RustSec advisories + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + rust-version: ${{ matrix.rust }} + manifest-path: ${{ matrix.manifest }} + command: check advisories + arguments: --config ${{ matrix.config }} --all-features --locked + + agent-supply-chain: + name: Agent advisories and SQLCipher sources + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Check advisories and bans per target, SQLCipher sources and notices + run: python3 scripts/ci/plan.py run agent-supply-chain + + bun-audit: + name: Bun advisories + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Audit every Bun lockfile + run: | + # shellcheck disable=SC2016 # The child shell expands its own variables. + nix develop --no-update-lock-file .#ci -c bash -uo pipefail -c ' + status=0 + for project in sdk apps/maple-research/frontend apps/maple-auth services/updates services/agent-updates; do + echo "::group::$project" + (cd "$project" && bun --no-env-file audit --audit-level=high) || status=1 + echo "::endgroup::" + done + exit "$status"' + + agent-provider: + name: Agent provider (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-26, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - if: runner.os != 'Windows' + uses: ./.github/actions/setup-nix + - name: Select pinned Xcode + if: runner.os == 'macOS' + run: python3 scripts/ci/select-xcode.py + - if: runner.os == 'Windows' + uses: ./.github/actions/setup-agent-windows + - name: Test released rusqlite against the static provider + run: python3 scripts/ci/plan.py run agent-provider + + store-bench: + name: Agent store benchmark + # The Linux reference was recorded on this runner class. + runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Measure the durable store and compare the Linux reference + run: python3 scripts/ci/plan.py run agent-perf + - name: Save measurements and their digest + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: store-bench-linux-ci-${{ github.run_id }}-${{ github.run_attempt }} + path: | + apps/maple-agent/target/store-bench-ci.json + apps/maple-agent/target/store-bench-ci.json.sha256 + if-no-files-found: warn + retention-days: 14 + + report: + name: Report + needs: [advisories, agent-supply-chain, bun-audit, agent-provider, store-bench] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read # ci.yml's latest scheduled run + issues: write # the single ci-nightly issue + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Create, update or close the ci-nightly issue + env: + GH_TOKEN: ${{ github.token }} + NEEDS: ${{ toJSON(needs) }} + DRY_RUN: ${{ github.event_name != 'schedule' }} + run: python3 scripts/ci/nightly_report.py diff --git a/apps/maple-agent/.githooks/pre-commit b/apps/maple-agent/.githooks/pre-commit index 52d976c74..2a7940320 100755 --- a/apps/maple-agent/.githooks/pre-commit +++ b/apps/maple-agent/.githooks/pre-commit @@ -1,6 +1,6 @@ #!/bin/sh # GPUI Agent pre-commit checks. Runs from apps/maple-agent inside its own flake. -# CI equivalent: agent-ci.yml (`just ci`). The default tier runs one clippy +# CI equivalent: ci.yml's Agent lanes (`just ci`). The default tier runs one clippy # pass and the workspace tests; MAPLE_HOOK_FULL=1 runs the complete `just ci` # (every feature-set clippy plus the headless tests). diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index 4a484f881..b0b388754 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -19,8 +19,9 @@ and release verification together when changing the compiled Auth destination. ## Build, test, run -`just` lists the recipes. `just ci` runs the same checks as CI (format, -clippy with `-D warnings` for every feature set, tests). Run it before a +`just` lists the recipes. `just ci` runs the checks of CI's Agent lanes +together (format, clippy with `-D warnings` for every feature set, tests); root +`just lane ` runs one lane exactly as CI does. Run `just ci` before a commit. The repository pre-commit hook (`.githooks/pre-commit` here) runs format, one workspace clippy pass, and the workspace tests when Agent files are staged; `MAPLE_HOOK_FULL=1` runs the full `just ci`. diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index a43b7deb3..05f9760d6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -621,12 +621,13 @@ parent-pipe shutdown, and absence of application-state writes. It does not change the desktop theme. Parser, environment-scrubbing and palette precedence tests run in the ordinary app test suite. -Root `.github/workflows/agent-ci.yml` selects this component when Agent or -its shared Rust dependencies change. It builds and tests Linux, macOS, and -Windows; Linux additionally runs the feature-matrix lint/headless checks and -a release build. `just ci` is the full local format, lint, build and test gate; -`just release` separately validates the optimized binary. PR jobs have no -signing or publishing credentials. See the root agent guide for shared checks. +The root `ci.yml` runs this component's lanes (`scripts/ci/lanes.py`) when +Agent or its shared Rust dependencies change: format, lint and tests on Linux +and clippy on macOS for every PR, and the full Linux, macOS and Windows suites +on master. Root `just lane ` runs one lane as CI does. `just ci` is the full +local format, lint, build and test gate; `just release` separately validates the +optimized binary. PR jobs have no signing or publishing credentials. See the +root agent guide for shared checks. Two by-hand checks complement the automated ones: the [scenario checklist](docs/scenario-checklist.md), one scenario per feature diff --git a/apps/maple-agent/crates/store-bench/README.md b/apps/maple-agent/crates/store-bench/README.md index 3b86d8f7c..2b9fe031c 100644 --- a/apps/maple-agent/crates/store-bench/README.md +++ b/apps/maple-agent/crates/store-bench/README.md @@ -70,7 +70,7 @@ dataset identity and settings. No synthetic database or key is uploaded. scenarios and their sample counts, effective durability settings, and indexed plans with and without statistics. Missing scenarios cannot silently remove a slow operation. Linux CI compares medians at 10x its actual Linux reference; -the scheduled `macos-26` full run uses 2x its actual macOS reference. Platform +the `macos-full` profile uses 2x its actual macOS reference. Platform mismatches are rejected. Native Mac small runs can use `--profile linux-ci --diagnostic` to validate workload evidence without comparing or recording a Linux runner gate. @@ -135,9 +135,10 @@ medians only; append tails, checkpoints and conversion throughput remain reported evidence without an invented acceptance threshold. The report's OS does not independently attest the scheduled runner label or hardware. -The separate performance workflow runs small Linux data on relevant PRs and -pushes, and both Linux and full macOS data on its schedule or manual dispatch. -Workflow artifacts are results and digests, with no signing or release action. +`nightly.yml` runs the small Linux data every night and uploads the result and +its digest; the `agent-perf` lane runs it on a PR labelled `ci:perf`. The full +macOS profile has no CI run until it has a reference. Neither signs or releases +anything. ## Seed a stopped Local profile diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index df056a7a6..a1f6124bf 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -1,13 +1,13 @@ # Agent desktop builds -`Maple Agent Desktop Builds` builds both hosted profiles when Agent inputs change -on protected `master`, and always on a manual dispatch from `master`. It compares -the current source with the last successful ancestor run that actually completed -all six Dev/Prod macOS/Linux/Windows downloaded-package verifiers. Failed, in-flight and -selector-only runs do not advance that baseline, so a later unrelated push still -builds outstanding Agent changes. Missing history, API failures or no verified -baseline in the last 100 successful master runs select a full build. PRs continue -to select unsigned previews from their whole merge-base diff. +`Maple Agent Desktop Builds` builds both hosted profiles when a protected +`master` push changes Agent inputs, and always on a manual dispatch from +`master`. Its `changes` job runs `scripts/ci/plan.py only agent-desktop` on the +push's own diff; an unusable diff or a selector error builds. No master run is +cancelled, so every Agent push gets its own packages; a failed run is re-run or +fixed forward. Pull requests that change packaging inputs rehearse unsigned Dev +packages in `ci.yml` (`agent-package-linux`, `agent-package-macos`); the +`ci:package` label adds `agent-package-windows`. Unrelated master commits have no new Agent artifacts. Existing downloads keep their original source SHA and provenance; they are never relabeled as a newer @@ -17,13 +17,13 @@ Developer ID signed, notarized, and stapled. Linux x86_64 builds are tar bundles with a bundled runtime and a normal application directory. CI checks each downloaded bundle's command-line startup and detached host lifecycle in an Ubuntu 24.04 container with no Nix installation and no network -access. Each master run completes independently; newer PR updates cancel older -previews. Windows x64 packages are per-user NSIS installers requiring Windows 10 -version 1703 (build 15063) or later. This baseline supplies the OS `icuuc.dll` +access. Each master run completes independently. Windows x64 packages are +per-user NSIS installers requiring Windows 10 version 1703 (build 15063) or later. This baseline supplies the OS `icuuc.dll` imported by GPUI; see Microsoft's [Windows ICU version history](https://learn.microsoft.com/en-us/windows/win32/intl/international-components-for-unicode--icu-). Trusted master packages use the existing Azure publisher to Authenticode-sign the application, -embedded uninstaller and final installer. PRs produce unsigned Dev previews. +embedded uninstaller and final installer. The `ci:package` PR label rehearses an +unsigned Dev installer. | Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | @@ -49,10 +49,9 @@ compiles a small updater-only scanner from its trusted checkout, before the step receiving Apple credentials. After packaging, that tool reads the actual `.app.tar.gz` through the installer's archive scanner, without extracting or executing the app, and checks its SHA-256 against the package checksums before -final artifact upload or attestation. The same gate covers unsigned Prod PR -packages. Dev uses a different bundle root and has no production updater. -PR previews use the same prebuilt verification on runners without signing -credentials. Runtime metadata, duplicate +final artifact upload or attestation. Dev uses a different bundle root and has +no production updater. PR rehearsals build Dev only, on runners without signing +credentials, and run the same prebuilt verification. Runtime metadata, duplicate Swift runtime, and real GPUI window/first-render checks run only in credential-free verification. GUI startup uses empty temporary state and does not sign in. @@ -165,13 +164,13 @@ gh attestation verify FILE -R MaplePrivacyLabs/Maple \ The source SHA is recorded in `build-info.json`. Provenance authenticates the build origin; Linux checksums alone do not provide that authentication. -Pull requests produce unsigned macOS previews and Linux packaging evidence on -fresh hosted runners. Their macOS artifacts include `unsigned` in the name and -cannot satisfy the signed-artifact verifier. The preview app is ad-hoc signed -without hardened runtime so macOS can load its bundled libraries without a -Developer ID Team ID. Signed master apps retain hardened runtime. Preview -verification rejects incompatible library-validation flags; downloaded previews -may still require macOS's **Open Anyway** approval. PR previews are not attested. +PR rehearsals upload unsigned Dev packages for seven days. Their artifacts +include `unsigned` in the name and cannot satisfy the signed-artifact verifier. +The preview app is ad-hoc signed without hardened runtime so macOS can load its +bundled libraries without a Developer ID Team ID. Signed master apps retain +hardened runtime. Preview verification rejects incompatible library-validation +flags; downloaded previews may still require macOS's **Open Anyway** approval. +PR previews are not attested. The macOS GUI smoke establishes window creation, entry into the first render, and process survival; it does not capture a presented frame or verify login, chat, @@ -203,10 +202,12 @@ continues to use the macOS feed. Dev has no update feed on either platform. Both platforms retain one release version and source commit; separate feed paths preserve compatibility with existing macOS clients. -Windows builds run on the pinned Windows Rust/MSVC environment in CI, with -`RUSTFLAGS='-D warnings -C target-feature=+crt-static'` set before fingerprinting -and compilation. `build-release.sh` selects the explicit x64 MSVC target so host -build tools keep their normal linkage, then stages the exact resulting executable. +Windows builds run on `blacksmith-8vcpu-windows-2025` with the pinned Rust/MSVC +environment from `.github/actions/setup-agent-windows` and +`RUSTFLAGS='-D warnings -C target-feature=+crt-static'`; signing stays on +GitHub's `windows-2025`. `build-release.sh` selects the explicit x64 MSVC target +so host build tools keep their normal linkage, then stages the exact resulting +executable. From the component directory in Git Bash on Windows: ```sh @@ -240,7 +241,7 @@ activation (40–41), and uninstall (50–53). A failed installer is not retried the verifier. These diagnostics do not establish installed upgrade qualification. For a produced Prod updater archive, compile the trusted data-only scanner and -validate the exact package directory (add `--unsigned` for PR packages): +validate the exact package directory (add `--unsigned` for unsigned packages): ```sh nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 7ffd4cf07..3a78f52d3 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -43,6 +43,41 @@ ci-lint: fmt-check git-metadata-test packaging-test ci-test: RUSTFLAGS="-D warnings" cargo test --workspace --locked +# The ci.yml agent-full-linux lane: the rest of `just ci` after ci-lint and ci-test. +ci-extra: + cargo clippy -p maple-agent-app --locked --no-default-features --features acp -- -D warnings + cargo clippy -p maple-agent-app --locked --no-default-features --features proxy -- -D warnings + RUSTFLAGS="-D warnings" cargo build --workspace --all-targets --locked + RUSTFLAGS="-D warnings" cargo test -p maple-agent-app --locked {{headless}} + +# The ci.yml agent-supply-chain lane: SQLCipher sources, per-target advisories and bans, notices. +supply-chain: + python3 -m unittest discover -s dependencies/sqlcipher -p 'test_*.py' + metadata="$(mktemp)"; trap 'rm -f "$metadata"' EXIT; \ + cargo metadata --locked --format-version 1 > "$metadata"; \ + python3 dependencies/sqlcipher/verify_sources.py --metadata "$metadata"; \ + python3 dependencies/sqlcipher/verify_sqlcipher.py --metadata "$metadata" + python3 scripts/audit-sqlcipher-dependencies.py + python3 scripts/generate-third-party-notices.py + python3 scripts/generate-third-party-notices.py --check + +# The ci.yml agent-package-linux/macos lanes: an unsigned Dev package, built and verified like master's. +package-rehearsal: + #!/usr/bin/env bash + set -euo pipefail + ./scripts/build-release.sh dev + if [ "$(uname -s)" = Darwin ]; then + ./scripts/verify-prebuilt-release.sh dev + ./scripts/package-release.sh dev --unsigned + ./scripts/verify-release.sh dev dist/dev --unsigned + else + ./scripts/package-release.sh dev --unsigned + ./scripts/verify-release.sh dev dist/dev --unsigned --platform linux-x86_64 + cargo build --locked -p maple-agent-updater --example validate-release-archive + ./scripts/verify-updater-archive.sh dist/dev --profile dev --platform linux-x86_64 --unsigned + ./scripts/verify-linux-bundle-docker.sh dev dist/dev + fi + # Exercise the production Git metadata helper without compiling the app graph. git-metadata-test: python3 scripts/test_git_metadata.py diff --git a/apps/maple-agent/scripts/verify-linux-bundle-docker.sh b/apps/maple-agent/scripts/verify-linux-bundle-docker.sh new file mode 100755 index 000000000..5b73ca4fd --- /dev/null +++ b/apps/maple-agent/scripts/verify-linux-bundle-docker.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Start a Linux bundle in a stock Ubuntu container with no Nix and no network, +# and check its embedded build identity against the package's build-info.json. +# Usage: verify-linux-bundle-docker.sh PROFILE PACKAGE_DIRECTORY +set -euo pipefail +profile="$1" +package="$(cd "$2" && pwd)" +scripts="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +shopt -s nullglob +archives=("$package"/*-linux-x86_64.tar.gz) +test "${#archives[@]}" -eq 1 +test -d /etc/ssl/certs +portable="$(mktemp)" +trap 'rm -f "$portable"' EXIT +docker run --rm --platform linux/amd64 --user 65534:65534 \ + --network none --read-only --tmpfs /tmp:exec,mode=1777 \ + --volume "$package:/artifacts:ro" \ + --volume "$scripts/smoke-linux-bundle-host.sh:/checks/smoke-linux-bundle-host.sh:ro" \ + --volume /etc/ssl/certs:/etc/ssl/certs:ro \ + ubuntu@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b /bin/sh -ec ' + cd /tmp + tar -xzpf /artifacts/*-linux-x86_64.tar.gz + ./maple-agent*.app/AppRun --version >&2 + ./maple-agent*.app/AppRun --build-info + MAPLE_AGENT_SMOKE_DIAGNOSTICS=1 /bin/sh /checks/smoke-linux-bundle-host.sh ./maple-agent*.app/AppRun >&2 + ' > "$portable" +python3 - "$profile" "$portable" "$package/build-info.json" <<'PY' +import json +import sys +actual = json.load(open(sys.argv[2])) +expected = json.load(open(sys.argv[3])) +if actual != expected or actual.get("profile") != sys.argv[1]: + raise SystemExit("portable Linux bundle build profile differs from verified package") +print("Verified Agent launch and embedded profile without a Nix installation") +PY diff --git a/docs/agent-ci-performance.md b/docs/agent-ci-performance.md deleted file mode 100644 index 93047b130..000000000 --- a/docs/agent-ci-performance.md +++ /dev/null @@ -1,376 +0,0 @@ -# Agent CI performance audit - -This change addresses [issue #1224](https://github.com/MaplePrivacyLabs/Maple/issues/1224). -The baseline below comes from completed Actions traces collected October 22, -2026 (Pacific time), before the cache and job changes. Runs use different -revisions and runner conditions; these are observations, not a controlled -performance benchmark. - -| Agent CI run | Workflow elapsed | Windows workspace build/test | Windows provider suite | -| --- | ---: | ---: | ---: | -| [39885038207](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39885038207) | 67m36s | 45m00s, cache miss | 18m16s | -| [39890827847](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39890827847) | 71m02s | 47m01s, cache miss | 19m23s | -| [39876263031](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39876263031) | 52m13s | 29m29s, target cache restored | 19m25s | -| [39842844293](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39842844293) | 77m52s | 48m03s, cache miss | 20m11s | - -Windows controlled the critical path. In the baseline its workspace build -reported 33m07s development compilation and 6m03s test compilation; the -provider reported 18m00s compilation and about 11 seconds executing tests. -Linux additionally built an optimized binary for 19m52s after its other checks. -Job creation-to-start delays were 3–9 seconds across 15 sampled platform jobs. -The evidence identifies compilation and serial scheduling costs; it does not -identify CPU, memory or disk saturation. - -Local validation also found Cargo selecting a home-directory Rustup Clippy -1.97 proxy while the Nix shell selected Rust/Cargo 1.98. The component shell -now pins `RUSTUP_TOOLCHAIN` to its Nix toolchain, covering external Cargo -subcommands such as Clippy and rustfmt. Linux CI fingerprints Clippy's actual -version as well. This local observation is not a measurement of hosted Clippy -selection or the time saved by the correction. - -Cache behavior was mixed: all baseline platform lookups missed, while later -runs restored some workspace targets. All twelve Desktop build lookups across -[39885037989](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39885037989), -[39891392302](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39891392302) -and [39876520067](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39876520067) -missed. Nix installer-cache hits are separate from Cargo target-cache reuse. -The old user key put the full Cargo.lock/flake digest in both exact and restore -keys, preventing compatible fallback after a version change. The upstream -provider suite compiled into a disposable target outside that workspace cache. - -In [cancelled master run 39891392236](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39891392236/job/120211837972), -Windows completed 45m20s of workspace build/test, then ran the provider for -12m14s before cancellation. Its workspace cache-save post step never ran. -Independent provider jobs let completed workspace jobs save earlier without -changing master cancellation or signed Desktop concurrency policies. - -The implementation preserves the workspace feature matrix, upstream provider -features and provenance checks, static linkage, source regeneration, notices, -optimized binary, and all package/signing/download validations. Compiler/native -compatibility and dependency identity are separate cache boundaries; provider -test artifacts have a dedicated exact-graph cache. Signing, publication and -credential boundaries are unchanged. - -## Validation after a trusted cache seed - -New cache namespaces are initially cold. PRs cannot seed them because writes -remain restricted to successful master pushes. Do not weaken this rule to -benchmark a PR. Before claiming issue acceptance: - -1. Retain the first successful trusted run's three-platform workspace/provider - and Desktop build logs and `maple-agent-*-metrics-*` artifacts. Record exact - revision, actual compiler/native identity, cache keys/hit state, action - restore/save bytes and durations, phase durations and target sizes. -2. Compare a subsequent run using that same dependency/toolchain configuration, - including a version-only application bump. Confirm compatible restore keys - and Cargo's reuse separately from total workflow wall time. A PR after a - trusted seed can demonstrate read-only restore. -3. In unprivileged validation branches, change provider revision, resolved - upstream test-only lock, helper, toolchain/native ABI, and flags/features one - boundary at a time. Check that the matching compatibility or exact provider - key changes and incompatible artifacts are not restored. Local regression - tests cover fingerprint changes; hosted invalidation remains separate proof. -4. Report wall time, sum of job durations (runner elapsed compute), cache - transfer/archive bytes, storage usage and failed/cancelled runs for cold and - warm cases. Keep selector-only runs out of build comparisons. Distinguish - assertion failures and artifact-service errors from capacity problems. - -Moving the baseline Windows provider off the serial path removes an 18-minute -dependency edge; it does not establish an 18-minute end-to-end saving. Extra -jobs duplicate setup/metadata work and maintain additional target caches. Cache -restore/save overhead, storage eviction, test-only resolution changes, and the -new slowest job determine the result. Dev/Prod packages and the plain CI binary -have different build inputs; their outputs have not been proved interchangeable. -Runner upgrades and further scheduling experiments should follow these results. - -## macOS cache retention and repeated application compilation - -The [master macOS workspace job at `dbb8e397`](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39915101523/job/120286893796) -passed in **18m31s** after restoring its exact target cache. Its measured build -phase was **8m26s** and its test phase **4m42s**. Native dependencies were reused. -The [later `c9cf8522` job](https://github.com/MaplePrivacyLabs/Maple/actions/runs/39919954389/job/120302912965) -passed in **50m08s**, with a cache miss and **33m21s** measured compilation. Its -SQLCipher/OpenSSL native build alone occupied **13m23s**, overlapping other -compilation. Source revisions and hosted runners differ; these are observed -phases, not a controlled hardware benchmark or additive estimates of savings. - -The two environment identities differed only in the complete Agent `justfile` -hash. Three Linux packaging-test recipe additions had invalidated macOS and -Windows even though those workspace jobs invoke Cargo directly. Configuration -fingerprints now follow the command owner: - -- Linux workspace CI retains the entire `justfile`, including recipe variables - and dependencies. Direct macOS/Windows workspace builds do not hash it. -- Nix flake inputs apply to macOS/Linux; the Windows native-setup helper applies - to Windows. Provider and release helpers apply to their respective profiles. -- Actual Rust/Cargo, compiler/native binaries, SDK, flags, Cargo configuration, - manifest features/profiles and common SQLCipher provenance checks remain - part of the compatibility boundary. Exact dependency keys and successful - master-only cache writes retain their existing policies. - -Changing this fingerprint policy intentionally creates a new cache generation. -Its first successful master run must seed it; a cold first PR is not evidence -that the new scope failed. Keep sufficient storage for overlapping native-cache -generations. The read-only audit found a configured **10 GiB** GitHub cap and -**10.085 GiB** of entries, including an obsolete **2.42 GiB** Windows generation. -That establishes quota pressure, not the cause of every missing entry. Blacksmith -Desktop/Linux caches use a separate backend and are outside that GitHub total. - -The app build script also watched Git reference files when they did not exist. -Cargo treats missing watched inputs as dirty, so an unchanged build followed by -test could rerun the script and recompile the app test binary. The warm macOS -sample repeated **3m18s** of app test compilation; the later cold sample repeated -**5m22s**. Hosted timings identify the repeated units; they did not capture the -original Cargo fingerprint reason. - -The production Git metadata helper now watches existing Git pointers, using the -nearest existing parent for an absent active branch reference. That parent is -needed to detect a packed branch advancing into a newly created loose ref. -Simply ignoring all missing references can embed an old commit SHA. Offline -fixtures import the production helper and verify unchanged build/test freshness -plus full/short SHA correctness across detached, loose, packed and linked -worktree transitions. The existing dirty-suffix refresh policy remains intact. -The platform CI jobs and component checks run those fixtures with their selected -toolchain. Quantify hosted savings only after the retained Cargo reports confirm -that the repeated application unit is fresh and tests still execute. - -## Incidental host toolchains must not block a compatible restore - -The [later master macOS job at `692b33ff`](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40024153792/job/120656381786) -passed in **57m53s** after another complete target miss. Its own actual-build -environment JSON and compatibility digest were identical to the retained -`cc67565b` seed. The cache action additionally enumerated every installed -Rustup toolchain: an unused host Rust changed from 1.99.0 to 1.99.1 while the -selected Nix Rust remained 1.98.0. That changed its supplemental restore hash -from `a5aee41c` to `a861070b`. The old compatible archive still existed; this -lookup miss was not evidence that it needed more time to warm. - -`agent_cache_command.py` now runs the action's real Rust/Cargo probes through -the selected component environment, but returns an empty result for its -optional installed-Rustup inventory. The actual `rustc -vV` remains in the -primary action's identity, and its normal manifest/lock dependency suffix -remains enabled. Actual compiler, flags, SDK and native compatibility continue -to be enforced by the existing fingerprint. - -When neither an exact hit nor a restored Cargo target is present, a second, -read-only action invocation searches the existing ABI/job/OS/architecture -prefix without the supplemental host-toolchain hash. Archive paths and their -cache-service version remain unchanged, allowing the existing master archives -to be used during migration. Its save step was disabled; at that stage only -the primary action saved stable exact keys after successful master validation. -That migration initially checked the restored target's `CACHEDIR.TAG`. The recovery implementation below checks -actual dependency libraries/fingerprints instead, because a valid archive can -omit the tag and otherwise trigger a second download. - -The cold job spent **36m00s** building and **16m42s** preparing its environment. -Its test phase rebuilt zero compilation units, so the Git metadata freshness -fix remained effective. Cache restoration, native dependency reuse and Nix -materialization time need separate hosted measurements; a shorter overall job -alone does not establish which cost was removed. - -## Pull-request package profiles - -Agent desktop pull requests build only the Dev profile on macOS and Linux. -The unsigned macOS packager and Linux downloaded-package verifier use the same -Dev-only selection, including fork and stacked PRs. Master pushes and manual -master dispatches retain Dev and Prod builds, signing, attestation and package -verification. The master change selector still requires all four Dev/Prod -downloaded verifiers before considering outstanding changes covered. - -This removes four runnable PR jobs (Prod macOS binary, unsigned macOS package, -Linux AppImage and downloaded Linux verifier), reducing desktop jobs from eight -to four. It does not remove platform workspace/provider tests or the separate -plain Linux release build. The job-count reduction saves their runner work; -the slowest remaining job still determines PR wall time. - -## October 25–26, 2026: repository-wide recovery audit - -The follow-up audit covers source at -[`6d6c259d`](https://github.com/MaplePrivacyLabs/Maple/tree/6d6c259dbfc24e05fabb0d508593fecf7820fdda/.github/workflows): -**50 workflows, 125 logical jobs before matrix expansion, and 746 steps**. -The cache inventory groups **134 declared instances across 41 workflows into -31 families**, including 79 Nix installer instances. An installer archive is -not a Rust dependency cache or a populated Nix build store. Inventory counts -record configuration; they do not prove that every instance ran or restored. - -The audit includes Agent, Research, SDK, Proxy, backend, mobile, web, package -consumers and publication boundaries. It separates compilation, environment -materialization, test execution, artifact transfer and cancellation. A green -selector or aggregate with intentionally skipped children is not fresh platform -build evidence. The historical measurements above remain valid observations -of their own revisions; they do not establish present cache persistence. - -### Observed cold and warm work - -| Actual job | Whole job | Measured build phase | Cache and compiler evidence | -| --- | ---: | ---: | --- | -| [Earlier Windows workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40060011728/job/120771414230) | 24m11s | 13m37s | Compatible target archive restored; native crypto reused; tests passed and a stable master seed was published. | -| [Earlier macOS workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40060011728/job/120771414217) | 19m08s | 10m08s | Exact primary archive restored; native crypto reused and tests passed. | -| [`ff4c1037` Windows workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40229042168/job/121276681205) | 49m43s | 35m09s | Both restores missed; 908/908 build units compiled. The SQLCipher/OpenSSL native unit took 13m24s; the new seed save was rejected. | -| [`ff4c1037` macOS workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40229042168/job/121276681265) | 48m04s | 34m49s | Both restores missed; 1293/1293 build units compiled. The native unit took 11m56s; the new seed save was rejected. | -| [`ff4c1037` Linux workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40229042168/job/121276681271) | 7m04s | Not separately compared here | The exact 2,784,641,540-byte archive was extracted; the log compiled 27 crates and no native crypto crates. | -| [`ff4c1037` macOS Dev producer](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40229042279/job/121276739447) | 12m51s | 10m13s | Exact release archive restored; 13/1261 units compiled, with no native crypto compilation. | - -These jobs used different revisions and hosted conditions. Native compilation -units overlap other work; their durations must not be added to the complete -build phase. The observations support a substantial recovery opportunity from -durable dependency reuse, not a controlled speedup percentage or a promised -end-to-end PR duration. - -The regression also appears in real PRs. The -[#1291 Windows workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40254500033/job/121353430523) -took 44m49s and its -[macOS workspace](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40254500033/job/121353430510) -took 37m54s after both target lookups missed and native crypto compiled. -That PR was based directly on `ff4c1037` and requested the same compatibility -and dependency keys as the completed master writers. Repeated misses there -were not a new compiler/native ABI change on every PR. - -### Persistence failed despite successful build jobs - -Completed master runs -[`e5870485`](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40172748044) -and [`ff4c1037`](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40229042168) -passed their macOS/Windows builds but received the cache-service warning: - -> Your usage has exceeded the allowed rate limit. Your cache is now read-only. - -This is an explicit refusal to reserve new cache entries. It is not an observed -HTTP 429 status, proof of a concurrent writer, or an established zero-budget -diagnosis. The underlying enforcement cause remained unresolved at this audit. -The cache action reported the failed save as a warning, so a successful job or -post-cache step did not mean that the requested seed persisted. Existing -archives in older compatibility namespaces did not satisfy the current keys. - -Blacksmith persistence must be checked separately. The Linux workspace archive -restored by the completed `ff4c1037` job was absent from a subsequent provider -inventory. A Linux Prod producer also restored an exact archive in one master -run, then missed the same key in a later run and rebuilt native dependencies. -That is same-key persistence evidence, not proof of a particular eviction -cause. A provider capacity request, a displayed quota, or an inventory entry -alone is not acceptance: retain upload receipts and prove subsequent extraction -and compiler reuse on each actual backend. - -Compatibility precision remains a separate concern. Direct macOS/Windows jobs -must not inherit unrelated Linux recipe changes. The audit also found whole -flake-output hashes and cross-platform dependency-feature settings entering -compatibility decisions even when the selected compiler/native build inputs -did not change. `agent_cache_scope.py` now wraps the unchanged legacy identity -and removes only dependency declarations/feature edges proved inactive on the native -target. Unknown cfg, overrides and ambiguous features remain conservative. -Target-specific regression cases must retain invalidation for real Rust, -linker/SDK/native ABI, Cargo configuration, flags, features, profile and -SQLCipher provenance changes. Narrowing a fingerprint cannot repair a rejected -write or a vanished archive. - -### SDK integration spent time preparing the wrong environment - -The [#1291 SDK integration job](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40254500039/job/121353433324) -passed in **34m34s**. Its database/backend setup step took **27m48s**, but the -backend Cargo build reported only **3m23s**. Approximately **24m19s** elapsed -before the full development environment was ready, including source builds of -Podman, TPM tooling, GnuPG, systemd, GPGME and BWS. The development-process -flags prevented service startup; they did not remove those packages from the -Nix shell's dependency closure. - -The recovery implementation adds a separate backend `#ci` environment and -selects it in the credential-free backend and SDK integration commands. It uses -the existing pinned Rust and native-library inputs while excluding unrelated -interactive/container/credential tooling. It retains Diesel, PostgreSQL server -and client tools, and the Python dependencies used by the actual checks. -PostgreSQL server tools are necessary because the OAuth tests create disposable -databases even when the SDK backend uses the workflow's PostgreSQL service. -Migrations, OAuth persistence, TypeScript integration and Rust integration stay -selected. The default developer shell, EIF derivations, signing and deployment -inputs are separate boundaries. The 24-minute observation identifies work to -remove; hosted savings from the smaller environment are not yet verified. - -### Cancellation could discard outstanding validation - -In the seven-day master snapshot, specifically cancelled Agent jobs consumed -approximately **977 execution minutes** after deduplicating carried-over rerun -receipts. This is consumed runtime, not a claim that all of it is avoidable. -Successful jobs or artifacts from a cancelled workflow can still have value, -and the latest selected source must still receive complete validation. - -The previous workflow-level cancellation ran before immediate-push path -selection. A selected run could be interrupted by a later unrelated push whose -selector then skipped every actual Agent job. One observed sequence was -[`6d6c259d`](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40263279799), -which contained a real Linux test failure while macOS/Windows were still -running, followed by the -[PCR-only master run](https://github.com/MaplePrivacyLabs/Maple/actions/runs/40265757961), -which skipped those jobs after the earlier workflow was cancelled. Its green -selector did not certify the outstanding Agent changes. - -Agent CI now selects cumulative changes from its last verified component -baseline rather than treating every immediate push as a complete baseline. -A completed successful ancestor must contain actual passing jobs for all three -workspaces, all three providers and the plain Linux release before it can be a -CI baseline. Desktop packaging keeps its separate four-consumer baseline. -Failed, cancelled or selector-only runs cannot consume outstanding work. The -existing cancellation policy remains; the latest run reselects unfinished -changes. PR updates can still replace superseded previews. Tests must cover -selected-to-unrelated, selected-to-selected, failed-to-unrelated and -cancelled-to-unrelated transitions, along with the required platform lanes. -This scheduling correction is not a new release or publication authorization. - -### Recovery implementation and acceptance - -The recovery implementation is present in the workflow/helpers; review and -validation are still in progress. Hosted cache persistence, test completion and -speed recovery remain unverified: - -- **Trusted dependency seeds:** artifact-backed recovery is the primary read - path, with native caches retained only as read-only bootstrap. PRs remain - read-only. The consumer proves the designated repository's push/master run, - exact platform/profile producer job and attempt, source commit and artifact - creation interval. That producer job and its steps must have completed - successfully; another lane's result is not substituted for this proof, and - the seed does not certify the rest of the workflow. Manifest compatibility, - API ZIP digest, archive digest, bounded sizes/file counts and safe paths are - verified before dependency-only extraction. Git sources require a reviewed - public-repository allowlist; unknown sources and colliding output names are - excluded. Local/path crates and shipping - or test executables are excluded. Provider seeds require the exact verified - test-only graph; other compatible seeds leave locked resolution/freshness to - Cargo. Missing or rejected seeds fall back to native bootstrap or cold build. -- **Bounded refresh and visible publication:** validated master-push builders - prepare/upload seeds after their existing checks. Preparation or required - upload failure fails the job. Artifacts have three-day retention; a matching - graph restored from a proven seed created within 24 hours avoids repacking, - otherwise a selected successful master build refreshes it. There is no new - daily release/dispatch. Restore receipts identify the source run/artifact, - bytes and extraction time; publication and current product tests remain - separate evidence. Transfer and retention costs still need measurement. -- **Backend CI environment:** the backend/SDK integration calls now select the - smaller pinned shell. Prove its closure omits unused developer tooling, then - run the same backend and both SDK integration suites on a fresh hosted worker. - Compare environment materialization separately from Cargo and tests. -- **Cumulative selection:** Agent CI and Desktop use their respective verified - baselines so unrelated pushes do not consume unverified changes. Preserve the - existing cancellation/profile policies and verify the latest selected head - and each downloaded consumer explicitly. -- **Deterministic history regression:** a pending-history persistence fix is - under review for the observed concurrent project/task test failure. A retry - that happens to pass is not a fix. Require a deterministic reproduction, - focused regressions and full selected platform tests before accepting it. - -Acceptance requires a successful trusted seed-producing run and a later real -master or PR consumer for every changed cache lane. Retain exact revisions, -workflow attempts, selected compiler/native identities, restored archive or -artifact metadata, bounded Cargo timing reports and successful tests. Prove -that a version-only change reuses compatible dependencies and that a real -incompatible compiler/native/feature change does not. Compare both end-to-end -critical path and total runner time across several warmed natural runs; do not -report earlier successful seeds as proof of a later restore. - -All existing coverage remains part of the acceptance contract: the three -workspace platforms, Linux feature/security checks, three verified provider -lanes, signed SQLCipher source regeneration, notices, the optimized unpackaged -Linux binary, Dev/Prod master packages, PR Dev packages and actual downloaded -consumers. The unpackaged, Dev and Prod profiles have distinct identities and -trust settings; their final binaries are not interchangeable. No improvement -here requires reducing encryption/password costs, dropping tests, merging -profile trust roots, weakening signing/provenance, changing PCR approvals, -dispatching a release or publishing/deploying an application. diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 604add80d..1a6322e8b 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -37,13 +37,11 @@ its table-driven tests when the dependency graph or component layout changes. The backend has its own root `opensecret-ci.yml` workflow and change selector; `sdk-integration.yml` tests both SDKs against `services/opensecret/` from the same checkout. Backend changes do not imply Research or Agent packaging. -Agent desktop packages compare master against the last successful ancestor run -with all six Dev/Prod macOS/Linux/Windows download verifiers completed; selector-only -and failed runs do not consume outstanding changes. Unrelated master pushes skip -Agent packages. Manual master dispatch always builds both profiles. PR previews -build and package only Dev on macOS, Linux and Windows, including the matching -Linux and Windows downloaded-package verifiers; Prod packages remain covered by master builds. -PRs keep their merge-base selection and signing stays confined to trusted master. +Agent desktop packages build on master pushes whose own diff changes Agent +inputs (`plan.py only agent-desktop`), and on every manual master dispatch. +Packaging-input PRs rehearse unsigned Dev packages on Linux and macOS in +`ci.yml` (Windows with the `ci:package` label); signing stays confined to +trusted master. Research desktop, Android and iOS PR workflows group runs by workflow and PR. A new run cancels earlier runs for that same unsigned preview; unrelated PRs and signed, master and manual queues keep their existing behavior. @@ -133,34 +131,27 @@ lives under `RUNNER_TEMP`, so separate Nix shells reach the same server despite their different `TMPDIR` values. Cache archive restoration and compiler hits remain separate measurements. -## Agent compilation and cache measurements - -Agent PR checks run in `ci.yml` (`agent-lint`, `agent-test`, `agent-macos`; -`agent-windows` on Windows paths or the `ci:windows` label), selected by -`scripts/ci/lanes.py` and reported by its `ci-ok` job. Full macOS and Windows -builds run on master: Agent CI (`agent-ci.yml`) runs on master pushes and -dispatch only, one never-cancelled run per commit, with the three-platform -workspace matrix, three-platform upstream SQLCipher provider suite, and plain -Linux optimized binary as independent jobs. The final `Agent CI required jobs` -check rejects failed or unexpectedly skipped selected jobs. The plain Linux -binary and Desktop Dev/Prod packages have separate profiles and caches. - -Before restoring targets, `scripts/ci/agent_cache_scope.py` wraps the existing -`agent_cache.py` identity without changing its legacy digest. It fingerprints -Rust inside the component Nix shell, or the explicitly selected Windows MSVC -environment, plus the native ABI/toolchain, flags, profile, Cargo configuration -and provider inputs. For a known native target and modern Cargo resolver it -omits only dependency declarations and feature edges proved inactive on that -target. Unknown cfg expressions, target overrides and ambiguous feature edges -stay conservative. Real compiler/native changes still invalidate; the original -identity remains available for legacy bootstrap. - -The component shell pins Rustup proxies to its Nix toolchain so Cargo's external -Clippy/rustfmt commands cannot silently select a host compiler. Windows setup -pins Cargo's linker to the selected MSVC `link.exe` and compiles/runs an offline -Cargo build-script fixture before restore. Cache probes use -`agent_cache_command.py` to identify the selected compiler while excluding its -optional incidental installed-Rustup inventory. +## Agent lanes and caches + +Agent checks are `ci.yml` lanes in `scripts/ci/lanes.py`, reported by `ci-ok`. +PRs run `agent-lint`, `agent-test` and `agent-macos`; `agent-windows` on Windows +paths or the `ci:windows` label; `agent-supply-chain` and `agent-provider` when +dependency inputs change; and `agent-package-linux`/`agent-package-macos` when +packaging inputs change (`ci:package` adds Windows). Master pushes also run +`agent-windows` and `agent-full-{linux,macos,windows}`, never cancelled; +`ci:full` runs every lane on a PR and `ci:perf` the store benchmark. +`nightly.yml` runs the advisories, SQLCipher sources, the provider suite on all +three OSes and the store benchmark, and keeps one `ci-nightly` issue. + +Lane caches use `Swatinem/rust-cache` with exact keys (`ci-v1--` plus a +hash of the Agent `flake.lock` and `Cargo.lock`), written only by master pushes +and the nightly `ci.yml` run, on Blacksmith only. Windows has no Nix: +`.github/actions/setup-agent-windows` pins Rust (`agent-lint` checks it equals +the flake's), MSVC, native Perl and the linker. The unsigned +`agent-desktop-build.yml` builders restore and, on master pushes, save the +exact key `ci-v1-agent-release--` with no fallback; +the PR packaging lanes only restore it, and signing, attestation and verifier +jobs restore nothing. `agent-windows-updater-tests.yml` separately runs native updater, filesystem, Authenticode rejection and application bootstrap component tests on @@ -190,69 +181,15 @@ expired references must be selected again explicitly. Its result proves only historical signature interoperability, not the new updater's installed upgrade or final-source release qualification. -Agent workspace, provider, plain release and Desktop Dev/Prod builders first -try `agent_dependency_archive.py` through the Actions artifact service. A -consumer accepts only this repository's designated push/master workflow and -its exact matching platform/profile job. GitHub run/attempt, source commit, -artifact creation interval and completed-successful producer job/steps must -agree with the manifest. This proves that dependency seed's producer job; it -does not declare unrelated workflow jobs or product checks green. Actual -compiler/native/profile compatibility must match, and providers additionally -require their exact verified upstream test-only dependency fingerprint. -Workspace/release archives record their dependency graph; Cargo still resolves -the current locked graph and checks freshness after a compatible restore. - -The archive includes only crates.io and reviewed, pinned public Git dependencies, -registry/index data and their permitted dependency build outputs. New Git -repositories require public-source review before entering the helper's allowlist; -unknown sources and colliding package/target names are excluded. Local/path -crates, application/test executables, app bundles, notices, credentials and -Git hooks are excluded. The helper verifies the API's artifact ZIP digest, -manifest/archive digest, bounded sizes/file counts and safe dependency-only -paths before extraction; it rejects links, special files, traversal and path -aliases. Missing, incompatible or unproven artifacts fall back to native -bootstrap or a cold build. These archives remain executable dependency build -inputs, so trusted provenance is required in addition to checksums. PRs only -read them and run with no signing or publication credentials. - -Native `rust-cache` and exact provider-cache lookups are now read-only bootstrap -sources. The initial lookup retains the scoped compiler/dependency key; an -older compatible namespace can be searched with the unchanged legacy identity -only when actual dependency libraries/fingerprints were not restored. It does -not rely on `CACHEDIR.TAG`, which can be absent from an otherwise usable archive. -Provider bootstrap never falls back across verified test-only graphs. - -After its existing validation passes, only a successful master-push builder -prepares/uploads a dependency seed. Preparation or required upload failure -fails the job instead of becoming a cache-save warning. Uploads have three-day -retention. A successful master builder can reuse its proven restore receipt -without repacking only when the dependency graph still matches and the seed -was created within the previous 24 hours; otherwise it refreshes the seed. -This refresh occurs during selected master builds, not a new daily dispatch. -Each builder preserves its platform/profile/job boundary; signed packages and -publication artifacts are separate outputs. - -Agent CI also compares master cumulatively against the last successful -ancestor whose three workspace jobs, three provider jobs and plain Linux -release job actually passed. Desktop packaging retains its independent -all-six-downloaded-verifier baseline. Selector-only, failed or cancelled runs -cannot consume outstanding changes; lookup/history uncertainty selects a build. - -Unsigned builders upload seven-day `maple-agent-*-metrics-*` artifacts with -actual environment identity, phase durations, logical target sizes, and bounded -current Cargo timing HTML. Action logs report exact/restore keys and transferred -cache bytes; logical target size is not compressed cache size. Windows build -and test are separate phases. See [the Agent performance audit](agent-ci-performance.md) -for the baseline, tradeoffs and cold/warm validation procedure. - ## macOS runners and Apple toolchain Research desktop/iOS builds, Agent release-binary jobs and native proxy macOS archives use ARM64 `blacksmith-6vcpu-macos-26` runners. -Artifact verifiers, TestFlight uploads, and Agent preview packaging/protected -signing use standard GitHub `macos-26` runners. Research signed builders retain -their existing `desktop-signing` or `apple-signing` environment, so their -provider receives that environment's secret context. Existing master pushes +Artifact verifiers, TestFlight uploads and Agent protected signing use standard +GitHub `macos-26` runners; Agent PR packaging rehearsals use Blacksmith. +Research signed builders retain their existing `desktop-signing` or +`apple-signing` environment, so their provider receives that environment's +secret context. Existing master pushes can build and upload independent Dev/Prod TestFlight artifacts after verification. The Agent's final signed-package provenance identifies its GitHub packaging/signing job; Blacksmith is trusted for prebuilt binary integrity. @@ -280,14 +217,15 @@ independent; there is no separate ONNX cache warmer. ## Windows runner pilot -Agent Windows x64 release binaries use standard GitHub `windows-2025`, pinned -Rust/MSVC selection, static CRT linkage and the selected SDK's shader compiler. -Credential-free builders upload the executable and third-party notices. A fresh +Agent Windows x64 release binaries build on `blacksmith-8vcpu-windows-2025` with +pinned Rust/MSVC selection, static CRT linkage and the selected SDK's shader +compiler. Credential-free builders upload the executable and third-party notices. A fresh master job inspects embedded PE profile/source metadata without executing the artifact, then reuses `windows-signing` and the existing Azure Artifact Signing helpers for the app, NSIS uninstaller and installer. Separate native verifiers check downloaded provenance, payload identity, signatures and isolated -install/uninstall. PRs build unsigned Dev installers without signing credentials. +install/uninstall; signing and verification stay on GitHub's `windows-2025`. +The `ci:package` PR label builds an unsigned Dev installer without credentials. These packages are Actions downloads; Windows automatic updates and built-in computer-use remain unsupported. diff --git a/scripts/ci/agent_cache.py b/scripts/ci/agent_cache.py deleted file mode 100644 index 2c8cccab2..000000000 --- a/scripts/ci/agent_cache.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env python3 -"""Fingerprint the actual Agent build environment before restoring Cargo targets. - -Run inside the component's pinned Nix shell, or the selected Windows MSVC -developer environment. Cargo.lock deliberately belongs to rust-cache's exact -dependency suffix, not this compatibility boundary. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import shlex -import shutil -import subprocess -import sys -import tomllib - - -ROOT = Path(__file__).resolve().parents[2] -COMPONENT = ROOT / "apps/maple-agent" -SCHEMA = 1 -CONFIG_FILES = ( - "apps/maple-agent/dependencies/sqlcipher/provenance.json", - "apps/maple-agent/dependencies/sqlcipher/verify_sources.py", - "apps/maple-agent/scripts/verify-sqlcipher-build.py", - "scripts/ci/agent_cache.py", -) -ENV_KEYS = { - "RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "RUSTDOCFLAGS", "RUSTC", - "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER", "CARGO_INCREMENTAL", - "RUSTC_BOOTSTRAP", "RUSTUP_TOOLCHAIN", "CARGO_BUILD_TARGET", - "CARGO_BUILD_RUSTFLAGS", "CARGO_BUILD_RUSTC", "CARGO_BUILD_RUSTC_WRAPPER", - "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", "PKG_CONFIG", "CMAKE", - "CC", "CXX", "AR", "CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS", - "NIX_CFLAGS_COMPILE", "NIX_LDFLAGS", "NIX_CC", "NIX_BINTOOLS", - "NIX_ENFORCE_PURITY", "SDKROOT", "DEVELOPER_DIR", - "MACOSX_DEPLOYMENT_TARGET", "MAPLE_RELEASE_PROFILE", - "VITE_OPEN_SECRET_PCR_ENVIRONMENT", "OPENSSL_SRC_PERL", - "INCLUDE", "LIB", "LIBPATH", "VCToolsInstallDir", "VCToolsVersion", - "WindowsSdkDir", "WindowsSDKVersion", "WindowsSDKLibVersion", - "VSCMD_ARG_TGT_ARCH", "VSCMD_ARG_HOST_ARCH", -} -ENV_PREFIXES = ( - "CARGO_PROFILE_", "CARGO_TARGET_", "CFLAGS_", "CXXFLAGS_", "CC_", - "CXX_", "AR_", "OPENSSL_", "SQLCIPHER_", "SQLITE_", "LIBSQLITE3_", - "PKG_CONFIG_", "CMAKE_", "NIX_CFLAGS_", "NIX_LDFLAGS_", -) -# These labels describe the actual commands retained by agent-ci.yml and the -# release helper. Relevant manifest tables and the command owners are hashed -# below, so edits to their features/profile settings cannot cross this boundary. -PROFILES = { - "ci": ["workspace:all-targets", "workspace:test", "app:acp,proxy", "app:acp", "app:proxy"], - "release": ["release:maple-agent-app:default-features"], - "provider": ["test:bundled-sqlcipher,modern-full", "libsqlite3-sys:bundled-sqlcipher-openssl4"], -} - - -def digest(value: object) -> str: - return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - - -def file_digest(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def command(arguments: list[str], *, allowed: tuple[int, ...] = (0,)) -> str: - selected = subprocess.run(arguments, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) - output = "\n".join(part.strip() for part in (selected.stdout, selected.stderr) if part.strip()) - if selected.returncode not in allowed: - raise ValueError(f"cannot identify build tool {arguments[0]} (exit {selected.returncode})") - if not output: - raise ValueError(f"build tool returned no identity: {arguments[0]}") - return output - - -def executable(name: str) -> str: - found = shutil.which(name) - if not found: - raise ValueError(f"required build tool is missing: {name}") - return str(Path(found).resolve()) - - -def compiler_identity(selection: str) -> dict[str, object]: - arguments = shlex.split(selection) - if not arguments: - raise ValueError("native compiler selection is empty") - path = executable(arguments[0]) - return { - "command": [path, *arguments[1:]], - "version": command([path, *arguments[1:], "--version"]), - "target": command([path, *arguments[1:], "-dumpmachine"]), - } - - -def native_build_tools(environment: dict[str, str]) -> dict[str, object]: - perl = executable(environment.get("OPENSSL_SRC_PERL", "perl")) - result = {"perl": {"path": perl, "version": command([perl, "-e", "print qq($^V\\n)"])}} - # openssl-src selects NASM when present, otherwise builds without assembly. - # Include absence explicitly so runner-image changes cannot cross that - # native-configuration boundary using a compatible target-cache restore. - for name in ("nasm", "cmake", "pkg-config"): - selected = shutil.which(name) - if not selected: - result[name] = None - continue - path = Path(selected).resolve() - identity = {"path": str(path), "sha256": file_digest(path), "version": None} - # Windows images may contain an incidental pkg-config.BAT whose Perl - # wrapper cannot even report its version. Bind its bytes and probe - # outcome without requiring an optional tool that this lane may not use. - # Required compiler/Perl probes and the actual build gates still fail. - try: - probe = subprocess.run([str(path), "--version"], capture_output=True, - text=True, encoding="utf-8", errors="replace", check=False, - timeout=10) - identity["version_probe_exit"] = probe.returncode - if probe.returncode == 0: - identity["version"] = "\n".join( - part.strip() for part in (probe.stdout, probe.stderr) if part.strip()) or None - except (OSError, subprocess.TimeoutExpired) as error: - identity["version_probe_error"] = type(error).__name__ - result[name] = identity - return result - - -def native_identity(system: str, environment: dict[str, str]) -> dict[str, object]: - if system == "Windows": - # Python exposes uppercase keys on Windows; copied environment mappings - # no longer retain os.environ's case-insensitive lookup behavior. - canonical = {key.upper(): key for key in ENV_KEYS} - environment = {canonical.get(key.upper(), key.upper()): value for key, value in environment.items()} - required = ("VCToolsInstallDir", "WindowsSdkDir", "WindowsSDKVersion") - if any(not environment.get(key) for key in required): - raise ValueError("select the MSVC developer environment before fingerprinting Windows targets") - compiler = Path(executable("cl.exe")).resolve() - toolset = Path(environment["VCToolsInstallDir"]).resolve() - if not compiler.is_relative_to(toolset): - raise ValueError("cl.exe is outside the selected MSVC toolset") - # cl's driver banner alone does not identify its compiler/linker DLLs. - binaries = {} - for name in ("cl.exe", "c1.dll", "c1xx.dll", "c2.dll", "link.exe"): - candidate = compiler.parent / name - if not candidate.is_file(): - raise ValueError(f"selected MSVC tool is missing: {name}") - binaries[name] = file_digest(candidate) - # A developer environment can make Rust trust Git Bash's PATH, where - # Git's unrelated link.exe shadows MSVC. Hashing the adjacent MSVC - # binary is insufficient unless Cargo is explicitly configured to use it. - configured_linker = environment.get("CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER") - linker = compiler.parent / "link.exe" - if not configured_linker or Path(configured_linker).resolve() != linker: - raise ValueError("pin Cargo's Windows linker to the selected MSVC link.exe before cache restore") - sdk = Path(environment["WindowsSdkDir"]).resolve() - sdk_version = environment["WindowsSDKVersion"].rstrip("/\\") - if not (sdk / "Include" / sdk_version).is_dir() or not (sdk / "Lib" / sdk_version).is_dir(): - raise ValueError("selected Windows SDK headers or libraries are missing") - return { - "compiler": str(compiler), "toolset": str(toolset), - "compiler_version": command([str(compiler)], allowed=(0, 2)), - "linker": {"path": str(linker), "sha256": binaries["link.exe"]}, - "binaries": binaries, "sdk": str(sdk), "sdk_version": sdk_version, - } - result = { - "cc": compiler_identity(environment.get("CC", "cc")), - "cxx": compiler_identity(environment.get("CXX", "c++")), - } - if system == "Darwin": - result["xcode"] = command(["/usr/bin/xcodebuild", "-version"]) - result["sdk_path"] = command(["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path"]) - result["sdk_version"] = command(["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-version"]) - return result - - -def dependency_feature_settings(value: dict, prefix: str = "") -> dict[str, object]: - """Keep feature selection at workspace and target scopes, excluding versions.""" - settings = {} - for key, child in value.items(): - if not isinstance(child, dict): - continue - name = f"{prefix}.{key}" if prefix else key - if key in ("dependencies", "dev-dependencies", "build-dependencies"): - for dependency, selection in child.items(): - if isinstance(selection, dict): - flags = {field: selection[field] for field in - ("features", "default-features", "optional", "workspace", "package") - if field in selection} - if flags: - settings[f"{name}.{dependency}"] = flags - else: - settings.update(dependency_feature_settings(child, name)) - return settings - - -def manifest_settings(root: Path) -> dict[str, object]: - component = root / "apps/maple-agent" - manifests = sorted({ - component / "Cargo.toml", component / "app/Cargo.toml", - *component.glob("crates/*/Cargo.toml"), - root / "sdk/rust/Cargo.toml", root / "proxy/Cargo.toml", - }) - result = {} - for path in manifests: - manifest = tomllib.loads(path.read_text(encoding="utf-8")) - package = manifest.get("package", {}) - workspace = manifest.get("workspace", {}) - # Package versions and dependency locks belong to the exact key. These - # settings instead affect the ABI, enabled code, or build profile. - result[str(path.relative_to(root))] = { - "features": manifest.get("features", {}), - "dependency-features": dependency_feature_settings(manifest), - "profile": manifest.get("profile", {}), - "resolver": manifest.get("resolver", workspace.get("resolver")), - "edition": package.get("edition", workspace.get("package", {}).get("edition")), - "rust-version": package.get("rust-version", workspace.get("package", {}).get("rust-version")), - } - return result - - -def cargo_configuration(directory: Path, root: Path, cargo_home: Path) -> dict[str, str]: - """Hash config contents without exposing external paths or config secrets.""" - result = {} - directory, root = directory.resolve(), root.resolve() - for depth, ancestor in enumerate((directory, *directory.parents)): - label = str(ancestor.relative_to(root)) if ancestor.is_relative_to(root) else f"ancestor-{depth}" - for name in ("config", "config.toml"): - path = ancestor / ".cargo" / name - if path.is_file(): - result[f"{label}/.cargo/{name}"] = file_digest(path) - for name in ("config", "config.toml"): - path = cargo_home / name - if path.is_file(): - result[f"cargo-home/{name}"] = file_digest(path) - return result - - -def configuration_files(system: str, profile: str) -> list[str]: - """Bind command owners only where that platform/profile executes them.""" - files = list(CONFIG_FILES) - if system != "Windows": - files.extend(("apps/maple-agent/flake.nix", "apps/maple-agent/flake.lock")) - else: - files.append("scripts/ci/agent_windows.py") - if system == "Linux" and profile == "ci": - # Mac/Windows call Cargo directly. Keep the whole Just command surface - # conservative for Linux, including recipe variables and dependencies. - files.append("apps/maple-agent/justfile") - if profile == "provider": - files.append("apps/maple-agent/scripts/test-rusqlite-provider.py") - if profile == "release": - files.append("apps/maple-agent/scripts/build-release.sh") - return files - - -def configuration(root: Path, system: str, profile: str, - cargo_config_dir: Path | None = None) -> dict[str, object]: - files = {name: file_digest(root / name) for name in configuration_files(system, profile)} - for directory in (root, root / "apps", root / "apps/maple-agent"): - for name in ("rust-toolchain", "rust-toolchain.toml"): - path = directory / name - if path.is_file(): - files[str(path.relative_to(root))] = file_digest(path) - cargo_home = Path(os.environ["CARGO_HOME"]).expanduser() if os.environ.get("CARGO_HOME") else Path.home() / ".cargo" - configs = {"workspace": cargo_configuration(root / "apps/maple-agent", root, cargo_home)} - if cargo_config_dir is not None: - # The provider suite executes Cargo from its copied upstream source, - # with a distinct hierarchy. Fingerprint after preparation so any - # upstream .cargo configuration is present before target-cache restore. - configs["suite"] = cargo_configuration(cargo_config_dir, root, cargo_home) - return {"files": files, "manifests": manifest_settings(root), "cargo_configs": configs} - - -def build_identity(profile: str, root: Path = ROOT, cargo_config_dir: Path | None = None) -> dict[str, object]: - system = platform.system() - rustc = executable(os.environ.get("RUSTC", os.environ.get("CARGO_BUILD_RUSTC", "rustc"))) - cargo = executable("cargo") - if system != "Windows" and (not rustc.startswith("/nix/store/") or not cargo.startswith("/nix/store/")): - raise ValueError("fingerprint Agent targets inside the component's pinned Nix shell") - environment = dict(os.environ) - if system == "Windows": - canonical = {key.upper(): key for key in ENV_KEYS} - environment = {canonical.get(key.upper(), key.upper()): value for key, value in environment.items()} - if profile == "release" and environment.get("MAPLE_RELEASE_PROFILE"): - variant = environment["MAPLE_RELEASE_PROFILE"] - if variant not in ("dev", "prod"): - raise ValueError("unsupported embedded Agent release profile") - # build-release.sh applies these values after cache restore. Record the - # effective build settings here as well as hashing that script below. - environment["VITE_OPEN_SECRET_PCR_ENVIRONMENT"] = "development" if variant == "dev" else "production" - if system == "Darwin": - environment["MACOSX_DEPLOYMENT_TARGET"] = "15.0" - selected_environment = { - key: value for key, value in environment.items() - if key in ENV_KEYS or key.startswith(ENV_PREFIXES) - } - return { - "schema": SCHEMA, "profile": profile, "features": PROFILES[profile], - "platform": system, "architecture": platform.machine(), - "runner_os": environment.get("RUNNER_OS"), "runner_arch": environment.get("RUNNER_ARCH"), - "rustc": {"path": rustc, "version": command([rustc, "-vV"])}, - "cargo": {"path": cargo, "version": command([cargo, "-V"])}, - # Cargo resolves external subcommands separately from rustc. Record the - # compiler-driving Clippy actually used by the Linux CI feature matrix. - "clippy": command([cargo, "clippy", "--version"]) if system == "Linux" and profile == "ci" else None, - "native": native_identity(system, environment), - "native_build_tools": native_build_tools(environment), - "environment": selected_environment, - "configuration": configuration(root, system, profile, cargo_config_dir), - } - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--profile", choices=PROFILES, required=True) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--cargo-config-dir", type=Path, help="also fingerprint Cargo configuration at this invocation directory") - arguments = parser.parse_args() - identity = build_identity(arguments.profile, cargo_config_dir=arguments.cargo_config_dir) - compatibility = digest(identity) - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(json.dumps({"compatibility": compatibility, **identity}, indent=2, sort_keys=True) + "\n", encoding="utf-8") - if os.environ.get("GITHUB_OUTPUT"): - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: - output.write(f"compatibility={compatibility}\n") - print(f"Agent {arguments.profile} cache compatibility: {compatibility}") - print(identity["rustc"]["version"]) - print(f"Native environment: {identity['platform']}/{identity['architecture']}; details: {arguments.output}") - - -if __name__ == "__main__": - try: - main() - except (OSError, ValueError, subprocess.SubprocessError) as error: - print(f"Agent cache fingerprint failed: {error}", file=sys.stderr) - sys.exit(1) diff --git a/scripts/ci/agent_cache_command.py b/scripts/ci/agent_cache_command.py deleted file mode 100644 index c38289b96..000000000 --- a/scripts/ci/agent_cache_command.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Run rust-cache probes in the Agent build environment. - -The action already passes tokenized arguments through cmd-format. Suppress only -its optional ambient Rustup inventory: that inventory does not select the -compiler used by these jobs. Actual Rust/Cargo probes must execute normally. -""" - -from __future__ import annotations - -from pathlib import Path -import platform -import subprocess -import sys - - -COMPONENT = Path(__file__).resolve().parents[2] / "apps/maple-agent" -OPTIONAL_TOOLCHAIN_LIST = ("rustup", "toolchain", "list", "--quiet") - - -def main(arguments: list[str] | None = None) -> int: - arguments = list(sys.argv[1:] if arguments is None else arguments) - if tuple(arguments) == OPTIONAL_TOOLCHAIN_LIST: - return 0 - if not arguments: - print("Agent cache command requires a probe executable", file=sys.stderr) - return 2 - executable = arguments[0].replace("\\", "/").rsplit("/", 1)[-1].lower() - if executable in ("rustup", "rustup.exe"): - print("Unexpected Rustup probe in Agent cache command", file=sys.stderr) - return 2 - command = arguments if platform.system() == "Windows" else [ - "nix", "develop", "--no-update-lock-file", str(COMPONENT), "-c", *arguments, - ] - try: - # Inherit both output streams. No joining, reparsing or shell expansion: - # paths with spaces and every argument retain the action's tokenization. - return subprocess.run(command, shell=False, check=False).returncode - except OSError as error: - print(f"Cannot run Agent cache probe: {error}", file=sys.stderr) - return 127 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/agent_cache_scope.py b/scripts/ci/agent_cache_scope.py deleted file mode 100644 index 79c8f8ccf..000000000 --- a/scripts/ci/agent_cache_scope.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -"""Scope Agent manifest cache inputs without changing the legacy fingerprint. - -Only dependencies proven inactive on this native target can be omitted. The -original identity remains available for an exact, independently checked cache -migration; compiler, native ABI, flags, profiles and Cargo configuration retain -their existing guards. -""" - -from __future__ import annotations - -import argparse -import copy -import json -import os -from pathlib import Path, PurePosixPath, PureWindowsPath -import re -import subprocess -import sys -import tomllib - -import agent_cache - - -# Bump this policy version when compatibility semantics change. Documentation -# or other source-only edits must not create a new cache namespace; normalized -# compiler/native/build inputs remain part of the canonical identity itself. -SCOPE_SCHEMA = 1 -DEPENDENCY_KINDS = ("dependencies", "dev-dependencies", "build-dependencies") -TOKEN = re.compile(r'\s*(?:(?P[A-Za-z_][A-Za-z_0-9]*)|(?P"(?:[^"\\]|\\.)*")|(?P[(),=]))') -# These are the native targets selected by the Agent workflows. A triple's -# architecture is not generally its first segment (i686 uses cfg arch=x86), -# and Python's platform name cannot identify Android or another custom target. -# Unknown native targets keep every manifest input until explicitly supported. -NATIVE_HOSTS = { - "x86_64-pc-windows-msvc": ("Windows", "windows", "x86_64"), - "x86_64-unknown-linux-gnu": ("Linux", "linux", "x86_64"), - "aarch64-apple-darwin": ("Darwin", "macos", "aarch64"), - "x86_64-apple-darwin": ("Darwin", "macos", "x86_64"), -} - - -class CfgParser: - """Evaluate a small known cfg subset, preserving unknowns in three values.""" - - def __init__(self, expression: str, values: dict[str, str | bool]): - self.tokens = [] - offset = 0 - while expression[offset:].strip(): - match = TOKEN.match(expression, offset) - if match is None: - raise ValueError("unknown cfg syntax") - self.tokens.append((match.lastgroup, match.group(match.lastgroup))) - offset = match.end() - self.index = 0 - self.values = values - - def pop(self, expected: str | None = None) -> tuple[str, str]: - if self.index >= len(self.tokens): - raise ValueError("incomplete cfg expression") - token = self.tokens[self.index] - if expected is not None and token[1] != expected: - raise ValueError("unexpected cfg token") - self.index += 1 - return token - - def peek(self) -> str | None: - return self.tokens[self.index][1] if self.index < len(self.tokens) else None - - def expression(self) -> bool | None: - kind, name = self.pop() - if kind != "word": - raise ValueError("expected cfg identifier") - if self.peek() == "=": - self.pop("=") - kind, literal = self.pop() - if kind != "string": - raise ValueError("expected cfg string") - value = self.values.get(name) - return value == json.loads(literal) if isinstance(value, str) else None - if self.peek() != "(": - value = self.values.get(name) - return value if isinstance(value, bool) else None - self.pop("(") - children = [] - while self.peek() != ")": - children.append(self.expression()) - if self.peek() != ",": - break - self.pop(",") - self.pop(")") - if name == "all": - return False if False in children else (True if all(x is True for x in children) else None) - if name == "any": - return True if True in children else (False if all(x is False for x in children) else None) - if name == "not" and len(children) == 1: - return None if children[0] is None else not children[0] - return None - - -def native_target(identity: dict) -> tuple[str, dict[str, str | bool]] | None: - """Do not infer cfg when Cargo configuration or overrides can select it.""" - configuration = identity.get("configuration", {}) - if any(configuration.get("cargo_configs", {}).values()): - return None - environment = identity.get("environment", {}) - if any(environment.get(key) for key in ( - "CARGO_BUILD_TARGET", "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER", - "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", - )): - return None - for key, value in environment.items(): - if "RUSTFLAGS" in key and re.search(r"--(?:cfg|target)(?:[=\s\x1f]|$)", value): - return None - match = re.search(r"^host:\s*(\S+)\s*$", identity.get("rustc", {}).get("version", ""), re.MULTILINE) - if match is None: - return None - host = match.group(1) - native = NATIVE_HOSTS.get(host) - if native is None or native[0] != identity.get("platform"): - return None - _, target_os, architecture = native - return host, { - "target_os": target_os, - "target_arch": architecture, - "target_family": "windows" if target_os == "windows" else "unix", - "windows": target_os == "windows", - "unix": target_os != "windows", - } - - -def target_matches(target: str, context: tuple[str, dict[str, str | bool]]) -> bool | None: - host, values = context - if not target.startswith("cfg("): - # Literal target triples are Cargo's other supported target-table form. - return target == host if re.fullmatch(r"[A-Za-z0-9_.-]+", target) else None - if not target.endswith(")"): - return None - try: - parser = CfgParser(target[4:-1], values) - result = parser.expression() - return result if parser.index == len(parser.tokens) else None - except ValueError: - return None - - -def manifest_path(root: Path, relative: str) -> Path: - normalized = PurePosixPath(relative.replace("\\", "/")) - if normalized.is_absolute() or PureWindowsPath(relative).drive or ".." in normalized.parts: - raise ValueError("manifest identity must use a repository-relative path") - return root.joinpath(*normalized.parts) - - -def scoped_manifest(settings: dict, manifest: dict, context: tuple[str, dict[str, str | bool]]) -> dict: - result = copy.deepcopy(settings) - declarations: dict[str, list[tuple[bool | None, object]]] = {} - inactive = [] - for kind in DEPENDENCY_KINDS: - for name, selection in manifest.get(kind, {}).items(): - declarations.setdefault(name, []).append((True, selection)) - for target, tables in manifest.get("target", {}).items(): - matches = target_matches(target, context) - for kind in DEPENDENCY_KINDS: - for name, selection in tables.get(kind, {}).items(): - declarations.setdefault(name, []).append((matches, selection)) - if matches is False: - inactive.append((f"target.{target}.{kind}.{name}", name, - isinstance(selection, dict) and selection.get("optional") is True)) - - features = manifest.get("features", {}) - # A plain optional-dependency feature or a strong dep/feature edge may - # activate the package's own cfg(feature). Keep these cases conservative. - ambiguous = set(features) - explicit_dependencies = set() - for selections in features.values(): - for selection in selections: - if selection.startswith("dep:"): - explicit_dependencies.add(selection[4:]) - if not selection.startswith("dep:") and "?/" not in selection: - ambiguous.add(selection.split("/", 1)[0]) - inactive_optional = { - name for name, selections in declarations.items() - if name not in ambiguous and name in explicit_dependencies and all( - matches is False and isinstance(selection, dict) and selection.get("optional") is True - for matches, selection in selections - ) - } - dependency_features = result.get("dependency-features", {}) - for key, name, optional in inactive: - # Even an inactive optional declaration can create a local implicit - # feature. Do not erase its input when feature edges are ambiguous. - # Without dep:name Cargo creates an implicit local feature, including - # under --all-features, even if the target dependency is not compiled. - if name not in ambiguous and (not optional or name in inactive_optional): - dependency_features.pop(key, None) - for name, selections in result.get("features", {}).items(): - result["features"][name] = [ - selection for selection in selections - if not ( - selection.startswith("dep:") and selection[4:] in inactive_optional - or "?/" in selection and selection.split("?/", 1)[0] in inactive_optional - ) - ] - return result - - -def canonical_identity(identity: dict, root: Path = agent_cache.ROOT) -> dict: - result = copy.deepcopy(identity) - result["scope_schema"] = SCOPE_SCHEMA - context = native_target(identity) - if context is None: - return result - manifests = result.get("configuration", {}).get("manifests", {}) - workspace = next((settings for relative, settings in manifests.items() - if relative.replace("\\", "/") == "apps/maple-agent/Cargo.toml"), {}) - # Resolver 1 can unify features from inactive target dependencies. Only the - # explicitly selected modern workspace resolvers give this pruning rule. - if str(workspace.get("resolver")) not in ("2", "3"): - return result - for relative, settings in manifests.items(): - manifest = tomllib.loads(manifest_path(root, relative).read_text(encoding="utf-8")) - manifests[relative] = scoped_manifest(settings, manifest, context) - return result - - -def build_document(profile: str, root: Path = agent_cache.ROOT, - cargo_config_dir: Path | None = None) -> dict: - legacy = agent_cache.build_identity(profile, root, cargo_config_dir) - canonical = canonical_identity(legacy, root) - return { - **canonical, - "compatibility": agent_cache.digest(canonical), - "legacy_compatibility": agent_cache.digest(legacy), - "legacy_identity": legacy, - } - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--profile", choices=agent_cache.PROFILES, required=True) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--cargo-config-dir", type=Path) - arguments = parser.parse_args() - document = build_document(arguments.profile, cargo_config_dir=arguments.cargo_config_dir) - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") - if os.environ.get("GITHUB_OUTPUT"): - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: - for key in ("compatibility", "legacy_compatibility"): - output.write(f"{key}={document[key]}\n") - print(f"Agent {arguments.profile} scoped cache compatibility: {document['compatibility']}") - print(f"Legacy cache compatibility: {document['legacy_compatibility']}") - print(document["rustc"]["version"]) - print(f"Native environment: {document['platform']}/{document['architecture']}; details: {arguments.output}") - - -if __name__ == "__main__": - try: - main() - except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: - print(f"Agent cache scope failed: {error}", file=sys.stderr) - sys.exit(1) diff --git a/scripts/ci/agent_change_detection.py b/scripts/ci/agent_change_detection.py deleted file mode 100644 index 2b819df20..000000000 --- a/scripts/ci/agent_change_detection.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -"""Select Agent CI without rebuilding either application for the other's changes.""" - -from __future__ import annotations - -import argparse -from collections.abc import Iterable -import sys - -from change_detection import DESKTOP_PLATFORMS, classify_path as research_routes - - -AGENT_PREFIX = "apps/maple-agent/" -AGENT_INERT_FILES = frozenset( - { - "AGENTS.md", - "CLAUDE.md", - "README.md", - "LICENSE", - ".gitignore", - } -) -AGENT_INERT_PREFIXES = ("docs/", ".githooks/") -SHARED_INPUTS = frozenset( - { - "flake.nix", - "flake.lock", - ".github/workflows/agent-ci.yml", - ".github/workflows/agent-desktop-build.yml", - "scripts/ci/agent_change_detection.py", - "scripts/ci/agent_master_selection.py", - "scripts/ci/agent_cache.py", - "scripts/ci/agent_cache_scope.py", - "scripts/ci/agent_dependency_archive.py", - "scripts/ci/agent_cache_command.py", - "scripts/ci/agent_windows.py", - "scripts/ci/agent_measure.py", - "scripts/ci/change_detection.py", - "scripts/ci/verify-agent-rust-deps.py", - "scripts/ci/apple-toolchain.json", - "scripts/ci/select-xcode.py", - "scripts/ci/install-windows-artifact-signing.ps1", - "scripts/ci/windows-artifact-sign.ps1", - } -) -KNOWN_INDEPENDENT_PREFIXES = ( - "apps/maple-research/", - "apps/maple-auth/", - ".agents/", - ".github/", - ".githooks/", - "docs/", - "scripts/", - "services/", -) -KNOWN_INDEPENDENT_FILES = frozenset( - {"AGENTS.md", "README.md", "LICENSE", ".gitignore", ".gitmodules", ".dockerignore", - ".repo_ignore", "repo.meta.json", "justfile", "setup-hooks.sh"} -) - - -def affects_agent(path: str) -> bool: - if not path or path.startswith("/") or ".." in path.split("/"): - return True - if path in SHARED_INPUTS: - return True - if path.startswith(AGENT_PREFIX): - relative = path.removeprefix(AGENT_PREFIX) - return relative not in AGENT_INERT_FILES and not relative.startswith(AGENT_INERT_PREFIXES) - if path.startswith("proxy/"): - # Both desktop apps consume the local proxy crate. Reuse the existing - # distinction between runtime/build inputs and standalone docs/tests/locks. - return bool(research_routes(path) & DESKTOP_PLATFORMS) - if path.startswith("sdk/rust/"): - # The Agent builds sdk/rust from the tree, like the proxy crate. - return bool(research_routes(path) & DESKTOP_PLATFORMS) - if path.startswith("sdk/"): - # The TypeScript SDK is not an Agent input. - return False - if path in KNOWN_INDEPENDENT_FILES or path.startswith(KNOWN_INDEPENDENT_PREFIXES): - return False - # A newly introduced root may be a build input; don't silently skip it. - return True - - -def classify_paths(paths: Iterable[str]) -> bool: - return any(affects_agent(path) for path in paths) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--all", action="store_true", help="Select Agent when a diff is unavailable") - args = parser.parse_args() - paths = ( - path.decode("utf-8", errors="surrogateescape") - for path in sys.stdin.buffer.read().split(b"\0") if path - ) if not args.all else () - print(f"agent={'true' if args.all or classify_paths(paths) else 'false'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/agent_dependency_archive.py b/scripts/ci/agent_dependency_archive.py deleted file mode 100644 index d644c1480..000000000 --- a/scripts/ci/agent_dependency_archive.py +++ /dev/null @@ -1,822 +0,0 @@ -#!/usr/bin/env python3 -"""Recover trusted Cargo dependency caches through the Actions artifact service. - -This helper never uploads, executes restored files, or changes Cargo's freshness -checks. Native Cargo caches remain an independent read source. Only successful -push/master platform jobs can supply an artifact accepted by restore. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime, timedelta, timezone -import gzip -import hashlib -import io -import json -import math -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import stat -import sys -import tarfile -import tempfile -import time -import urllib.error -import urllib.parse -import urllib.request -import zipfile - - -SCHEMA = 1 -WORKFLOWS = {"agent-ci.yml", "agent-desktop-build.yml"} -# A clean hosted target is ~4.4 GB before dependency pruning, plus ~4.2 GB of -# public sources. Its prior zstd archive was already ~2.95 GB; allow gzip-1 -# headroom while retaining bounded downloads and three-day workflow retention. -MAX_ARCHIVE = 6 * 1024**3 -MAX_UNPACKED = 16 * 1024**3 -MAX_FILE = 3 * 1024**3 -MAX_FILES = 300_000 -MAX_MANIFEST = 512 * 1024 -MAX_CANDIDATES = 10 -MAX_AGE_DAYS = 7 -CHUNK = 1024 * 1024 -HASH = re.compile(r"[0-9a-f]{64}\Z") -SHA = re.compile(r"[0-9a-f]{40}\Z") -REPOSITORY = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\Z") -PACKAGE = re.compile(r"[A-Za-z0-9_-]+\Z") -RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))} -LIB_EXTENSIONS = {".rlib", ".rmeta", ".d", ".so", ".dylib", ".dll", ".lib", ".a", ".pdb", ".exp", - ".dll.lib", ".dll.exp", ".dll.pdb"} -# These source repositories were independently reviewed as public. A GitHub URL -# and pinned commit alone do not establish that cached repository data is public. -REVIEWED_GIT_REPOSITORIES = { - "1password/cpace", "mapleprivacylabs/rusqlite", "proptest-rs/proptest", "trycua/cua", - "zed-industries/font-kit", "zed-industries/scap", "zed-industries/wasm_thread", - "zed-industries/xim-rs", "zed-industries/zed", -} - - -class Rejected(ValueError): - """An untrusted, incompatible, unsafe, or oversized cache is not usable.""" - - -def sha256(path: Path) -> str: - result = hashlib.sha256() - with path.open("rb") as source: - for chunk in iter(lambda: source.read(CHUNK), b""): - result.update(chunk) - return result.hexdigest() - - -def json_read(path: Path, limit: int = MAX_MANIFEST): - if path.stat().st_size > limit: - raise Rejected("JSON document exceeds the cache limit") - return json.loads(path.read_text(encoding="utf-8")) - - -def environment(path: Path): - value = json_read(path) - compatibility = value.get("compatibility", "") - payload = {key: item for key, item in value.items() if key not in {"compatibility", "legacy_compatibility", "legacy_identity"}} - calculated = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - if not HASH.fullmatch(compatibility) or compatibility != calculated: - raise Rejected("actual compiler/native compatibility document is invalid") - if value.get("profile") not in ("ci", "provider", "release"): - raise Rejected("unsupported Cargo cache profile") - for key in ("platform", "architecture"): - if not re.fullmatch(r"[A-Za-z0-9_-]+", value.get(key, "")): - raise Rejected("invalid cache platform identity") - return value - - -def artifact_name(identity, dependency=""): - if identity["profile"] == "provider" and not HASH.fullmatch(dependency): - raise Rejected("provider archives require the verified test-only dependency fingerprint") - if dependency and not HASH.fullmatch(dependency): - raise Rejected("invalid dependency fingerprint") - suffix = f"-{dependency}" if dependency else "" - return f"maple-agent-deps-v{SCHEMA}-{identity['profile']}-{identity['platform']}-{identity['architecture']}-{identity['compatibility']}{suffix}" - - -def path_parts(name: str): - path = PurePosixPath(name) - if not name or not path.parts or path.is_absolute() or path.as_posix() != name: - raise Rejected("noncanonical archive path") - for part in path.parts: - if part in (".", "..") or any(ord(c) < 32 for c in part) or any(c in part for c in "\\:"): - raise Rejected("unsafe archive path") - if part.endswith((".", " ")) or part.split(".")[0].upper() in RESERVED: - raise Rejected("archive path aliases a Windows special name") - return path.parts - - -def trusted_producer(job_name, workflow): - repository = os.environ.get("GITHUB_REPOSITORY", "") - if not REPOSITORY.fullmatch(repository): - raise Rejected("a repository-scoped Actions context is required") - if os.environ.get("GITHUB_EVENT_NAME") != "push" or os.environ.get("GITHUB_REF") != "refs/heads/master": - raise Rejected("only push/master jobs may prepare dependency archives") - head = os.environ.get("GITHUB_SHA", "") - if not SHA.fullmatch(head): - raise Rejected("producer commit identity is missing") - run_id, attempt = int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]) - if run_id <= 0 or attempt <= 0 or not job_name or workflow not in WORKFLOWS: - raise Rejected("producer run, job or workflow identity is invalid") - return {"repository": repository, "run_id": run_id, "run_attempt": attempt, - "head_sha": head, "job_name": job_name, "workflow": workflow} - - -def packages(metadata): - """Use Cargo source identity, never workspace-directory location, as authority.""" - public, local = [], set() - for package in metadata["packages"]: - source = package.get("source") - names = {package["name"], *(target["name"] for target in package.get("targets", []) - if set(target["kind"]) & {"lib", "rlib", "proc-macro", "dylib", "staticlib", "cdylib"})} - if not all(PACKAGE.fullmatch(name) for name in names): - raise Rejected("invalid Cargo package/target name") - if source is None: - local.update(name.replace("-", "_") for name in names) - local.add(package["name"]) - continue - if source.startswith("registry+"): - allowed = source == "registry+https://github.com/rust-lang/crates.io-index" - elif source.startswith("git+"): - url = urllib.parse.urlsplit(source[4:]) - repository = url.path.strip("/").casefold().removesuffix(".git") - allowed = (url.scheme == "https" and url.hostname == "github.com" and not url.username - and not url.password and set(urllib.parse.parse_qs(url.query)) <= {"rev", "tag", "branch"} - and SHA.fullmatch(url.fragment) is not None and repository in REVIEWED_GIT_REPOSITORIES) - else: - allowed = False - if allowed: - public.append(package) - else: - # Unreviewed/private sources must not leak through target artifacts - # or through name collisions with an otherwise reviewed dependency. - local.update(name.replace("-", "_") for name in names) - local.add(package["name"]) - package_names = {package["name"] for package in public} - local - targets = {target["name"].replace("-", "_") for package in public - for target in package.get("targets", []) - if set(target["kind"]) & {"lib", "rlib", "proc-macro", "dylib", "staticlib", "cdylib"}} - local - return public, package_names, targets - - -def dependency_graph(metadata, public): - features = {node["id"]: sorted(node.get("features", [])) for node in metadata.get("resolve", {}).get("nodes", [])} - selected = sorted((package["name"], package["version"], package["source"], features.get(package["id"], [])) for package in public) - return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - - -def receipt_reader(job_name): - return {"run_id": os.environ.get("GITHUB_RUN_ID"), "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"), "job_name": job_name} - - -def receipt_location(receipt, target, cargo_home): - if receipt and (receipt.is_symlink() or receipt.resolve().is_relative_to(target.resolve()) or receipt.resolve().is_relative_to(cargo_home.resolve())): - raise Rejected("artifact receipts must be fresh job-local files outside restored Cargo caches") - - -def source_roots(public, cargo_home): - roots = set() - for package in public: - try: - relative = Path(package["manifest_path"]).resolve().relative_to(cargo_home) - except ValueError: - continue - parts = relative.parts - if len(parts) >= 5 and parts[:2] == ("registry", "src"): - roots.add(PurePosixPath(*parts[:4]).as_posix()) - elif len(parts) >= 5 and parts[:2] == ("git", "checkouts"): - roots.add(PurePosixPath(*parts[:4]).as_posix()) - roots.add(PurePosixPath("git", "db", parts[2]).as_posix()) - return roots - - -def tree_files(root: Path, skip=None): - """Dereference internal links; never put link entries in the portable archive.""" - if not root.exists(): - return - boundary = root.resolve() - - def walk(path, logical, ancestors): - if skip and skip(logical): - return - selected = path.resolve() - if not selected.is_relative_to(boundary): - raise Rejected("dependency tree contains a link outside its source/output root") - if selected.is_dir(): - if selected in ancestors or len(ancestors) >= 64: - raise Rejected("dependency tree contains a directory-link cycle") - for child in sorted(selected.iterdir()): - yield from walk(child, logical / child.name, ancestors | {selected}) - elif selected.is_file(): - yield selected, logical - else: - raise Rejected("dependency tree contains a nonregular file") - yield from walk(root, PurePosixPath(), set()) - - -def sanitized_git(relative, source, cargo_home): - """Return None to omit hooks, or bytes to replace local Git configuration.""" - parts = relative.parts - metadata = parts[:2] == ("git", "db") or ".git" in parts - if not metadata: - return False - if parts[0] == "git" and parts[-1] == ".git": - raise Rejected("regular .git pointers cannot enter a portable Cargo source cache") - if "hooks" in parts or source.name.endswith(".lock") or source.name == "config.worktree": - return None - if source.name == "config": - bare = parts[:2] == ("git", "db") - return f"[core]\nrepositoryformatversion = 0\nbare = {'true' if bare else 'false'}\nfilemode = false\n".encode() - if parts[-3:] == ("objects", "info", "alternates"): - lines = source.read_text(encoding="utf-8").splitlines() - replacements = [] - for line in lines: - destination = (source.parent / line).resolve() - if not destination.is_relative_to(cargo_home / "git/db") or not destination.is_dir(): - raise Rejected("Git object alternates escape the cached public repositories") - replacements.append(os.path.relpath(destination, source.parent).replace(os.sep, "/")) - return ("\n".join(replacements) + "\n").encode() - return False - - -def cargo_records(public, cargo_home): - roots = source_roots(public, cargo_home) - for root in sorted(roots): - if not (cargo_home / root).resolve().is_relative_to(cargo_home): - raise Rejected("Cargo source storage escapes its declared home") - def skip(relative): - return "hooks" in relative.parts and (root.startswith("git/db/") or ".git" in relative.parts) - for source, relative in tree_files(cargo_home / root, skip): - relative = PurePosixPath(root) / relative - replacement = sanitized_git(relative, source, cargo_home) - if replacement is not None: - yield source, "cargo/" + relative.as_posix(), replacement - # Registry archives and sparse index records are selected by Cargo metadata, - # never by copying the Cargo home (which contains credentials and binaries). - for package in public: - if not package["source"].startswith("registry+"): - continue - try: - parts = Path(package["manifest_path"]).resolve().relative_to(cargo_home).parts - except ValueError: - continue - if parts[:2] != ("registry", "src") or len(parts) < 5: - continue - registry = parts[2] - name = package["name"].lower() - index = f"1/{name}" if len(name) == 1 else f"2/{name}" if len(name) == 2 else f"3/{name[0]}/{name}" if len(name) == 3 else f"{name[:2]}/{name[2:4]}/{name}" - for relative in (f"registry/cache/{registry}/{package['name']}-{package['version']}.crate", - f"registry/index/{registry}/.cache/{index}"): - source = cargo_home / relative - if source.is_file() and not source.is_symlink(): - yield source, "cargo/" + relative, False - for registry in {PurePosixPath(root).parts[2] for root in roots if root.startswith("registry/src/")}: - source = cargo_home / "registry/index" / registry / "config.json" - if source.is_file() and not source.is_symlink(): - config = json_read(source) - # Do not persist mirror URLs, auth configuration, or private fields. - url = urllib.parse.urlsplit(config.get("dl", "")) - if url.scheme == "https" and url.hostname in {"static.crates.io", "crates.io"} and not url.username and not url.password and not url.query: - cleaned = json.dumps({"dl": config["dl"], "api": "https://crates.io"}).encode() - yield source, f"cargo/registry/index/{registry}/config.json", cleaned - - -def profile_part(parts): - for index, part in enumerate(parts): - if part in ("debug", "release") and index <= 2: - prefix = parts[:index] - if all(item == "build" or re.fullmatch(r"[A-Za-z0-9_]+(?:-[A-Za-z0-9_]+){2,}", item) for item in prefix): - return parts[index + 1:] - return () - - -def target_allowed(parts, names, targets): - tail = profile_part(parts) - if len(tail) < 2: - return False - if tail[0] in {"build", ".fingerprint"}: - package, separator, fingerprint = tail[1].rpartition("-") - return bool(separator and package in names and re.fullmatch(r"[0-9a-f]{8,64}", fingerprint) - and "incremental" not in tail) - if tail[0] == "deps" and len(tail) == 2: - name = tail[1] - extension = next((item for item in sorted(LIB_EXTENSIONS, key=len, reverse=True) if name.endswith(item)), "") - stem = name[:-len(extension)] if extension else name - stem, separator, fingerprint = stem.rpartition("-") - return bool(extension in LIB_EXTENSIONS and separator and re.fullmatch(r"[0-9a-f]{8,64}", fingerprint) - and (stem in targets or stem.removeprefix("lib") in targets)) - return False - - -def target_records(target, names, targets): - # Do not traverse app bundles, notices, incremental data or other unrelated - # target outputs merely to filter them later (macOS bundles contain links). - bases = [target, target / "build"] - for base in tuple(bases): - if base.is_dir(): - bases.extend(child for child in base.iterdir() if child.is_dir() and re.fullmatch(r"[A-Za-z0-9_]+(?:-[A-Za-z0-9_]+){2,}", child.name)) - for base in bases: - if base.exists() and (base.is_symlink() or not base.resolve().is_relative_to(target)): - raise Rejected("Cargo build layout escapes its target directory") - for profile in ("debug", "release"): - if (base / profile).is_symlink(): - raise Rejected("Cargo profile directory is a symlink") - for kind in ("deps", ".fingerprint", "build"): - directory = base / profile / kind - if not directory.is_dir() or directory.is_symlink(): - continue - for child in sorted(directory.iterdir()): - relative = PurePosixPath(*child.relative_to(target).parts) - if not target_allowed(relative.parts, names, targets): - continue - if kind == "deps": - if not child.is_file() or child.is_symlink(): - raise Rejected("dependency library is not an ordinary file") - yield child, "target/" + relative.as_posix(), False - else: - if not child.is_dir() or child.is_symlink() or not child.resolve().is_relative_to(target): - raise Rejected("dependency build output escapes its target directory") - for source, inner in tree_files(child): - yield source, "target/" + (relative / inner).as_posix(), False - - -def pack(identity, metadata, target, cargo_home, output, job_name, workflow, dependency="", receipt=None): - started = time.monotonic() - producer = trusted_producer(job_name, workflow) - name = artifact_name(identity, dependency) - public, names, targets = packages(metadata) - graph = dependency_graph(metadata, public) - receipt_location(receipt, target, cargo_home) - if receipt and receipt.is_file(): - previous = json_read(receipt) - if (previous.get("compatibility") == identity["compatibility"] and previous.get("dependency_fingerprint") == dependency - and previous.get("reader") == receipt_reader(job_name) - and previous.get("dependency_graph") == graph - and datetime.now(timezone.utc) - timedelta(days=1) < instant(previous["created_at"]) <= datetime.now(timezone.utc)): - return {"needs_upload": False, "artifact_name": name, "dependency_graph": graph, - "source_artifact_id": previous["artifact_id"], "seed_created_at": previous["created_at"]} - if target.is_symlink() or cargo_home.is_symlink(): - raise Rejected("Cargo cache roots must not be symlinks") - target, cargo_home = target.resolve(), cargo_home.resolve() - if target == cargo_home or target.is_relative_to(cargo_home) or cargo_home.is_relative_to(target): - raise Rejected("Cargo home and target roots must be separate") - if output.resolve().is_relative_to(target) or output.resolve().is_relative_to(cargo_home): - raise Rejected("dependency bundle must be outside its input cache roots") - if output.exists() and any(output.iterdir()): - raise Rejected("dependency bundle output must be new or empty") - output.mkdir(parents=True, exist_ok=True) - archive = output / "dependencies.tar.gz" - entries, total, seen = 0, 0, {} - records = cargo_records(public, cargo_home) - - def all_records(): - yield from records - yield from target_records(target, names, targets) - - with archive.open("wb") as raw, gzip.GzipFile(filename="", fileobj=raw, mode="wb", compresslevel=1, mtime=0) as compressed, tarfile.open(fileobj=compressed, mode="w|", format=tarfile.PAX_FORMAT) as stream: - for source, relative, replacement in all_records(): - path_parts(relative) - if relative.casefold() in seen: - if seen[relative.casefold()] != relative: - raise Rejected("dependency inputs contain case-alias paths") - continue - seen[relative.casefold()] = relative - attributes = source.stat() - size = len(replacement) if replacement is not False else attributes.st_size - entries, total = entries + 1, total + size - if entries > MAX_FILES or total > MAX_UNPACKED or size > MAX_FILE or raw.tell() > MAX_ARCHIVE: - raise Rejected("dependency archive exceeds bounded size/file limits") - member = tarfile.TarInfo(relative) - member.size, member.mtime = size, attributes.st_mtime - member.mode = 0o755 if attributes.st_mode & 0o111 else 0o644 - if replacement is False: - with source.open("rb") as content: - stream.addfile(member, content) - else: - stream.addfile(member, io.BytesIO(replacement)) - if not entries or archive.stat().st_size > MAX_ARCHIVE: - raise Rejected("dependency archive is empty or exceeds its compressed-size limit") - manifest = {"schema": SCHEMA, "needs_upload": True, "artifact_name": name, "compatibility": identity["compatibility"], - "profile": identity["profile"], "platform": identity["platform"], "architecture": identity["architecture"], - "dependency_fingerprint": dependency, "dependency_graph": graph, "producer": producer, - "package_names": sorted(names), "target_names": sorted(targets), - "archive_sha256": sha256(archive), "archive_bytes": archive.stat().st_size, - "unpacked_bytes": total, "files": entries, "pack_seconds": round(time.monotonic() - started, 3)} - (output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8") - return manifest - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, newurl): - return None - - -class GitHub: - def __init__(self, repository, token): - if not REPOSITORY.fullmatch(repository) or not token: - raise Rejected("repository-scoped Actions read access is required") - self.repository, self.token = repository, token - - def request(self, endpoint): - return urllib.request.Request(f"https://api.github.com/repos/{self.repository}/{endpoint}", - headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "Maple-dependency-cache"}) - - def get(self, endpoint): - with urllib.request.build_opener(NoRedirect()).open(self.request(endpoint), timeout=30) as response: - content = response.read(4 * 1024 * 1024 + 1) - if len(content) > 4 * 1024 * 1024: - raise Rejected("Actions metadata exceeds its bounded response size") - return json.loads(content) - - def download(self, artifact_id, destination, digest): - try: - response = urllib.request.build_opener(NoRedirect()).open(self.request(f"actions/artifacts/{artifact_id}/zip"), timeout=30) - except urllib.error.HTTPError as error: - if error.code not in (301, 302, 303, 307, 308): - raise - location = error.headers.get("Location", "") - if error.fp is not None: - error.close() - url = urllib.parse.urlsplit(location) - allowed = (".blob.core.windows.net", ".actions.githubusercontent.com", ".githubusercontent.com") - if url.scheme != "https" or not url.hostname or not url.hostname.endswith(allowed) or url.username or url.password: - raise Rejected("Actions download redirect has an unexpected HTTPS origin") - # Never carry the GitHub Authorization header to the signed blob URL, - # including a second redirect to a different origin. - response = urllib.request.build_opener(NoRedirect()).open(urllib.request.Request(location), timeout=60) - observed, count = hashlib.sha256(), 0 - with response, destination.open("wb") as output: - length = response.headers.get("Content-Length") - if length and int(length) > MAX_ARCHIVE + MAX_MANIFEST + 1024**2: - raise Rejected("artifact download exceeds its size limit") - while chunk := response.read(CHUNK): - count += len(chunk) - if count > MAX_ARCHIVE + MAX_MANIFEST + 1024**2: - raise Rejected("artifact download exceeds its size limit") - observed.update(chunk) - output.write(chunk) - if observed.hexdigest() != digest: - raise Rejected("Actions artifact ZIP SHA256 differs from API digest") - return count - - -def instant(value): - return datetime.fromisoformat(value.replace("Z", "+00:00")) - - -def source_run(api, artifact, name, workflow, now): - """Establish immutable run/repository identity before downloading any ZIP.""" - if (artifact.get("name") != name or artifact.get("expired") or not isinstance(artifact.get("id"), int) - or artifact.get("size_in_bytes", MAX_ARCHIVE + 1) > MAX_ARCHIVE + MAX_MANIFEST + 1024**2 - or not re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("digest", ""))): - raise Rejected("artifact identity, digest, expiry or size is invalid") - created = instant(artifact["created_at"]) - if not now - timedelta(days=MAX_AGE_DAYS) <= created <= now + timedelta(minutes=5): - raise Rejected("artifact is outside the bounded retention window") - origin = artifact["workflow_run"] - run = api.get(f"actions/runs/{int(origin['id'])}") - if (run.get("id") != origin["id"] or run.get("repository", {}).get("full_name") != api.repository - or run.get("head_repository", {}).get("full_name") != api.repository - or run.get("event") != "push" or run.get("head_branch") != "master" - or run.get("path") != f".github/workflows/{workflow}" - or not SHA.fullmatch(run.get("head_sha", "")) - or origin.get("head_sha") != run["head_sha"] or origin.get("head_branch") != "master" - or not isinstance(run.get("repository", {}).get("id"), int) - or not isinstance(run.get("head_repository", {}).get("id"), int) - or origin.get("repository_id") != run.get("repository", {}).get("id") - or origin.get("head_repository_id") != run.get("head_repository", {}).get("id")): - raise Rejected("artifact was not produced by this repository's push/master workflow") - if type(run.get("run_attempt")) is not int or run["run_attempt"] < 1: - raise Rejected("producer run has an invalid attempt bound") - return run - - -def source_proof(api, artifact, name, job_name, workflow, now, attempt=None, run=None): - # The latest run attempt may rerun only another platform's failed job. - # After the ZIP digest has bound its manifest, prove the declared original - # producer attempt rather than invalidating an earlier successful seed. - run = run or source_run(api, artifact, name, workflow, now) - if attempt is None: - attempt = run["run_attempt"] - if type(attempt) is not int or not 1 <= attempt <= run["run_attempt"]: - raise Rejected("manifest producer attempt is outside the trusted run bound") - created = instant(artifact["created_at"]) - jobs = api.get(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100") - if jobs.get("total_count", 101) > 100: - raise Rejected("platform job proof exceeds the bounded jobs scan") - selected = [job for job in jobs["jobs"] if job.get("name") == job_name] - if len(selected) != 1 or selected[0].get("status") != "completed" or selected[0].get("conclusion") != "success": - raise Rejected("the exact producer platform job did not complete successfully") - job = selected[0] - if not job.get("steps") or any(step.get("conclusion") not in ("success", "skipped") for step in job["steps"]): - raise Rejected("producer job has incomplete or failed checks") - if not instant(job["started_at"]) <= created <= instant(job["completed_at"]) + timedelta(seconds=30): - raise Rejected("artifact did not originate inside the proven successful job attempt") - return {"repository": api.repository, "run_id": int(run["id"]), "run_attempt": attempt, - "head_sha": run["head_sha"], "job_name": job_name, "workflow": workflow} - - -def unzip(zip_path, directory): - with zipfile.ZipFile(zip_path) as archive: - members = archive.infolist() - if len(members) != 2 or {item.filename for item in members} != {"manifest.json", "dependencies.tar.gz"}: - raise Rejected("artifact ZIP must contain exactly its manifest and dependency archive") - for member in members: - mode = member.external_attr >> 16 - limit = MAX_MANIFEST if member.filename == "manifest.json" else MAX_ARCHIVE - if (member.is_dir() or member.flag_bits & 1 or stat.S_IFMT(mode) not in (0, stat.S_IFREG) - or member.file_size < 0 or member.file_size > limit): - raise Rejected("unsafe artifact ZIP entry") - with archive.open(member) as source, (directory / member.filename).open("wb") as output: - copied = 0 - while chunk := source.read(CHUNK): - copied += len(chunk) - if copied > member.file_size or copied > limit: - raise Rejected("artifact ZIP entry exceeds its declared size") - output.write(chunk) - if copied != member.file_size: - raise Rejected("artifact ZIP entry is truncated") - - -def member_destination(name, manifest, cargo_home, target): - parts = path_parts(name) - if parts[0] == "target" and target_allowed(parts[1:], manifest["package_names"], manifest["target_names"]): - return target.joinpath(*parts[1:]) - if parts[0] == "cargo" and len(parts) >= 4: - tail = parts[1:] - registry = tail[:2] in (("registry", "src"), ("registry", "cache"), ("registry", "index")) - git = tail[:2] in (("git", "db"), ("git", "checkouts")) - if registry or git: - if git and tail[-1] == ".git": - raise Rejected("regular .git pointers cannot enter a portable Cargo source cache") - if (".git" in tail or tail[:2] == ("git", "db")) and "hooks" in tail: - raise Rejected("Git hooks cannot enter the dependency cache") - return cargo_home.joinpath(*tail) - raise Rejected("archive entry is outside the dependency-only namespaces") - - -def safe_destination(path, root): - if any(parent.is_symlink() for parent in root.parents): - raise Rejected("cache destination has a symlink ancestor") - if root.is_symlink() or root.exists() and not root.is_dir(): - raise Rejected("cache destination root is not an ordinary directory") - current = root - for part in path.relative_to(root).parts: - current /= part - if current.is_symlink(): - raise Rejected("cache destination contains a symlink") - if current != path and current.exists() and not current.is_dir(): - raise Rejected("cache destination parent is not a directory") - if path.exists() and not path.is_file(): - raise Rejected("cache destination file has an unexpected type") - - -def tar_envelope(archive): - """Bound physical TAR headers before tarfile can allocate PAX payloads.""" - headers, physical = 0, 0 - with gzip.open(archive, "rb") as stream: - while True: - header = stream.read(512) - if not header or header == bytes(512): - break - if len(header) != 512: - raise Rejected("truncated dependency TAR header") - headers += 1 - if headers > MAX_FILES * 2: - raise Rejected("dependency TAR has too many physical headers") - encoded = header[124:136] - try: - size = int.from_bytes(encoded[1:], "big") if encoded[0] == 0x80 else int(encoded.rstrip(b"\0 ").lstrip(b" ") or b"0", 8) - except ValueError as error: - raise Rejected("invalid dependency TAR size") from error - kind = header[156:157] - if size < 0 or size > MAX_FILE or kind not in (b"0", b"\0", tarfile.XHDTYPE): - raise Rejected("unsupported dependency TAR entry type/size") - if kind == tarfile.XHDTYPE: - if size > 64 * 1024: - raise Rejected("oversized dependency TAR extended metadata") - payload = stream.read(size) - remaining = payload - while remaining: - try: - length = int(remaining.split(b" ", 1)[0]) - record = remaining[:length] - key = record.split(b" ", 1)[1].split(b"=", 1)[0] - except (ValueError, IndexError) as error: - raise Rejected("invalid dependency TAR extended metadata") from error - if length <= 0 or length > len(remaining) or not record.endswith(b"\n") or key not in (b"path", b"mtime"): - raise Rejected("unsupported dependency TAR extended metadata") - remaining = remaining[length:] - skipped = (512 - size % 512) % 512 - else: - skipped = ((size + 511) // 512) * 512 - physical += 512 + ((size + 511) // 512) * 512 - if physical > MAX_UNPACKED + MAX_FILES * 2048: - raise Rejected("dependency TAR exceeds its physical decompression budget") - while skipped: - chunk = stream.read(min(CHUNK, skipped)) - if not chunk: - raise Rejected("truncated dependency TAR contents") - skipped -= len(chunk) - - -def unpack(directory, identity, producer, cargo_home, target, dependency=""): - manifest = json_read(directory / "manifest.json") - expected = artifact_name(identity, dependency) - if (manifest.get("schema") != SCHEMA or manifest.get("artifact_name") != expected - or manifest.get("compatibility") != identity["compatibility"] - or manifest.get("dependency_fingerprint") != dependency or manifest.get("producer") != producer - or any(manifest.get(key) != identity[key] for key in ("profile", "platform", "architecture"))): - raise Rejected("dependency manifest does not match the actual environment and proven producer") - if (type(manifest.get("files")) is not int or not 0 < manifest["files"] <= MAX_FILES - or type(manifest.get("unpacked_bytes")) is not int or not 0 <= manifest["unpacked_bytes"] <= MAX_UNPACKED - or not isinstance(manifest.get("package_names"), list) or not isinstance(manifest.get("target_names"), list) - or not all(isinstance(name, str) and PACKAGE.fullmatch(name) for name in manifest["package_names"] + manifest["target_names"]) - or not HASH.fullmatch(manifest.get("dependency_graph", ""))): - raise Rejected("dependency manifest has invalid size or package bounds") - archive = directory / "dependencies.tar.gz" - if (archive.stat().st_size > MAX_ARCHIVE or archive.stat().st_size != manifest["archive_bytes"] - or sha256(archive) != manifest["archive_sha256"]): - raise Rejected("dependency tar size or SHA256 differs from the trusted manifest") - tar_envelope(archive) - cargo_home, target = cargo_home.absolute(), target.absolute() - scope = {"package_names": set(manifest["package_names"]), "target_names": set(manifest["target_names"])} - seen, destinations, count, size = set(), [], 0, 0 - # Preflight every entry and every existing destination before writing any - # cache file. extractall is deliberately never used for either archive. - with tarfile.open(archive, mode="r|gz") as stream: - for member in stream: - if (not member.isreg() or member.issparse() or not 0 <= member.size <= MAX_FILE - or not math.isfinite(member.mtime) or not 0 <= member.mtime <= 4_102_444_800): - raise Rejected("dependency tar may contain only ordinary nonsparse files") - folded = member.name.casefold() - if folded in seen: - raise Rejected("dependency tar contains duplicate/case-alias paths") - seen.add(folded) - count, size = count + 1, size + member.size - if count > MAX_FILES or size > MAX_UNPACKED: - raise Rejected("dependency tar exceeds extraction bounds") - destination = member_destination(member.name, scope, cargo_home, target) - parts = PurePosixPath(member.name).parts - metadata = parts[:3] == ("cargo", "git", "db") or ".git" in parts - if metadata and parts[-1] in ("config", "config.worktree", "alternates") and member.size > 64 * 1024: - raise Rejected("Git metadata exceeds its bounded size") - root = target if member.name.startswith("target/") else cargo_home - safe_destination(destination, root) - destinations.append((member.name, destination)) - if count != manifest["files"] or size != manifest["unpacked_bytes"]: - raise Rejected("dependency tar totals differ from the trusted manifest") - staging = directory / "staged" - staging.mkdir() - with tarfile.open(archive, mode="r|gz") as stream: - extracted = 0 - for member in stream: - if extracted >= len(destinations): - raise Rejected("dependency archive gained entries between verification passes") - name, _ = destinations[extracted] - extracted += 1 - if member.name != name: - raise Rejected("dependency archive changed between verification passes") - selected = staging.joinpath(*PurePosixPath(name).parts) - selected.parent.mkdir(parents=True, exist_ok=True) - with stream.extractfile(member) as source, selected.open("wb") as output: - shutil.copyfileobj(source, output, CHUNK) - if selected.stat().st_size != member.size: - raise Rejected("dependency tar entry is truncated") - parts = PurePosixPath(name).parts - metadata = parts[:3] == ("cargo", "git", "db") or ".git" in parts - if metadata and parts[-1] == "config": - bare = parts[:3] == ("cargo", "git", "db") - expected_config = f"[core]\nrepositoryformatversion = 0\nbare = {'true' if bare else 'false'}\nfilemode = false\n".encode() - if selected.read_bytes() != expected_config: - raise Rejected("Git configuration was not sanitized by the dependency packer") - if metadata and parts[-1] == "config.worktree": - raise Rejected("Git worktree configuration cannot enter the dependency cache") - os.chmod(selected, 0o755 if member.mode & 0o111 else 0o644) - os.utime(selected, (member.mtime, member.mtime)) - if extracted != len(destinations): - raise Rejected("dependency archive lost entries between verification passes") - for name, _ in destinations: - parts = PurePosixPath(name).parts - selected = staging.joinpath(*parts) - if parts[:3] in (("cargo", "git", "db"), ("cargo", "git", "checkouts")) and parts[-3:] == ("objects", "info", "alternates"): - for line in selected.read_text(encoding="utf-8").splitlines(): - if not line or PurePosixPath(line).is_absolute() or "\\" in line or ":" in line or any(ord(c) < 32 for c in line): - raise Rejected("Git object alternates must be bounded relative paths") - referenced = (selected.parent / line).resolve() - if not referenced.is_relative_to((staging / "cargo/git/db").resolve()) or not referenced.is_dir(): - raise Rejected("Git object alternates escape the restored public object storage") - if parts[:3] == ("cargo", "registry", "index") and parts[-1] == "config.json": - config = json_read(selected, 64 * 1024) - url = urllib.parse.urlsplit(config.get("dl", "")) - if (set(config) != {"dl", "api"} or config["api"] != "https://crates.io" or url.scheme != "https" - or url.hostname not in {"static.crates.io", "crates.io"} or url.username or url.password or url.query): - raise Rejected("registry index configuration is not sanitized public configuration") - for name, destination in destinations: - root = target if name.startswith("target/") else cargo_home - safe_destination(destination, root) - destination.parent.mkdir(parents=True, exist_ok=True) - staged = staging.joinpath(*PurePosixPath(name).parts) - if destination.exists() and not os.access(destination, os.W_OK): - # Cargo Git object storage can use read-only hardlinks. Never chmod - # a restored destination through a potentially shared inode. - if sha256(destination) == sha256(staged): - continue - raise Rejected("a different read-only dependency file already exists") - os.replace(staged, destination) - return manifest - - -def outputs(values): - if os.environ.get("GITHUB_OUTPUT"): - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: - for key, value in values.items(): - output.write(f"{key}={value}\n") - - -def restore(api, identity, target, cargo_home, job_name, workflow, dependency="", receipt=None): - started = time.monotonic() - name = artifact_name(identity, dependency) - receipt_location(receipt, target, cargo_home) - candidates = api.get("actions/artifacts?" + urllib.parse.urlencode({"name": name, "per_page": MAX_CANDIDATES})) - for listed in candidates.get("artifacts", [])[:MAX_CANDIDATES]: - try: - artifact = api.get(f"actions/artifacts/{int(listed['id'])}") - now = datetime.now(timezone.utc) - run = source_run(api, artifact, name, workflow, now) - with tempfile.TemporaryDirectory(prefix="maple-dependency-restore-") as temporary: - work = Path(temporary) - transferred = api.download(artifact["id"], work / "artifact.zip", artifact["digest"].removeprefix("sha256:")) - unzip(work / "artifact.zip", work) - declared = json_read(work / "manifest.json").get("producer", {}) - attempt = declared.get("run_attempt") - if type(attempt) is not int: - raise Rejected("manifest producer attempt is missing") - proof = source_proof(api, artifact, name, job_name, workflow, now, attempt=attempt, run=run) - manifest = unpack(work, identity, proof, cargo_home, target, dependency) - if receipt: - receipt.parent.mkdir(parents=True, exist_ok=True) - receipt.write_text(json.dumps({"compatibility": identity["compatibility"], "dependency_fingerprint": dependency, - "dependency_graph": manifest["dependency_graph"], "created_at": artifact["created_at"], - "artifact_id": artifact["id"], "artifact_digest": artifact["digest"], "producer": proof, - "reader": receipt_reader(job_name)}, sort_keys=True) + "\n", encoding="utf-8") - result = {"restored": "true", "artifact-id": artifact["id"], "source-run-id": proof["run_id"], - "archive-bytes": manifest["archive_bytes"], "download-bytes": transferred, - "restore-seconds": round(time.monotonic() - started, 3), "seed-created-at": artifact["created_at"]} - outputs(result) - print(json.dumps(result, sort_keys=True)) - return True - except (Rejected, OSError, EOFError, OverflowError, KeyError, TypeError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error: - print(f"Dependency artifact {listed.get('id', 'unknown')} rejected: {type(error).__name__}", file=sys.stderr) - outputs({"restored": "false"}) - print("No compatible, proven successful master dependency artifact; Cargo will build cold") - return False - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("pack", "restore")) - parser.add_argument("--environment", type=Path, required=True) - parser.add_argument("--target", type=Path, required=True) - parser.add_argument("--cargo-home", type=Path, default=Path(os.environ.get("CARGO_HOME", str(Path.home() / ".cargo")))) - parser.add_argument("--job-name", required=True) - parser.add_argument("--workflow", choices=sorted(WORKFLOWS), default="agent-ci.yml") - parser.add_argument("--dependency-fingerprint", default="") - parser.add_argument("--metadata", type=Path) - parser.add_argument("--output-dir", type=Path) - parser.add_argument("--receipt", type=Path, help="write restored artifact proof, or reuse that proof during pack") - arguments = parser.parse_args() - identity = environment(arguments.environment) - if arguments.command == "pack": - if not arguments.metadata or not arguments.output_dir: - parser.error("pack requires --metadata and --output-dir") - manifest = pack(identity, json_read(arguments.metadata, 64 * 1024**2), arguments.target, - arguments.cargo_home, arguments.output_dir, arguments.job_name, - arguments.workflow, arguments.dependency_fingerprint, arguments.receipt) - outputs({"needs-upload": str(manifest["needs_upload"]).lower(), "packed": str(manifest["needs_upload"]).lower(), - "artifact-name": manifest["artifact_name"], "archive-bytes": manifest.get("archive_bytes", 0)}) - print(json.dumps({key: manifest[key] for key in ("needs_upload", "artifact_name", "archive_bytes", "unpacked_bytes", "files", "pack_seconds", "source_artifact_id", "seed_created_at") if key in manifest}, sort_keys=True)) - else: - try: - api = GitHub(os.environ.get("GITHUB_REPOSITORY", ""), os.environ.get("GH_TOKEN", os.environ.get("GITHUB_TOKEN", ""))) - restore(api, identity, arguments.target, arguments.cargo_home, arguments.job_name, - arguments.workflow, arguments.dependency_fingerprint, arguments.receipt) - except (Rejected, OSError, ValueError, KeyError, TypeError) as error: - outputs({"restored": "false"}) - print(f"Dependency artifact recovery unavailable ({type(error).__name__}); Cargo will build cold") - - -if __name__ == "__main__": - try: - main() - except (Rejected, OSError, ValueError, KeyError, TypeError) as error: - print(f"Dependency archive refused: {type(error).__name__}: {error}", file=sys.stderr) - sys.exit(1) diff --git a/scripts/ci/agent_master_selection.py b/scripts/ci/agent_master_selection.py deleted file mode 100644 index df566682d..000000000 --- a/scripts/ci/agent_master_selection.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Retain Agent changes until the selected master lane actually validates them.""" - -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path -import re -import subprocess -import sys - -from agent_change_detection import classify_paths - - -ROOT = Path(__file__).resolve().parents[2] -REPOSITORY = "MaplePrivacyLabs/Maple" -WORKFLOW = ".github/workflows/agent-desktop-build.yml" -VERIFIERS = frozenset( - f"Verify downloaded {platform} ({profile})" - for platform in ("macOS", "Linux", "Windows") for profile in ("dev", "prod") -) -CI_WORKFLOW = ".github/workflows/agent-ci.yml" -CI_VERIFIERS = frozenset({ - "Agent (blacksmith-8vcpu-ubuntu-2404)", - "Agent (macos-26)", - "Agent (windows-latest)", - "Agent provider (blacksmith-8vcpu-ubuntu-2404)", - "Agent provider (macos-26)", - "Agent provider (windows-latest)", - "Agent Linux release binary", -}) - - -def api(path: str) -> dict: - result = subprocess.run( - ["gh", "api", "--method", "GET", path], - check=True, capture_output=True, text=True, timeout=15, - ) - return json.loads(result.stdout) - - -def git(root: Path, *args: str) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", *args], cwd=root, check=True, capture_output=True, timeout=15, - ) - - -def select_master( - head: str, current_run_id: int, root: Path = ROOT, *, lane: str = "desktop", -) -> tuple[bool, str]: - if not re.fullmatch(r"[0-9a-f]{40}", head): - raise ValueError("invalid current commit") - if lane not in ("desktop", "ci"): - raise ValueError("unknown Agent validation lane") - workflow = CI_WORKFLOW if lane == "ci" else WORKFLOW - verifiers = CI_VERIFIERS if lane == "ci" else VERIFIERS - runs = api( - f"repos/{REPOSITORY}/actions/workflows/{workflow.rsplit('/', 1)[1]}/runs" - "?branch=master&status=success&per_page=100" - )["workflow_runs"] - for run in runs: - if ( - run["id"] == current_run_id - or run["repository"]["full_name"] != REPOSITORY - or run["head_repository"]["full_name"] != REPOSITORY - or run["head_branch"] != "master" - or run["event"] not in ("push", "workflow_dispatch") - or run["path"] != workflow - or run["status"] != "completed" - or run["conclusion"] != "success" - ): - continue - base = run["head_sha"] - if not re.fullmatch(r"[0-9a-f]{40}", base): - raise ValueError("invalid baseline commit") - ancestor = subprocess.run( - ["git", "merge-base", "--is-ancestor", base, head], - cwd=root, capture_output=True, timeout=15, - ) - if ancestor.returncode == 1: - continue - if ancestor.returncode != 0: - raise ValueError("baseline history unavailable") - jobs = api( - f"repos/{REPOSITORY}/actions/runs/{run['id']}/jobs?filter=latest&per_page=100" - ) - if jobs["total_count"] != len(jobs["jobs"]): - raise ValueError("incomplete baseline job list") - verified = { - job["name"] for job in jobs["jobs"] - if job["status"] == "completed" and job["conclusion"] == "success" - } - if not verifiers <= verified: - # Selector-only successes and incomplete profiles cannot advance - # the baseline past an Agent change that still needs packaging. - continue - paths = git(root, "diff", "--no-renames", "--name-only", "-z", base, head).stdout - changed = [path.decode("utf-8", errors="surrogateescape") - for path in paths.split(b"\0") if path] - selected = classify_paths(changed) - return selected, ( - f"Agent inputs {'changed' if selected else 'unchanged'} since fully verified " - f"master run {run['id']} at {base}; current commit {head}." - ) - return True, "No fully verified ancestor in the last 100 successful master runs; building." - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--lane", choices=("desktop", "ci"), default="desktop") - parser.add_argument("--head", default=os.environ.get("GITHUB_SHA", "")) - parser.add_argument("--run-id", type=int, default=os.environ.get("GITHUB_RUN_ID", "0")) - args = parser.parse_args() - selected = True - if (os.environ.get("GITHUB_EVENT_NAME") != "push" - or os.environ.get("GITHUB_REPOSITORY") != REPOSITORY - or os.environ.get("GITHUB_REF") != "refs/heads/master"): - reason = "Manual or non-master event; building without a baseline lookup." - else: - try: - selected, reason = select_master(args.head, args.run_id, lane=args.lane) - except (KeyError, TypeError, ValueError, OSError, subprocess.SubprocessError): - reason = "Master baseline lookup or diff unavailable; building." - print(reason, file=sys.stderr) - print(f"agent={'true' if selected else 'false'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/agent_measure.py b/scripts/ci/agent_measure.py deleted file mode 100644 index f33e5ba09..000000000 --- a/scripts/ci/agent_measure.py +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env python3 -"""Measure CI phases without retaining commands, private environment, or output. - -Run from apps/maple-agent, for example: - python3 ../../scripts/ci/agent_measure.py --phase build --target target -- cargo build --locked --timings - python3 ../../scripts/ci/agent_measure.py --summarize target - -The child inherits stdout/stderr. Only the final summary scans target trees; -logical file bytes are not compressed cache archive or network transfer bytes. -""" - -import argparse -from datetime import datetime, timezone -import heapq -import json -import os -from pathlib import Path -import re -import stat -import subprocess -import sys -import time - - -MAX_PHASES = 256 -MAX_PHASE_BYTES = 1024 * 1024 -MAX_REPORTS = 8 -MAX_REPORT_BYTES = 16 * 1024 * 1024 -MAX_TOTAL_REPORT_BYTES = 32 * 1024 * 1024 - - -def context(): - """Only public Actions identifiers belong in the measurement artifact.""" - return {name: os.environ.get(variable, "")[:128] for name, variable in ( - ("run_id", "GITHUB_RUN_ID"), ("run_attempt", "GITHUB_RUN_ATTEMPT"), - ("job", "GITHUB_JOB"), ("runner_os", "RUNNER_OS"), ("runner_arch", "RUNNER_ARCH"), - )} - - -def output_path(directory, name): - path = directory / name - if path.is_symlink(): - raise ValueError("A CI measurement output is a symlink") - return path - - -def prepare(directory): - if directory.is_symlink(): - raise ValueError("The CI measurement directory is a symlink") - directory.mkdir(parents=True, exist_ok=True) - marker = output_path(directory, "context.json") - previous = None - if marker.exists() and marker.stat().st_size < 4096: - previous = json.loads(marker.read_text(encoding="utf-8")) - current = context() - if previous != current: - # A restored target cache must not contribute another run's phases. - output_path(directory, "phases.jsonl").write_text("", encoding="utf-8") - marker.write_text(json.dumps(current) + "\n", encoding="utf-8") - return current - - -def read_phases(directory): - source = output_path(directory, "phases.jsonl") - if not source.exists(): - return [] - if source.stat().st_size > MAX_PHASE_BYTES: - raise ValueError("CI measurement history exceeds its byte limit") - records = [json.loads(line) for line in source.read_text(encoding="utf-8").splitlines() if line] - if len(records) > MAX_PHASES: - raise ValueError("CI measurement history exceeds its phase limit") - return records - - -def run_phase(phase, target, command, directory): - current = prepare(directory) - if len(read_phases(directory)) >= MAX_PHASES: - raise ValueError("CI measurement history exceeds its phase limit") - started_unix_ns = time.time_ns() - started = time.monotonic() - try: - # Inherit both streams: no pipes, tee, or unbounded compiler log copy. - result = subprocess.run(command, check=False) - code = result.returncode if result.returncode >= 0 else 128 - result.returncode - except FileNotFoundError: - print("CI phase command was not found", file=sys.stderr) - code = 127 - except PermissionError: - print("CI phase command was not executable", file=sys.stderr) - code = 126 - except KeyboardInterrupt: - code = 130 - record = { - "schema_version": 1, "context": current, "phase": phase, "target": str(target), - "started_at": datetime.fromtimestamp(started_unix_ns / 1e9, timezone.utc).isoformat(), - "started_unix_ns": started_unix_ns, - "elapsed_seconds": round(time.monotonic() - started, 6), "exit_code": code, - } - try: - serialized = json.dumps(record, sort_keys=True) + "\n" - destination = output_path(directory, "phases.jsonl") - if destination.stat().st_size + len(serialized.encode("utf-8")) > MAX_PHASE_BYTES: - raise ValueError("CI measurement history exceeds its byte limit") - with destination.open("a", encoding="utf-8") as stream: - stream.write(serialized) - except (OSError, ValueError): - print("Failed to record CI phase measurements", file=sys.stderr) - # Never replace a failing build's exit code with an instrumentation error. - return code or 1 - print(f"CI phase {phase}: {record['elapsed_seconds']:.3f}s, exit {code}", flush=True) - return code - - -def target_size(target, directory): - result = {"target": str(target), "logical_bytes": 0, "regular_files": 0, "scan_errors": 0} - if target.is_symlink(): - result["scan_errors"] = 1 - return result - result["exists"] = target.is_dir() - metrics_path = directory.absolute() - - def scan_error(_error): - result["scan_errors"] += 1 - - for root, folders, files in os.walk(target, followlinks=False, onerror=scan_error): - folders[:] = [name for name in folders - if not (Path(root) / name).is_symlink() - and (Path(root) / name).absolute() != metrics_path] - for name in files: - try: - info = (Path(root) / name).lstat() - if stat.S_ISREG(info.st_mode): - result["logical_bytes"] += info.st_size - result["regular_files"] += 1 - except OSError: - scan_error(None) - return result - - -def collect_timings(targets, directory, phases): - """Retain a bounded selection of current-run Cargo HTML timing reports.""" - destination = output_path(directory, "cargo-timings") - destination.mkdir(exist_ok=True) - for old in destination.iterdir(): - if old.name.endswith(".html") and (old.is_file() or old.is_symlink()): - old.unlink() - # Cargo filenames are date-stamped, but their mtime provides a common - # current-run boundary across targets, including restored cache contents. - earliest = min((phase["started_unix_ns"] for phase in phases), default=None) - candidates = [] - available = 0 - if earliest is not None: - for index, target in enumerate(targets): - timings = target / "cargo-timings" - if timings.is_symlink() or not timings.is_dir(): - continue - with os.scandir(timings) as entries: - for entry in entries: - # Exclude cargo-timing.html: Cargo also writes this alias - # of the newest date-stamped report, doubling upload bytes. - if not (entry.name.startswith("cargo-timing-") and entry.name.endswith(".html") - and entry.is_file(follow_symlinks=False)): - continue - info = entry.stat(follow_symlinks=False) - if info.st_mtime_ns < earliest: - continue - available += 1 - item = (info.st_mtime_ns, index, entry.name, info.st_size, Path(entry.path)) - if len(candidates) < MAX_REPORTS: - heapq.heappush(candidates, item) - else: - heapq.heappushpop(candidates, item) - reports = [] - total = 0 - for _mtime, index, name, size, source in sorted(candidates, reverse=True): - limit = min(MAX_REPORT_BYTES, MAX_TOTAL_REPORT_BYTES - total) - if size > limit: - continue - # Bound reads as well as uploads if a report changes during collection. - with source.open("rb") as stream: - content = stream.read(limit + 1) - if len(content) > limit: - continue - copied = output_path(destination, f"target-{index}-{name}") - copied.write_bytes(content) - reports.append({"source": str(source), "artifact_path": str(copied.relative_to(directory)), - "bytes": len(content)}) - total += len(content) - return {"reports": reports, "available_current_reports": available, - "retained_bytes": total, "max_reports": MAX_REPORTS, - "max_report_bytes": MAX_REPORT_BYTES, "max_total_bytes": MAX_TOTAL_REPORT_BYTES} - - -def markdown(value): - return str(value).replace("|", "\\|").replace("\n", " ").replace("\r", " ") - - -def summarize(targets, directory): - current = prepare(directory) - phases = read_phases(directory) - sizes = [target_size(target, directory) for target in targets] - timings = collect_timings(targets, directory, phases) - summary = {"schema_version": 1, "context": current, "phases": phases, "targets": sizes, - "cargo_timings": timings, "cache_archive_bytes": None, - "cache_transfer_bytes": None} - output_path(directory, "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") - rows = ["### Maple Agent CI measurements", "", "| Phase | Target | Seconds | Exit |", - "| --- | --- | ---: | ---: |"] - rows.extend(f"| {markdown(row['phase'])} | {markdown(row['target'])} | " - f"{row['elapsed_seconds']:.3f} | {row['exit_code']} |" for row in phases) - rows.extend(["", "| Target | Logical bytes | Regular files | Scan errors |", - "| --- | ---: | ---: | ---: |"]) - rows.extend(f"| {markdown(row['target'])} | {row['logical_bytes']} | " - f"{row['regular_files']} | {row['scan_errors']} |" for row in sizes) - rows.extend(["", "Logical target bytes exclude symlinks and measurement artifacts. " - "Compressed cache archive and network transfer bytes are not measured.", "", - f"Retained {len(timings['reports'])} of {timings['available_current_reports']} " - f"current Cargo HTML timing reports ({timings['retained_bytes']} bytes). " - "Reports are bounded to 8 files, 16 MiB each, and 32 MiB total.", ""]) - rendered = "\n".join(rows) - output_path(directory, "summary.md").write_text(rendered, encoding="utf-8") - github_summary = os.environ.get("GITHUB_STEP_SUMMARY") - if github_summary: - with Path(github_summary).open("a", encoding="utf-8") as stream: - stream.write(rendered) - print(rendered) - return 0 - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--phase") - mode.add_argument("--summarize", nargs="+", type=Path, metavar="TARGET") - parser.add_argument("--target", type=Path, default=Path("target")) - parser.add_argument("--metrics-dir", type=Path, default=Path("target/ci-metrics")) - parser.add_argument("command", nargs=argparse.REMAINDER) - args = parser.parse_args(argv) - if args.phase and not re.fullmatch(r"[A-Za-z0-9_.:-]{1,80}", args.phase): - parser.error("phase must be 1-80 letters, digits, dots, colons, dashes, or underscores") - command = args.command[1:] if args.command[:1] == ["--"] else args.command - if args.phase and not command: - parser.error("--phase requires a command after --") - if args.summarize and command: - parser.error("--summarize does not execute a command") - try: - if args.phase: - return run_phase(args.phase, args.target, command, args.metrics_dir) - return summarize(args.summarize, args.metrics_dir) - except (OSError, ValueError, KeyError, TypeError): - # Do not print exception details: an executable argument/path could - # contain a credential even though these workflows should never do so. - print("CI measurement collection failed", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 1af3317aa..4b2c97821 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -25,16 +25,20 @@ class Runner: LINUX = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_RUNNER") LINUX_HEAVY = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_HEAVY_RUNNER") MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") -WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") # read by ci.yml itself +WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") GITHUB = Runner("ubuntu-latest") # free GitHub runner for lanes that compile nothing heavy -# ci.yml pins this toolchain for agent-windows; agent-lint checks that the Agent -# flake's rustc still reports it. +# .github/actions/setup-agent-windows pins this toolchain (Windows has no Nix); +# agent-lint checks that the Agent flake's rustc still reports it. AGENT_WINDOWS_RUST = "1.98.0" PR, QUEUE, PUSH, NIGHTLY, DISPATCH = ( "pull_request", "merge_group", "push", "schedule", "workflow_dispatch") GATE: FrozenSet[str] = frozenset({PR, QUEUE, PUSH, NIGHTLY, DISPATCH}) PR_EVENTS: FrozenSet[str] = frozenset({PR, QUEUE}) # events that use `pr_paths` +NOT_NIGHTLY: FrozenSet[str] = GATE - {NIGHTLY} # nightly.yml runs these every night instead +PR_SIDE: FrozenSet[str] = frozenset({PR, QUEUE, DISPATCH}) # master and nightly run these elsewhere +# ci.yml's generic matrix jobs; any other `job` is the id of a special ci.yml job. +MATRIX_JOBS = ("nix", "windows") # Never selects anything: notes, docs and hooks at the root or a component root # (one or two levels down). Deeper files can be build inputs: the Agent compiles @@ -71,6 +75,15 @@ class Runner: "scripts/build-*", "scripts/package-*", "scripts/verify-*", "release-profiles.json", "release_profile.rs", "about.toml", "app/build.rs", "app/packaging/**", "flake.lock", )) + (".github/workflows/agent-desktop-build.yml",) +# What agent-desktop-build.yml builds and signs on master (`plan.py only agent-desktop`). +AGENT_DESKTOP = AGENT + RUST_SDK + PROXY_RUNTIME + ( + ".github/workflows/agent-desktop-build.yml", ".github/actions/setup-agent-windows/**", + "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json", + "scripts/ci/agent_windows.py", "scripts/ci/install-windows-artifact-signing.ps1", + "scripts/ci/windows-artifact-sign.ps1", +) +# Selectors for workflows outside ci.yml, read by `plan.py only NAME`. +SELECTORS = {"agent-desktop": AGENT_DESKTOP} RESEARCH = "apps/maple-research/frontend/" TAURI = RESEARCH + "src-tauri/" @@ -83,7 +96,7 @@ class Lane: pr_paths: Optional[Tuple[str, ...]] = None # narrower selector for PRs and the merge queue labels: Tuple[str, ...] = ("ci:full",) # PR labels that add the lane events: FrozenSet[str] = GATE - job: str = "nix" # the generic matrix, or a special ci.yml job id + job: str = "nix" # a MATRIX_JOBS job, or a special ci.yml job id runner: Runner = LINUX flake: Optional[str] = None # `nix develop` flake, from the repository root workdir: str = "." @@ -97,11 +110,26 @@ class Lane: legacy: Optional[str] = None # the workflow that still runs this lane -AGENT_FLAKE = dict( - flake="./apps/maple-agent", workdir="apps/maple-agent", workspaces="apps/maple-agent -> target", +AGENT_DIR = dict( + workdir="apps/maple-agent", workspaces="apps/maple-agent -> target", cache_files=("apps/maple-agent/flake.lock", "apps/maple-agent/Cargo.lock"), env=(("RUSTFLAGS", "-D warnings"),), ) +AGENT_FLAKE = dict(flake="./apps/maple-agent", **AGENT_DIR) +AGENT_WIN = dict(job="windows", runner=WINDOWS, **AGENT_DIR) # pinned rustup, no flake +AGENT_DEPENDENCIES = tuple("apps/maple-agent/" + glob for glob in ( + "Cargo.lock", "**/Cargo.toml", "deny.toml", "about.toml", "flake.nix", "flake.lock", "dependencies/**", + "scripts/*third*party*notices*", "scripts/audit-sqlcipher-dependencies.py", "scripts/verify-sqlcipher-build.py", + "app/assets/dict/LICENSE", "crates/maple-agent/src/store/keys/SOURCES.md", + "crates/maple-agent/src/store/keys/eff-large-wordlist.txt", +)) +WINDOWS_PACKAGE = ( + "./scripts/build-release.sh dev && python3 scripts/windows-release.py prepare dev" + " && python3 scripts/windows-release.py package dev --unsigned" + # The stock image exports Azure CLI paths that the fail-closed signing guard rejects. + " && env -u AZURE_CONFIG_DIR -u AZURE_DEVOPS_CACHE_DIR -u AZURE_EXTENSION_DIR" + " python3 scripts/windows-release.py verify dev dist/dev --unsigned --smoke" +) LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), @@ -115,7 +143,40 @@ class Lane: cache="agent-macos", timeout=25, **AGENT_FLAKE), # PRs get Windows clippy only for Windows-specific paths or the ci:windows label. Lane("agent-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=AGENT_WINDOWS, - labels=("ci:windows", "ci:full"), job="agent-windows", runner=WINDOWS, timeout=45), + labels=("ci:windows", "ci:full"), run="cargo clippy --workspace --all-targets --locked -- -D warnings", + cache="agent-windows", timeout=45, **AGENT_WIN), + # The rest of `just ci`, and the native macOS and Windows suites: master, nightly and ci:full. + Lane("agent-full-linux", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), run="just ci-extra", + cache="agent-full-linux", timeout=30, gate=False, **AGENT_FLAKE), + Lane("agent-full-macos", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), runner=MACOS, + run="python3 scripts/test_git_metadata.py && cargo build --workspace --all-targets --locked" + " && ./scripts/macos-debug-app.sh && cargo test --workspace --locked", + cache="agent-full-macos", timeout=45, gate=False, **AGENT_FLAKE), + Lane("agent-full-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), + run="python3 scripts/test_git_metadata.py && cargo build --workspace --all-targets --locked" + " && cargo test --workspace --locked", + cache="agent-full-windows", timeout=60, gate=False, **AGENT_WIN), + # Pinned SQLCipher sources, per-target advisories and bans, and notices. + Lane("agent-supply-chain", AGENT_DEPENDENCIES, events=NOT_NIGHTLY, run="just supply-chain", **AGENT_FLAKE), + # Released rusqlite's own tests against the static SQLCipher provider (nightly.yml: all three OSes). + Lane("agent-provider", tuple("apps/maple-agent/" + glob for glob in ( + "Cargo.lock", "dependencies/sqlcipher/**", "scripts/test-rusqlite-provider.py", + "scripts/test_rusqlite_provider.py", + )), events=NOT_NIGHTLY, run="python3 scripts/test_rusqlite_provider.py && mkdir -p target" + " && cargo metadata --locked --format-version 1 > target/provider-metadata.json" + " && python3 scripts/test-rusqlite-provider.py target/provider-metadata.json", + timeout=20, **AGENT_FLAKE), + Lane("agent-perf", (), labels=("ci:perf", "ci:full"), events=PR_SIDE, run="just store-bench-ci", + timeout=30, gate=False, **AGENT_FLAKE), + # Unsigned Dev packages built the way agent-desktop-build.yml builds master's. + Lane("agent-package-linux", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, + job="agent-package-linux", run="just package-rehearsal", timeout=60, gate=False, **AGENT_FLAKE), + Lane("agent-package-macos", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, + job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, gate=False, + **AGENT_FLAKE), + Lane("agent-package-windows", (), labels=("ci:package", "ci:full"), events=PR_SIDE, + job="agent-package-windows", runner=WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, + env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=60, gate=False), # Every root flake check: actionlint, workflow policy and contracts, CI script # tests, the release gates, and release metadata (read from the version files). Lane("repo", ( @@ -125,20 +186,8 @@ class Lane: RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", ), runner=GITHUB, run="nix flake check --no-update-lock-file --print-build-logs", timeout=15), - # Legacy lanes. Lanes that differ only by OS (agent-package, research-desktop) - # are split when they move, and OpenSecret will route through - # opensecret_change_detection.py. - Lane("agent-full", AGENT, legacy="agent-ci.yml"), - Lane("agent-supply-chain", tuple("apps/maple-agent/" + glob for glob in ( - "Cargo.lock", "**/Cargo.toml", "deny.toml", "about.toml", "flake.lock", "dependencies/**", - "scripts/generate-third-party-notices.py", "scripts/audit-sqlcipher-dependencies.py", - )), legacy="agent-sqlcipher-supply-chain.yml"), - Lane("agent-provider-linux", tuple("apps/maple-agent/" + glob for glob in ( - "Cargo.lock", "dependencies/sqlcipher/**", "scripts/test-rusqlite-provider.py", - "scripts/test_rusqlite_provider.py", - )), legacy="agent-ci.yml"), - Lane("agent-package", AGENT_PACKAGING, labels=("ci:package", "ci:full"), legacy="agent-desktop-build.yml"), - Lane("agent-perf", (), labels=("ci:perf",), legacy="agent-store-performance.yml"), + # Legacy lanes. research-desktop is split by OS when it moves, and OpenSecret + # will route through opensecret_change_detection.py. Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", "apps/maple-agent/release-profiles.json", "apps/maple-agent/scripts/release-info.py"), legacy="agent-updates-tests.yml"), diff --git a/scripts/ci/nightly_report.py b/scripts/ci/nightly_report.py new file mode 100644 index 000000000..f6ba78439 --- /dev/null +++ b/scripts/ci/nightly_report.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Keep one open `ci-nightly` issue while a nightly check fails. + +nightly.yml's report job runs this with its jobs' results in NEEDS. Failures +there, or in ci.yml's latest completed scheduled run, open the issue or comment +on it; a green night closes it. DRY_RUN=true prints instead of writing. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +import subprocess +from typing import List, Optional, Tuple + +LABEL = "ci-nightly" +TITLE = "Nightly CI failures" + + +def gh(path: str, method: str = "GET", payload: Optional[dict] = None) -> object: + result = subprocess.run(["gh", "api", "--method", method, path, *(["--input", "-"] if payload else [])], + input=json.dumps(payload) if payload else None, + check=True, capture_output=True, text=True) + return json.loads(result.stdout or "null") + + +def failures(needs: dict, ci_run: Optional[dict]) -> List[str]: + lines = [f"- `{job}`: {state.get('result')}" for job, state in sorted(needs.items()) + if state.get("result") != "success"] + if ci_run is None: + lines.append("- ci.yml: no completed scheduled run on master") + elif ci_run.get("conclusion") != "success": + lines.append(f"- ci.yml scheduled run of {ci_run['created_at'][:10]}: {ci_run.get('conclusion')} " + f"({ci_run['html_url']})") + return lines + + +def decide(lines: List[str], issue: Optional[int], run_url: str) -> Optional[Tuple[str, str]]: + """The one write to make: create, comment or close (None when green and closed).""" + if lines: + day = datetime.now(timezone.utc).strftime("%Y-%m-%d") + return ("comment" if issue else "create"), f"Nightly checks failed on {day} ({run_url}):\n\n" + "\n".join(lines) + return ("close", f"Every nightly check passed ({run_url}).") if issue else None + + +def main() -> int: + repo = os.environ["GITHUB_REPOSITORY"] + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + run_url = f"{server}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}" + runs = gh(f"repos/{repo}/actions/workflows/ci.yml/runs?event=schedule&branch=master&status=completed&per_page=1") + lines = failures(json.loads(os.environ["NEEDS"]), (runs["workflow_runs"] or [None])[0]) + open_issues = [item["number"] for item in gh(f"repos/{repo}/issues?labels={LABEL}&state=open&per_page=100") + if "pull_request" not in item] + issue = min(open_issues) if open_issues else None + action = decide(lines, issue, run_url) + if action is None: + print("Every nightly check passed; no open ci-nightly issue.") + return 0 + kind, body = action + print(f"{kind} {f'#{issue}' if issue else 'a new issue'}:\n{body}") + if os.environ.get("DRY_RUN") == "true": + return 0 + if kind == "create": + gh(f"repos/{repo}/issues", "POST", {"title": TITLE, "body": body, "labels": [LABEL]}) + else: + gh(f"repos/{repo}/issues/{issue}/comments", "POST", {"body": body}) + if kind == "close": + gh(f"repos/{repo}/issues/{issue}", "PATCH", {"state": "closed", "state_reason": "completed"}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index 96e8fc9ed..1f7902d31 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """Select and run the ci.yml lanes defined in scripts/ci/lanes.py. - github ci.yml's plan job: write the `lanes` and `nix` outputs and a summary + github ci.yml's plan job: write the `lanes` and matrix outputs and a summary run LANE run one lane's command in its flake, as CI does (`just lane LANE`) + only NAME another workflow's push selector: print selected=true|false explain show what changed paths select (stdin, or --base/--head) - check-rust the Agent flake's rustc must equal the agent-windows pin + check-rust the Agent flake's rustc must equal the Windows rustup pin lint the table classifies every tracked file and matches ci.yml """ @@ -16,17 +17,20 @@ import json import os from pathlib import Path +import platform import re import shlex +import shutil import subprocess import sys from typing import Dict, Iterable, List, Optional, Sequence -from lanes import (AGENT_WINDOWS_RUST, CI_INFRA, DISPATCH, INERT, LANES, NIGHTLY, NOT_YET_TRACKED, PR, - PR_EVENTS, PUSH, QUEUE, Lane) +from lanes import (AGENT_WINDOWS_RUST, CI_INFRA, DISPATCH, INERT, LANES, MATRIX_JOBS, NIGHTLY, NOT_YET_TRACKED, + PR, PR_EVENTS, PUSH, QUEUE, SELECTORS, Lane) ROOT = Path(__file__).resolve().parents[2] CI_WORKFLOW = ROOT / ".github/workflows/ci.yml" +WINDOWS_SETUP = ROOT / ".github/actions/setup-agent-windows/action.yml" Selection = Dict[str, List[str]] # lane id -> why it was selected @@ -155,18 +159,20 @@ def cache_key(lane: Lane) -> str: return digest.hexdigest()[:16] -def outputs(selected: Selection) -> tuple: - lanes = {lane.id: lane.id in selected for lane in LANES if lane.legacy is None} - nix = [{ - "id": lane.id, - "runner": os.environ.get(lane.runner.variable or "") or lane.runner.label, - "timeout": lane.timeout, - "flake": lane.flake or "", - "cache": lane.cache, - "cache_key": cache_key(lane) if lane.cache else "", - "workspaces": lane.workspaces, - } for lane in LANES if lane.id in selected and lane.job == "nix"] - return json.dumps(lanes, separators=(",", ":")), json.dumps(nix, separators=(",", ":")) +def outputs(selected: Selection) -> Dict[str, str]: + """`lanes` maps every active lane to whether it runs; each matrix job gets its entries.""" + result = {"lanes": {lane.id: lane.id in selected for lane in LANES if lane.legacy is None}} + for job in MATRIX_JOBS: + result[job] = [{ + "id": lane.id, + "runner": os.environ.get(lane.runner.variable or "") or lane.runner.label, + "timeout": lane.timeout, + "flake": lane.flake or "", + "cache": lane.cache, + "cache_key": cache_key(lane) if lane.cache else "", + "workspaces": lane.workspaces, + } for lane in LANES if lane.id in selected and lane.job == job] + return {name: json.dumps(value, separators=(",", ":")) for name, value in result.items()} def summary(event: str, selected: Selection) -> str: @@ -193,9 +199,8 @@ def github() -> int: # Master, nightly and dispatch runs verify everything rather than nothing. print(f"::warning::lane selection failed ({error!r}); selecting every lane") selected = everything(event, "selection failed") - lanes, nix = outputs(selected) with open(os.environ["GITHUB_OUTPUT"], "a") as output: - output.write(f"lanes={lanes}\nnix={nix}\n") + output.writelines(f"{name}={value}\n" for name, value in outputs(selected).items()) text = summary(event, selected) if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as output: @@ -239,20 +244,44 @@ def run(name: str) -> int: print(f"{name}: no ci.yml command in scripts/ci/lanes.py", file=sys.stderr) return 2 command = ["bash", "-euo", "pipefail", "-c", f"cd {shlex.quote(lane.workdir)} && {lane.run}"] - if lane.flake: + if platform.system() == "Windows": + # No Nix on Windows: setup-agent-windows pins the toolchain instead. Process + # creation searches System32 (WSL's bash.exe) before PATH, so take Git Bash from PATH. + command[0] = shutil.which("bash") or "bash" + elif lane.flake: command = ["nix", "develop", "--no-update-lock-file", lane.flake, "-c", *command] print("+ " + " ".join(shlex.quote(part) for part in command), flush=True) return subprocess.run(command, cwd=ROOT, env={**os.environ, **dict(lane.env)}).returncode +def only(name: str) -> int: + """agent-desktop-build.yml's changes job: did this push change `name`'s inputs? + + Anything but a push with a usable diff (dispatch, a new branch, a force-push, + an error) selects the build, as do unclassified paths.""" + paths = None + try: + if os.environ.get("GITHUB_EVENT_NAME") == PUSH: + payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + paths = changed_paths(payload["before"], payload["after"], merge_base=False) + except Exception as error: # noqa: BLE001 - fail safe: build + print(f"::warning::{name} selection failed ({error!r}); building", file=sys.stderr) + selected = paths is None or any( + not inert(path) and (not classified(path) or matches(path, SELECTORS[name])) for path in paths) + print(f"{name}: {'no usable diff' if paths is None else f'{len(paths)} changed paths'}, " + f"selected={str(selected).lower()}", file=sys.stderr) + print(f"selected={str(selected).lower()}") + return 0 + + def check_rust(expected: str = AGENT_WINDOWS_RUST) -> int: reported = subprocess.run(["rustc", "--version"], check=True, capture_output=True, text=True).stdout version = reported.split()[1] if version != expected: - print(f"::error::The Agent flake has rustc {version}, but ci.yml's agent-windows job pins {expected}. " - "Update AGENT_WINDOWS_RUST in scripts/ci/lanes.py and the ci.yml toolchain together.") + print(f"::error::The Agent flake has rustc {version}, but setup-agent-windows pins {expected}. Update " + "AGENT_WINDOWS_RUST in scripts/ci/lanes.py and .github/actions/setup-agent-windows together.") return 1 - print(f"rustc {version} matches the agent-windows pin") + print(f"rustc {version} matches the Windows rustup pin") return 0 @@ -265,7 +294,8 @@ def tracked_files() -> List[str]: def lint_problems(files: Sequence[str]) -> List[str]: problems = [] - globs = {g.lstrip("!") for g in INERT + CI_INFRA} | {g.lstrip("!") for lane in LANES for g in lane_globs(lane)} + globs = ({g.lstrip("!") for g in INERT + CI_INFRA + sum(SELECTORS.values(), ())} + | {g.lstrip("!") for lane in LANES for g in lane_globs(lane)}) problems += [f"matches no tracked file: {g}" for g in sorted(globs - NOT_YET_TRACKED) if not any(_pattern(g).fullmatch(f) for f in files)] problems += [f"unclassified: {f}" for f in files if not inert(f) and not classified(f)] @@ -273,11 +303,11 @@ def lint_problems(files: Sequence[str]) -> List[str]: problems += [f"duplicate lane: {i}" for i in sorted({i for i in ids if ids.count(i) > 1})] workflow = CI_WORKFLOW.read_text() jobs = set(re.findall(r"^ ([A-Za-z0-9_-]+):", workflow.split("\njobs:\n", 1)[1], re.M)) - special = {lane.job for lane in LANES if lane.legacy is None and lane.job != "nix"} - if jobs - {"plan", "nix", "ci-ok"} != special: - problems.append(f"ci.yml special jobs {sorted(jobs - {'plan', 'nix', 'ci-ok'})} != lanes {sorted(special)}") - if f"toolchain: {AGENT_WINDOWS_RUST}\n" not in workflow: - problems.append(f"ci.yml does not pin agent-windows to {AGENT_WINDOWS_RUST}") + expected = {"plan", "ci-ok", *MATRIX_JOBS} | {lane.job for lane in LANES if lane.legacy is None} + if jobs != expected: + problems.append(f"ci.yml jobs {sorted(jobs)} != plan, ci-ok, matrix and special lane jobs {sorted(expected)}") + if f"toolchain: {AGENT_WINDOWS_RUST}\n" not in WINDOWS_SETUP.read_text(): + problems.append(f"setup-agent-windows does not pin Rust {AGENT_WINDOWS_RUST}") declared = set(re.findall(r"^\s+- (\S+)$", (ROOT / ".github/actionlint.yaml").read_text(), re.M)) for lane in LANES: if lane.runner.label not in declared and not re.match(r"(ubuntu|macos|windows)-", lane.runner.label): @@ -290,6 +320,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: modes = parser.add_subparsers(dest="mode", required=True) modes.add_parser("github") modes.add_parser("run").add_argument("lane") + modes.add_parser("only").add_argument("name", choices=sorted(SELECTORS)) explain_parser = modes.add_parser("explain") explain_parser.add_argument("--event", default=PR, choices=(PR, QUEUE, PUSH)) explain_parser.add_argument("--base") @@ -301,6 +332,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return github() if args.mode == "run": return run(args.lane) + if args.mode == "only": + return only(args.name) if args.mode == "explain": return explain(args.event, args.base, args.head) if args.mode == "check-rust": diff --git a/scripts/ci/test_agent_cache.py b/scripts/ci/test_agent_cache.py deleted file mode 100644 index fbfe60e41..000000000 --- a/scripts/ci/test_agent_cache.py +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for compatible Cargo-target cache boundaries.""" - -import json -import os -from pathlib import Path -import subprocess -import shutil -import tempfile -import unittest -from unittest.mock import patch - -import agent_cache - - -SYSTEMS = ("Linux", "Darwin", "Windows") -PROFILES = ("ci", "provider", "release") -# Independent command-ownership expectations. Mutating every file below must -# invalidate exactly these OS/profile combinations, with common ABI/source -# guards retained even where a verification helper is not the compiler owner. -CONFIG_INPUTS = { - "apps/maple-agent/flake.nix": ({"Linux", "Darwin"}, set(PROFILES)), - "apps/maple-agent/flake.lock": ({"Linux", "Darwin"}, set(PROFILES)), - "apps/maple-agent/justfile": ({"Linux"}, {"ci"}), - "apps/maple-agent/dependencies/sqlcipher/provenance.json": (set(SYSTEMS), set(PROFILES)), - "apps/maple-agent/dependencies/sqlcipher/verify_sources.py": (set(SYSTEMS), set(PROFILES)), - "apps/maple-agent/scripts/verify-sqlcipher-build.py": (set(SYSTEMS), set(PROFILES)), - "apps/maple-agent/scripts/test-rusqlite-provider.py": (set(SYSTEMS), {"provider"}), - "apps/maple-agent/scripts/build-release.sh": (set(SYSTEMS), {"release"}), - "scripts/ci/agent_cache.py": (set(SYSTEMS), set(PROFILES)), - "scripts/ci/agent_windows.py": ({"Windows"}, set(PROFILES)), -} - - -class AgentCacheTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - for name in CONFIG_INPUTS: - path = self.root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("pinned configuration\n", encoding="utf-8") - manifests = ( - "apps/maple-agent/Cargo.toml", "apps/maple-agent/app/Cargo.toml", - "apps/maple-agent/crates/core/Cargo.toml", "sdk/rust/Cargo.toml", "proxy/Cargo.toml", - ) - for name in manifests: - path = self.root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('[package]\nname = "fixture"\nversion = "1.0.0"\nedition = "2024"\n[features]\ndefault = ["native"]\nnative = []\n', encoding="utf-8") - self.environment = {"RUSTFLAGS": "-D warnings", "CARGO_PROFILE_DEV_DEBUG": "0", "CARGO_HOME": str(self.root / "cargo-home")} - self.patches = ( - patch.dict(os.environ, self.environment, clear=True), - patch.object(agent_cache.platform, "system", return_value="Linux"), - patch.object(agent_cache.platform, "machine", return_value="x86_64"), - patch.object(agent_cache, "executable", side_effect=lambda name: f"/nix/store/toolchain/bin/{name}"), - patch.object(agent_cache, "command", side_effect=lambda args: " ".join(args) + " version 1"), - patch.object(agent_cache, "native_identity", return_value={"cc": "/nix/store/native/bin/cc", "sdk": "1"}), - patch.object(agent_cache, "native_build_tools", return_value={"perl": "native", "nasm": None}), - ) - for selected in self.patches: - selected.start() - self.addCleanup(selected.stop) - - def identity(self, profile="ci", system=None): - if system is None: - return agent_cache.build_identity(profile, self.root) - with patch.object(agent_cache.platform, "system", return_value=system): - return agent_cache.build_identity(profile, self.root) - - def change_file(self, name, suffix="changed\n"): - path = self.root / name - path.write_text(path.read_text(encoding="utf-8") + suffix, encoding="utf-8") - - def test_version_only_manifest_and_lock_bump_keeps_compatible_prefix(self): - before = agent_cache.digest(self.identity()) - for name in ("apps/maple-agent/Cargo.toml", "apps/maple-agent/app/Cargo.toml"): - path = self.root / name - path.write_text(path.read_text().replace('version = "1.0.0"', 'version = "1.0.1"')) - (self.root / "apps/maple-agent/Cargo.lock").write_text("changed local package version") - self.assertEqual(agent_cache.digest(self.identity()), before) - - def test_workspace_package_version_does_not_change_prefix(self): - path = self.root / "apps/maple-agent/Cargo.toml" - path.write_text('[workspace]\nresolver = "2"\n[workspace.package]\nversion = "1.0.0"\nedition = "2024"\n') - before = agent_cache.digest(self.identity()) - path.write_text(path.read_text().replace('"1.0.0"', '"1.0.1"')) - self.assertEqual(agent_cache.digest(self.identity()), before) - - def test_configuration_mutations_follow_platform_and_profile_ownership(self): - before = {(system, profile): self.identity(profile, system) - for system in SYSTEMS for profile in PROFILES} - for (system, profile), identity in before.items(): - with self.subTest(system=system, profile=profile): - expected = {name for name, (systems, profiles) in CONFIG_INPUTS.items() - if system in systems and profile in profiles} - self.assertEqual(set(identity["configuration"]["files"]), expected) - for name, (systems, profiles) in CONFIG_INPUTS.items(): - path = self.root / name - original = path.read_text(encoding="utf-8") - self.change_file(name) - try: - for (system, profile), identity in before.items(): - with self.subTest(file=name, system=system, profile=profile): - changed = agent_cache.digest(self.identity(profile, system)) != agent_cache.digest(identity) - self.assertEqual(changed, system in systems and profile in profiles) - finally: - path.write_text(original, encoding="utf-8") - - def test_linux_packaging_recipes_do_not_invalidate_mac_or_windows_workspace(self): - path = self.root / "apps/maple-agent/justfile" - path.write_text( - 'set shell := ["bash", "-euo", "pipefail", "-c"]\n' - 'ci: fmt-check clippy test\n' - 'test:\n cargo test --workspace --locked\n' - 'packaging-test:\n' - ' python3 ./scripts/test_linux_release_appimage.py\n', - encoding="utf-8", - ) - before = {system: self.identity("ci", system) for system in SYSTEMS} - # These packaging-test additions caused a fresh cache generation on - # both direct-Cargo workspace lanes despite unchanged native settings. - self.change_file("apps/maple-agent/justfile", - ' python3 ./scripts/test_linux_release_bundle.py\n' - ' python3 ./scripts/test_linux_install.py\n' - ' python3 ./scripts/test_smoke_linux_bundle_host.py\n') - for system in SYSTEMS: - with self.subTest(system=system): - after = self.identity("ci", system) - if system == "Linux": - self.assertNotEqual(after, before[system]) - else: - self.assertEqual(after, before[system]) - - def test_feature_and_profile_changes_invalidate_prefix(self): - path = self.root / "apps/maple-agent/Cargo.toml" - before = agent_cache.digest(self.identity()) - self.change_file(str(path.relative_to(self.root)), '\n[profile.dev]\nopt-level = 1\n') - self.assertNotEqual(agent_cache.digest(self.identity()), before) - before = agent_cache.digest(self.identity()) - path.write_text(path.read_text().replace('default = ["native"]', 'default = []')) - self.assertNotEqual(agent_cache.digest(self.identity()), before) - - def test_cargo_config_changes_invalidate_prefix(self): - before = agent_cache.digest(self.identity()) - path = self.root / "apps/maple-agent/.cargo/config.toml" - path.parent.mkdir() - path.write_text('[build]\nrustflags = ["-C", "target-cpu=native"]\n') - self.assertNotEqual(agent_cache.digest(self.identity()), before) - - def test_target_dependency_features_invalidate_but_versions_do_not(self): - path = self.root / "apps/maple-agent/Cargo.toml" - self.change_file(str(path.relative_to(self.root)), - '\n[target.\'cfg(windows)\'.dependencies]\n' - 'provider = { version = "1.0", features = ["static"], default-features = false }\n') - before = agent_cache.digest(self.identity()) - path.write_text(path.read_text().replace('version = "1.0"', 'version = "1.1"')) - self.assertEqual(agent_cache.digest(self.identity()), before) - path.write_text(path.read_text().replace('features = ["static"]', 'features = ["dynamic"]')) - self.assertNotEqual(agent_cache.digest(self.identity()), before) - - def test_cargo_home_config_contents_invalidate_without_exposing_secrets(self): - before = agent_cache.digest(self.identity()) - home = Path(self.environment["CARGO_HOME"]) - home.mkdir() - (home / "config.toml").write_text('[build]\nrustflags = ["private-native-flag"]\n') - after = self.identity() - self.assertNotEqual(agent_cache.digest(after), before) - serialized = json.dumps(after) - self.assertNotIn("private-native-flag", serialized) - self.assertNotIn(str(home), serialized) - self.assertIn("cargo-home/config.toml", after["configuration"]["cargo_configs"]["workspace"]) - - def test_ancestor_above_repository_is_included_with_stable_label(self): - # Keep all test configurations under a disposable parent, never the - # machine's real home or ancestor .cargo directories. - with tempfile.TemporaryDirectory() as temporary: - parent = Path(temporary) - repository = parent / "checkout" - shutil.copytree(self.root, repository) - before = agent_cache.build_identity("ci", repository) - (parent / ".cargo").mkdir() - (parent / ".cargo/config").write_text('[build]\ntarget = "changed-native-target"\n') - after = agent_cache.build_identity("ci", repository) - self.assertNotEqual(agent_cache.digest(after), agent_cache.digest(before)) - configs = after["configuration"]["cargo_configs"]["workspace"] - self.assertIn("ancestor-3/.cargo/config", configs) - self.assertNotIn(str(parent), json.dumps(configs)) - - def test_provider_directory_configs_change_prefix_without_temporary_path(self): - copies = [] - for name in ("provider-first", "provider-second"): - work = self.root / name - (work / ".cargo").mkdir(parents=True) - (work / ".cargo/config.toml").write_text('[build]\nrustflags = ["-Dwarnings"]\n') - copies.append(work) - first = agent_cache.build_identity("provider", self.root, copies[0]) - second = agent_cache.build_identity("provider", self.root, copies[1]) - # External suite directories have stable depth labels rather than - # names of disposable source copies. - with tempfile.TemporaryDirectory() as temporary: - external = Path(temporary) / "suite" - shutil.copytree(copies[0], external) - external_first = agent_cache.build_identity("provider", self.root, external) - renamed = external.with_name("renamed-suite") - external.rename(renamed) - external_second = agent_cache.build_identity("provider", self.root, renamed) - self.assertEqual(external_first, external_second) - self.assertNotIn(str(renamed), json.dumps(external_second["configuration"]["cargo_configs"])) - self.assertNotEqual(first, second) - (copies[0] / ".cargo/config.toml").write_text('[build]\nrustflags = ["-Ctarget-cpu=native"]\n') - self.assertNotEqual(agent_cache.build_identity("provider", self.root, copies[0]), first) - - def test_absent_cargo_home_uses_default_home_without_writing_it(self): - fixture_home = self.root / "default-home" - with patch.dict(os.environ, clear=True), patch.object(agent_cache.Path, "home", return_value=fixture_home): - before = self.identity() - self.assertFalse(fixture_home.exists()) - (fixture_home / ".cargo").mkdir(parents=True) - (fixture_home / ".cargo/config.toml").write_text('[build]\nincremental = false\n') - after = self.identity() - self.assertNotEqual(agent_cache.digest(after), agent_cache.digest(before)) - - def test_actual_compiler_native_abi_flags_and_lane_invalidate(self): - before = agent_cache.digest(self.identity()) - with patch.object(agent_cache, "command", return_value="actual compiler revision 2"): - self.assertNotEqual(agent_cache.digest(self.identity()), before) - with patch.object(agent_cache, "native_identity", return_value={"cc": "changed ABI"}): - self.assertNotEqual(agent_cache.digest(self.identity()), before) - with patch.object(agent_cache, "native_build_tools", return_value={"perl": "native", "nasm": "2"}): - self.assertNotEqual(agent_cache.digest(self.identity()), before) - with patch.dict(os.environ, {"RUSTFLAGS": "-D warnings -C target-cpu=x86-64-v3"}): - self.assertNotEqual(agent_cache.digest(self.identity()), before) - self.assertNotEqual(agent_cache.digest(self.identity("provider")), before) - self.assertNotEqual(agent_cache.digest(self.identity("release")), before) - - def test_actual_cargo_clippy_selection_changes_linux_ci_prefix(self): - before = self.identity() - previous_command = agent_cache.command.side_effect - with patch.object(agent_cache, "command", side_effect=lambda arguments: "different clippy compiler" if "clippy" in arguments else previous_command(arguments)): - after = self.identity() - self.assertEqual(before["rustc"], after["rustc"]) - self.assertNotEqual(before["clippy"], after["clippy"]) - self.assertNotEqual(agent_cache.digest(before), agent_cache.digest(after)) - - def test_non_clippy_ci_lanes_do_not_require_clippy(self): - for system, profile in (("Darwin", "ci"), ("Windows", "ci"), ("Linux", "provider"), ("Linux", "release")): - with self.subTest(system=system, profile=profile), patch.object(agent_cache.platform, "system", return_value=system): - agent_cache.command.reset_mock() - self.assertIsNone(self.identity(profile)["clippy"]) - self.assertFalse(any("clippy" in call.args[0] for call in agent_cache.command.call_args_list)) - - def test_release_variants_and_native_environment_changes_invalidate(self): - before = agent_cache.digest(self.identity("release")) - for key, value in (("MAPLE_RELEASE_PROFILE", "prod"), ("NIX_CFLAGS_COMPILE", "-isystem /nix/store/changed/include"), ("MACOSX_DEPLOYMENT_TARGET", "15.0")): - with self.subTest(key=key), patch.dict(os.environ, {key: value}): - self.assertNotEqual(agent_cache.digest(self.identity("release")), before) - - def test_unrelated_environment_is_not_recorded(self): - before = self.identity() - with patch.dict(os.environ, {"GH_TOKEN": "private", "APPLE_ID_PASSWORD": "private", "GITHUB_RUN_ID": "different-run"}): - self.assertEqual(self.identity(), before) - - def test_unix_rejects_host_toolchain_before_cache_restore(self): - with patch.object(agent_cache, "executable", return_value="/usr/bin/rustc"): - with self.assertRaisesRegex(ValueError, "pinned Nix shell"): - self.identity() - - def test_output_reports_compatibility_and_actual_compiler(self): - output = self.root / "target/environment.json" - github_output = self.root / "github-output" - expected = self.identity() - with patch.object(agent_cache, "build_identity", return_value=expected), patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}), patch("sys.argv", ["agent_cache.py", "--profile", "ci", "--output", str(output)]): - agent_cache.main() - actual = json.loads(output.read_text()) - self.assertEqual(actual["compatibility"], agent_cache.digest(expected)) - self.assertEqual(actual["rustc"], expected["rustc"]) - self.assertEqual(github_output.read_text(), f"compatibility={agent_cache.digest(expected)}\n") - - -class NativeEnvironmentTests(unittest.TestCase): - def test_optional_windows_wrapper_probe_failure_is_fingerprinted(self): - with tempfile.TemporaryDirectory() as temporary: - wrapper = Path(temporary) / "pkg-config.BAT" - wrapper.write_bytes(b"broken preinstalled Perl wrapper") - with patch.object(agent_cache, "executable", return_value="/native/perl"), \ - patch.object(agent_cache, "command", return_value="v5.40"), \ - patch.object(agent_cache.shutil, "which", side_effect=lambda name: str(wrapper) if name == "pkg-config" else None), \ - patch.object(agent_cache.subprocess, "run", return_value=subprocess.CompletedProcess([], 29, "", "wrapper error")): - before = agent_cache.native_build_tools({}) - self.assertEqual(before["pkg-config"]["version_probe_exit"], 29) - self.assertIsNone(before["pkg-config"]["version"]) - self.assertNotIn("wrapper error", json.dumps(before)) - wrapper.write_bytes(b"updated wrapper") - self.assertNotEqual(agent_cache.native_build_tools({}), before) - - def test_required_perl_probe_still_fails_closed(self): - with patch.object(agent_cache, "executable", return_value="/native/perl"), \ - patch.object(agent_cache, "command", side_effect=ValueError("required Perl failed")): - with self.assertRaisesRegex(ValueError, "required Perl failed"): - agent_cache.native_build_tools({}) - - def test_optional_version_probe_timeout_keeps_file_identity(self): - with tempfile.TemporaryDirectory() as temporary: - wrapper = Path(temporary) / "pkg-config.BAT" - wrapper.write_bytes(b"preinstalled wrapper") - with patch.object(agent_cache, "executable", return_value="/native/perl"), \ - patch.object(agent_cache, "command", return_value="v5.40"), \ - patch.object(agent_cache.shutil, "which", side_effect=lambda name: str(wrapper) if name == "pkg-config" else None), \ - patch.object(agent_cache.subprocess, "run", side_effect=subprocess.TimeoutExpired("optional", 10)): - tools = agent_cache.native_build_tools({}) - self.assertEqual(tools["pkg-config"]["version_probe_error"], "TimeoutExpired") - self.assertEqual(len(tools["pkg-config"]["sha256"]), 64) - - def test_windows_requires_selected_toolset_and_sdk(self): - with self.assertRaisesRegex(ValueError, "MSVC developer environment"): - agent_cache.native_identity("Windows", {}) - - def test_windows_environment_names_are_case_insensitive(self): - with patch.object(agent_cache, "executable", return_value="/unselected/cl.exe"): - with self.assertRaisesRegex(ValueError, "outside the selected"): - agent_cache.native_identity("Windows", {"VCTOOLSINSTALLDIR": "/selected", "WINDOWSSDKDIR": "/sdk", "WINDOWSSDKVERSION": "10.0"}) - - def test_openssl_native_perl_and_nasm_absence_are_recorded(self): - with patch.object(agent_cache, "executable", return_value="/native/perl"), patch.object(agent_cache.shutil, "which", return_value=None), patch.object(agent_cache, "command", return_value="v5.40"): - tools = agent_cache.native_build_tools({"OPENSSL_SRC_PERL": "/native/perl"}) - self.assertEqual(tools["perl"], {"path": "/native/perl", "version": "v5.40"}) - self.assertIsNone(tools["nasm"]) - - def test_windows_rejects_different_cl_on_path(self): - with patch.object(agent_cache, "executable", return_value="/unselected/cl.exe"): - with self.assertRaisesRegex(ValueError, "outside the selected"): - agent_cache.native_identity("Windows", {"VCToolsInstallDir": "/selected", "WindowsSdkDir": "/sdk", "WindowsSDKVersion": "10.0"}) - - def test_windows_toolset_binary_and_sdk_changes_are_fingerprinted(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - compiler = root / "toolset/bin/cl.exe" - compiler.parent.mkdir(parents=True) - for name in ("cl.exe", "c1.dll", "c1xx.dll", "c2.dll", "link.exe"): - (compiler.parent / name).write_bytes(name.encode()) - sdk = root / "sdk" - for version in ("10.0", "11.0"): - (sdk / "Include" / version).mkdir(parents=True) - (sdk / "Lib" / version).mkdir(parents=True) - environment = {"VCToolsInstallDir": str(root / "toolset"), "WindowsSdkDir": str(sdk), "WindowsSDKVersion": "10.0\\", - "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER": str(compiler.parent / "link.exe")} - with patch.object(agent_cache, "executable", return_value=str(compiler)), patch.object(agent_cache, "command", return_value="MSVC version"): - before = agent_cache.native_identity("Windows", environment) - self.assertEqual(before["linker"]["path"], str((compiler.parent / "link.exe").resolve())) - for wrong_linker in ("", str(root / "Git/usr/bin/link.exe")): - with self.subTest(linker=wrong_linker): - wrong = {**environment, "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER": wrong_linker} - with self.assertRaisesRegex(ValueError, "pin Cargo's Windows linker"): - agent_cache.native_identity("Windows", wrong) - (compiler.parent / "link.exe").write_bytes(b"new selected linker") - self.assertNotEqual(agent_cache.native_identity("Windows", environment), before) - (compiler.parent / "c2.dll").write_bytes(b"new native backend") - self.assertNotEqual(agent_cache.native_identity("Windows", environment), before) - environment["WindowsSDKVersion"] = "11.0\\" - self.assertEqual(agent_cache.native_identity("Windows", environment)["sdk_version"], "11.0") - - def test_macos_collects_actual_xcode_and_sdk(self): - with patch.object(agent_cache, "compiler_identity", return_value={"version": "clang"}), patch.object(agent_cache, "command", side_effect=lambda args: " ".join(args)): - native = agent_cache.native_identity("Darwin", {"CC": "/selected/clang", "CXX": "/selected/clang++"}) - self.assertIn("xcodebuild -version", native["xcode"]) - self.assertIn("--show-sdk-path", native["sdk_path"]) - self.assertIn("--show-sdk-version", native["sdk_version"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_cache_command.py b/scripts/ci/test_agent_cache_command.py deleted file mode 100644 index a59e640e8..000000000 --- a/scripts/ci/test_agent_cache_command.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""Check compiler-probe forwarding independently of the ambient Rustup list.""" - -import contextlib -import io -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -import unittest -from unittest.mock import patch - -import agent_cache_command as wrapper - - -class AgentCacheCommandTests(unittest.TestCase): - def test_only_exact_optional_inventory_is_empty_success_without_tool_access(self): - stdout, stderr = io.StringIO(), io.StringIO() - with patch.object(wrapper.subprocess, "run", side_effect=AssertionError("tool accessed")), \ - patch.object(wrapper.platform, "system", side_effect=AssertionError("platform accessed")), \ - contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - self.assertEqual(wrapper.main(["rustup", "toolchain", "list", "--quiet"]), 0) - self.assertEqual(stdout.getvalue(), "") - self.assertEqual(stderr.getvalue(), "") - - def test_unexpected_rustup_probes_fail_without_running_or_faking_versions(self): - probes = ( - ["rustup", "toolchain", "list"], - ["rustup", "toolchain", "list", "--quiet", "--verbose"], - ["rustup", "--version"], - ["rustup", "run", "stable", "rustc", "-vV"], - ["/host/bin/rustup", "toolchain", "list", "--quiet"], - [r"C:\Users\runner\.cargo\bin\rustup.exe", "toolchain", "list", "--quiet"], - ["rustup.exe", "toolchain", "list", "--quiet"], - ) - for probe in probes: - stdout, stderr = io.StringIO(), io.StringIO() - with self.subTest(probe=probe), patch.object(wrapper.subprocess, "run") as run, \ - contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - self.assertEqual(wrapper.main(probe), 2) - run.assert_not_called() - self.assertEqual(stdout.getvalue(), "") - self.assertIn("Unexpected Rustup probe", stderr.getvalue()) - - def test_unix_forwards_real_probes_through_exact_component_shell(self): - component = Path("/work/Maple checkout with spaces/apps/maple-agent") - for system in ("Darwin", "Linux"): - for probe in (["rustc", "-vV"], - ["cargo", "metadata", "--all-features", "--format-version", "1"], - ["cargo", "metadata", "--all-features", "--format-version", "1", "--no-deps"]): - with self.subTest(system=system, probe=probe), \ - patch.object(wrapper.platform, "system", return_value=system), \ - patch.object(wrapper, "COMPONENT", component), \ - patch.object(wrapper.subprocess, "run") as run: - run.return_value.returncode = 0 - self.assertEqual(wrapper.main(probe), 0) - run.assert_called_once_with( - ["nix", "develop", "--no-update-lock-file", str(component), "-c", *probe], - shell=False, check=False, - ) - - def test_windows_forwards_native_selected_executable_and_arguments_unchanged(self): - probe = [r"C:\Rust toolchain\bin\cargo.exe", "metadata", "--manifest-path", - r"D:\Maple checkout\Cargo.toml", "--format-version", "1", - "literal 'quotes'", 'literal "quotes"', "$(no expansion)", "x;y"] - with patch.object(wrapper.platform, "system", return_value="Windows"), \ - patch.object(wrapper.subprocess, "run") as run: - run.return_value.returncode = 0 - self.assertEqual(wrapper.main(probe), 0) - run.assert_called_once_with(probe, shell=False, check=False) - - def test_failed_probe_exit_is_not_replaced_with_a_successful_identity(self): - with patch.object(wrapper.platform, "system", return_value="Linux"), \ - patch.object(wrapper.subprocess, "run") as run: - run.return_value.returncode = 37 - self.assertEqual(wrapper.main(["rustc", "-vV"]), 37) - - def test_missing_probe_or_shell_fails_closed(self): - stdout, stderr = io.StringIO(), io.StringIO() - with patch.object(wrapper.platform, "system", return_value="Windows"), \ - patch.object(wrapper.subprocess, "run", side_effect=FileNotFoundError("missing rustc")), \ - contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - self.assertEqual(wrapper.main(["rustc", "-vV"]), 127) - self.assertEqual(stdout.getvalue(), "") - self.assertIn("missing rustc", stderr.getvalue()) - - def test_empty_command_fails_without_executing_anything(self): - with patch.object(wrapper.subprocess, "run") as run, contextlib.redirect_stderr(io.StringIO()): - self.assertEqual(wrapper.main([]), 2) - run.assert_not_called() - - -@unittest.skipIf(os.name == "nt", "Unix Nix executable fixture") -class CommandLineTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory(prefix="Agent cache command ") - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.script = self.root / "Maple checkout with spaces/scripts/ci/agent_cache_command.py" - self.script.parent.mkdir(parents=True) - shutil.copyfile(wrapper.__file__, self.script) - self.component = self.script.parents[2] / "apps/maple-agent" - self.tools = self.root / "tools with spaces" - self.tools.mkdir() - self.receipt = self.root / "arguments.json" - self.rustup_access = self.root / "rustup-access" - self.env = dict(os.environ, PATH=str(self.tools) + os.pathsep + os.environ.get("PATH", ""), - CACHE_PROBE_RECEIPT=str(self.receipt), CACHE_RUSTUP_ACCESS=str(self.rustup_access)) - self.tool("nix", '''import json, os, sys -expected = ["develop", "--no-update-lock-file", ''' + repr(str(self.component)) + ''', "-c"] -assert sys.argv[1:5] == expected, sys.argv -with open(os.environ["CACHE_PROBE_RECEIPT"], "w") as receipt: - json.dump(sys.argv[1:], receipt) -os.execvp(sys.argv[5], sys.argv[5:]) -''') - - def tool(self, name, source): - path = self.tools / name - path.write_text(f"#!{sys.executable}\n" + source, encoding="utf-8") - path.chmod(0o755) - - def invoke(self, *arguments): - return subprocess.run([sys.executable, str(self.script), *arguments], env=self.env, - capture_output=True, text=True, check=False) - - def test_cli_preserves_space_paths_literal_arguments_streams_and_failure(self): - marker = self.root / "must not execute" - arguments = ["path with spaces", '"quoted value"', r"C:\some path\Cargo.toml", - "$(touch forbidden)", f"; touch {marker}", "a&b", ""] - probe = [sys.executable, "-c", - "import json,sys; print(json.dumps(sys.argv[1:])); " - "print('actual probe stderr',file=sys.stderr); sys.exit(23)", *arguments] - result = self.invoke(*probe) - self.assertEqual(result.returncode, 23, result.stderr) - self.assertEqual(json.loads(result.stdout), arguments) - self.assertEqual(result.stderr, "actual probe stderr\n") - self.assertEqual(json.loads(self.receipt.read_text()), - ["develop", "--no-update-lock-file", str(self.component), "-c", *probe]) - self.assertFalse(marker.exists()) - - def test_ambient_inventory_drift_never_runs_rustup_or_changes_real_compiler_probe(self): - self.tool("rustc", "import sys; assert sys.argv[1:] == ['-vV']; print('actual pinned compiler identity')\n") - outputs = [] - for inventory in ("stable-host-1", "stable-host-2\\nnightly-host"): - self.tool("rustup", "import os; from pathlib import Path; " - "Path(os.environ['CACHE_RUSTUP_ACCESS']).write_text('accessed'); " - f"print({inventory!r}); raise SystemExit(91)\n") - inventory_result = self.invoke("rustup", "toolchain", "list", "--quiet") - self.assertEqual((inventory_result.returncode, inventory_result.stdout, inventory_result.stderr), (0, "", "")) - probe = self.invoke("rustc", "-vV") - self.assertEqual(probe.returncode, 0, probe.stderr) - outputs.append(probe.stdout) - self.assertEqual(outputs, ["actual pinned compiler identity\n"] * 2) - self.assertFalse(self.rustup_access.exists()) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_cache_scope.py b/scripts/ci/test_agent_cache_scope.py deleted file mode 100644 index f1af74bad..000000000 --- a/scripts/ci/test_agent_cache_scope.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -"""Regressions for target-aware keys and exact legacy identity migration.""" - -import copy -import io -import json -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -import agent_cache -import agent_cache_scope as scope - - -APP = "apps/maple-agent/app/Cargo.toml" -MAC_DEPENDENCY = '\n[target.\'cfg(target_os = "macos")\'.dependencies]\nraw-window-handle = { version = "0.6", optional = true }\n' - - -class AgentCacheScopeTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - for relative in ("apps/maple-agent/Cargo.toml", APP, "sdk/rust/Cargo.toml", "proxy/Cargo.toml"): - path = self.root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('[package]\nname = "fixture"\nversion = "1.0"\nedition = "2024"\n', encoding="utf-8") - self.app = self.root / APP - self.app.write_text('[package]\nname = "fixture"\n[features]\ndefault = ["desktop"]\ndesktop = []\n', encoding="utf-8") - with (self.root / "apps/maple-agent/Cargo.toml").open("a", encoding="utf-8") as manifest: - manifest.write('\n[workspace]\nresolver = "2"\n') - - def identity(self, system="Windows"): - host = {"Windows": "x86_64-pc-windows-msvc", "Linux": "x86_64-unknown-linux-gnu", "Darwin": "aarch64-apple-darwin"}[system] - return { - "schema": 1, "profile": "ci", "platform": system, "architecture": host.split("-")[0], - "rustc": {"path": "/pinned/rustc", "version": f"rustc 1.98.0\nhost: {host}\nrelease: 1.98.0"}, - "native": {"compiler": "pinned", "sdk": "pinned"}, - "environment": {"RUSTFLAGS": "-D warnings", "CARGO_PROFILE_DEV_DEBUG": "0"}, - "configuration": {"files": {"guard": "original"}, "manifests": agent_cache.manifest_settings(self.root), "cargo_configs": {"workspace": {}}}, - } - - def key(self, identity): - return agent_cache.digest(scope.canonical_identity(identity, self.root)) - - def add_mac_dependency(self, edge="dep:raw-window-handle"): - self.app.write_text(self.app.read_text().replace('desktop = []', f'desktop = ["{edge}"]') + MAC_DEPENDENCY, encoding="utf-8") - - def test_mac_only_optional_dependency_preserves_windows_and_linux_keys(self): - before = {system: self.key(self.identity(system)) for system in ("Windows", "Linux", "Darwin")} - self.add_mac_dependency() - for system in ("Windows", "Linux", "Darwin"): - with self.subTest(system=system): - after = self.key(self.identity(system)) - self.assertEqual(after == before[system], system != "Darwin") - - def test_active_dependency_features_and_other_feature_edges_remain_guarded(self): - self.add_mac_dependency() - before = self.key(self.identity("Darwin")) - self.app.write_text(self.app.read_text().replace('optional = true', 'optional = true, features = ["std"]')) - self.assertNotEqual(self.key(self.identity("Darwin")), before) - before = self.key(self.identity()) - self.app.write_text(self.app.read_text().replace('desktop = [', 'desktop = ["new-local-feature", ')) - self.assertNotEqual(self.key(self.identity()), before) - - def test_plain_optional_feature_and_strong_edge_are_conservative(self): - for edge in ("raw-window-handle", "raw-window-handle/std"): - with self.subTest(edge=edge): - self.app.write_text('[package]\nname = "fixture"\n[features]\ndefault = ["desktop"]\ndesktop = []\n', encoding="utf-8") - before = self.key(self.identity()) - self.add_mac_dependency(edge) - self.assertNotEqual(self.key(self.identity()), before) - canonical = scope.canonical_identity(self.identity(), self.root) - settings = canonical["configuration"]["manifests"][APP] - self.assertTrue(settings["dependency-features"]) - self.assertIn(edge, settings["features"]["desktop"]) - - def test_explicit_local_feature_with_dependency_name_is_not_erased(self): - self.add_mac_dependency() - self.app.write_text(self.app.read_text().replace('[features]', '[features]\nraw-window-handle = []')) - canonical = scope.canonical_identity(self.identity(), self.root) - settings = canonical["configuration"]["manifests"][APP] - self.assertIn("raw-window-handle", settings["features"]) - self.assertIn("dep:raw-window-handle", settings["features"]["desktop"]) - - def test_weak_inactive_dependency_feature_is_removed_without_dropping_feature_name(self): - self.add_mac_dependency() - self.app.write_text(self.app.read_text().replace('desktop = ["dep:raw-window-handle"]', - 'desktop = ["dep:raw-window-handle", "raw-window-handle?/std"]')) - canonical = scope.canonical_identity(self.identity(), self.root) - self.assertEqual(canonical["configuration"]["manifests"][APP]["features"]["desktop"], []) - self.assertIn("desktop", canonical["configuration"]["manifests"][APP]["features"]) - - def test_inactive_optional_dependency_implicit_local_feature_is_preserved(self): - for edge in (None, "raw-window-handle?/std"): - with self.subTest(edge=edge): - self.app.write_text('[package]\nname = "fixture"\n[features]\ndefault = ["desktop"]\ndesktop = []\n', encoding="utf-8") - before = self.key(self.identity()) - if edge is None: - self.app.write_text(self.app.read_text() + MAC_DEPENDENCY) - else: - self.add_mac_dependency(edge) - identity = self.identity() - canonical = scope.canonical_identity(identity, self.root) - self.assertNotEqual(agent_cache.digest(canonical), before) - self.assertEqual(canonical["configuration"], identity["configuration"]) - - def test_dependency_declared_active_or_unknown_elsewhere_keeps_feature_edges(self): - self.add_mac_dependency() - for table in ("dependencies", "target.'cfg(feature = \"custom\")'.dependencies"): - with self.subTest(table=table): - original = self.app.read_text() - self.app.write_text(original + f'\n[{table}]\nraw-window-handle = {{version = "0.6", optional = true}}\n') - canonical = scope.canonical_identity(self.identity(), self.root) - self.assertIn("dep:raw-window-handle", canonical["configuration"]["manifests"][APP]["features"]["desktop"]) - self.app.write_text(original) - - def test_cfg_three_value_logic_preserves_unknowns(self): - context = scope.native_target(self.identity()) - cases = { - 'cfg(target_os = "windows")': True, - 'cfg(target_os = "macos")': False, - 'cfg(unix)': False, 'cfg(windows)': True, - 'cfg(all(target_os = "macos", unknown))': False, - 'cfg(any(target_os = "macos", unknown))': None, - 'cfg(not(unknown))': None, - 'cfg(target_feature = "sse2")': None, - 'cfg(feature = "custom")': None, - 'cfg(not(windows))': False, - 'cfg(all(windows, target_arch = "x86_64",))': True, - 'cfg(unknown_function(windows))': None, - 'cfg(target_os = "macos" broken)': None, - 'aarch64-apple-darwin': False, - 'x86_64-pc-windows-msvc': True, - } - for expression, expected in cases.items(): - with self.subTest(expression=expression): - self.assertIs(scope.target_matches(expression, context), expected) - - def test_unknown_cfg_dependency_metadata_and_features_stay_strict(self): - self.app.write_text(self.app.read_text() + '\n[target.\'cfg(custom_runtime)\'.dependencies]\ncustom = {version = "1", optional = true}\n') - identity = self.identity() - canonical = scope.canonical_identity(identity, self.root) - self.assertEqual(canonical["configuration"], identity["configuration"]) - - def test_cargo_config_target_wrapper_and_cfg_overrides_disable_pruning(self): - self.add_mac_dependency() - mutations = ( - lambda x: x["configuration"]["cargo_configs"]["workspace"].update({"config.toml": "digest"}), - lambda x: x["environment"].update(CARGO_BUILD_TARGET="aarch64-apple-darwin"), - lambda x: x["environment"].update(RUSTC_WRAPPER="custom-wrapper"), - lambda x: x["environment"].update(RUSTFLAGS="--cfg windows"), - lambda x: x["environment"].update(CARGO_ENCODED_RUSTFLAGS="--cfg\x1fwindows"), - lambda x: x["environment"].update(CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS="--target=other"), - ) - for mutate in mutations: - identity = self.identity() - mutate(identity) - self.assertEqual(scope.canonical_identity(identity, self.root)["configuration"], identity["configuration"]) - - def test_legacy_or_unknown_workspace_resolver_disables_pruning(self): - self.add_mac_dependency() - for resolver in (None, "1", "future"): - with self.subTest(resolver=resolver): - identity = self.identity() - identity["configuration"]["manifests"]["apps/maple-agent/Cargo.toml"]["resolver"] = resolver - self.assertEqual(scope.canonical_identity(identity, self.root)["configuration"], identity["configuration"]) - - def test_unknown_native_triples_do_not_infer_architecture_or_target_os(self): - self.add_mac_dependency() - for system, host in ( - ("Windows", "i686-pc-windows-msvc"), - ("Linux", "armv7-unknown-linux-gnueabihf"), - ("Linux", "aarch64-linux-android"), - ): - with self.subTest(host=host): - identity = self.identity(system) - identity["rustc"]["version"] = f"rustc 1.98.0\nhost: {host}" - self.assertIsNone(scope.native_target(identity)) - self.assertEqual(scope.canonical_identity(identity, self.root)["configuration"], identity["configuration"]) - - def test_abi_toolchain_flags_profiles_and_configuration_are_not_weakened(self): - self.add_mac_dependency() - identity = self.identity() - before = self.key(identity) - for section, key, value in ( - ("native", "sdk", "changed"), ("rustc", "path", "/different/rustc"), - ("environment", "RUSTFLAGS", "-D warnings -C opt-level=2"), - ("environment", "CARGO_PROFILE_DEV_DEBUG", "1"), - ): - with self.subTest(section=section, key=key): - changed = copy.deepcopy(identity) - changed[section][key] = value - self.assertNotEqual(self.key(changed), before) - changed = copy.deepcopy(identity) - changed["configuration"]["files"]["guard"] = "changed" - self.assertNotEqual(self.key(changed), before) - self.app.write_text(self.app.read_text() + '\n[profile.dev]\nopt-level = 2\n') - self.assertNotEqual(self.key(self.identity()), before) - - def test_flat_migration_document_preserves_exact_legacy_and_canonical_hashes(self): - self.add_mac_dependency() - legacy = self.identity() - with patch.object(agent_cache, "build_identity", return_value=legacy) as build: - document = scope.build_document("ci", self.root, self.root / "provider") - build.assert_called_once_with("ci", self.root, self.root / "provider") - self.assertEqual(document["legacy_identity"], legacy) - self.assertEqual(document["legacy_compatibility"], agent_cache.digest(legacy)) - canonical = {key: value for key, value in document.items() if key not in {"compatibility", "legacy_compatibility", "legacy_identity"}} - self.assertEqual(document["compatibility"], agent_cache.digest(canonical)) - self.assertEqual(canonical["scope_schema"], 1) - self.assertIn("rustc", document) - self.assertEqual(legacy, self.identity(), "canonicalization mutated the legacy migration identity") - - def test_cli_preserves_profile_directory_and_publishes_both_hashes(self): - self.add_mac_dependency() - with patch.object(agent_cache, "build_identity", return_value=self.identity()): - document = scope.build_document("provider", self.root) - destination = self.root / "metrics/environment.json" - outputs = self.root / "outputs" - provider = self.root / "copied-provider" - arguments = ["agent_cache_scope.py", "--profile", "provider", "--output", str(destination), - "--cargo-config-dir", str(provider)] - with patch.object(scope.sys, "argv", arguments), patch.object(scope, "build_document", return_value=document) as build, \ - patch.dict(os.environ, {"GITHUB_OUTPUT": str(outputs)}), patch("sys.stdout", new_callable=io.StringIO): - scope.main() - build.assert_called_once_with("provider", cargo_config_dir=provider) - self.assertEqual(json.loads(destination.read_text()), document) - self.assertEqual(outputs.read_text().splitlines(), [ - f"compatibility={document['compatibility']}", - f"legacy_compatibility={document['legacy_compatibility']}", - ]) - - def test_windows_manifest_names_and_unsafe_paths(self): - self.add_mac_dependency() - identity = self.identity() - manifests = identity["configuration"]["manifests"] - manifests[APP.replace("/", "\\")] = manifests.pop(APP) - scope.canonical_identity(identity, self.root) - for relative in ("../outside/Cargo.toml", "/outside/Cargo.toml", "C:\\outside\\Cargo.toml"): - with self.subTest(relative=relative), self.assertRaises(ValueError): - scope.manifest_path(self.root, relative) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_change_detection.py b/scripts/ci/test_agent_change_detection.py deleted file mode 100644 index e84765cd7..000000000 --- a/scripts/ci/test_agent_change_detection.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Regression tests for the two native apps' independent/shared input boundary.""" - -from pathlib import Path -import subprocess -import sys -import unittest - -from agent_change_detection import affects_agent, classify_paths -from change_detection import DESKTOP_PLATFORMS, classify_path as research_routes - - -class AgentChangeDetectionTests(unittest.TestCase): - def test_agent_runtime_build_and_asset_inputs_select_only_agent(self): - for path in ( - "apps/maple-agent/app/src/main.rs", - "apps/maple-agent/app/assets/fonts/Maple.ttf", - "apps/maple-agent/crates/maple-agent/src/agent.rs", - "apps/maple-agent/Cargo.toml", - "apps/maple-agent/Cargo.lock", - "apps/maple-agent/flake.nix", - "apps/maple-agent/flake.lock", - "apps/maple-agent/rust-toolchain.toml", - "apps/maple-agent/justfile", - "apps/maple-agent/scripts/macos-debug-app.sh", - "apps/maple-agent/scripts/build-release.sh", - "apps/maple-agent/scripts/package-release.sh", - "apps/maple-agent/scripts/verify-release.sh", - "apps/maple-agent/release_profile.rs", - "apps/maple-agent/release-profiles.json", - "apps/maple-agent/app/packaging/macos-entitlements.plist", - "apps/maple-agent/app/macos/Info.plist", - "apps/maple-agent/new-build-input", - ): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - self.assertEqual(research_routes(path), frozenset()) - - def test_shared_rust_runtime_inputs_select_both_desktop_apps(self): - for path in ("proxy/Cargo.toml", "proxy/src/proxy.rs", "proxy/build.rs"): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - self.assertEqual(research_routes(path), DESKTOP_PLATFORMS) - - def test_research_typescript_and_independent_services_skip_agent(self): - for path in ( - "apps/maple-auth/src/main.tsx", - "apps/maple-auth/package.json", - "apps/maple-auth/bun.lock", - "apps/maple-auth/vite.config.ts", - "apps/maple-research/frontend/src/main.tsx", - "apps/maple-research/frontend/src-tauri/src/lib.rs", - "sdk/src/lib/index.ts", "sdk/package.json", "sdk/flake.nix", - "services/updates/src/index.ts", "services/opensecret/src/main.rs", - ".github/workflows/desktop-pr-build.yml", "scripts/ci/desktop-pr.sh", - ): - with self.subTest(path=path): - self.assertFalse(affects_agent(path)) - - def test_docs_and_standalone_dependency_inputs_skip_both_apps(self): - for path in ( - "apps/maple-agent/README.md", "apps/maple-agent/AGENTS.md", - "apps/maple-agent/CLAUDE.md", "apps/maple-agent/LICENSE", - "apps/maple-agent/docs/development.md", "README.md", - "sdk/rust/README.md", "sdk/rust/tests/client.rs", "sdk/rust/Cargo.lock", - "sdk/rust/examples/api_usage.rs", - "sdk/src/lib/index.ts", "sdk/package.json", - "proxy/README.md", "proxy/tests/health.rs", "proxy/Cargo.lock", - "proxy/Dockerfile", "proxy/flake.nix", - ): - with self.subTest(path=path): - self.assertFalse(affects_agent(path)) - self.assertEqual(research_routes(path), frozenset()) - - def test_rust_sdk_build_inputs_select_agent_and_desktop_research(self): - # The Agent and the Research native shell build sdk/rust from the tree. - for path in ( - "sdk/rust/Cargo.toml", "sdk/rust/src/client.rs", "sdk/rust/build.rs", - "sdk/rust/assets/aws_nitro_root.der", - ): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - self.assertTrue(research_routes(path) >= {"macos", "linux", "windows"}) - - def test_selector_and_shared_tooling_changes_select_agent(self): - for path in ( - "flake.nix", "flake.lock", ".github/workflows/agent-ci.yml", - ".github/workflows/agent-desktop-build.yml", - "scripts/ci/agent_change_detection.py", "scripts/ci/change_detection.py", - "scripts/ci/agent_master_selection.py", - "scripts/ci/agent_cache.py", "scripts/ci/agent_measure.py", - "scripts/ci/agent_cache_scope.py", "scripts/ci/agent_dependency_archive.py", - "scripts/ci/agent_cache_command.py", - "scripts/ci/agent_windows.py", - "scripts/ci/windows-artifact-sign.ps1", - "scripts/ci/install-windows-artifact-signing.ps1", - "scripts/ci/verify-agent-rust-deps.py", - "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py", - ): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - - def test_agent_packaging_workflow_stays_independent_of_research(self): - self.assertTrue(affects_agent(".github/workflows/agent-desktop-build.yml")) - self.assertEqual(research_routes(".github/workflows/agent-desktop-build.yml"), frozenset()) - for path in ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - self.assertEqual(research_routes(path), frozenset({"macos", "ios"})) - - def test_unknown_roots_and_invalid_paths_fail_safe(self): - for path in ("new-build-config.toml", "", "/tmp/file", "../file", - "apps/maple-agent/../maple-research/frontend/src/main.tsx"): - with self.subTest(path=path): - self.assertTrue(affects_agent(path)) - - def test_mixed_changes_and_empty_diff(self): - self.assertFalse(classify_paths([])) - self.assertFalse(classify_paths(["README.md", "sdk/rust/README.md"])) - self.assertTrue(classify_paths(["README.md", "proxy/src/proxy.rs"])) - - def test_cli_preserves_null_delimited_names_and_explicit_fallback(self): - script = Path(__file__).with_name("agent_change_detection.py") - for arguments, paths, expected in ( - ([], b"README.md\0apps/maple-agent/app/assets/a\nspace name\0", b"agent=true\n"), - ([], b"README.md\0apps/maple-agent/docs/design notes.md\0", b"agent=false\n"), - ([], b"", b"agent=false\n"), - (["--all"], b"", b"agent=true\n"), - ): - result = subprocess.run([sys.executable, str(script), *arguments], input=paths, - check=True, capture_output=True) - self.assertEqual(result.stdout, expected) - - - def test_component_hook_scripts_do_not_select_agent(self) -> None: - self.assertFalse(affects_agent("apps/maple-agent/.githooks/pre-commit")) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_dependency_archive.py b/scripts/ci/test_agent_dependency_archive.py deleted file mode 100644 index efd7bc90d..000000000 --- a/scripts/ci/test_agent_dependency_archive.py +++ /dev/null @@ -1,575 +0,0 @@ -#!/usr/bin/env python3 -"""Dependency pruning, artifact provenance, transport and extraction boundaries.""" - -import copy -from datetime import datetime, timedelta, timezone -import hashlib -import gzip -import io -import json -import os -from pathlib import Path -import shutil -import tarfile -import tempfile -import unittest -from unittest.mock import patch -import urllib.error -import zipfile - -import agent_dependency_archive as cache - - -REPO = "MaplePrivacyLabs/Maple" -JOB = "Agent (windows-latest)" -REV = "a" * 40 -REGISTRY = "registry+https://github.com/rust-lang/crates.io-index" -STAMP = "1234567890abcdef" - - -class Fixture(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.home, self.target, self.bundle = self.root / "cargo", self.root / "target", self.root / "bundle" - raw = {"profile": "ci", "platform": "Windows", "architecture": "AMD64", "native": {"compiler": "pinned"}} - self.identity = {"compatibility": hashlib.sha256(json.dumps(raw, sort_keys=True, separators=(",", ":")).encode()).hexdigest(), **raw} - self.producer = {"repository": REPO, "run_id": 41, "run_attempt": 2, - "head_sha": REV, "job_name": JOB, "workflow": "agent-ci.yml"} - self.environment = {"GITHUB_REPOSITORY": REPO, "GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/master", - "GITHUB_SHA": REV, "GITHUB_RUN_ID": "41", "GITHUB_RUN_ATTEMPT": "2"} - selected = patch.dict(os.environ, self.environment, clear=True) - selected.start() - self.addCleanup(selected.stop) - self.metadata = {"packages": [], "resolve": {"nodes": []}} - self.package("public", "1.0.0", REGISTRY, self.home / "registry/src/index-abc/public-1.0.0/Cargo.toml") - self.package("gitlib", "2.0.0", f"git+https://github.com/MaplePrivacyLabs/rusqlite?rev={REV}#{REV}", self.home / "git/checkouts/public-abc/1234567/Cargo.toml") - self.package("maple-app", "0.0.3", None, self.root / "checkout/apps/maple-agent/app/Cargo.toml") - self.package("maple-sdk", "0.4.0", None, self.root / "checkout/sdk/rust/Cargo.toml") - self.write("registry/cache/index-abc/public-1.0.0.crate", b"released archive") - self.write("registry/index/index-abc/.cache/pu/bl/public", b"public index record") - self.write("registry/index/index-abc/config.json", b'{"dl":"https://static.crates.io/crates","api":"https://crates.io","private":"secret"}') - self.write("credentials.toml", b"do not cache credentials") - self.write("config.toml", b"do not cache config") - self.write("bin/cargo-private", b"do not cache binaries") - self.write("git/db/public-abc/config", b"[credential]\nhelper = secret\n") - self.write("git/db/public-abc/objects/object", b"public Git object") - self.write("git/checkouts/public-abc/1234567/.git/config", b"[remote]\nurl = https://user:secret@example.com\n") - self.write("git/checkouts/public-abc/1234567/.git/hooks/post-checkout", b"do not execute hook") - self.write("git/checkouts/public-abc/1234567/.git/objects/info/alternates", (str(self.home / "git/db/public-abc/objects") + "\n").encode()) - for prefix in ("debug", "build/debug"): - for name in ("public", "gitlib", "maple-app", "maple-sdk"): - self.write(f"{prefix}/deps/lib{name.replace('-', '_')}-{STAMP}.rlib", name.encode(), self.target) - self.write(f"{prefix}/deps/{name.replace('-', '_')}-{STAMP}.d", b"dep-info", self.target) - self.write(f"{prefix}/.fingerprint/{name}-{STAMP}/lib-fingerprint", b"freshness", self.target) - self.write(f"{prefix}/build/{name}-{STAMP}/out/native.lib", b"native", self.target) - self.write(f"{prefix}/incremental/private/data", b"incremental", self.target) - self.write(f"{prefix}/maple-agent.exe", b"workspace executable", self.target) - - def write(self, relative, content, root=None): - path = (root or self.home) / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(content) - os.utime(path, (1_700_000_000, 1_700_000_000)) - return path - - def package(self, name, version, source, manifest): - manifest.parent.mkdir(parents=True, exist_ok=True) - manifest.write_text(f'[package]\nname="{name}"\nversion="{version}"\n') - package_id = f"{source or 'path+workspace'}#{name}@{version}" - self.metadata["packages"].append({"name": name, "version": version, "source": source, - "id": package_id, "manifest_path": str(manifest), "targets": [{"name": name, "kind": ["lib"]}]}) - self.metadata["resolve"]["nodes"].append({"id": package_id, "features": ["default"]}) - - def pack(self, **options): - return cache.pack(self.identity, self.metadata, self.target, self.home, self.bundle, JOB, "agent-ci.yml", **options) - - def tar_names(self): - with tarfile.open(self.bundle / "dependencies.tar.gz", "r:gz") as archive: - return archive.getnames() - - def unpack(self): - return cache.unpack(self.bundle, self.identity, self.producer, self.root / "restored-cargo", self.root / "restored-target") - - def malicious_tar(self, members): - self.pack() - with tarfile.open(self.bundle / "dependencies.tar.gz", "w:gz") as archive: - total = 0 - for name, kind, payload in members: - info = tarfile.TarInfo(name) - info.type = kind - info.size = len(payload) if kind == tarfile.REGTYPE else 0 - info.linkname = "../../outside" if kind in (tarfile.SYMTYPE, tarfile.LNKTYPE) else "" - archive.addfile(info, io.BytesIO(payload) if kind == tarfile.REGTYPE else None) - total += info.size - manifest_path = self.bundle / "manifest.json" - manifest = json.loads(manifest_path.read_text()) - manifest.update(files=len(members), unpacked_bytes=total, archive_bytes=(self.bundle / "dependencies.tar.gz").stat().st_size, - archive_sha256=cache.sha256(self.bundle / "dependencies.tar.gz")) - manifest_path.write_text(json.dumps(manifest)) - - -class ArchiveTests(Fixture): - def test_roundtrip_only_public_dependencies_preserving_freshness(self): - manifest = self.pack() - names = self.tar_names() - self.assertTrue(any("build/debug/build/public-" in name for name in names)) - self.assertTrue(any("debug/deps/libpublic-" in name for name in names)) - self.assertFalse(any(any(part in name for part in ("maple_app", "maple-app", "maple_sdk", "maple-sdk", "incremental", "credentials", "cargo/bin", "hooks")) for name in names)) - self.assertNotIn("cargo/config.toml", names) - self.unpack() - restored = self.root / f"restored-target/debug/deps/libpublic-{STAMP}.rlib" - self.assertEqual(restored.read_bytes(), b"public") - self.assertEqual(restored.stat().st_mtime, 1_700_000_000) - self.assertEqual(self.producer, manifest["producer"]) - self.assertNotIn("secret", (self.root / "restored-cargo/git/db/public-abc/config").read_text()) - alternate = self.root / "restored-cargo/git/checkouts/public-abc/1234567/.git/objects/info/alternates" - self.assertFalse(Path(alternate.read_text().strip()).is_absolute()) - - def test_path_package_name_collision_excludes_ambiguous_output(self): - self.package("local-public", "1.0.0", None, self.root / "checkout/local-public/Cargo.toml") - self.metadata["packages"][-1]["targets"][0]["name"] = "public" - self.pack() - self.assertFalse(any("deps/libpublic-" in name for name in self.tar_names())) - - def test_unreviewed_git_sources_outputs_and_name_collisions_are_excluded(self): - self.package("private-helper", "1.0.0", f"git+https://github.com/unreviewed/private?rev={REV}#{REV}", - self.home / "git/checkouts/private-abc/1234567/Cargo.toml") - self.metadata["packages"][-1]["targets"][0]["name"] = "public" - self.write("git/checkouts/private-abc/1234567/secret-source.rs", b"private source") - self.write(f"debug/build/private-helper-{STAMP}/out/private.lib", b"private native output", self.target) - self.write(f"debug/.fingerprint/private-helper-{STAMP}/lib-fingerprint", b"private fingerprint", self.target) - self.write(f"debug/deps/libprivate_helper-{STAMP}.rlib", b"private library", self.target) - # A private package can also collide with a reviewed package name. - self.package("gitlib", "3.0.0", f"git+https://github.com/unreviewed/another?rev={REV}#{REV}", - self.home / "git/checkouts/private-other/1234567/Cargo.toml") - self.pack() - names = self.tar_names() - self.assertFalse(any("private-abc" in name or "private-other" in name or "private-helper" in name or "private_helper" in name for name in names)) - self.assertFalse(any("deps/libpublic-" in name or "deps/libgitlib-" in name or "/build/gitlib-" in name or "/.fingerprint/gitlib-" in name for name in names)) - - def test_reviewed_git_repository_normalization_keeps_security_guards(self): - original = self.metadata["packages"][1]["source"] - for repository in ("MaplePrivacyLabs/rusqlite", "MAPLEPRIVACYLABS/RUSQLITE.git", "MaplePrivacyLabs/Rusqlite.GIT/"): - self.metadata["packages"][1]["source"] = f"git+https://GitHub.com/{repository}?rev={REV}#{REV}" - self.assertIn("gitlib", cache.packages(self.metadata)[1]) - for source in (f"git+http://github.com/MaplePrivacyLabs/rusqlite#{REV}", - f"git+https://user:secret@github.com/MaplePrivacyLabs/rusqlite#{REV}", - "git+https://github.com/MaplePrivacyLabs/rusqlite#short", - f"git+https://github.com/MaplePrivacyLabs/rusqlite?token=secret#{REV}"): - self.metadata["packages"][1]["source"] = source - self.assertNotIn("gitlib", cache.packages(self.metadata)[1]) - self.metadata["packages"][1]["source"] = original - - def test_unrelated_application_bundle_symlinks_are_not_traversed(self): - bundle = self.target / "debug/Maple.app/Contents/Frameworks" - bundle.mkdir(parents=True) - (bundle / "system").symlink_to(self.root, target_is_directory=True) - self.pack() - self.assertFalse(any("Maple.app" in name for name in self.tar_names())) - - def test_link_outside_a_selected_dependency_root_is_refused(self): - source = self.home / "registry/src/index-abc/public-1.0.0/private" - source.symlink_to(self.home / "credentials.toml") - with self.assertRaises(cache.Rejected): - self.pack() - - def test_git_pointer_files_are_refused_on_pack_and_restore(self): - checkout_git = self.home / "git/checkouts/public-abc/1234567/.git" - shutil.rmtree(checkout_git) - checkout_git.write_text("gitdir: /outside/private-object-store\n") - with self.assertRaises(cache.Rejected): - self.pack() - shutil.rmtree(self.bundle) - checkout_git.unlink() - checkout_git.mkdir() - self.malicious_tar([("cargo/git/checkouts/repo/rev/.git", tarfile.REGTYPE, b"gitdir: /outside/private-object-store\n")]) - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-cargo").exists()) - - def test_pr_and_manual_producers_cannot_pack(self): - for event, ref in (("pull_request", "refs/pull/2/merge"), ("workflow_dispatch", "refs/heads/master"), ("push", "refs/heads/topic")): - with self.subTest(event=event), patch.dict(os.environ, {"GITHUB_EVENT_NAME": event, "GITHUB_REF": ref}): - with self.assertRaises(cache.Rejected): - self.pack() - - def test_recent_receipt_skips_compression_for_workspace_only_bump(self): - public, _, _ = cache.packages(self.metadata) - receipt = self.root / "receipt.json" - receipt.write_text(json.dumps({"compatibility": self.identity["compatibility"], "dependency_fingerprint": "", - "dependency_graph": cache.dependency_graph(self.metadata, public), "created_at": datetime.now(timezone.utc).isoformat(), "artifact_id": 17, - "reader": cache.receipt_reader(JOB)})) - self.metadata["packages"][-1]["version"] = "0.4.1" - result = self.pack(receipt=receipt) - self.assertFalse(result["needs_upload"]) - self.assertFalse(self.bundle.exists()) - self.metadata["resolve"]["nodes"][0]["features"].append("new-native-feature") - self.assertTrue(self.pack(receipt=receipt)["needs_upload"]) - - def test_old_receipt_and_changed_dependency_refresh_the_seed(self): - public, _, _ = cache.packages(self.metadata) - receipt = self.root / "receipt.json" - receipt.write_text(json.dumps({"compatibility": self.identity["compatibility"], "dependency_fingerprint": "", - "dependency_graph": cache.dependency_graph(self.metadata, public), "created_at": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat(), "artifact_id": 17})) - self.assertTrue(self.pack(receipt=receipt)["needs_upload"]) - - def test_provider_requires_verified_test_only_graph(self): - identity = {**self.identity, "profile": "provider"} - with self.assertRaises(cache.Rejected): - cache.artifact_name(identity) - self.assertNotEqual(cache.artifact_name(identity, "b" * 64), cache.artifact_name(identity, "c" * 64)) - - def test_environment_accepts_scoped_flat_identity_without_changing_old_helper(self): - raw = {key: value for key, value in self.identity.items() if key != "compatibility"} - raw["scope_schema"] = 1 - value = {**raw, "compatibility": hashlib.sha256(json.dumps(raw, sort_keys=True, separators=(",", ":")).encode()).hexdigest(), - "legacy_identity": {"old": "full raw identity"}, "legacy_compatibility": "b" * 64} - path = self.root / "environment.json" - path.write_text(json.dumps(value)) - self.assertEqual(cache.environment(path), value) - value["native"]["compiler"] = "changed" - path.write_text(json.dumps(value)) - with self.assertRaises(cache.Rejected): - cache.environment(path) - - def test_archive_digest_and_manifest_identity_are_enforced(self): - self.pack() - with (self.bundle / "dependencies.tar.gz").open("ab") as archive: - archive.write(b"tampered") - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-target").exists()) - - def test_compressed_and_member_budgets_remain_separate(self): - self.assertEqual(cache.MAX_ARCHIVE, 6 * 1024**3) - self.assertEqual(cache.MAX_FILE, 3 * 1024**3) - with patch.object(cache, "MAX_ARCHIVE", 1): - with self.assertRaises(cache.Rejected): - self.pack() - shutil.rmtree(self.bundle) - # A larger compressed-archive allowance cannot admit a single member - # over its independent bound, even before tarfile reads its payload. - header = tarfile.TarInfo("target/debug/deps/libpublic-" + STAMP + ".rlib") - header.size = cache.MAX_FILE + 1 - archive = self.root / "oversized-member.tar.gz" - archive.write_bytes(gzip.compress(header.tobuf(format=tarfile.USTAR_FORMAT))) - self.assertLess(archive.stat().st_size, cache.MAX_ARCHIVE) - with self.assertRaises(cache.Rejected): - cache.tar_envelope(archive) - - def test_unsafe_paths_links_aliases_and_local_outputs_are_refused(self): - attacks = ("../escape", "/absolute", "cargo/credentials.toml", "cargo/bin/tool", "cargo/registry/src/a/../escape", - "cargo/registry/src/a/CON.txt", "cargo/registry/src/a/file:stream", "cargo/registry/src/a/back\\slash", - "cargo/registry/src/a/trailing.", f"target/debug/deps/libmaple_app-{STAMP}.rlib") - for name in attacks: - with self.subTest(name=name): - shutil.rmtree(self.bundle, ignore_errors=True) - self.malicious_tar([(name, tarfile.REGTYPE, b"payload")]) - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-cargo").exists()) - for kind in (tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.FIFOTYPE, tarfile.CHRTYPE): - with self.subTest(kind=kind): - shutil.rmtree(self.bundle) - self.malicious_tar([("cargo/registry/src/a/file", kind, b"")]) - with self.assertRaises(cache.Rejected): - self.unpack() - - def test_duplicate_case_alias_and_destination_symlink_fail_before_writes(self): - self.malicious_tar([("cargo/registry/src/a/file", tarfile.REGTYPE, b"one"), ("cargo/registry/src/a/FILE", tarfile.REGTYPE, b"two")]) - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-cargo").exists()) - shutil.rmtree(self.bundle) - self.pack() - destination = self.root / "restored-target/debug/deps" - destination.parent.mkdir(parents=True) - outside = self.root / "outside" - outside.mkdir() - destination.symlink_to(outside, target_is_directory=True) - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertEqual(list(outside.iterdir()), []) - self.assertFalse((self.root / "restored-cargo").exists()) - - def test_untrusted_git_configuration_and_unpack_bombs_are_rejected(self): - self.malicious_tar([("cargo/git/db/public/config", tarfile.REGTYPE, b"[credential]\nhelper = malicious\n")]) - with self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-cargo").exists()) - shutil.rmtree(self.bundle) - self.pack() - with patch.object(cache, "MAX_UNPACKED", 1): - with self.assertRaises(cache.Rejected): - self.unpack() - - def test_symlink_ancestor_and_unsafe_git_or_registry_metadata_are_rejected(self): - self.pack() - outside = self.root / "outside" - outside.mkdir() - parent = self.root / "linked-parent" - parent.symlink_to(outside, target_is_directory=True) - with self.assertRaises(cache.Rejected): - cache.unpack(self.bundle, self.identity, self.producer, self.root / "restored-cargo", parent / "target") - self.assertEqual(list(outside.iterdir()), []) - for name, payload in (("cargo/git/checkouts/repo/rev/.git/objects/info/alternates", b"/outside/objects\n"), - ("cargo/registry/index/index/config.json", b'{"dl":"https://private.example/token","api":"https://crates.io"}')): - shutil.rmtree(self.bundle) - self.malicious_tar([(name, tarfile.REGTYPE, payload)]) - with self.subTest(name=name), self.assertRaises(cache.Rejected): - self.unpack() - self.assertFalse((self.root / "restored-cargo").exists()) - - def test_oversized_pax_is_rejected_before_tarfile_reads_payload(self): - self.pack() - header = tarfile.TarInfo("PaxHeader") - header.type, header.size = tarfile.XHDTYPE, 1024**3 - archive = self.bundle / "dependencies.tar.gz" - archive.write_bytes(gzip.compress(header.tobuf(format=tarfile.USTAR_FORMAT))) - manifest = json.loads((self.bundle / "manifest.json").read_text()) - manifest.update(archive_bytes=archive.stat().st_size, archive_sha256=cache.sha256(archive)) - (self.bundle / "manifest.json").write_text(json.dumps(manifest)) - with patch.object(cache.tarfile, "open", side_effect=AssertionError("unbounded PAX parser must not run")): - with self.assertRaises(cache.Rejected): - self.unpack() - - def test_receipt_cannot_be_replayed_from_restored_cache_or_prior_reader_job(self): - with self.assertRaises(cache.Rejected): - self.pack(receipt=self.target / "receipt.json") - public, _, _ = cache.packages(self.metadata) - receipt = self.root / "receipt.json" - receipt.write_text(json.dumps({"compatibility": self.identity["compatibility"], "dependency_fingerprint": "", - "dependency_graph": cache.dependency_graph(self.metadata, public), "created_at": datetime.now(timezone.utc).isoformat(), "artifact_id": 17, - "reader": {**cache.receipt_reader(JOB), "run_id": "older-job"}})) - self.assertTrue(self.pack(receipt=receipt)["needs_upload"]) - - -class API: - repository = REPO - - def __init__(self, artifact, run, jobs, zip_path=None): - self.artifact, self.run, self.jobs, self.zip_path = artifact, run, jobs, zip_path - - def get(self, endpoint): - if endpoint.startswith("actions/artifacts?"): - return {"artifacts": [self.artifact]} - if endpoint.startswith("actions/artifacts/"): - return self.artifact - if "/attempts/" in endpoint: - return self.jobs - return self.run - - def download(self, artifact_id, destination, digest): - if cache.sha256(self.zip_path) != digest: - raise cache.Rejected("ZIP digest mismatch") - shutil.copyfile(self.zip_path, destination) - return destination.stat().st_size - - -class ProvenanceTests(Fixture): - def setUp(self): - super().setUp() - now = datetime.now(timezone.utc) - self.artifact = {"id": 7, "name": cache.artifact_name(self.identity), "expired": False, "size_in_bytes": 50, - "digest": "sha256:" + "b" * 64, "created_at": (now - timedelta(hours=1)).isoformat(), - "workflow_run": {"id": 41, "head_sha": REV, "head_branch": "master", "repository_id": 2, "head_repository_id": 2}} - self.run = {"id": 41, "run_attempt": 2, "event": "push", "head_sha": REV, "head_branch": "master", - "path": ".github/workflows/agent-ci.yml", "repository": {"id": 2, "full_name": REPO}, - "head_repository": {"id": 2, "full_name": REPO}, "conclusion": "failure"} - self.jobs = {"total_count": 1, "jobs": [{"id": 8, "name": JOB, "status": "completed", "conclusion": "success", - "started_at": (now - timedelta(hours=2)).isoformat(), "completed_at": (now - timedelta(minutes=30)).isoformat(), - "steps": [{"name": "build/test/provenance gates", "conclusion": "success"}]}]} - - def proof(self): - return cache.source_proof(API(self.artifact, self.run, self.jobs), self.artifact, - cache.artifact_name(self.identity), JOB, "agent-ci.yml", datetime.now(timezone.utc)) - - def test_exact_successful_platform_job_is_usable_despite_other_job_failure(self): - self.assertEqual(self.proof(), self.producer) - - def test_earlier_successful_platform_seed_survives_partial_later_rerun(self): - with patch.dict(os.environ, {"GITHUB_RUN_ATTEMPT": "1"}): - self.pack() - self.producer["run_attempt"] = 1 - zipped = self.root / "earlier-attempt.zip" - with zipfile.ZipFile(zipped, "w") as archive: - for name in ("manifest.json", "dependencies.tar.gz"): - archive.write(self.bundle / name, name) - self.artifact["digest"] = "sha256:" + cache.sha256(zipped) - api = API(self.artifact, self.run, self.jobs, zipped) - events = [] - get, download = api.get, api.download - - def attempted(endpoint): - events.append(endpoint) - if "/attempts/2/jobs" in endpoint: - return {"total_count": 1, "jobs": [{"name": "Agent (different-platform)", "conclusion": "failure"}]} - return get(endpoint) - - def transferred(*arguments): - events.append("verified-zip-download") - return download(*arguments) - - receipt = self.root / "receipt.json" - with patch.object(api, "get", side_effect=attempted), patch.object(api, "download", side_effect=transferred): - self.assertTrue(cache.restore(api, self.identity, self.root / "restored-target", self.root / "restored-cargo", - JOB, "agent-ci.yml", receipt=receipt)) - jobs_request = "actions/runs/41/attempts/1/jobs?per_page=100" - self.assertIn(jobs_request, events) - self.assertLess(events.index("verified-zip-download"), events.index(jobs_request)) - self.assertFalse(any("/attempts/2/jobs" in event for event in events)) - self.assertEqual(json.loads(receipt.read_text())["producer"]["run_attempt"], 1) - - def test_future_or_noninteger_manifest_attempt_is_rejected(self): - api = API(self.artifact, self.run, self.jobs) - for attempt in (0, 3, "1", True): - with self.subTest(attempt=attempt), self.assertRaises(cache.Rejected): - cache.source_proof(api, self.artifact, cache.artifact_name(self.identity), JOB, - "agent-ci.yml", datetime.now(timezone.utc), attempt=attempt) - - def test_pr_manual_fork_wrong_workflow_and_head_cannot_supply_seed(self): - for change in ({"event": "pull_request"}, {"event": "workflow_dispatch"}, {"head_branch": "topic"}, - {"head_repository": {"id": 3, "full_name": "fork/Maple"}}, {"path": ".github/workflows/other.yml"}, - {"head_sha": "c" * 40}): - with self.subTest(change=change): - original = self.run - self.run = {**self.run, **change} - with self.assertRaises(cache.Rejected): - self.proof() - self.run = original - - def test_wrong_attempt_timestamp_failed_or_duplicate_job_is_rejected(self): - for conclusion in ("failure", "skipped", "cancelled"): - self.jobs["jobs"][0]["conclusion"] = conclusion - with self.assertRaises(cache.Rejected): - self.proof() - self.jobs["jobs"][0]["conclusion"] = "success" - self.artifact["created_at"] = (datetime.now(timezone.utc) - timedelta(hours=4)).isoformat() - with self.assertRaises(cache.Rejected): - self.proof() - self.artifact["created_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() - self.jobs["jobs"].append(copy.deepcopy(self.jobs["jobs"][0])) - with self.assertRaises(cache.Rejected): - self.proof() - - def test_missing_digest_expiry_and_age_fail_closed(self): - for change in ({"digest": None}, {"expired": True}, {"created_at": (datetime.now(timezone.utc) - timedelta(days=8)).isoformat()}, {"size_in_bytes": cache.MAX_ARCHIVE * 2}): - original = self.artifact - self.artifact = {**self.artifact, **change} - with self.subTest(change=change), self.assertRaises((cache.Rejected, TypeError)): - self.proof() - self.artifact = original - - def test_end_to_end_restore_writes_verified_receipt_and_never_executes(self): - self.pack() - zipped = self.root / "bundle.zip" - with zipfile.ZipFile(zipped, "w") as archive: - for name in ("manifest.json", "dependencies.tar.gz"): - archive.write(self.bundle / name, name) - self.artifact["digest"] = "sha256:" + cache.sha256(zipped) - receipt = self.root / "receipt.json" - with patch.object(cache.os, "system", side_effect=AssertionError("restore must not execute")): - self.assertTrue(cache.restore(API(self.artifact, self.run, self.jobs, zipped), self.identity, - self.root / "restored-target", self.root / "restored-cargo", JOB, "agent-ci.yml", receipt=receipt)) - proven = json.loads(receipt.read_text()) - self.assertEqual(proven["artifact_digest"], self.artifact["digest"]) - self.assertEqual(proven["producer"], self.producer) - self.assertTrue((self.root / f"restored-target/debug/deps/libpublic-{STAMP}.rlib").is_file()) - - def test_corrupt_service_zip_digest_is_a_cold_miss(self): - self.pack() - zipped = self.root / "corrupt.zip" - zipped.write_bytes(b"corrupt bytes") - self.assertFalse(cache.restore(API(self.artifact, self.run, self.jobs, zipped), self.identity, - self.root / "restored-target", self.root / "restored-cargo", JOB, "agent-ci.yml")) - self.assertFalse((self.root / "restored-target").exists()) - - def test_zip_accepts_only_two_regular_bounded_expected_files(self): - for name in ("../manifest.json", "unexpected", "directory/manifest.json"): - zipped = self.root / "malicious.zip" - with zipfile.ZipFile(zipped, "w") as archive: - archive.writestr(name, b"bad") - archive.writestr("dependencies.tar.gz", b"data") - with self.subTest(name=name), self.assertRaises(cache.Rejected): - cache.unzip(zipped, self.root) - - def test_scan_is_bounded_and_unavailable_service_never_blocks_build(self): - api = API(self.artifact, self.run, self.jobs) - requested = [] - - def get(endpoint): - requested.append(endpoint) - if endpoint.startswith("actions/artifacts?"): - return {"artifacts": [{"id": index} for index in range(100)]} - return {"id": 0, "name": "wrong-name"} - - with patch.object(api, "get", side_effect=get): - self.assertFalse(cache.restore(api, self.identity, self.target, self.home, JOB, "agent-ci.yml")) - self.assertEqual(len(requested), cache.MAX_CANDIDATES + 1) - identity = self.root / "identity.json" - identity.write_text(json.dumps(self.identity)) - github_output = self.root / "github-output" - with patch.dict(os.environ, {"GH_TOKEN": "read-only-token", "GITHUB_OUTPUT": str(github_output)}), \ - patch("sys.argv", ["agent_dependency_archive.py", "restore", "--environment", str(identity), "--target", str(self.root / "new-target"), "--job-name", JOB]), \ - patch.object(cache.GitHub, "get", side_effect=OSError("service unavailable")): - cache.main() - self.assertEqual(github_output.read_text(), "restored=false\n") - self.assertFalse((self.root / "new-target").exists()) - - -class Response(io.BytesIO): - def __init__(self, value): - super().__init__(value) - self.headers = {"Content-Length": str(len(value))} - - -class TransportTests(unittest.TestCase): - def test_api_auth_is_never_forwarded_to_artifact_blob(self): - api = cache.GitHub(REPO, "private-token") - observed = [] - data = b"verified ZIP bytes" - - class Opener: - def open(self, request, timeout): - observed.append(request) - if len(observed) == 1: - raise urllib.error.HTTPError(request.full_url, 302, "redirect", {"Location": "https://safe.blob.core.windows.net/artifact?signed=opaque"}, None) - return Response(data) - - with tempfile.TemporaryDirectory() as temporary, patch.object(cache.urllib.request, "build_opener", return_value=Opener()): - self.assertEqual(api.download(7, Path(temporary) / "artifact.zip", hashlib.sha256(data).hexdigest()), len(data)) - self.assertEqual(observed[0].get_header("Authorization"), "Bearer private-token") - self.assertIsNone(observed[1].get_header("Authorization")) - self.assertNotIn("private-token", observed[1].full_url) - - def test_foreign_redirect_and_wrong_digest_are_refused(self): - api = cache.GitHub(REPO, "private-token") - - class Redirect: - def open(self, request, timeout): - raise urllib.error.HTTPError(request.full_url, 302, "redirect", {"Location": "https://attacker.example/artifact"}, None) - - with tempfile.TemporaryDirectory() as temporary, patch.object(cache.urllib.request, "build_opener", return_value=Redirect()): - with self.assertRaises(cache.Rejected): - api.download(7, Path(temporary) / "artifact.zip", "b" * 64) - with tempfile.TemporaryDirectory() as temporary, patch.object(cache.urllib.request, "build_opener") as opener: - opener.return_value.open.return_value = Response(b"wrong ZIP") - with self.assertRaises(cache.Rejected): - api.download(7, Path(temporary) / "artifact.zip", "b" * 64) - - def test_download_bytes_are_bounded_even_when_header_lies(self): - api = cache.GitHub(REPO, "private-token") - response = Response(b"too many streamed bytes") - response.headers["Content-Length"] = "1" - with tempfile.TemporaryDirectory() as temporary, patch.object(cache.urllib.request, "build_opener") as opener, \ - patch.object(cache, "MAX_ARCHIVE", 1), patch.object(cache, "MAX_MANIFEST", 1), patch.object(cache, "CHUNK", 1): - # Tighten the fixed transport-overhead margin by using an object - # whose stream lies about producing enormous chunks. - response.read = lambda size: b"x" * (1024**2 + 3) - opener.return_value.open.return_value = response - with self.assertRaises(cache.Rejected): - api.download(7, Path(temporary) / "artifact.zip", "b" * 64) -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_master_selection.py b/scripts/ci/test_agent_master_selection.py deleted file mode 100644 index f613a80c2..000000000 --- a/scripts/ci/test_agent_master_selection.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Master lanes retain cancelled, failed and skipped work across later pushes.""" - -from contextlib import redirect_stderr, redirect_stdout -import io -import os -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import agent_master_selection as selector - - -def run_record(run_id, head, **overrides): - run = { - "id": run_id, - "head_sha": head, - "repository": {"full_name": selector.REPOSITORY}, - "head_repository": {"full_name": selector.REPOSITORY}, - "head_branch": "master", - "event": "push", - "path": selector.WORKFLOW, - "status": "completed", - "conclusion": "success", - } - run.update(overrides) - return run - - -def verified_jobs(**conclusions): - jobs = [ - {"name": name, "status": "completed", - "conclusion": conclusions.get(name, "success")} - for name in sorted(selector.VERIFIERS) - ] - return {"total_count": len(jobs), "jobs": jobs} - - -class GitFixture(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.git("init", "--quiet") - self.base = self.commit("apps/maple-agent/app/src/main.rs", "original\n") - - def git(self, *args): - return subprocess.run( - ["git", "-c", "user.name=CI test", "-c", "user.email=ci@example.invalid", - *args], cwd=self.root, check=True, capture_output=True, text=True, - ).stdout.strip() - - def commit(self, path, content): - target = self.root / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - self.git("add", "--", path) - self.git("commit", "--quiet", "-m", "fixture") - return self.git("rev-parse", "HEAD") - - -class MasterSelectionTests(GitFixture): - def select(self, head, runs, jobsets=None, current_run=999): - jobsets = jobsets or {} - - def response(path): - if "/workflows/" in path: - return {"workflow_runs": runs} - run_id = int(path.split("/runs/")[1].split("/")[0]) - return jobsets.get(run_id, verified_jobs()) - - with patch.object(selector, "api", side_effect=response) as api: - result = selector.select_master(head, current_run, self.root) - return result, api - - def test_failed_or_inflight_change_remains_selected_after_unrelated_push(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "new Agent input\n") - head = self.commit("README.md", "unrelated later push\n") - for status, conclusion in (("completed", "failure"), ("queued", None), - ("in_progress", None)): - with self.subTest(status=status): - (selected, reason), api = self.select(head, [ - run_record(2, changed, status=status, conclusion=conclusion), - run_record(1, self.base), - ]) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - self.assertEqual(api.call_count, 2) - - def test_missed_run_remains_selected_after_unrelated_push(self): - self.commit("apps/maple-agent/app/src/main.rs", "missed Agent change\n") - head = self.commit("README.md", "unrelated\n") - (selected, _), _ = self.select(head, [run_record(1, self.base)]) - self.assertTrue(selected) - - def test_selector_only_success_does_not_advance_baseline(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "not packaged\n") - head = self.commit("README.md", "unrelated\n") - jobs = verified_jobs(**{name: "skipped" for name in selector.VERIFIERS}) - (selected, reason), _ = self.select( - head, [run_record(2, changed), run_record(1, self.base)], {2: jobs}, - ) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - - def test_each_profile_and_platform_must_have_actual_success(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "not fully verified\n") - head = self.commit("README.md", "unrelated\n") - for verifier in selector.VERIFIERS: - with self.subTest(verifier=verifier): - (selected, reason), _ = self.select( - head, [run_record(2, changed), run_record(1, self.base)], - {2: verified_jobs(**{verifier: "failure"})}, - ) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - - def test_previous_macos_linux_only_runs_cannot_consume_windows_changes(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "Windows packaging change\n") - head = self.commit("README.md", "unrelated later push\n") - jobs = verified_jobs() - jobs["jobs"] = [job for job in jobs["jobs"] if "Windows" not in job["name"]] - jobs["total_count"] = len(jobs["jobs"]) - (selected, _), _ = self.select(head, [run_record(2, changed)], {2: jobs}) - self.assertTrue(selected) - - def test_partial_latest_attempt_cannot_borrow_older_green_jobs(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "partial retry\n") - head = self.commit("README.md", "unrelated\n") - jobs = verified_jobs() - jobs["jobs"] = jobs["jobs"][:1] - jobs["total_count"] = 1 - (selected, _), api = self.select( - head, [run_record(2, changed), run_record(1, self.base)], {2: jobs}, - ) - self.assertTrue(selected) - jobs_calls = [call.args[0] for call in api.call_args_list if "/jobs?" in call.args[0]] - self.assertEqual(len(jobs_calls), 2) - self.assertTrue(all("filter=latest&per_page=100" in path for path in jobs_calls)) - - def test_current_run_and_untrusted_candidates_cannot_hide_changes(self): - head = self.commit("apps/maple-agent/app/src/main.rs", "new input\n") - candidates = ( - run_record(999, head), - run_record(2, head, repository={"full_name": "other/Maple"}), - run_record(2, head, head_repository={"full_name": "other/Maple"}), - run_record(2, head, head_branch="feature"), - run_record(2, head, event="pull_request"), - run_record(2, head, path=".github/workflows/other.yml"), - ) - for candidate in candidates: - with self.subTest(candidate=candidate): - (selected, reason), api = self.select( - head, [candidate, run_record(1, self.base)], - ) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - self.assertEqual(api.call_count, 2) - - def test_nonancestor_candidate_is_not_a_baseline(self): - head = self.commit("apps/maple-agent/app/src/main.rs", "new input\n") - foreign = self.git("commit-tree", "HEAD^{tree}", "-m", "unrelated history") - (selected, reason), api = self.select( - head, [run_record(2, foreign), run_record(1, self.base)], - ) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - self.assertEqual(api.call_count, 2) - - def test_trusted_manual_success_can_supply_verified_baseline(self): - packaged = self.commit("apps/maple-agent/app/src/main.rs", "packaged\n") - head = self.commit("README.md", "unrelated\n") - (selected, reason), _ = self.select( - head, [run_record(2, packaged, event="workflow_dispatch")], - ) - self.assertFalse(selected) - self.assertIn("master run 2", reason) - - def test_unknown_shared_and_selector_inputs_select_packaging(self): - for path in ( - "new-build-config.toml", "flake.lock", "proxy/src/proxy.rs", - "sdk/rust/src/client.rs", ".github/workflows/agent-desktop-build.yml", - "scripts/ci/agent_master_selection.py", - ): - with self.subTest(path=path): - self.git("reset", "--hard", self.base) - self.git("clean", "-fd") - head = self.commit(path, "changed\n") - (selected, _), _ = self.select(head, [run_record(1, self.base)]) - self.assertTrue(selected) - - def test_net_revert_to_verified_inputs_skips_packaging(self): - self.commit("apps/maple-agent/app/src/main.rs", "reverted change\n") - self.commit("apps/maple-agent/app/src/main.rs", "original\n") - head = self.commit("README.md", "unrelated\n") - (selected, _), _ = self.select(head, [run_record(1, self.base)]) - self.assertFalse(selected) - - def test_no_verified_history_selects_packaging(self): - (selected, _), _ = self.select(self.base, []) - self.assertTrue(selected) - - def test_incomplete_job_page_is_not_proof(self): - jobs = verified_jobs() - jobs["total_count"] += 1 - with self.assertRaises(ValueError): - self.select(self.base, [run_record(1, self.base)], {1: jobs}) - - -class MainFallbackTests(unittest.TestCase): - def invoke(self, event="push", response=None, error=None): - stdout, stderr = io.StringIO(), io.StringIO() - environment = { - "GITHUB_EVENT_NAME": event, - "GITHUB_REPOSITORY": selector.REPOSITORY, - "GITHUB_REF": "refs/heads/master", - "GITHUB_SHA": "a" * 40, - "GITHUB_RUN_ID": "999", - } - with patch.dict(os.environ, environment), patch.object( - selector, "api", return_value=response, side_effect=error, - ) as api, patch("sys.argv", ["agent_master_selection.py"]), redirect_stdout( - stdout, - ), redirect_stderr(stderr): - self.assertEqual(selector.main(), 0) - self.assertEqual(stdout.getvalue(), "agent=true\n") - self.assertIn("building", stderr.getvalue()) - return api - - def test_missing_history_and_lookup_errors_build(self): - for response, error in ( - ({"workflow_runs": []}, None), - ({}, None), - (None, OSError("unavailable")), - (None, subprocess.TimeoutExpired("gh", 15)), - ): - with self.subTest(response=response, error=error): - self.invoke(response=response, error=error) - - def test_manual_force_build_avoids_api(self): - api = self.invoke(event="workflow_dispatch", error=AssertionError("must not query")) - api.assert_not_called() - - -class MasterCISelectionTests(GitFixture): - def select(self, head, runs, jobsets=None): - jobsets = jobsets or {} - - def response(path): - if "/workflows/" in path: - self.assertIn("/agent-ci.yml/runs?", path) - return {"workflow_runs": runs} - run_id = int(path.split("/runs/")[1].split("/")[0]) - return jobsets.get(run_id, self.jobs()) - - with patch.object(selector, "api", side_effect=response): - return selector.select_master(head, 999, self.root, lane="ci") - - @staticmethod - def record(run_id, head, **overrides): - return run_record(run_id, head, path=selector.CI_WORKFLOW, **overrides) - - @staticmethod - def jobs(**conclusions): - jobs = [{"name": name, "status": "completed", - "conclusion": conclusions.get(name, "success")} - for name in sorted(selector.CI_VERIFIERS)] - return {"total_count": len(jobs), "jobs": jobs} - - def test_unrelated_push_retains_cancelled_or_failed_agent_changes(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "unverified change\n") - head = self.commit("services/opensecret/pcrDev.json", "unrelated later push\n") - for conclusion in ("cancelled", "failure"): - with self.subTest(conclusion=conclusion): - selected, reason = self.select(head, [ - self.record(2, changed, conclusion=conclusion), - self.record(1, self.base), - ]) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - - def test_green_selector_only_does_not_consume_unverified_changes(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "cancelled change\n") - head = self.commit("README.md", "unrelated\n") - skipped = self.jobs(**{name: "skipped" for name in selector.CI_VERIFIERS}) - skipped["jobs"].append({"name": "Agent CI required jobs", "status": "completed", - "conclusion": "success"}) - skipped["total_count"] += 1 - selected, reason = self.select(head, [self.record(2, changed), self.record(1, self.base)], - {2: skipped}) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - - def test_every_platform_provider_and_release_must_actually_pass(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "partly validated\n") - head = self.commit("README.md", "unrelated\n") - for name in selector.CI_VERIFIERS: - for conclusion in ("skipped", "failure", "cancelled"): - with self.subTest(job=name, conclusion=conclusion): - selected, reason = self.select( - head, [self.record(2, changed), self.record(1, self.base)], - {2: self.jobs(**{name: conclusion})}, - ) - self.assertTrue(selected) - self.assertIn("master run 1", reason) - - def test_completed_platform_validation_allows_later_unrelated_push_to_skip(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "fully validated\n") - head = self.commit("README.md", "unrelated\n") - selected, reason = self.select(head, [self.record(2, changed)]) - self.assertFalse(selected) - self.assertIn("master run 2", reason) - - def test_desktop_packaging_success_cannot_replace_ci_validation(self): - changed = self.commit("apps/maple-agent/app/src/main.rs", "only packaged\n") - head = self.commit("README.md", "unrelated\n") - selected, _ = self.select(head, [run_record(2, changed)]) - self.assertTrue(selected) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_agent_measure.py b/scripts/ci/test_agent_measure.py deleted file mode 100644 index 4498cf5ba..000000000 --- a/scripts/ci/test_agent_measure.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Execution and artifact bounds for CI phase instrumentation.""" - -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock - - -SCRIPT = Path(__file__).with_name("agent_measure.py") -SPEC = importlib.util.spec_from_file_location("agent_measure", SCRIPT) -measure = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(measure) - - -class AgentMeasurementTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory(prefix="ci measurements ") - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.metrics = self.root / "target/ci-metrics" - self.environment = {**os.environ, "GITHUB_RUN_ID": "test-run", "GITHUB_RUN_ATTEMPT": "1", - "GITHUB_JOB": "desktop", "GITHUB_STEP_SUMMARY": str(self.root / "github-summary"), - "PRIVATE_CI_TEST_SECRET": "must-not-be-in-metrics"} - - def invoke(self, *args, environment=None): - return subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.root, - env=environment or self.environment, capture_output=True, text=True) - - def phase(self, exit_code=0): - return self.invoke("--phase", "test-build", "--target", "target/debug-ci", "--", - sys.executable, "-c", f"import sys; print('child output'); sys.exit({exit_code})") - - def records(self): - return [json.loads(line) for line in (self.metrics / "phases.jsonl").read_text().splitlines()] - - def test_failing_command_streams_output_records_duration_and_preserves_failure(self): - result = self.phase(23) - self.assertEqual(result.returncode, 23, result.stderr) - self.assertIn("child output", result.stdout) - record, = self.records() - self.assertEqual(record["exit_code"], 23) - self.assertEqual(record["phase"], "test-build") - self.assertEqual(record["target"], "target/debug-ci") - self.assertGreater(record["elapsed_seconds"], 0) - contents = "".join(path.read_text() for path in self.metrics.iterdir() if path.is_file()) - self.assertNotIn("child output", contents) - self.assertNotIn("sys.exit", contents) - self.assertNotIn("must-not-be-in-metrics", contents) - - def test_missing_command_records_127(self): - result = self.invoke("--phase", "missing", "--", "./missing-command") - self.assertEqual(result.returncode, 127) - self.assertEqual(self.records()[0]["exit_code"], 127) - - def test_restored_metrics_from_another_attempt_are_reset(self): - self.assertEqual(self.phase(9).returncode, 9) - next_attempt = {**self.environment, "GITHUB_RUN_ATTEMPT": "2"} - result = self.invoke("--phase", "retry", "--", sys.executable, "-c", "pass", environment=next_attempt) - self.assertEqual(result.returncode, 0, result.stderr) - record, = self.records() - self.assertEqual(record["phase"], "retry") - self.assertEqual(record["context"]["run_attempt"], "2") - - def test_summary_counts_regular_files_once_without_metrics_or_symlinks(self): - self.assertEqual(self.phase().returncode, 0) - target = self.root / "target" - (target / "payload").write_bytes(b"12345") - (target / "nested").mkdir() - (target / "nested/small").write_bytes(b"123") - (target / "outside").symlink_to(self.root) - (target / "file-link").symlink_to(target / "payload") - result = self.invoke("--summarize", "target") - self.assertEqual(result.returncode, 0, result.stderr) - summary = json.loads((self.metrics / "summary.json").read_text()) - self.assertEqual(summary["targets"][0]["logical_bytes"], 8) - self.assertEqual(summary["targets"][0]["regular_files"], 2) - self.assertIsNone(summary["cache_archive_bytes"]) - self.assertIn("not measured", (self.root / "github-summary").read_text()) - - def test_collection_limits_current_html_and_excludes_restored_reports_and_links(self): - self.assertEqual(self.phase().returncode, 0) - started = self.records()[0]["started_unix_ns"] - timing_dir = self.root / "target/cargo-timings" - timing_dir.mkdir() - for index in range(12): - report = timing_dir / f"cargo-timing-{index:02}.html" - report.write_text(f"report {index}") - os.utime(report, ns=(started + index + 1, started + index + 1)) - old = timing_dir / "cargo-timing-old.html" - old.write_text("stale") - os.utime(old, ns=(started - 1, started - 1)) - (timing_dir / "cargo-timing.html").write_text("duplicate alias") - (timing_dir / "cargo-timing-link.html").symlink_to(old) - result = self.invoke("--summarize", "target") - self.assertEqual(result.returncode, 0, result.stderr) - summary = json.loads((self.metrics / "summary.json").read_text()) - timings = summary["cargo_timings"] - self.assertEqual(timings["available_current_reports"], 12) - self.assertEqual(len(timings["reports"]), 8) - retained = sorted(path.name for path in (self.metrics / "cargo-timings").iterdir()) - self.assertEqual(retained, [f"target-0-cargo-timing-{index:02}.html" for index in range(4, 12)]) - - def test_timing_size_limits_apply_before_copying(self): - self.assertEqual(self.phase().returncode, 0) - started = self.records()[0]["started_unix_ns"] - timing_dir = self.root / "target/cargo-timings" - timing_dir.mkdir() - for index, size in enumerate((6, 6, 6, 17)): - report = timing_dir / f"cargo-timing-{index}.html" - report.write_bytes(b"x" * size) - os.utime(report, ns=(started + index + 1, started + index + 1)) - with mock.patch.dict(os.environ, self.environment), mock.patch.object(measure, "MAX_REPORT_BYTES", 16), \ - mock.patch.object(measure, "MAX_TOTAL_REPORT_BYTES", 10): - result = measure.collect_timings([self.root / "target"], self.metrics, self.records()) - self.assertEqual(result["retained_bytes"], 6) - self.assertEqual(len(result["reports"]), 1) - - def test_phase_rejects_invalid_name_and_missing_command(self): - self.assertEqual(self.invoke("--phase", "bad\nname", "--", "echo").returncode, 2) - self.assertEqual(self.invoke("--phase", "no-command").returncode, 2) - self.assertFalse(self.metrics.exists()) - - def test_instrumentation_failure_does_not_replace_command_failure(self): - with mock.patch.object(measure, "prepare", return_value={}), \ - mock.patch.object(measure, "read_phases", return_value=[]), \ - mock.patch.object(measure.subprocess, "run", return_value=subprocess.CompletedProcess([], 29)): - self.assertEqual(measure.run_phase("failure", Path("target"), ["unused"], self.metrics), 29) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index c0776667b..cc2b3ddff 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -38,6 +38,9 @@ def module(name: str, path: str): "maple-dev-xcode", "release-onnxruntime-ios-", "${{ runner.os }}-bun-", "${{ runner.os }}-gradle-", ) ONNX_CACHE = "apps/maple-research/frontend/src-tauri/onnxruntime-ios" +# The unsigned Agent builders that write the exact-key release cache; ci.yml's packaging lanes read it. +AGENT_RELEASE_WRITERS = frozenset(("agent-desktop-build.yml", job) for job in ("macos-build", "windows-build", "linux")) +AGENT_RELEASE_READERS = frozenset(("ci.yml", f"agent-package-{os}") for os in ("linux", "macos", "windows")) # Jobs that may mint an OIDC token: attestation, signing identity, FlakeHub, registries. OIDC_JOBS = frozenset({ ("agent-desktop-build.yml", "macos"), ("agent-desktop-build.yml", "windows"), @@ -248,6 +251,22 @@ def test_c4_onnx_cache_text_matches_release_and_only_signed_lanes_write_it(self) if cache_writer(step): self.assertIn(name, RELEASE_FEEDING) + def test_c4_agent_release_cache_is_exact_and_written_only_by_master_builders(self): + restores = [(name, job_id, step["with"]) for name, job_id, step in cache_steps() + if step["with"].get("key", "").startswith("ci-v1-agent-release-")] + self.assertEqual({(name, job_id) for name, job_id, _ in restores}, + AGENT_RELEASE_WRITERS | AGENT_RELEASE_READERS) + self.assertEqual(len({(cache["key"], cache["path"]) for _, _, cache in restores}), 1) + self.assertFalse([cache for _, _, cache in restores if "restore-keys" in cache]) # exact keys only + writers = [(name, job_id, step) for name, job_id, step in cache_steps() if cache_writer(step) + and (name, job_id) in AGENT_RELEASE_WRITERS | AGENT_RELEASE_READERS] + self.assertEqual({(name, job_id) for name, job_id, _ in writers}, AGENT_RELEASE_WRITERS) + for _, job_id, step in writers: + with self.subTest(job=job_id): + self.assertTrue(step["uses"].startswith("actions/cache/save@")) + self.assertEqual(step["with"]["path"], restores[0][2]["path"]) + self.assertIn("github.event_name == 'push' && github.ref == 'refs/heads/master'", step["if"]) + def test_c4_dev_testflight_build_number_is_the_run_number(self): job = workflows()["ios-dev-testflight.yml"]["jobs"]["build-ios-dev"] identity = next(step for step in job["steps"] if step.get("id") == "identity") diff --git a/scripts/ci/test_nightly_report.py b/scripts/ci/test_nightly_report.py new file mode 100644 index 000000000..5a1fb1db8 --- /dev/null +++ b/scripts/ci/test_nightly_report.py @@ -0,0 +1,57 @@ +"""The nightly report keeps exactly one ci-nightly issue in step with the checks.""" + +import contextlib +import io +import json +import os +import unittest +from unittest import mock + +import nightly_report + +GREEN = {"conclusion": "success", "created_at": "2026-10-26T04:17:00Z", "html_url": "https://ci/1"} + + +class NightlyReportTests(unittest.TestCase): + def test_failures_list_failed_jobs_and_a_red_or_missing_scheduled_ci_run(self): + needs = {"store-bench": {"result": "success"}, "agent-provider": {"result": "failure"}, + "advisories": {"result": "cancelled"}} + self.assertEqual(nightly_report.failures(needs, GREEN), + ["- `advisories`: cancelled", "- `agent-provider`: failure"]) + red = dict(GREEN, conclusion="failure") + self.assertEqual(nightly_report.failures({}, red), + ["- ci.yml scheduled run of 2026-10-26: failure (https://ci/1)"]) + self.assertEqual(len(nightly_report.failures({}, None)), 1) + + def test_one_issue_is_created_commented_or_closed(self): + self.assertEqual(nightly_report.decide(["- x"], None, "u")[0], "create") + self.assertEqual(nightly_report.decide(["- x"], 7, "u")[0], "comment") + self.assertEqual(nightly_report.decide([], 7, "u")[0], "close") + self.assertIsNone(nightly_report.decide([], None, "u")) + + def run_main(self, dry_run, open_issues, needs): + calls = [] + + def gh(path, method="GET", payload=None): + calls.append((method, path, payload)) + if "/actions/workflows/ci.yml/runs" in path: + return {"workflow_runs": [GREEN]} + return [{"number": number} for number in open_issues] + [{"number": 1, "pull_request": {}}] + + env = {"GITHUB_REPOSITORY": "o/r", "GITHUB_RUN_ID": "9", "NEEDS": json.dumps(needs), "DRY_RUN": dry_run} + with mock.patch.dict(os.environ, env), mock.patch.object(nightly_report, "gh", gh), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(nightly_report.main(), 0) + return [(method, path) for method, path, _ in calls if method != "GET"] + + def test_writes_only_outside_a_dry_run_and_closes_on_green(self): + failing = {"agent-provider": {"result": "failure"}} + self.assertEqual(self.run_main("true", [], failing), []) + self.assertEqual(self.run_main("false", [], failing), [("POST", "repos/o/r/issues")]) + self.assertEqual(self.run_main("false", [5], failing), [("POST", "repos/o/r/issues/5/comments")]) + self.assertEqual(self.run_main("false", [5], {}), + [("POST", "repos/o/r/issues/5/comments"), ("PATCH", "repos/o/r/issues/5")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_opensecret_change_detection.py b/scripts/ci/test_opensecret_change_detection.py index 396ad9f84..44fb2ba27 100644 --- a/scripts/ci/test_opensecret_change_detection.py +++ b/scripts/ci/test_opensecret_change_detection.py @@ -8,12 +8,18 @@ import tempfile import unittest -from agent_change_detection import affects_agent from change_detection import classify_path as research_routes +from lanes import PUSH from opensecret_change_detection import CHECK_OUTPUTS, OUTPUTS, classify_paths +from test_plan import AGENT_PUSH, selected from test_workflow_policy import ROOT, load +def affects_agent(path): + """Whether a master push of `path` runs any Agent lane in ci.yml.""" + return bool(selected([path], PUSH) & AGENT_PUSH) + + class OpenSecretChangeDetectionTests(unittest.TestCase): def assert_routes(self, paths, *selected): self.assertEqual(classify_paths(paths), {name: name in selected for name in OUTPUTS}) diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index db42ee968..afdfdc2d2 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -1,4 +1,4 @@ -"""Lane selection: the table, the planner's events, and the Agent selector it replaces.""" +"""Lane selection: the table, the planner's events, and the Agent selectors it replaced.""" import contextlib import io @@ -18,9 +18,15 @@ ACTIVE = {lane.id for lane in lanes.LANES if lane.legacy is None} AGENT_PR = {"agent-lint", "agent-test", "agent-macos"} +AGENT_PUSH = AGENT_PR | {"agent-windows", "agent-full-linux", "agent-full-macos", "agent-full-windows"} +AGENT_PACKAGE = {"agent-package-linux", "agent-package-macos"} SHA_A, SHA_B = "a" * 40, "b" * 40 +def active(event): + return {lane.id for lane in plan.active(event)} + + def selected(paths, event=PR, labels=()): return set(plan.select(event, paths, labels)) @@ -52,15 +58,16 @@ def test_agent_runtime_build_and_asset_inputs_select_agent(self): "apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md", ): with self.subTest(path=path): - self.assertEqual(selected([path]), AGENT_PR) + # Dependency and packaging inputs add their own Agent lanes (AgentLaneTests). + self.assertLessEqual(AGENT_PR, selected([path])) + self.assertTrue(all(lane.startswith("agent-") for lane in selected([path]))) def test_rust_sdk_and_proxy_runtime_inputs_select_agent(self): for path in ("proxy/Cargo.toml", "proxy/src/proxy.rs", "proxy/build.rs", "sdk/rust/Cargo.toml", "sdk/rust/src/client.rs", "sdk/rust/build.rs", "sdk/rust/assets/aws_nitro_root.der"): with self.subTest(path=path): self.assertEqual(selected([path]) - {"repo"}, AGENT_PR) # proxy/Cargo.toml feeds the release gates - self.assertEqual({lane for lane in selected([path], PUSH) if lane.startswith("agent-")}, - {lane for lane in ACTIVE if lane.startswith("agent-")}) + self.assertEqual({lane for lane in selected([path], PUSH) if lane.startswith("agent-")}, AGENT_PUSH) def test_research_typescript_and_independent_services_skip_agent(self): for path in ( @@ -98,12 +105,9 @@ def test_intentional_differences_select_only_the_repo_lane(self): for path in ( # The Agent flake has no root inputs. "flake.nix", "flake.lock", - # These drive agent-ci.yml and agent-desktop-build.yml, which still own them. - ".github/workflows/agent-ci.yml", ".github/workflows/agent-desktop-build.yml", - "scripts/ci/agent_change_detection.py", "scripts/ci/agent_master_selection.py", - "scripts/ci/agent_cache.py", "scripts/ci/agent_cache_scope.py", "scripts/ci/agent_cache_command.py", - "scripts/ci/agent_dependency_archive.py", "scripts/ci/agent_measure.py", "scripts/ci/change_detection.py", - # Signing scripts: no Agent PR lane signs anything. + # The Research selector; the Agent no longer imports it. + "scripts/ci/change_detection.py", + # Signing scripts: no ci.yml Agent lane signs anything (agent-desktop-build.yml does). "scripts/ci/windows-artifact-sign.ps1", "scripts/ci/install-windows-artifact-signing.ps1", ): with self.subTest(path=path): @@ -114,8 +118,8 @@ def test_unknown_roots_and_malformed_paths_select_every_lane(self): for path in ("new-build-config.toml", "newroot/x", "", "/tmp/file", "../file", "docs/../flake.nix", "apps/maple-agent/../maple-research/frontend/src/main.tsx"): with self.subTest(path=path): - self.assertEqual(selected([path]), ACTIVE) - self.assertEqual(selected([path], PUSH), ACTIVE) + self.assertEqual(selected([path]), active(PR)) + self.assertEqual(selected([path], PUSH), active(PUSH)) def test_mixed_changes_and_empty_diff(self): self.assertEqual(selected([]), set()) @@ -156,7 +160,7 @@ def test_windows_clippy_runs_on_prs_only_for_windows_paths_or_labels(self): self.assertNotIn("agent-windows", selected([other])) self.assertIn("agent-windows", selected([other], PUSH)) self.assertIn("agent-windows", selected(["docs/x.md"], labels=["ci:windows"])) - self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), ACTIVE) + self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), active(PR)) self.assertEqual(selected(["docs/x.md"], labels=["unrelated"]), set()) def test_inert_paths_select_nothing(self): @@ -186,6 +190,57 @@ def test_every_tracked_file_is_classified_and_ci_yml_matches(self): self.assertIn("unclassified: newroot/x", plan.lint_problems(["newroot/x"])) +class AgentLaneTests(unittest.TestCase): + """The lanes that replaced agent-ci.yml, the supply-chain and store workflows, and PR packaging.""" + + def test_dependency_inputs_select_supply_chain_and_the_provider_suite(self): + both = {"agent-supply-chain", "agent-provider"} + for path, lanes_ in (("apps/maple-agent/Cargo.lock", both), + ("apps/maple-agent/dependencies/sqlcipher/provenance.json", both), + ("apps/maple-agent/scripts/test_rusqlite_provider.py", {"agent-provider"}), + ("apps/maple-agent/crates/maple-agent/Cargo.toml", {"agent-supply-chain"}), + ("apps/maple-agent/app/assets/dict/LICENSE", {"agent-supply-chain"}), + ("apps/maple-agent/app/src/main.rs", set())): + with self.subTest(path=path): + self.assertEqual(selected([path]) & both, lanes_) + self.assertEqual(selected([path], PUSH) & both, lanes_) + self.assertEqual(selected([path], NIGHTLY) & both, set()) # nightly.yml runs them + + def test_packaging_rehearsal_runs_on_the_narrow_selector_or_a_label(self): + for path in ("apps/maple-agent/scripts/build-release.sh", "apps/maple-agent/flake.lock", + "apps/maple-agent/app/packaging/macos-entitlements.plist", + "apps/maple-agent/scripts/verify-linux-bundle-docker.sh", + ".github/workflows/agent-desktop-build.yml"): + with self.subTest(path=path): + self.assertLessEqual(AGENT_PACKAGE, selected([path])) + self.assertFalse({lane for lane in selected([path], PUSH) if lane.startswith("agent-package")}) + self.assertFalse(AGENT_PACKAGE & selected(["apps/maple-agent/app/src/main.rs"])) + self.assertFalse({"agent-package-windows"} & selected(["apps/maple-agent/scripts/build-release.sh"])) + self.assertEqual(selected(["docs/x.md"], labels=["ci:package"]), AGENT_PACKAGE | {"agent-package-windows"}) + self.assertEqual(selected(["docs/x.md"], labels=["ci:perf"]), {"agent-perf"}) + + def test_full_suites_run_on_master_nightly_and_ci_full_only(self): + full = {"agent-full-linux", "agent-full-macos", "agent-full-windows"} + self.assertFalse(full & selected(["apps/maple-agent/app/src/main.rs"])) + self.assertLessEqual(full, selected(["apps/maple-agent/app/src/main.rs"], PUSH)) + self.assertLessEqual(full, selected(["docs/x.md"], labels=["ci:full"])) + self.assertLessEqual(full | {"agent-windows"}, active(NIGHTLY)) + self.assertFalse(({"agent-perf", "agent-supply-chain", "agent-provider"} | AGENT_PACKAGE) & active(NIGHTLY)) + self.assertFalse(full & selected([".github/workflows/ci.yml"])) # CI edits prove the gate lanes only + + def test_agent_desktop_selector_covers_the_signed_builds_inputs(self): + for path in ("apps/maple-agent/app/src/main.rs", "sdk/rust/src/client.rs", "proxy/src/proxy.rs", + ".github/workflows/agent-desktop-build.yml", ".github/actions/setup-agent-windows/action.yml", + "scripts/ci/select-xcode.py", "scripts/ci/agent_windows.py", + "scripts/ci/windows-artifact-sign.ps1"): + with self.subTest(path=path): + self.assertTrue(plan.matches(path, lanes.AGENT_DESKTOP)) + for path in ("flake.lock", "sdk/src/index.ts", "sdk/rust/tests/x.rs", "proxy/Dockerfile", + ".github/workflows/ci.yml", "scripts/ci/plan.py"): + with self.subTest(path=path): + self.assertFalse(plan.matches(path, lanes.AGENT_DESKTOP)) + + class PlannerTests(unittest.TestCase): def github(self, event, payload, **env): with tempfile.TemporaryDirectory() as temporary: @@ -205,7 +260,7 @@ def test_push_without_a_usable_base_selects_everything(self): with self.subTest(before=before): code, outputs = self.github(PUSH, {"before": before, "after": SHA_B}) self.assertEqual(code, 0) - self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, ACTIVE) + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, active(PUSH)) def test_schedule_and_dispatch(self): _, outputs = self.github(NIGHTLY, {}) @@ -220,20 +275,25 @@ def test_malformed_event_fails_a_pr_and_selects_everything_on_push(self): self.assertEqual(self.github(QUEUE, {})[0], 1) code, outputs = self.github(PUSH, {}) self.assertEqual(code, 0) - self.assertTrue(all(outputs["lanes"].values())) + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, active(PUSH)) code, outputs = self.github(DISPATCH, {}, LANES_INPUT="no-such-lane") self.assertEqual((code, all(outputs["lanes"].values())), (0, True)) def test_outputs_empty_matrix_and_runner_overrides(self): - lanes_json, nix_json = plan.outputs({}) - self.assertEqual(nix_json, "[]") - self.assertEqual(json.loads(lanes_json), dict.fromkeys(sorted(ACTIVE), False)) - with mock.patch.dict(os.environ, {"CI_LINUX_HEAVY_RUNNER": "blacksmith-16vcpu-ubuntu-2404"}): - entries = json.loads(plan.outputs(dict.fromkeys(ACTIVE, ["x"]))[1]) - runners = {entry["id"]: entry["runner"] for entry in entries} - self.assertEqual(runners, {"agent-lint": "blacksmith-8vcpu-ubuntu-2404", - "agent-test": "blacksmith-16vcpu-ubuntu-2404", - "agent-macos": "blacksmith-6vcpu-macos-26", "repo": "ubuntu-latest"}) + empty = plan.outputs({}) + self.assertEqual((empty["nix"], empty["windows"]), ("[]", "[]")) + self.assertEqual(json.loads(empty["lanes"]), dict.fromkeys(sorted(ACTIVE), False)) + overrides = {"CI_LINUX_HEAVY_RUNNER": "blacksmith-16vcpu-ubuntu-2404", "CI_WINDOWS_RUNNER": "windows-2025"} + with mock.patch.dict(os.environ, overrides): + result = plan.outputs(dict.fromkeys(ACTIVE, ["x"])) + runners = {entry["id"]: entry["runner"] for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])} + linux, mac = "blacksmith-8vcpu-ubuntu-2404", "blacksmith-6vcpu-macos-26" + self.assertEqual(runners, { + "agent-lint": linux, "agent-test": "blacksmith-16vcpu-ubuntu-2404", "agent-macos": mac, + "agent-windows": "windows-2025", "agent-full-linux": linux, "agent-full-macos": mac, + "agent-full-windows": "windows-2025", "agent-supply-chain": linux, "agent-provider": linux, + "agent-perf": linux, "repo": "ubuntu-latest"}) + entries = [entry for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])] self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) def test_run_executes_the_lane_command_in_its_flake(self): @@ -241,10 +301,15 @@ def test_run_executes_the_lane_command_in_its_flake(self): contextlib.redirect_stderr(io.StringIO()): run.return_value.returncode = 0 self.assertEqual(plan.run("agent-test"), 0) - self.assertEqual(plan.run("agent-windows"), 2) # a special ci.yml job - command, = run.call_args.args + command, = run.call_args.args + self.assertEqual(plan.run("no-such-lane"), 2) + # Windows has no Nix; Git Bash comes from PATH, never System32's WSL launcher. + with mock.patch.object(plan.platform, "system", return_value="Windows"), \ + mock.patch.object(plan.shutil, "which", return_value="C:/Git/usr/bin/bash.exe"): + self.assertEqual(plan.run("agent-provider"), 0) self.assertEqual(command, ["nix", "develop", "--no-update-lock-file", "./apps/maple-agent", "-c", "bash", "-euo", "pipefail", "-c", "cd apps/maple-agent && just ci-test"]) + self.assertEqual(run.call_args.args[0][:2], ["C:/Git/usr/bin/bash.exe", "-euo"]) self.assertEqual(run.call_args.kwargs["env"]["RUSTFLAGS"], "-D warnings") def test_check_rust_compares_the_flake_compiler_with_the_windows_pin(self): @@ -298,6 +363,29 @@ def test_missing_or_zero_base_has_no_usable_diff(self): with self.subTest(base=base): self.assertIsNone(plan.changed_paths(base, self.base, merge_base=False)) + def only(self, event, payload): + event_path = self.root / "event.json" + event_path.write_text(json.dumps(payload)) + output = io.StringIO() + with mock.patch.dict(os.environ, {"GITHUB_EVENT_NAME": event, "GITHUB_EVENT_PATH": str(event_path)}), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(plan.only("agent-desktop"), 0) + return output.getvalue() + + def test_only_selects_the_desktop_build_for_its_inputs_and_whenever_unsure(self): + docs = self.commit("apps/maple-agent/docs/design.md") + self.assertEqual(self.only(PUSH, {"before": self.base, "after": docs}), "selected=false\n") + typescript = self.commit("sdk/src/index.ts") + self.assertEqual(self.only(PUSH, {"before": docs, "after": typescript}), "selected=false\n") + runtime = self.commit("proxy/src/main.rs") + self.assertEqual(self.only(PUSH, {"before": typescript, "after": runtime}), "selected=true\n") + unknown = self.commit("newroot/x") + self.assertEqual(self.only(PUSH, {"before": runtime, "after": unknown}), "selected=true\n") + for event, payload in ((PUSH, {"before": "0" * 40, "after": docs}), (PUSH, {"before": SHA_A, "after": docs}), + (PUSH, {}), (DISPATCH, {})): + with self.subTest(event=event, payload=payload): + self.assertEqual(self.only(event, payload), "selected=true\n") + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index 297004af6..89dbb1e53 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -37,7 +37,11 @@ def test_native_tests_have_no_release_or_signing_authority(self): self.assertNotIn("id-token", json.dumps(config)) def test_native_toolchain_matches_existing_windows_pin_and_avoids_artifact_cache(self): - existing = next(step for step in workflow("agent-desktop-build.yml")["jobs"]["windows-build"]["steps"] + # agent-desktop-build.yml's windows-build takes its toolchain from this action. + setup = json.loads(subprocess.check_output( + ["yq", "-o=json", ".", str(ROOT / ".github/actions/setup-agent-windows/action.yml")], text=True, + )) + existing = next(step for step in setup["runs"]["steps"] if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) for job in workflow()["jobs"].values(): steps = job["steps"] diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index 1a5cf19dc..e19814cc3 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -148,8 +148,8 @@ def matrix_values(matrix, key: str) -> set: def runner_labels(name: str, job_id: str, job: dict) -> set: - if (name, job_id) == ("ci.yml", "nix"): # the planner fills this matrix from lanes.py - return {lane.runner.label for lane in lanes.LANES} + if name == "ci.yml" and job_id in lanes.MATRIX_JOBS: # the planner fills these matrices from lanes.py + return {lane.runner.label for lane in lanes.LANES if lane.job == job_id} values = job.get("runs-on", []) labels = set() for value in values if isinstance(values, list) else [values]: @@ -250,7 +250,7 @@ def test_7_every_job_has_a_timeout(self): def test_8_ci_always_reports_one_verdict(self): config = workflows()["ci.yml"] for event, settings in config["on"].items(): - self.assertFalse({"paths", "paths-ignore"} & set(settings or {}), event) + self.assertFalse(isinstance(settings, dict) and {"paths", "paths-ignore"} & set(settings), event) verdict = config["jobs"]["ci-ok"] self.assertEqual(verdict["if"], "always()") self.assertEqual(set(verdict["needs"]), set(config["jobs"]) - {"ci-ok"}) From 202b7f8ddc1867d22c59f5af601b4e8aba2e0901 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:36:05 +0000 Subject: [PATCH 04/21] ci: move SDK, proxy, Research and service checks into ci.yml lanes Activate the sdk-ts, sdk-rust, proxy, deps-{research,sdk,proxy}, proxy-container(-arm64), research-web, research-rust, research-ios, research-android, research-desktop-{macos,linux,windows}, research-dev-macos, auth, updates and agent-updates lanes. PR lanes restore only caches that master writes and never save one. frontend-tests, web-build and rust-tests become push-only evidence twins (rust-tests on Blacksmith, writing ci-v1-research-rust); proxy-rust keeps only the release rehearsal. Delete the twelve workflows these lanes replace. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/develop-maple-proxy/SKILL.md | 13 +- .../skills/develop-opensecret-sdk/SKILL.md | 7 +- .../references/automated-checks.md | 7 +- .github/workflows/agent-updates-tests.yml | 49 --- .github/workflows/android-pr-build.yml | 80 ----- .github/workflows/auth-pages-ci.yml | 23 +- .github/workflows/ci.yml | 223 ++++++++++++- .github/workflows/desktop-pr-build.yml | 296 ------------------ .github/workflows/frontend-tests.yml | 23 +- .github/workflows/mobile-pr-build.yml | 74 ----- .github/workflows/pages-preview-build.yml | 26 +- .github/workflows/proxy-container.yml | 64 ---- .github/workflows/proxy-rust.yml | 73 +---- .github/workflows/proxy-supply-chain.yml | 42 --- .github/workflows/rust-tests.yml | 46 ++- .github/workflows/sdk-publish-tests.yml | 105 ------- .github/workflows/sdk-rust.yml | 62 ---- .github/workflows/sdk-supply-chain.yml | 40 --- .github/workflows/sdk-typescript.yml | 84 ----- .github/workflows/supply-chain.yml | 44 --- .github/workflows/updates-tests.yml | 55 ---- .github/workflows/web-build.yml | 64 +--- apps/maple-research/.githooks/pre-commit | 6 +- .../docs/development-contracts.md | 2 +- .../docs/research-dev-desktop.md | 21 +- docs/pages-deployments.md | 8 +- docs/repository-workflows.md | 38 ++- flake.nix | 11 - proxy/.githooks/pre-commit | 4 +- proxy/README.md | 7 +- scripts/ci/android-pr.sh | 4 +- scripts/ci/change_detection.py | 3 - scripts/ci/lanes.py | 136 ++++++-- scripts/ci/test_contracts.py | 23 +- scripts/ci/test_pages_workflows.py | 11 +- scripts/ci/test_plan.py | 101 ++++-- scripts/ci/test_workflow_policy.py | 36 +-- sdk/.githooks/pre-commit | 6 +- sdk/README.md | 8 +- sdk/rust/README.md | 4 +- services/updates/.githooks/pre-commit | 2 +- 41 files changed, 580 insertions(+), 1351 deletions(-) delete mode 100644 .github/workflows/agent-updates-tests.yml delete mode 100644 .github/workflows/android-pr-build.yml delete mode 100644 .github/workflows/desktop-pr-build.yml delete mode 100644 .github/workflows/mobile-pr-build.yml delete mode 100644 .github/workflows/proxy-container.yml delete mode 100644 .github/workflows/proxy-supply-chain.yml delete mode 100644 .github/workflows/sdk-publish-tests.yml delete mode 100644 .github/workflows/sdk-rust.yml delete mode 100644 .github/workflows/sdk-supply-chain.yml delete mode 100644 .github/workflows/sdk-typescript.yml delete mode 100644 .github/workflows/supply-chain.yml delete mode 100644 .github/workflows/updates-tests.yml diff --git a/.agents/skills/develop-maple-proxy/SKILL.md b/.agents/skills/develop-maple-proxy/SKILL.md index bb01f44be..5e20a87ec 100644 --- a/.agents/skills/develop-maple-proxy/SKILL.md +++ b/.agents/skills/develop-maple-proxy/SKILL.md @@ -58,16 +58,19 @@ nix develop --no-update-lock-file .#ci -c \ ./scripts/ci/verify-local-rust-deps.sh ``` -Root workflows own proxy Rust, daily supply-chain, non-publishing container, -and native-release rehearsal checks. `proxy/src/**`, `proxy/Cargo.toml`, and +The root `ci.yml` lanes `proxy`, `deps-proxy`, and `proxy-container` (both +architectures on `master`) run these checks, cargo-deny, and a non-publishing +container build; `just lane proxy` runs the first locally. `proxy-rust.yml` +rehearses the native release builds, and `nightly.yml` re-checks advisories. +`proxy/src/**`, `proxy/Cargo.toml`, and unknown proxy build inputs are desktop application inputs; tests, examples, docs, the standalone lockfile, and container-only files do not by themselves route expensive Maple app builds. Rust SDK build inputs (`sdk/rust/Cargo.toml`, `src/`, `build.rs`, `assets/`) select proxy and desktop checks too, because the proxy and both desktops build `sdk/rust` from the tree. When a new input changes either graph, update -`scripts/ci/change_detection.py`, its table-driven tests, and workflow paths in -the same change. +`scripts/ci/change_detection.py`, `scripts/ci/lanes.py`, and their table-driven +tests in the same change. ## Exercise the right runtime @@ -115,7 +118,7 @@ cargo package --locked --manifest-path proxy/Cargo.toml If the proxy references a new `maple-sdk` version, publish that SDK crate first. Do not publish either crate from Maple's application Release workflow. -Root proxy container CI builds without pushing. After a successful stable Maple +CI's proxy container lanes build without pushing. After a successful stable Maple Release, `.github/workflows/proxy-publish.yml` independently compares that release's proxy version with the previous stable Maple Release. Unchanged versions skip, including the unbackfilled 0.3.3 baseline. A strictly newer, diff --git a/.agents/skills/develop-opensecret-sdk/SKILL.md b/.agents/skills/develop-opensecret-sdk/SKILL.md index 9c87c4e8a..075142d21 100644 --- a/.agents/skills/develop-opensecret-sdk/SKILL.md +++ b/.agents/skills/develop-opensecret-sdk/SKILL.md @@ -59,7 +59,7 @@ nix develop --no-update-lock-file -c bun run build ``` For tests without service fixtures, use the credential-free selection in -`.github/workflows/sdk-typescript.yml`. An unfiltered `bun test` also collects +`scripts/ci/build-sdk-publish.sh`. An unfiltered `bun test` also collects integration tests that require a configured backend and test identities; use `sdk-integration.yml` for that complete local-stack setup. @@ -76,8 +76,9 @@ nix develop --no-update-lock-file -c bash -lc ' ' ``` -Run focused tests while iterating, then match the root path-scoped workflows: -`sdk-typescript.yml`, `sdk-rust.yml`, and `sdk-supply-chain.yml` as applicable. +Run focused tests while iterating, then match CI from the repository root with +`just lane sdk-ts`, `just lane sdk-rust`, and `just lane deps-sdk` as applicable +(lanes in `scripts/ci/lanes.py`). The repository pre-commit hook runs `sdk/.githooks/pre-commit` in this shell when SDK files are staged: the Rust format, Clippy, `--lib` tests, and docs, and the TypeScript format, build, and top-level unit tests. It never runs the diff --git a/.agents/skills/validate-maple/references/automated-checks.md b/.agents/skills/validate-maple/references/automated-checks.md index 926043a36..ae5d10636 100644 --- a/.agents/skills/validate-maple/references/automated-checks.md +++ b/.agents/skills/validate-maple/references/automated-checks.md @@ -122,7 +122,8 @@ Do not cite the pre-commit hook as complete proof: it runs only the format, lint ## Build the affected platform -Use these commands to mirror PR artifact builds. Then launch and smoke the result separately. +Use these commands to mirror PR artifact builds (the `research-desktop-*`, `research-android` and +`research-ios` lanes; `just lane ` runs one). Then launch and smoke the result separately. These scripts deliberately hide local `.env*` files and compile fixed PR endpoint profiles. Their artifacts are PR packaging evidence; they do not prove @@ -151,9 +152,11 @@ Run on real Windows from Git Bash/MSYS: ### Android -Run on x86_64 Linux: +Run on x86_64 Linux. The PR lane builds one ABI in debug; the second command +rehearses the four-ABI release build that master runs signed: ```bash +nix develop --no-update-lock-file .#android -c ./scripts/ci/android-pr.sh MAPLE_ANDROID_FAKE_SIGNING=1 MAPLE_ANDROID_WEB_ENVIRONMENT=pr nix develop --no-update-lock-file .#android -c ./scripts/ci/android-release.sh ``` diff --git a/.github/workflows/agent-updates-tests.yml b/.github/workflows/agent-updates-tests.yml deleted file mode 100644 index 2578f846f..000000000 --- a/.github/workflows/agent-updates-tests.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Agent Update Delivery Tests - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/agent-updates-*.yml" - - "services/agent-updates/**" - - "services/download-tracking/**" - - "scripts/ci/agent-updates.sh" - - "scripts/ci/test_agent_updates_workflows.py" - - "scripts/ci/_common.sh" - - "apps/maple-agent/scripts/release-info.py" - - "apps/maple-agent/release-profiles.json" - - "flake.nix" - - "flake.lock" - pull_request: - paths: - - ".github/workflows/agent-updates-*.yml" - - "services/agent-updates/**" - - "services/download-tracking/**" - - "scripts/ci/agent-updates.sh" - - "scripts/ci/test_agent_updates_workflows.py" - - "scripts/ci/_common.sh" - - "apps/maple-agent/scripts/release-info.py" - - "apps/maple-agent/release-profiles.json" - - "flake.nix" - - "flake.lock" - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - persist-credentials: false - - name: Install pinned Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - name: Check isolated Agent delivery - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/agent-updates.sh diff --git a/.github/workflows/android-pr-build.yml b/.github/workflows/android-pr-build.yml deleted file mode 100644 index c6a511ec3..000000000 --- a/.github/workflows/android-pr-build.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Android App PR Build - -permissions: - contents: read - -on: - pull_request: - branches: [ master ] - -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - changes: - uses: ./.github/workflows/app-change-detection.yml - - build-android: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.android != 'false') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('apps/maple-research/frontend/bun.lock', 'sdk/bun.lock') }} - - - name: Cache Gradle - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('apps/maple-research/frontend/src-tauri/gen/android/**/*.gradle*', 'apps/maple-research/frontend/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Cache sccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/.cache/sccache - key: ${{ runner.os }}-sccache-android-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-android- - ${{ runner.os }}-sccache- - - - name: Build Tauri Android App (fake signed release) - run: nix develop --no-update-lock-file .#android -c ./scripts/ci/android-release.sh - env: - MAPLE_ANDROID_FAKE_SIGNING: "1" - MAPLE_ANDROID_WEB_ENVIRONMENT: pr - - - name: Show sccache stats - if: always() - run: nix develop --no-update-lock-file .#android -c ./scripts/ci/sccache-stats.sh - - - name: Upload Android fake signed PR artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-android-fake-signed-release - path: | - apps/maple-research/frontend/src-tauri/gen/android/app/build/outputs/apk/universal/release/*.apk - apps/maple-research/frontend/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/*.aab - apps/maple-research/frontend/src-tauri/target/reproducibility/android-release-*.sha256 - retention-days: 5 diff --git a/.github/workflows/auth-pages-ci.yml b/.github/workflows/auth-pages-ci.yml index cb7f4d377..02b160568 100644 --- a/.github/workflows/auth-pages-ci.yml +++ b/.github/workflows/auth-pages-ci.yml @@ -6,26 +6,32 @@ permissions: on: workflow_dispatch: pull_request: - # Include stacked PRs and forks; this job has no publishing authority. + # Include stacked PRs and forks; this job has no publishing authority. The + # Pages and publisher tests run in ci.yml's repo lane. paths: - - ".github/workflows/auth-pages-*.yml" + - ".github/workflows/auth-pages-ci.yml" - "apps/maple-auth/**" - "scripts/ci/auth-*.sh" - - "scripts/ci/pages_*.py" - - "scripts/ci/test_pages_*.py" + - "scripts/ci/pages_artifact.py" + - "scripts/ci/pages_auth_build.py" - "flake.nix" - "flake.lock" push: branches: [master] paths: - - ".github/workflows/auth-pages-*.yml" + - ".github/workflows/auth-pages-ci.yml" - "apps/maple-auth/**" - "scripts/ci/auth-*.sh" - - "scripts/ci/pages_*.py" - - "scripts/ci/test_pages_*.py" + - "scripts/ci/pages_artifact.py" + - "scripts/ci/pages_auth_build.py" - "flake.nix" - "flake.lock" +concurrency: + # A newer PR head replaces an older build; master builds never cancel. + group: auth-pages-ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: auth: if: >- @@ -52,9 +58,6 @@ jobs: - name: Verify pinned Nix runtime run: bash scripts/ci/verify-nix-runtime.sh - - name: Test Pages artifact and deployment boundaries - run: nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-linux.pages - - name: Test the standalone auth app run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2baab0f65..194243428 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ # CI: the single required check (ci-ok) for every pull request, on any base. # Lane selection lives in scripts/ci/lanes.py and scripts/ci/plan.py. Generic # lanes run in the `nix` and `windows` matrices; a special job exists only where -# a step list cannot be expressed generically (services, OIDC, artifacts). +# a step list cannot be expressed generically (services, OIDC, artifacts, or a +# cache that another workflow writes). # Never add `paths:` here: ci-ok must always report. name: CI @@ -238,10 +239,228 @@ jobs: if-no-files-found: error retention-days: 7 + # Research Rust on PRs, warm from rust-tests.yml's master cache (read-only). + research-rust: + name: research-rust + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-rust'] + runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Restore master's Research Rust cache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Exactly rust-tests.yml's key and paths; it is the only writer. + key: ci-v1-research-rust-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock', 'flake.lock') }} + restore-keys: ci-v1-research-rust- + path: | + ~/.cache/sccache + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + - name: Check formatting and local dependencies, then test + run: python3 scripts/ci/plan.py run research-rust + + # Unsigned Research platform builds. They restore what the master signed + # builds save and never save anything. + research-ios: + name: research-ios + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-ios'] + runs-on: ${{ vars.CI_MACOS_RUNNER || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 120 # a missed ONNX Runtime cache builds it from source + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Select pinned Xcode + id: select_xcode + run: python3 scripts/ci/select-xcode.py + - uses: ./.github/actions/setup-nix + - name: Restore master's ONNX Runtime iOS artifact (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + apps/maple-research/frontend/src-tauri/onnxruntime-ios + key: release-onnxruntime-ios-artifact-1.23.2-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}-${{ hashFiles('flake.lock', 'flake.nix', 'scripts/ci/apple-toolchain.json', 'scripts/ci/select-xcode.py', 'scripts/ci/_common.sh', 'scripts/ci/ios-onnxruntime.sh', 'apps/maple-research/frontend/src-tauri/scripts/build-ios-onnxruntime-all.sh', 'apps/maple-research/frontend/src-tauri/scripts/canonicalize-static-archive.py', 'apps/maple-research/frontend/src-tauri/scripts/onnxruntime-pins.sh') }} + restore-keys: | + release-onnxruntime-ios-artifact-1.23.2-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}- + - name: Build the iOS simulator app + run: python3 scripts/ci/plan.py run research-ios + - name: Upload the simulator app + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-ios-simulator-pr + path: | + apps/maple-research/frontend/src-tauri/gen/apple/build/**/*.app + apps/maple-research/frontend/src-tauri/target/reproducibility/ios-*.sha256 + retention-days: 5 + + research-desktop-macos: + name: research-desktop-macos + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-desktop-macos'] + runs-on: ${{ vars.CI_MACOS_RUNNER || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Select pinned Xcode + run: python3 scripts/ci/select-xcode.py + - uses: ./.github/actions/setup-nix + - name: Restore master's macOS sccache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/Library/Caches/Mozilla.sccache + key: ${{ runner.os }}-sccache-macos-release-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} + restore-keys: ${{ runner.os }}-sccache-macos-release- + - name: Build the unsigned universal app and DMG + run: python3 scripts/ci/plan.py run research-desktop-macos + - name: Upload the unsigned app + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-macos-universal-pr + path: | + apps/maple-research/frontend/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg + apps/maple-research/frontend/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app + apps/maple-research/frontend/src-tauri/target/reproducibility/maple-macos-app.tar.gz + apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-macos-*.sha256 + retention-days: 5 + + research-desktop-linux: + name: research-desktop-linux + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-desktop-linux'] + runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Restore master's Linux sccache (read-only) + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/sccache + key: ${{ runner.os }}-sccache-linux-release-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} + restore-keys: ${{ runner.os }}-sccache-linux-release- + - name: Allow bubblewrap user namespaces + # Ubuntu 24.04 restricts them; AppImage packaging fails without them. + run: | + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + if [ -e /proc/sys/kernel/unprivileged_userns_clone ]; then + sudo sysctl -w kernel.unprivileged_userns_clone=1 + fi + - name: Build unsigned packages with fake updater signatures + run: python3 scripts/ci/plan.py run research-desktop-linux + - name: Upload the packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-linux-pr + path: | + apps/maple-research/frontend/src-tauri/target/release/bundle/appimage/*.AppImage + apps/maple-research/frontend/src-tauri/target/release/bundle/appimage/*.AppImage.sig + apps/maple-research/frontend/src-tauri/target/release/bundle/deb/*.deb + apps/maple-research/frontend/src-tauri/target/release/bundle/deb/*.deb.sig + apps/maple-research/frontend/src-tauri/target/release/bundle/rpm/*.rpm + apps/maple-research/frontend/src-tauri/target/release/bundle/rpm/*.rpm.sig + apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-linux-fake-updater.pub + apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-linux-*.sha256 + retention-days: 5 + + # No cache: desktop-build.yml writes the Windows one to GitHub's store, which + # a Blacksmith runner cannot read. + research-desktop-windows: + name: research-desktop-windows + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-desktop-windows'] + runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # v1 + with: + bun-version: 1.3.5 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below + with: + toolchain: 1.94.1 # desktop-build.yml's Windows toolchain + - name: Build the unsigned installer + run: python3 scripts/ci/plan.py run research-desktop-windows + - name: Upload the installer and its proofs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-windows-x64-pr + path: | + apps/maple-research/frontend/src-tauri/target/release/bundle/nsis/*.exe + apps/maple-research/frontend/src-tauri/target/release/maple.exe + apps/maple-research/frontend/src-tauri/resources/windows/*.dll + apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-windows-*.sha256 + retention-days: 5 + + research-desktop-windows-verify: + name: research-desktop-windows-verify + needs: [plan, research-desktop-windows] + if: fromJSON(needs.plan.outputs.lanes)['research-desktop-windows-verify'] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-nix + - name: Download the installer and its proofs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: maple-windows-x64-pr + path: artifacts + - name: Verify the reproducibility proofs + run: python3 scripts/ci/plan.py run research-desktop-windows-verify + + research-dev-macos: + name: research-dev-macos + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['research-dev-macos'] + runs-on: ${{ vars.CI_MACOS_RUNNER || 'blacksmith-6vcpu-macos-26' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # The package's evidence names the exact PR head, not the merge commit. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Select pinned Xcode + run: python3 scripts/ci/select-xcode.py + - uses: ./.github/actions/setup-nix + - name: Build the unsigned Research Dev app + run: python3 scripts/ci/plan.py run research-dev-macos + - name: Upload the Dev package and its identity proof + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: maple-research-dev-macos-pr + path: | + apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev-macos.tar.gz + apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json + if-no-files-found: error + retention-days: 5 + ci-ok: name: ci-ok if: always() - needs: [plan, nix, windows, agent-package-linux, agent-package-macos, agent-package-windows] + needs: [plan, nix, windows, agent-package-linux, agent-package-macos, agent-package-windows, research-rust, + research-ios, research-desktop-macos, research-desktop-linux, research-desktop-windows, + research-desktop-windows-verify, research-dev-macos] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml deleted file mode 100644 index fb65222b4..000000000 --- a/.github/workflows/desktop-pr-build.yml +++ /dev/null @@ -1,296 +0,0 @@ -name: Desktop App PR Build - -permissions: - contents: read - -on: - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. - types: [opened, synchronize, reopened, labeled] - -concurrency: - # Label events cannot cancel useful source-validation runs or other labels. - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && format('-label-{0}', github.event.label.name) || '' }} - cancel-in-progress: true - -jobs: - # Keep metadata-only check names separate from source-validation results. - changes: - name: changes${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - if: github.event.action != 'labeled' - uses: ./.github/workflows/app-change-detection.yml - - build-macos: - name: build-macos${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - needs: changes - if: ${{ always() && !cancelled() && github.event.action != 'labeled' && (needs.changes.result != 'success' || needs.changes.outputs.macos != 'false') }} - runs-on: blacksmith-6vcpu-macos-26 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Select pinned Xcode - run: python3 scripts/ci/select-xcode.py - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache sccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/Library/Caches/Mozilla.sccache - key: ${{ runner.os }}-sccache-macos-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-macos- - ${{ runner.os }}-sccache- - - - name: Build Tauri App (macOS, unsigned) - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/desktop-pr.sh - - - name: Show sccache stats - if: always() - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/sccache-stats.sh - - - name: Upload macOS PR Build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-macos-universal-pr - path: | - apps/maple-research/frontend/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg - apps/maple-research/frontend/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app - apps/maple-research/frontend/src-tauri/target/reproducibility/maple-macos-app.tar.gz - apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-macos-*.sha256 - retention-days: 5 - - build-research-dev-macos: - name: build-research-dev-macos${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - needs: changes - if: >- - ${{ always() && !cancelled() && ( - (github.event.action == 'labeled' && github.event.label.name == 'ci:research-dev-macos') || - (github.event.action != 'labeled' && ( - needs.changes.result != 'success' || needs.changes.outputs.macos != 'false' || - contains(github.event.pull_request.labels.*.name, 'ci:research-dev-macos') - )) - ) }} - # A fresh source or opt-in run replaces an older Dev candidate for this PR. - concurrency: - group: research-dev-macos-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - runs-on: blacksmith-6vcpu-macos-26 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - - name: Select pinned Xcode - run: python3 scripts/ci/select-xcode.py - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache sccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/Library/Caches/Mozilla.sccache - key: ${{ runner.os }}-sccache-research-dev-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-research-dev- - - - name: Build isolated Research Dev app (unsigned) - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/research-dev-desktop.sh - - - name: Upload Research Dev PR package and identity proof - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-research-dev-macos-pr - path: | - apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev-macos.tar.gz - apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json - if-no-files-found: error - retention-days: 5 - - build-linux: - name: build-linux${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - needs: changes - if: ${{ always() && !cancelled() && github.event.action != 'labeled' && (needs.changes.result != 'success' || needs.changes.outputs.linux != 'false') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache sccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/.cache/sccache - key: ${{ runner.os }}-sccache-linux-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-linux- - ${{ runner.os }}-sccache- - - - name: Allow bubblewrap user namespaces - run: | - set -euo pipefail - # Ubuntu 24.04 restricts unprivileged user namespaces by default; linuxdeploy/AppImage - # packaging uses bubblewrap and otherwise fails with "bwrap: setting up uid map: Permission denied". - if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - if [ -e /proc/sys/kernel/unprivileged_userns_clone ]; then - sudo sysctl -w kernel.unprivileged_userns_clone=1 - fi - - - name: Build Tauri App (Linux, unsigned + fake updater signing) - run: nix develop --no-update-lock-file .#desktop-linux -c ./scripts/ci/desktop-pr.sh - env: - MAPLE_TAURI_FAKE_UPDATER_SIGNING: "1" - - - name: Show sccache stats - if: always() - run: nix develop --no-update-lock-file .#desktop-linux -c ./scripts/ci/sccache-stats.sh - - - name: Upload Linux PR Builds - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-linux-pr - path: | - apps/maple-research/frontend/src-tauri/target/release/bundle/appimage/*.AppImage - apps/maple-research/frontend/src-tauri/target/release/bundle/appimage/*.AppImage.sig - apps/maple-research/frontend/src-tauri/target/release/bundle/deb/*.deb - apps/maple-research/frontend/src-tauri/target/release/bundle/deb/*.deb.sig - apps/maple-research/frontend/src-tauri/target/release/bundle/rpm/*.rpm - apps/maple-research/frontend/src-tauri/target/release/bundle/rpm/*.rpm.sig - apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-linux-fake-updater.pub - apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-linux-*.sha256 - retention-days: 5 - - build-windows: - name: build-windows${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - needs: changes - if: ${{ always() && !cancelled() && github.event.action != 'labeled' && (needs.changes.result != 'success' || needs.changes.outputs.windows != 'false') }} - runs-on: blacksmith-8vcpu-windows-2025 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Record Windows runner capacity and build tools - shell: pwsh - run: | - Write-Output "Image: $env:ImageOS $env:ImageVersion" - Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber | ConvertTo-Json -Compress - Get-CimInstance Win32_Processor | Select-Object Name, NumberOfLogicalProcessors | ConvertTo-Json -Compress - Get-CimInstance Win32_ComputerSystem | Select-Object TotalPhysicalMemory | ConvertTo-Json -Compress - Get-PSDrive -PSProvider FileSystem | Select-Object Name, Used, Free | ConvertTo-Json -Compress - $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' - & $vswhere -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -format json - - - name: Setup Bun - uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # was v1 - with: - bun-version: 1.3.5 - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable - with: - toolchain: 1.94.1 - - - name: Install sccache - shell: bash - run: | - SCCACHE_VERSION=0.16.0 - SCCACHE_SHA256="c82319cc392b693e9fc72e6567cce47e218308f00fbf1df942e39b62e30f98da" - SCCACHE_ARCHIVE="sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc.tar.gz" - SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${SCCACHE_ARCHIVE}" - # Run download/verify/extract inside a subshell cd'd to RUNNER_TEMP so - # the archive and target dir are referenced by relative names. MSYS2 tar - # mangles Windows paths like D:\a when given absolute -C/-f arguments - # (even with --force-local); relative paths sidestep that. - ( - cd "$RUNNER_TEMP" - curl --fail --location --show-error --silent "$SCCACHE_URL" --output "$SCCACHE_ARCHIVE" - echo "${SCCACHE_SHA256} ${SCCACHE_ARCHIVE}" | sha256sum --check - - tar xzf "$SCCACHE_ARCHIVE" - ) - SCCACHE_BIN_DIR="$RUNNER_TEMP/sccache-v${SCCACHE_VERSION}-x86_64-pc-windows-msvc" - echo "$SCCACHE_BIN_DIR" >> "$GITHUB_PATH" - "$SCCACHE_BIN_DIR/sccache.exe" --version - - - name: Cache sccache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~\AppData\Local\Mozilla\sccache - key: ${{ runner.os }}-sccache-windows-v0.16.0-rust-1.94.1-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-windows-v0.16.0-rust-1.94.1- - - - name: Build Tauri App (Windows, unsigned) - shell: bash - run: ./scripts/ci/desktop-windows-pr.sh - - - name: Show sccache stats - if: always() - run: sccache --show-stats - - - name: Upload Windows PR Build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-windows-x64-pr - path: | - apps/maple-research/frontend/src-tauri/target/release/bundle/nsis/*.exe - apps/maple-research/frontend/src-tauri/target/release/maple.exe - apps/maple-research/frontend/src-tauri/resources/windows/*.dll - apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-windows-*.sha256 - retention-days: 5 - - verify-windows-artifacts: - name: verify-windows-artifacts${{ github.event.action == 'labeled' && format(' (label {0})', github.event.label.name) || '' }} - needs: - - build-windows - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Download Windows artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # was v4 - with: - name: maple-windows-x64-pr - path: artifacts - - - name: Verify Windows artifact reproducibility proofs - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts windows diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index f064159f2..cbe29be84 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -1,5 +1,8 @@ name: Frontend Tests +# Release evidence for every master push that changes the Research frontend +# (.agents/skills/release-maple/scripts/ci_evidence.py). Pull requests run the +# same script in ci.yml's research-web lane. Never cancelled. permissions: contents: read @@ -16,22 +19,11 @@ on: - "scripts/ci/frontend.sh" - "flake.nix" - "flake.lock" - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. - paths: - - ".github/workflows/frontend-tests.yml" - - "apps/maple-research/frontend/**" - - "!apps/maple-research/frontend/src-tauri/**" - - "scripts/prepare-frontend-deps.sh" - - "scripts/prepare-typescript-sdk.sh" - - "scripts/ci/_common.sh" - - "scripts/ci/frontend.sh" - - "flake.nix" - - "flake.lock" jobs: test-frontend: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -46,12 +38,5 @@ jobs: - name: Verify pinned Nix runtime run: bash scripts/ci/verify-nix-runtime.sh - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('apps/maple-research/frontend/bun.lock', 'sdk/bun.lock') }} - - name: Run frontend checks run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/frontend.sh diff --git a/.github/workflows/mobile-pr-build.yml b/.github/workflows/mobile-pr-build.yml deleted file mode 100644 index d76b0c2f6..000000000 --- a/.github/workflows/mobile-pr-build.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: iOS App PR Build - -permissions: - contents: read - -on: - pull_request: - branches: [ master ] - -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - changes: - uses: ./.github/workflows/app-change-detection.yml - - build-ios: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.ios != 'false') }} - runs-on: blacksmith-6vcpu-macos-26 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Select pinned Xcode - id: select_xcode - run: python3 scripts/ci/select-xcode.py - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Xcode DerivedData and SourcePackages - uses: irgaly/xcode-cache@4141f139f00e335c6e1031fb93e667181f86146f # was v1 - with: - key: xcode-cache-ios-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}-${{ github.workflow }}-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - xcode-cache-ios-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}-${{ github.workflow }}- - xcode-cache-ios-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}- - - - name: Restore master ONNX Runtime iOS artifact - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - apps/maple-research/frontend/src-tauri/onnxruntime-ios - key: release-onnxruntime-ios-artifact-1.23.2-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}-${{ hashFiles('flake.lock', 'flake.nix', 'scripts/ci/apple-toolchain.json', 'scripts/ci/select-xcode.py', 'scripts/ci/_common.sh', 'scripts/ci/ios-onnxruntime.sh', 'apps/maple-research/frontend/src-tauri/scripts/build-ios-onnxruntime-all.sh', 'apps/maple-research/frontend/src-tauri/scripts/canonicalize-static-archive.py', 'apps/maple-research/frontend/src-tauri/scripts/onnxruntime-pins.sh') }} - restore-keys: | - release-onnxruntime-ios-artifact-1.23.2-xcode${{ steps.select_xcode.outputs.version }}-${{ steps.select_xcode.outputs.build }}- - - - name: Prepare ONNX Runtime iOS artifact - run: nix develop --no-update-lock-file .#apple -c ./scripts/ci/ios-onnxruntime.sh - timeout-minutes: 90 - - - name: Build Tauri iOS App (simulator) - run: nix develop --no-update-lock-file .#apple -c ./scripts/ci/ios-pr.sh - env: - MAPLE_IOS_DOWNLOAD_SIMULATOR_RUNTIME: "1" - timeout-minutes: 90 - - - name: Upload iOS PR Simulator App - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-ios-simulator-pr - path: | - apps/maple-research/frontend/src-tauri/gen/apple/build/**/*.app - apps/maple-research/frontend/src-tauri/target/reproducibility/ios-*.sha256 - retention-days: 5 diff --git a/.github/workflows/pages-preview-build.yml b/.github/workflows/pages-preview-build.yml index bdbdff6b8..64f76b683 100644 --- a/.github/workflows/pages-preview-build.yml +++ b/.github/workflows/pages-preview-build.yml @@ -26,9 +26,6 @@ on: - ".github/workflows/pages-*.yml" - "apps/maple-research/frontend/**" - "!apps/maple-research/frontend/src-tauri/**" - # Older open PR heads retain their development preview until rebased. - - "frontend/**" - - "!frontend/src-tauri/**" - "scripts/prepare-frontend-deps.sh" - "scripts/prepare-typescript-sdk.sh" - "scripts/ci/_common.sh" @@ -38,6 +35,11 @@ on: - "flake.nix" - "flake.lock" +concurrency: + # A newer PR head replaces an older preview build; master builds never cancel. + group: pages-preview-build-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-preview: # Untrusted PR code runs here without deployment credentials or write permissions. @@ -82,19 +84,8 @@ jobs: MAPLE_IOS_DEV_AUTH_ORIGIN: ${{ vars.MAPLE_IOS_DEV_AUTH_ORIGIN }} - name: Describe the preview artifact - id: artifact run: | - set -euo pipefail - # Existing PR heads may still use the pre-relocation source layout. - # Accept exactly one known output location; never search arbitrary paths. - artifact_dir="" - for candidate in apps/maple-research/frontend/src-tauri/target/reproducibility frontend/src-tauri/target/reproducibility; do - if [ -f "$candidate/maple-web-dist.tar.gz" ]; then - [ -z "$artifact_dir" ] || { echo "Ambiguous preview artifact layout" >&2; exit 1; } - artifact_dir="$candidate" - fi - done - [ -n "$artifact_dir" ] || { echo "Missing preview artifact" >&2; exit 1; } + artifact_dir=apps/maple-research/frontend/src-tauri/target/reproducibility nix develop --no-update-lock-file "$GITHUB_WORKSPACE/.pages-tools#pages" \ -c python3 -I "$GITHUB_WORKSPACE/.pages-tools/scripts/ci/pages_artifact.py" manifest \ --archive "$artifact_dir/maple-web-dist.tar.gz" \ @@ -103,14 +94,13 @@ jobs: --run-id "$GITHUB_RUN_ID" \ --run-attempt "$GITHUB_RUN_ATTEMPT" \ --output "$artifact_dir/pages-artifact.json" - printf 'directory=%s\n' "$artifact_dir" >> "$GITHUB_OUTPUT" - name: Upload the preview artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: maple-pages-preview-${{ github.run_id }}-${{ github.run_attempt }} path: | - ${{ steps.artifact.outputs.directory }}/maple-web-dist.tar.gz - ${{ steps.artifact.outputs.directory }}/pages-artifact.json + apps/maple-research/frontend/src-tauri/target/reproducibility/maple-web-dist.tar.gz + apps/maple-research/frontend/src-tauri/target/reproducibility/pages-artifact.json if-no-files-found: error retention-days: 5 diff --git a/.github/workflows/proxy-container.yml b/.github/workflows/proxy-container.yml deleted file mode 100644 index 381fbde78..000000000 --- a/.github/workflows/proxy-container.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Proxy container CI - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/proxy-container.yml" - - ".github/workflows/proxy-publish.yml" - - ".dockerignore" - - "proxy/Dockerfile" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/build.rs" - - "sdk/rust/src/**" - - "sdk/rust/assets/**" - - "proxy/Cargo.lock" - - "proxy/src/**" - pull_request: - paths: - - ".github/workflows/proxy-container.yml" - - ".github/workflows/proxy-publish.yml" - - ".dockerignore" - - "proxy/Dockerfile" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/build.rs" - - "sdk/rust/src/**" - - "sdk/rust/assets/**" - - "proxy/Cargo.lock" - - "proxy/src/**" - -jobs: - proxy-container: - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - - platform: linux/arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # was v3 - - - name: Build proxy container without publishing - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # was v5 - with: - context: . - file: proxy/Dockerfile - platforms: ${{ matrix.platform }} - cache-from: type=gha,scope=proxy-${{ matrix.platform }} - # Cache availability must not turn a successful image build/push into a failure. - cache-to: type=gha,mode=max,scope=proxy-${{ matrix.platform }},ignore-error=true - push: false diff --git a/.github/workflows/proxy-rust.yml b/.github/workflows/proxy-rust.yml index e55b6dfc5..32f8d38e3 100644 --- a/.github/workflows/proxy-rust.yml +++ b/.github/workflows/proxy-rust.yml @@ -1,5 +1,8 @@ name: Proxy Rust CI +# Rehearses release.yml's native proxy builds (test-release-gates.sh keeps the +# two matrices equal). The proxy's own checks are ci.yml's `proxy` lane. No +# cache: three of the four runners are GitHub-hosted. permissions: contents: read @@ -9,80 +12,27 @@ on: paths: - ".github/workflows/proxy-rust.yml" - "proxy/Cargo.toml" + - "proxy/Cargo.lock" + - "proxy/rust-toolchain.toml" + - "proxy/src/**" - "sdk/rust/Cargo.toml" - "sdk/rust/build.rs" - "sdk/rust/src/**" - "sdk/rust/assets/**" - - "proxy/Cargo.lock" - - "proxy/clippy.toml" - - "proxy/flake.nix" - - "proxy/flake.lock" - - "proxy/rust-toolchain.toml" - - "proxy/rustfmt.toml" - - "proxy/src/**" - - "proxy/tests/**" - - "proxy/examples/**" - "scripts/ci/proxy-release.sh" pull_request: + # A PR that changes the release script or toolchain rehearses it before merge. paths: - ".github/workflows/proxy-rust.yml" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/build.rs" - - "sdk/rust/src/**" - - "sdk/rust/assets/**" - - "proxy/Cargo.lock" - - "proxy/clippy.toml" - - "proxy/flake.nix" - - "proxy/flake.lock" - "proxy/rust-toolchain.toml" - - "proxy/rustfmt.toml" - - "proxy/src/**" - - "proxy/tests/**" - - "proxy/examples/**" - "scripts/ci/proxy-release.sh" + workflow_dispatch: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" jobs: - proxy-rust: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # was v2 - with: - workspaces: proxy -> target - add-rust-environment-hash-key: "false" - key: ${{ hashFiles('proxy/Cargo.lock', 'proxy/rust-toolchain.toml', 'proxy/flake.lock') }} - - - name: Run credential-free proxy checks - run: | - nix develop --no-update-lock-file ./proxy -c bash -lc ' - set -euo pipefail - cd proxy - cargo fmt --all -- --check - cargo clippy --locked --all-targets --all-features -- -D warnings - cargo test --locked --all-features - RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features - cargo machete - ' - proxy-native-release: name: proxy-native-release (${{ matrix.archive }}) runs-on: ${{ matrix.runner }} @@ -109,13 +59,6 @@ jobs: with: toolchain: 1.89.0 - - name: Cache proxy Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # was v2 - with: - workspaces: proxy -> target - add-rust-environment-hash-key: "false" - key: native-release-${{ matrix.archive }}-${{ hashFiles('proxy/Cargo.lock') }} - - name: Rehearse native proxy release asset shell: bash run: ./scripts/ci/proxy-release.sh proxy-release-rehearsal "${{ matrix.archive }}" diff --git a/.github/workflows/proxy-supply-chain.yml b/.github/workflows/proxy-supply-chain.yml deleted file mode 100644 index 670ee11f9..000000000 --- a/.github/workflows/proxy-supply-chain.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Proxy Rust supply-chain checks - -on: - pull_request: - paths: - - ".github/workflows/proxy-supply-chain.yml" - - "proxy/deny.toml" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "proxy/Cargo.lock" - push: - branches: [master] - paths: - - ".github/workflows/proxy-supply-chain.yml" - - "proxy/deny.toml" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "proxy/Cargo.lock" - schedule: - - cron: "29 7 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - proxy-cargo-deny: - name: Proxy RustSec advisories and malicious crates - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Check proxy RustSec advisories and incident denylist - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # was v2 - with: - manifest-path: proxy/Cargo.toml - command: check advisories bans - arguments: --config proxy/deny.toml --all-features --locked diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 2991537c6..42ce537fd 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -1,5 +1,9 @@ name: Rust Unit Tests +# Release evidence for every master push that changes the Research Rust inputs +# (.agents/skills/release-maple/scripts/ci_evidence.py), and the only writer of +# the cache that ci.yml's research-rust lane restores on pull requests. Never +# cancelled. permissions: contents: read @@ -22,28 +26,18 @@ on: - "scripts/ci/test_agent_rust_deps.py" - "flake.nix" - "flake.lock" - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. - paths: - - ".github/workflows/rust-tests.yml" - - "apps/maple-research/frontend/src-tauri/**" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/build.rs" - - "sdk/rust/src/**" - - "sdk/rust/assets/**" - - "proxy/src/**" - - "scripts/ci/_common.sh" - - "scripts/ci/rust.sh" - - "scripts/ci/verify-local-rust-deps.sh" - - "scripts/ci/verify-agent-rust-deps.py" - - "scripts/ci/test_agent_rust_deps.py" - - "flake.nix" - - "flake.lock" + +# The same compiler settings as ci.yml, so research-rust hits this sccache. +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" jobs: rust-tests: - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -58,14 +52,16 @@ jobs: - name: Verify pinned Nix runtime run: bash scripts/ci/verify-nix-runtime.sh - - name: Cache sccache + - name: Cache sccache and Cargo downloads uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 with: - path: ~/.cache/sccache - key: ${{ runner.os }}-sccache-rust-tests-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-sccache-rust-tests- - ${{ runner.os }}-sccache- + key: ci-v1-research-rust-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock', 'flake.lock') }} + restore-keys: ci-v1-research-rust- + path: | + ~/.cache/sccache + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db - name: Run Rust unit tests run: | diff --git a/.github/workflows/sdk-publish-tests.yml b/.github/workflows/sdk-publish-tests.yml deleted file mode 100644 index fc168f502..000000000 --- a/.github/workflows/sdk-publish-tests.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: SDK publishing boundary tests - -permissions: - contents: read - -on: - pull_request: - paths: - - ".github/workflows/sdk-publish-*.yml" - - "scripts/ci/sdk_publish.py" - - "scripts/ci/sdk_crates_upload.py" - - "scripts/ci/build-sdk-publish.sh" - - "scripts/ci/check-sdk-package-consumers.py" - - "scripts/ci/test_sdk_publish*.py" - - "scripts/ci/test_sdk_crates_upload.py" - - "sdk/src/**" - - "sdk/rust/**" - - "sdk/package.json" - - "sdk/bun.lock" - - "sdk/bunfig.toml" - - "sdk/tsconfig*.json" - - "sdk/vite.config.ts" - - "sdk/rust-toolchain.toml" - - "sdk/flake.nix" - - "sdk/flake.lock" - - "flake.nix" - - "flake.lock" - push: - branches: [master] - paths: - - ".github/workflows/sdk-publish-*.yml" - - "scripts/ci/sdk_publish.py" - - "scripts/ci/sdk_crates_upload.py" - - "scripts/ci/build-sdk-publish.sh" - - "scripts/ci/check-sdk-package-consumers.py" - - "scripts/ci/test_sdk_publish*.py" - - "scripts/ci/test_sdk_crates_upload.py" - - "sdk/src/**" - - "sdk/rust/**" - - "sdk/package.json" - - "sdk/bun.lock" - - "sdk/bunfig.toml" - - "sdk/tsconfig*.json" - - "sdk/vite.config.ts" - - "sdk/rust-toolchain.toml" - - "sdk/flake.nix" - - "sdk/flake.lock" - - "flake.nix" - - "flake.lock" - -jobs: - boundaries: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Test publication security boundaries without credentials - run: nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-linux.sdk-publishing - - packages: - name: SDK package (${{ matrix.kind }}) - runs-on: ubuntu-24.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - kind: [npm, rust] - env: - SDK_KIND: ${{ matrix.kind }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Exercise the real package builder without publishing credentials - run: bash scripts/ci/build-sdk-publish.sh "$SDK_KIND" "$RUNNER_TEMP/sdk-package" - - - name: Validate Rust upload metadata without credentials or a network request - if: ${{ matrix.kind == 'rust' }} - run: | - set -euo pipefail - SDK_VERSION=$(python3 -I -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("sdk/rust/Cargo.toml").read_text())["package"]["version"])') - python3 -I scripts/ci/sdk_crates_upload.py --directory "$RUNNER_TEMP/sdk-package" \ - --version "$SDK_VERSION" --validate-only diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml deleted file mode 100644 index 68eaaa700..000000000 --- a/.github/workflows/sdk-rust.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: SDK Rust CI - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/sdk-rust.yml" - - "sdk/rust/**" - - "sdk/rust-toolchain.toml" - - "sdk/flake.nix" - - "sdk/flake.lock" - pull_request: - branches: [master] - paths: - - ".github/workflows/sdk-rust.yml" - - "sdk/rust/**" - - "sdk/rust-toolchain.toml" - - "sdk/flake.nix" - - "sdk/flake.lock" - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-D warnings" - -jobs: - sdk-rust: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # was v2 - with: - workspaces: sdk/rust -> target - add-rust-environment-hash-key: "false" - key: ${{ hashFiles('sdk/rust/Cargo.lock', 'sdk/rust-toolchain.toml', 'sdk/flake.lock') }} - - - name: Run credential-free Rust SDK checks - run: | - nix develop --no-update-lock-file ./sdk -c bash -lc ' - set -euo pipefail - cd sdk/rust - cargo fmt --all -- --check - cargo clippy --locked --all-targets --all-features -- -D warnings - cargo test --locked --all-features --lib - RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features - ' diff --git a/.github/workflows/sdk-supply-chain.yml b/.github/workflows/sdk-supply-chain.yml deleted file mode 100644 index 3c9186c7c..000000000 --- a/.github/workflows/sdk-supply-chain.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: SDK Rust supply-chain checks - -on: - pull_request: - paths: - - ".github/workflows/sdk-supply-chain.yml" - - "sdk/deny.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/Cargo.lock" - push: - branches: [master] - paths: - - ".github/workflows/sdk-supply-chain.yml" - - "sdk/deny.toml" - - "sdk/rust/Cargo.toml" - - "sdk/rust/Cargo.lock" - schedule: - - cron: "17 7 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - sdk-cargo-deny: - name: SDK RustSec advisories and malicious crates - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Check SDK RustSec advisories and incident denylist - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # was v2 - with: - manifest-path: sdk/rust/Cargo.toml - command: check advisories bans - arguments: --config sdk/deny.toml --all-features --locked diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml deleted file mode 100644 index cfb38e492..000000000 --- a/.github/workflows/sdk-typescript.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: SDK TypeScript CI - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/sdk-typescript.yml" - - "sdk/src/**" - - "sdk/.npmrc" - - "sdk/.prettierrc.json" - - "sdk/bun.lock" - - "sdk/bunfig.toml" - - "sdk/eslint.config.js" - - "sdk/package.json" - - "sdk/tsconfig.build.json" - - "sdk/tsconfig.json" - - "sdk/vite.config.ts" - - "sdk/flake.nix" - - "sdk/flake.lock" - pull_request: - branches: [master] - paths: - - ".github/workflows/sdk-typescript.yml" - - "sdk/src/**" - - "sdk/.npmrc" - - "sdk/.prettierrc.json" - - "sdk/bun.lock" - - "sdk/bunfig.toml" - - "sdk/eslint.config.js" - - "sdk/package.json" - - "sdk/tsconfig.build.json" - - "sdk/tsconfig.json" - - "sdk/vite.config.ts" - - "sdk/flake.nix" - - "sdk/flake.lock" - -jobs: - sdk-typescript: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-sdk-bun-${{ hashFiles('sdk/bun.lock') }} - - - name: Run credential-free TypeScript SDK checks - run: | - nix develop --no-update-lock-file ./sdk -c bash -lc ' - set -euo pipefail - cd sdk - bun install --frozen-lockfile --ignore-scripts - bun audit --audit-level=high - bun run format:check - bun run build - VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 \ - VITE_OPEN_SECRET_PCR_ENVIRONMENT=development \ - bun test \ - src/lib/test/*.test.ts \ - src/lib/test/integration/attestation.test.ts \ - src/lib/test/integration/developerHook.test.ts \ - src/lib/test/integration/liveAttestation.test.ts \ - src/lib/test/integration/pcr.test.ts \ - src/lib/test/integration/platformPushSettings.test.ts \ - src/lib/test/integration/web.test.ts \ - --timeout 30000 - ' diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml deleted file mode 100644 index 508c98d12..000000000 --- a/.github/workflows/supply-chain.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Rust supply-chain checks - -on: - pull_request: - paths: - - ".github/workflows/supply-chain.yml" - - "apps/maple-research/deny.toml" - - "apps/maple-research/frontend/src-tauri/Cargo.toml" - - "apps/maple-research/frontend/src-tauri/Cargo.lock" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - push: - branches: [master] - paths: - - ".github/workflows/supply-chain.yml" - - "apps/maple-research/deny.toml" - - "apps/maple-research/frontend/src-tauri/Cargo.toml" - - "apps/maple-research/frontend/src-tauri/Cargo.lock" - - "proxy/Cargo.toml" - - "sdk/rust/Cargo.toml" - schedule: - - cron: "41 6 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - cargo-deny: - name: RustSec advisories and malicious crates - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - - name: Check RustSec advisories and incident denylist - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 - with: - manifest-path: apps/maple-research/frontend/src-tauri/Cargo.toml - command: check advisories bans - arguments: --config apps/maple-research/deny.toml --all-features --locked diff --git a/.github/workflows/updates-tests.yml b/.github/workflows/updates-tests.yml deleted file mode 100644 index cb617b94d..000000000 --- a/.github/workflows/updates-tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Updater Worker Tests - -permissions: - contents: read - -on: - push: - branches: [master] - paths: - - ".github/workflows/updates-tests.yml" - - ".github/workflows/updates-publish.yml" - - "services/updates/**" - - "services/download-tracking/**" - - "scripts/ci/_common.sh" - - "scripts/ci/updates.sh" - - "flake.nix" - - "flake.lock" - pull_request: - branches: [master] - paths: - - ".github/workflows/updates-tests.yml" - - ".github/workflows/updates-publish.yml" - - "services/updates/**" - - "services/download-tracking/**" - - "scripts/ci/_common.sh" - - "scripts/ci/updates.sh" - - "flake.nix" - - "flake.lock" - -jobs: - test-updates-worker: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-updates-bun-${{ hashFiles('services/updates/bun.lock') }} - - - name: Run updater Worker checks - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/updates.sh diff --git a/.github/workflows/web-build.yml b/.github/workflows/web-build.yml index 46a8d1280..55094e14e 100644 --- a/.github/workflows/web-build.yml +++ b/.github/workflows/web-build.yml @@ -1,5 +1,8 @@ name: Web App Build +# Release evidence: the attested master web artifact and its independent +# verification (.agents/skills/release-maple/scripts/ci_evidence.py). Pull +# requests build the same artifact in ci.yml's research-web lane. Never cancelled. permissions: contents: read @@ -16,61 +19,11 @@ on: - "scripts/ci/web.sh" - "flake.nix" - "flake.lock" - pull_request: - # Run unprivileged validation for stacked PRs as well as master targets. - paths: - - ".github/workflows/web-build.yml" - - "apps/maple-research/frontend/**" - - "!apps/maple-research/frontend/src-tauri/**" - - "scripts/prepare-frontend-deps.sh" - - "scripts/prepare-typescript-sdk.sh" - - "scripts/ci/_common.sh" - - "scripts/ci/web.sh" - - "flake.nix" - - "flake.lock" jobs: - build-web: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('apps/maple-research/frontend/bun.lock', 'sdk/bun.lock') }} - - - name: Build web artifact - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/web.sh - env: - MAPLE_WEB_ENVIRONMENT: pr - - - name: Upload web artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 - with: - name: maple-web - path: | - apps/maple-research/frontend/src-tauri/target/reproducibility/maple-web-dist.tar.gz - apps/maple-research/frontend/src-tauri/target/reproducibility/web-final.sha256 - retention-days: 5 - build-web-master: - if: github.event_name == 'push' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read id-token: write @@ -90,13 +43,6 @@ jobs: - name: Verify pinned Nix runtime run: bash scripts/ci/verify-nix-runtime.sh - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('apps/maple-research/frontend/bun.lock', 'sdk/bun.lock') }} - - name: Build web artifact run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/web.sh env: @@ -122,9 +68,9 @@ jobs: retention-days: 5 verify-web-master-artifact: - if: github.event_name == 'push' needs: build-web-master runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read steps: diff --git a/apps/maple-research/.githooks/pre-commit b/apps/maple-research/.githooks/pre-commit index 5a0091946..5bdc587c5 100755 --- a/apps/maple-research/.githooks/pre-commit +++ b/apps/maple-research/.githooks/pre-commit @@ -2,8 +2,8 @@ # Research app pre-commit checks. Runs from the repository root inside the # root flake's ci shell (the orchestrator sets MAPLE_HOOK_SELECTED to # research_frontend and/or research_rust). -# CI equivalents: frontend-tests.yml (scripts/ci/frontend.sh) and -# rust-tests.yml (scripts/ci/rust.sh). +# CI equivalents: the ci.yml research-web lane (scripts/ci/frontend.sh) and +# research-rust lane (scripts/ci/rust.sh). set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" @@ -32,7 +32,7 @@ if hook_selected research_rust; then cd "$TAURI_DIR" || exit 1 hook_run sh -c 'cargo metadata --locked --format-version 1 >/dev/null' hook_run cargo fmt --check - # CI (rust-tests.yml) runs no Clippy for src-tauri, and `-D warnings` fails on + # CI (research-rust) runs no Clippy for src-tauri, and `-D warnings` fails on # the vendored patches/ crates, so the hook matches CI: format and tests only. hook_run ./scripts/run-with-desktop-onnxruntime.sh cargo test --locked --all-targets fi diff --git a/apps/maple-research/docs/development-contracts.md b/apps/maple-research/docs/development-contracts.md index 0d2462642..c572b3c26 100644 --- a/apps/maple-research/docs/development-contracts.md +++ b/apps/maple-research/docs/development-contracts.md @@ -31,7 +31,7 @@ checks for every affected client path. The Maple Proxy source lives under `proxy/`. From the repository root, run its Rust commands through `nix develop --no-update-lock-file ./proxy -c bash -lc 'cd proxy && ...'`; -root path-scoped workflows own proxy CI. Proxy runtime changes select desktop +the root `ci.yml` `proxy` lane owns proxy CI. Proxy runtime changes select desktop builds; SDK-only changes do not, since Research builds the SDK source its own manifests select. Container, test, documentation, and standalone lockfile changes remain independent. diff --git a/apps/maple-research/docs/research-dev-desktop.md b/apps/maple-research/docs/research-dev-desktop.md index bde214532..6c3a9f35d 100644 --- a/apps/maple-research/docs/research-dev-desktop.md +++ b/apps/maple-research/docs/research-dev-desktop.md @@ -45,17 +45,16 @@ apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev-macos.tar.gz ``` -The unprivileged Desktop App PR Build workflow runs this same script as a -separate macOS job and uploads `maple-research-dev-macos-pr` for review. That -artifact is a local-install rehearsal package, not a signed release or deployment. -The Dev job checks out the exact PR head; tie the downloaded package and its -profile hashes to that source run and checkout log when recording test evidence. - -Frontend-only changes normally skip desktop packages. A maintainer can add -`ci:research-dev-macos` to a PR to request just this Dev package immediately and -on later source updates while the label remains. Adding other labels does not -build packages or interrupt source-validation runs. Normal native-change jobs -continue to use the existing platform selectors. +CI's unprivileged `research-dev-macos` lane runs this same script on a macOS +runner and uploads `maple-research-dev-macos-pr` for review. That artifact is a +local-install rehearsal package, not a signed release or deployment. The lane +checks out the exact PR head; tie the downloaded package and its profile hashes +to that source run and checkout log when recording test evidence. + +The lane runs when the Dev profile or this script changes, or when a PR carries +`ci:research-dev-macos` (or `ci:package`). Labels are read when the run starts: +add the label, then re-run the `CI` workflow. Other native changes use the +`research-desktop-*` lanes. The different bundle ID separates the WebView's stored credentials and Tauri's app config/data directories from the production app. Dev does not run the old diff --git a/docs/pages-deployments.md b/docs/pages-deployments.md index f0f4ba015..0ba1d7e3b 100644 --- a/docs/pages-deployments.md +++ b/docs/pages-deployments.md @@ -26,7 +26,8 @@ does not establish live publisher settings or deployed state. `MAPLE_WEB_ENVIRONMENT=pr` for both PRs and master. It builds the PR head SHA, not a production-configured master artifact. A separate unprivileged checkout at the workflow/merge SHA supplies manifest tooling for older PR heads. -Path filters cover the existing web build inputs and the Pages CI files. +Path filters cover the existing web build inputs and the Pages CI files; a +newer PR head cancels an older preview build. Fork PRs retain ordinary CI but receive no hosted preview. Pushes to other branches without a qualifying PR do not get automatic previews from this path. @@ -172,7 +173,7 @@ publication requires a Research release. | Lane | Source and configuration | Result | | --- | --- | --- | -| `Auth Pages CI` | PRs targeting any base, including forks and stacked branches; relevant master pushes; optional master dispatch; `dev` profile | Publisher checks, standalone Auth checks/build; internal PR and master runs produce `maple-auth-development-RUN-ATTEMPT` with manifest profile `auth-dev` | +| `Auth Pages CI` | PRs targeting any base, including forks and stacked branches; relevant master pushes; optional master dispatch; `dev` profile | Standalone Auth checks/build; internal PR and master runs produce `maple-auth-development-RUN-ATTEMPT` with manifest profile `auth-dev` | | `Auth Pages build` | Manual dispatch on protected `master`; `release` profile | `maple-auth-production-RUN-ATTEMPT` artifact with manifest profile `auth-release` | | `Publish Auth Pages` | Manual protected-master dispatch selecting the exact successful production build run/attempt | `maple-auth` / `maple-auth.pages.dev`, ref and environment `auth-pages-production`, public URL `https://auth.maple.ai` | | `Publish Auth Dev Pages` — stable | Automatically follows successful master `Auth Pages CI`; optional manual recovery selecting run/attempt | `maple-auth-dev` / `maple-auth-dev.pages.dev`, ref and environment `auth-pages-development`, public URL `https://auth-dev.maple.ai` | @@ -180,7 +181,8 @@ publication requires a Research release. The Dev producer reuses the existing Auth CI job rather than running a second test/build lane. Push and PR path filters cover Auth source, its build scripts, -Pages tooling/workflows and shared Nix inputs. Unrelated application changes +the artifact tooling and shared Nix inputs; a newer PR head cancels an older +build. The Pages and publisher tests run in `ci.yml`'s `repo` lane. Unrelated application changes alone do not trigger it. PR checkout and manifest use the exact PR head SHA, not GitHub's synthetic merge SHA. Forks retain the unprivileged CI checks but do not produce a publishable artifact. Each publisher executes trusted master diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 1a6322e8b..7ba9711d4 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -28,7 +28,7 @@ workflows when a lane changes. desktop builds for proxy runtime inputs and native builds for Rust SDK build inputs (`sdk/rust/Cargo.toml`, `src/`, `build.rs`, `assets/`), because the proxy, the Agent and the Research native shell build both from the tree as -path dependencies; the proxy workflows' path filters list the same inputs. +path dependencies; the proxy and Research lanes in `scripts/ci/lanes.py` list the same inputs. TypeScript SDK changes never select app builds: each frontend builds the SDK source its own manifest selects, so changing that manifest (a pin bump or a local link) is what selects its lanes. Tests, docs, container-only inputs, and @@ -42,9 +42,6 @@ inputs (`plan.py only agent-desktop`), and on every manual master dispatch. Packaging-input PRs rehearse unsigned Dev packages on Linux and macOS in `ci.yml` (Windows with the `ci:package` label); signing stays confined to trusted master. -Research desktop, Android and iOS PR workflows group runs by workflow and PR. -A new run cancels earlier runs for that same unsigned preview; unrelated PRs -and signed, master and manual queues keep their existing behavior. The separate `opensecret-eif.yml` builds dev/prod EIF candidates on same-repository PRs changing measured inputs without editing PCR approvals. Each diagnostic build explicitly realizes the user ramdisk selected by its EIF @@ -116,7 +113,7 @@ runners. Signing jobs retain their protected environments. The desktop release build command only receives Apple environment variables on macOS, but that condition does not restrict the runner's job secret context: approving a signing job trusts its runner with the selected environment's secrets. PR builds have -no signing credentials and retain their existing branch-scoped caches. No +no signing credentials and only restore caches that master writes. No persistent vendor disks or Docker builders are configured by these workflows. Research Linux packaging builds AppImage and DEB through Tauri, then creates @@ -181,6 +178,24 @@ expired references must be selected again explicitly. Its result proves only historical signature interoperability, not the new updater's installed upgrade or final-source release qualification. +## Client and service lanes + +SDK, proxy, Research and service checks are `ci.yml` lanes too. `sdk-ts` and +`sdk-rust` run the publishers' own builder (`scripts/ci/build-sdk-publish.sh`); +`proxy` runs `just check`, rustdoc and `cargo machete`; `deps-research`, +`deps-sdk` and `deps-proxy` run cargo-deny advisories and bans when that +dependency graph changes; `proxy-container` builds the image when its recipe +changes, and master builds both architectures. `research-web`, `research-rust` +and `auth` run on PRs only: `frontend-tests.yml`, `web-build.yml`, +`rust-tests.yml` and `auth-pages-ci.yml` cover master, and `rust-tests.yml` +writes the `ci-v1-research-rust-` cache that `research-rust` restores. `updates` +and `agent-updates` run on every event. The unsigned Research platform builds +(`research-desktop-{macos,linux,windows}`, `research-ios`, `research-android`, +`research-dev-macos`) run on PRs that touch their native inputs or carry +`ci:package`, `ci:ios`, `ci:android`, `ci:windows` or `ci:research-dev-macos`. +They restore the caches the signed master builds save and never save one. +`proxy-rust.yml` rehearses the release's native proxy builds. + ## macOS runners and Apple toolchain Research desktop/iOS builds, Agent release-binary jobs @@ -229,12 +244,13 @@ The `ci:package` PR label builds an unsigned Dev installer without credentials. These packages are Actions downloads; Windows automatic updates and built-in computer-use remain unsupported. -The Research unsigned Windows PR package uses -`blacksmith-8vcpu-windows-2025` (Windows Server 2025, public beta). The job -records its actual capacity and Visual Studio Build Tools installation before -running the existing pinned Bun/Rust, ONNX Runtime, VC runtime DLL staging, -and NSIS build. Its cache remains restore-only, and downloaded package proofs -are checked independently on a standard GitHub runner. Signed Windows master +The Research unsigned Windows PR package (`research-desktop-windows`, on +Windows paths or the `ci:windows`/`ci:package` labels) uses +`blacksmith-8vcpu-windows-2025` (Windows Server 2025, public beta) with the +pinned Bun/Rust, ONNX Runtime, VC runtime DLL staging and NSIS build. It has no +cache: the signed master build writes its cache to GitHub's store, which +Blacksmith runners cannot read. Downloaded package proofs are checked +independently on a standard GitHub runner. Signed Windows master and release builds remain on GitHub's `windows-2025`; this pilot does not validate Azure signing, updater signing, or installed application behavior. Windows master/release artifact verifiers also use standard `ubuntu-latest` diff --git a/flake.nix b/flake.nix index 894815056..d2a08c5c0 100644 --- a/flake.nix +++ b/flake.nix @@ -789,17 +789,6 @@ touch "$out" ''; - # Built by sdk-publish-tests.yml; ci-scripts runs the same tests. - sdk-publishing = pkgs.runCommand "maple-sdk-publishing-check" { - nativeBuildInputs = with pkgs; [ mapleBash git python3 yq-go ]; - src = ./.; - } '' - cd "$src" - python3 -I -m unittest discover -s scripts/ci -p 'test_sdk_publish*.py' - python3 -I -m unittest discover -s scripts/ci -p 'test_sdk_crates_upload.py' - touch "$out" - ''; - release-metadata = pkgs.runCommand "maple-release-metadata-check" { nativeBuildInputs = commonPackages; src = ./.; diff --git a/proxy/.githooks/pre-commit b/proxy/.githooks/pre-commit index 31c3b66ce..90d22a77b 100755 --- a/proxy/.githooks/pre-commit +++ b/proxy/.githooks/pre-commit @@ -1,7 +1,7 @@ #!/bin/sh # Proxy pre-commit checks. Runs from proxy/ inside the proxy flake. -# CI equivalent: proxy-rust.yml (the proxy-rust job; the native release -# rehearsal and cargo-deny stay in CI). +# CI equivalent: the ci.yml proxy lane (the native release rehearsal in +# proxy-rust.yml and cargo-deny stay in CI). set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" diff --git a/proxy/README.md b/proxy/README.md index 23c3679db..06293a04d 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -587,9 +587,10 @@ environment: ### Docker Images & CI/CD The source lives under [`proxy/`](https://github.com/MaplePrivacyLabs/Maple/tree/master/proxy) -in the Maple repository. Root, path-scoped workflows run locked Rust checks, -supply-chain policy, and non-publishing AMD64/ARM64 container builds for proxy -changes. After every successful stable Maple Release, a separate serialized +in the Maple repository. The root `ci.yml` workflow runs locked Rust checks, +supply-chain policy, and non-publishing container builds for proxy changes +(lanes `proxy`, `deps-proxy`, `proxy-container` and, on `master`, +`proxy-container-arm64`). After every successful stable Maple Release, a separate serialized publisher compares the checked-in proxy version with the previous stable Maple Release and rejects changed container inputs without a version bump. Version `0.3.3` remains the explicit unbackfilled migration baseline. A new or missing diff --git a/scripts/ci/android-pr.sh b/scripts/ci/android-pr.sh index 99768753e..4ce0402ad 100755 --- a/scripts/ci/android-pr.sh +++ b/scripts/ci/android-pr.sh @@ -109,7 +109,8 @@ tauri.android.versionCode=${version_code} EOF cd "${FRONTEND_DIR}" -bun tauri android build --debug --apk --config '{"build":{"beforeBuildCommand":null}}' +# One ABI keeps the PR lane fast; android-build.yml builds and verifies all four on master. +bun tauri android build --debug --apk --target aarch64 --config '{"build":{"beforeBuildCommand":null}}' android_artifacts=() while IFS= read -r -d '' file; do @@ -119,7 +120,6 @@ done < <(find "${TAURI_DIR}/gen/android/app/build/outputs/apk" -type f -name '*. for artifact in "${android_artifacts[@]}"; do verify_android_apk_target_sdk "${artifact}" verify_android_artifact_native_libraries "${artifact}" - verify_android_onnxruntime_artifact "${artifact}" done repro_dir="${TAURI_DIR}/target/reproducibility" diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index 8378ededc..c7df96482 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -60,12 +60,9 @@ WORKFLOW_ROUTES = { ".github/workflows/android-build.yml": frozenset({"android"}), - ".github/workflows/android-pr-build.yml": frozenset({"android"}), ".github/workflows/app-change-detection.yml": NATIVE_PLATFORMS, ".github/workflows/desktop-build.yml": DESKTOP_PLATFORMS, - ".github/workflows/desktop-pr-build.yml": DESKTOP_PLATFORMS, ".github/workflows/mobile-build.yml": frozenset({"ios"}), - ".github/workflows/mobile-pr-build.yml": frozenset({"ios"}), } CI_SCRIPT_ROUTES = { diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 4b2c97821..6541d0853 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -131,6 +131,34 @@ class Lane: " python3 scripts/windows-release.py verify dev dist/dev --unsigned --smoke" ) +PROXY_CONTAINER = ("proxy/Dockerfile", ".dockerignore", "proxy/Cargo.lock") + PROXY_RUNTIME + RUST_SDK +SDK_CRATE = ( + 'crate="$(mktemp -d)" && ./scripts/ci/build-sdk-publish.sh rust "$crate"' + ' && python3 -I scripts/ci/sdk_crates_upload.py --directory "$crate" --validate-only --version' + ' "$(python3 -I -c \'import pathlib, tomllib; print(tomllib.loads(' + 'pathlib.Path("sdk/rust/Cargo.toml").read_text())["package"]["version"])\')"' +) + + +def deny(manifest: str, component: str) -> str: + """cargo-deny with the component's own toolchain. The Agent flake's nixpkgs pins + the cargo-deny that agent-supply-chain and nightly.yml's cargo-deny-action run.""" + return ("nix shell --no-update-lock-file --inputs-from ./apps/maple-agent nixpkgs#cargo-deny -c cargo deny" + f" --manifest-path {manifest} --config {component}/deny.toml --all-features --locked" + " check advisories bans") + + +DEPS = dict(events=NOT_NIGHTLY, runner=GITHUB, gate=False) +# Research platform builds: PR side only, never on CI edits (ci:full runs them). +PLATFORM = dict(events=PR_SIDE, gate=False) +DESKTOP = tuple(TAURI + glob for glob in ("tauri.conf.json", "build.rs", "scripts/onnxruntime-pins.sh", + "scripts/run-with-desktop-onnxruntime.sh")) + tuple( + "scripts/ci/" + name for name in ("desktop-pr.sh", "desktop-release.sh", "_common.sh", "tauri_bundle_type.py", + "verify-release-artifacts.sh", "attestation-manifest.sh")) +WINDOWS_DESKTOP = (TAURI + "tauri.windows.conf.json", TAURI + "resources/windows/**", TAURI + "scripts/*windows*", + "scripts/ci/desktop-windows-*.sh", "scripts/ci/canonical-windows-*.py") +WINDOWS_LABELS = ("ci:windows", "ci:package", "ci:full") + LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", @@ -186,52 +214,98 @@ class Lane: RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", ), runner=GITHUB, run="nix flake check --no-update-lock-file --print-build-logs", timeout=15), - # Legacy lanes. research-desktop is split by OS when it moves, and OpenSecret - # will route through opensecret_change_detection.py. - Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", - "apps/maple-agent/release-profiles.json", "apps/maple-agent/scripts/release-info.py"), - legacy="agent-updates-tests.yml"), + # The SDKs, proxy and container. build-sdk-publish.sh is the publishers' own builder. Lane("sdk-ts", SDK_TS + ("sdk/.prettierrc.json", "sdk/eslint.config.js", "sdk/justfile", "sdk/.env.example", "sdk/LICENSE.md", "scripts/ci/build-sdk-publish.sh", "scripts/ci/check-sdk-package-consumers.py"), - legacy="sdk-typescript.yml, sdk-publish-tests.yml"), + runner=GITHUB, run='./scripts/ci/build-sdk-publish.sh npm "$(mktemp -d)"'), Lane("sdk-rust", ("sdk/rust/**", "sdk/rust-toolchain.toml", "sdk/flake.*", "scripts/ci/build-sdk-publish.sh", "scripts/ci/sdk_crates_upload.py"), - legacy="sdk-rust.yml, sdk-publish-tests.yml"), - Lane("proxy", ("proxy/**", "!proxy/Dockerfile"), also=RUST_SDK, legacy="proxy-rust.yml"), - Lane("proxy-container", ("proxy/Dockerfile", ".dockerignore"), legacy="proxy-container.yml"), - Lane("deps", ("apps/maple-research/deny.toml", TAURI + "Cargo.*", "sdk/deny.toml", "sdk/rust/Cargo.*", - "proxy/deny.toml", "proxy/Cargo.*", "services/opensecret/deny.toml", - "services/opensecret/Cargo.*"), - legacy="supply-chain.yml, sdk-supply-chain.yml, proxy-supply-chain.yml, opensecret-ci.yml"), + runner=GITHUB, flake=".#pages", run=SDK_CRATE), # .#pages: a Python with tomllib + Lane("proxy", ("proxy/**", "!proxy/Dockerfile"), also=RUST_SDK, runner=GITHUB, flake="./proxy", + workdir="proxy", run='just check && RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features' + " && cargo machete", + env=(("RUSTFLAGS", "-D warnings"),)), + # PRs build the image only when its recipe changes; master builds both architectures. + Lane("proxy-container", PROXY_CONTAINER, pr_paths=("proxy/Dockerfile", ".dockerignore"), runner=GITHUB, + run="docker build --platform linux/amd64 -f proxy/Dockerfile .", timeout=20), + Lane("proxy-container-arm64", PROXY_CONTAINER, pr_paths=(), runner=Runner("ubuntu-24.04-arm"), + run="docker build --platform linux/arm64 -f proxy/Dockerfile .", timeout=20, gate=False), + # Advisories and bans when a dependency graph changes. A new advisory is drift, + # not a CI change, so CI edits skip these; nightly.yml re-checks advisories. + Lane("deps-research", ("apps/maple-research/deny.toml", TAURI + "Cargo.toml", TAURI + "Cargo.lock", + "proxy/Cargo.toml", "sdk/rust/Cargo.toml"), + run=deny(TAURI + "Cargo.toml", "apps/maple-research"), flake=".#ci", **DEPS), + Lane("deps-sdk", ("sdk/deny.toml", "sdk/rust/Cargo.toml", "sdk/rust/Cargo.lock"), + run=deny("sdk/rust/Cargo.toml", "sdk"), flake="./sdk", **DEPS), + Lane("deps-proxy", ("proxy/deny.toml", "proxy/Cargo.toml", "proxy/Cargo.lock", "sdk/rust/Cargo.toml"), + run=deny("proxy/Cargo.toml", "proxy"), flake="./proxy", **DEPS), + + # Research web and Rust on PRs; frontend-tests.yml, web-build.yml and + # rust-tests.yml verify master (rust-tests.yml also writes research-rust's cache). Lane("research-web", (RESEARCH + "**", "!" + TAURI + "**", "scripts/prepare-*.sh", "scripts/ci/frontend.sh", "scripts/ci/web.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), - legacy="frontend-tests.yml, web-build.yml"), + events=PR_SIDE, runner=GITHUB, flake=".#ci", + run="./scripts/ci/frontend.sh && MAPLE_WEB_ENVIRONMENT=pr ./scripts/ci/web.sh"), Lane("research-rust", (TAURI + "**", "!" + TAURI + "gen/**", "scripts/ci/rust.sh", "scripts/ci/verify-local-rust-deps.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), - also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), legacy="rust-tests.yml"), + also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), events=PR_SIDE, + job="research-rust", flake=".#ci", timeout=20, + run="(cd apps/maple-research/frontend/src-tauri && cargo fmt --check)" + " && ./scripts/ci/verify-local-rust-deps.sh && ./scripts/ci/rust.sh"), + # Unsigned platform builds for PRs that touch native inputs, or by label; + # desktop-build.yml, mobile-build.yml and android-build.yml build master. Lane("research-ios", (TAURI + "gen/apple/**", TAURI + "icons/ios/**", TAURI + "capabilities/mobile-ios.json", TAURI + "tauri.ios-dev.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", TAURI + "scripts/*ios*", TAURI + "scripts/canonicalize-static-archive.py", - TAURI + "scripts/onnxruntime-pins.sh", "scripts/ci/ios-*.sh", + TAURI + "scripts/onnxruntime-pins.sh", "scripts/ci/ios-*.sh", "scripts/ci/ios-build-profile.py", + "scripts/ci/canonical-ios-app-hash.py", "scripts/ci/_common.sh", "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"), - labels=("ci:ios", "ci:full"), legacy="mobile-pr-build.yml"), + labels=("ci:ios", "ci:full"), job="research-ios", runner=MACOS, flake=".#apple", + run="./scripts/ci/ios-onnxruntime.sh && ./scripts/ci/ios-pr.sh", + env=(("MAPLE_IOS_DOWNLOAD_SIMULATOR_RUNTIME", "1"),), timeout=120, **PLATFORM), Lane("research-android", (TAURI + "gen/android/**", TAURI + "icons/android/**", TAURI + "capabilities/mobile-android.json", TAURI + "scripts/*android*", - "scripts/ci/android-*.sh"), - labels=("ci:android", "ci:full"), legacy="android-pr-build.yml"), - Lane("research-desktop", (TAURI + "tauri*.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", - TAURI + "build.rs", TAURI + "resources/windows/**", TAURI + "dmg/**", - TAURI + "scripts/provide-*-onnxruntime.sh", "scripts/ci/desktop-*.sh", - "scripts/ci/_common.sh", "scripts/ci/tauri_bundle_type.py", "scripts/ci/canonical-*.py", - "scripts/ci/dmg-settings.py", "scripts/ci/verify-release-artifacts.sh", - "scripts/ci/attestation-manifest.sh", "scripts/ci/*.ps1"), - labels=("ci:package", "ci:full"), legacy="desktop-pr-build.yml"), - Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", - "scripts/ci/pages_artifact.py", "flake.lock"), - legacy="auth-pages-ci.yml"), - Lane("updates", ("services/updates/**", "services/download-tracking/**", "scripts/ci/updates.sh"), - legacy="updates-tests.yml"), + "scripts/ci/android-*.sh", "scripts/ci/_common.sh"), + labels=("ci:android", "ci:full"), flake=".#android", run="./scripts/ci/android-pr.sh", timeout=30, + **PLATFORM), + Lane("research-desktop-macos", DESKTOP + (TAURI + "tauri.macos.conf.json", TAURI + "Info.plist", + TAURI + "Entitlements.plist", TAURI + "dmg/**", TAURI + "scripts/*macos*", + "scripts/ci/dmg-settings.py", "scripts/ci/test-canonical-macho.py", + "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"), + labels=("ci:package", "ci:full"), job="research-desktop-macos", runner=MACOS, flake=".#ci", + run="./scripts/ci/desktop-pr.sh", timeout=45, **PLATFORM), + Lane("research-desktop-linux", DESKTOP + (TAURI + "scripts/*linux*",), labels=("ci:package", "ci:full"), + job="research-desktop-linux", flake=".#desktop-linux", run="./scripts/ci/desktop-pr.sh", + env=(("MAPLE_TAURI_FAKE_UPDATER_SIGNING", "1"),), timeout=45, **PLATFORM), + # Windows runs on PRs only for Windows paths or a label; its proofs are verified on Linux. + Lane("research-desktop-windows", WINDOWS_DESKTOP, labels=WINDOWS_LABELS, job="research-desktop-windows", + runner=WINDOWS, run="./scripts/ci/desktop-windows-pr.sh", timeout=60, **PLATFORM), + Lane("research-desktop-windows-verify", WINDOWS_DESKTOP, labels=WINDOWS_LABELS, + job="research-desktop-windows-verify", runner=GITHUB, flake=".#ci", + run="./scripts/ci/verify-release-artifacts.sh artifacts windows", **PLATFORM), + # The unsigned Research Dev app for OAuth rehearsal (apps/maple-research/docs/research-dev-desktop.md). + Lane("research-dev-macos", (TAURI + "desktop-dev-profile.json", TAURI + "tauri.desktop-dev.conf.json", + "scripts/ci/research-dev-desktop.sh", "scripts/ci/research-dev-profile.py"), + labels=("ci:research-dev-macos", "ci:package", "ci:full"), job="research-dev-macos", runner=MACOS, + flake=".#ci", run="./scripts/ci/research-dev-desktop.sh", timeout=45, **PLATFORM), + + # Services and the hosted Auth app. auth-pages-ci.yml builds Auth on master. + Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", "flake.nix", + "flake.lock"), + events=PR_SIDE, runner=GITHUB, flake=".#ci", run="./scripts/ci/auth-ci.sh"), + Lane("updates", ("services/updates/**", "services/download-tracking/**", "scripts/ci/updates.sh", + "scripts/ci/_common.sh", "flake.nix", "flake.lock"), + runner=GITHUB, flake=".#ci", run="./scripts/ci/updates.sh"), + Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", + "scripts/ci/test_agent_updates_workflows.py", "apps/maple-agent/release-profiles.json", + "apps/maple-agent/scripts/release-info.py", "scripts/ci/_common.sh", "flake.nix", + "flake.lock"), + runner=GITHUB, flake=".#ci", run="./scripts/ci/agent-updates.sh"), + + # Legacy lanes: OpenSecret will route through opensecret_change_detection.py. + Lane("deps-opensecret", ("services/opensecret/deny.toml", "services/opensecret/Cargo.*"), + legacy="opensecret-ci.yml"), Lane("os-rust", ("services/opensecret/**", ".gitmodules"), legacy="opensecret-ci.yml"), Lane("os-sdk-e2e", ("sdk/test/**", ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh"), also=RUST_SDK + SDK_TS, legacy="sdk-integration.yml"), diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index cc2b3ddff..130dd8caf 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -92,13 +92,6 @@ def module(name: str, path: str): TEMPORARY = { # Signed-lane hygiene: CodeQL runs on every master push, never cancelled. "evidence": frozenset({"codeql.yml"}), - # The client lanes delete the PR builds and drop the evidence twins' caches. - "reserved": frozenset({ - ("android-pr-build.yml", "build-android"), ("desktop-pr-build.yml", "build-macos"), - ("desktop-pr-build.yml", "build-research-dev-macos"), ("desktop-pr-build.yml", "build-linux"), - ("mobile-pr-build.yml", "build-ios"), ("frontend-tests.yml", "test-frontend"), - ("rust-tests.yml", "rust-tests"), ("web-build.yml", "build-web"), ("web-build.yml", "build-web-master"), - }), # Signed-lane hygiene moves id-token from the workflow level to the attesting jobs. "oidc": frozenset({("android-build.yml", "changes"), ("desktop-build.yml", "changes"), ("desktop-build.yml", "verify-desktop-artifacts")}), @@ -228,7 +221,7 @@ def test_c4_signed_lanes_own_the_release_cache_keys(self): steps = list(cache_steps()) writers = {(name, job_id) for name, job_id, step in steps if cache_writer(step) and name not in RELEASE_FEEDING and step["with"].get("key", "").startswith(RESERVED_PREFIXES)} - self.assertEqual(writers, TEMPORARY["reserved"]) + self.assertEqual(writers, set()) feeders = [step["with"]["key"] for name, _, step in steps if name in RELEASE_FEEDING - {"release.yml"} and cache_writer(step)] for job_id, job in workflows()["release.yml"]["jobs"].items(): @@ -242,7 +235,8 @@ def test_c4_signed_lanes_own_the_release_cache_keys(self): def test_c4_onnx_cache_text_matches_release_and_only_signed_lanes_write_it(self): onnx = [(name, step) for name, _, step in cache_steps() if step["with"].get("path", "").strip() == ONNX_CACHE] - self.assertLessEqual({"release.yml", "mobile-build.yml", "ios-dev-testflight.yml"}, {n for n, _ in onnx}) + self.assertLessEqual({"release.yml", "mobile-build.yml", "ios-dev-testflight.yml", "ci.yml"}, + {n for n, _ in onnx}) release = next(step["with"] for name, step in onnx if name == "release.yml") for name, step in onnx: with self.subTest(workflow=name): @@ -251,6 +245,17 @@ def test_c4_onnx_cache_text_matches_release_and_only_signed_lanes_write_it(self) if cache_writer(step): self.assertIn(name, RELEASE_FEEDING) + def test_c4_ci_restores_only_what_a_master_workflow_saves(self): + # Same key and paths as a writer that only master pushes run, so PR lanes start warm. + saved = {(step["with"]["key"], step["with"].get("path", "").strip()) for name, _, step in cache_steps() + if cache_writer(step) and triggers(workflows()[name]) <= {"push", "workflow_dispatch"}} + for name, job_id, step in cache_steps(): + if name == "ci.yml": + with self.subTest(job=job_id): + self.assertTrue(step["uses"].startswith("actions/cache/restore@")) + if not step["with"]["key"].startswith("ci-v1-agent-release-"): # the next test + self.assertIn((step["with"]["key"], step["with"]["path"].strip()), saved) + def test_c4_agent_release_cache_is_exact_and_written_only_by_master_builders(self): restores = [(name, job_id, step["with"]) for name, job_id, step in cache_steps() if step["with"].get("key", "").startswith("ci-v1-agent-release-")] diff --git a/scripts/ci/test_pages_workflows.py b/scripts/ci/test_pages_workflows.py index 4c716d726..dfcbe8d4c 100644 --- a/scripts/ci/test_pages_workflows.py +++ b/scripts/ci/test_pages_workflows.py @@ -92,8 +92,9 @@ def test_build_is_unprivileged_and_skips_forks(self): job = build["jobs"]["build-preview"] self.assertNotIn("permissions", job) self.assertNotIn("environment", job) - # Fork PRs never produce a preview artifact. + # Fork PRs and PRs into other bases (stack layers) never produce a preview artifact. self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", normalized(job["if"])) + self.assertEqual(build["on"]["pull_request"]["branches"], ["master"]) self.assertEqual( job["env"]["SOURCE_SHA"], "${{ github.event_name == 'pull_request' && " @@ -139,12 +140,10 @@ def test_preview_artifact_is_bound_to_one_run_and_attempt(self): upload["name"], "maple-pages-preview-${{ github.run_id }}-${{ github.run_attempt }}", ) + directory = "apps/maple-research/frontend/src-tauri/target/reproducibility" self.assertEqual( upload["path"].splitlines(), - [ - "${{ steps.artifact.outputs.directory }}/maple-web-dist.tar.gz", - "${{ steps.artifact.outputs.directory }}/pages-artifact.json", - ], + [f"{directory}/maple-web-dist.tar.gz", f"{directory}/pages-artifact.json"], ) self.assertEqual(upload["if-no-files-found"], "error") descriptions = [ @@ -158,7 +157,7 @@ def test_preview_artifact_is_bound_to_one_run_and_attempt(self): descriptions[0], ) for argument in ( - "--profile pr", '--sha "$SOURCE_SHA"', + f"artifact_dir={directory}", "--profile pr", '--sha "$SOURCE_SHA"', '--run-id "$GITHUB_RUN_ID"', '--run-attempt "$GITHUB_RUN_ATTEMPT"', ): self.assertIn(argument, descriptions[0]) diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index afdfdc2d2..75e98f20d 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -35,9 +35,13 @@ def owners(path): return {lane.id for lane in lanes.LANES if plan.matches(path, plan.lane_globs(lane))} +def agent_lanes(lanes_): + return {lane for lane in lanes_ if lane.startswith("agent-") and lane != "agent-updates"} + + def agent(paths, event=PR): """The decision agent_change_detection.py made: does any Agent check run?""" - return any(lane.startswith("agent-") for lane in selected(paths, event)) + return bool(agent_lanes(selected(paths, event))) class PortedAgentSelectorTests(unittest.TestCase): @@ -60,14 +64,14 @@ def test_agent_runtime_build_and_asset_inputs_select_agent(self): with self.subTest(path=path): # Dependency and packaging inputs add their own Agent lanes (AgentLaneTests). self.assertLessEqual(AGENT_PR, selected([path])) - self.assertTrue(all(lane.startswith("agent-") for lane in selected([path]))) + self.assertTrue(all(lane.startswith("agent-") for lane in selected([path]) - {"agent-updates"})) def test_rust_sdk_and_proxy_runtime_inputs_select_agent(self): for path in ("proxy/Cargo.toml", "proxy/src/proxy.rs", "proxy/build.rs", "sdk/rust/Cargo.toml", "sdk/rust/src/client.rs", "sdk/rust/build.rs", "sdk/rust/assets/aws_nitro_root.der"): with self.subTest(path=path): - self.assertEqual(selected([path]) - {"repo"}, AGENT_PR) # proxy/Cargo.toml feeds the release gates - self.assertEqual({lane for lane in selected([path], PUSH) if lane.startswith("agent-")}, AGENT_PUSH) + self.assertEqual(agent_lanes(selected([path])), AGENT_PR) + self.assertEqual(agent_lanes(selected([path], PUSH)), AGENT_PUSH) def test_research_typescript_and_independent_services_skip_agent(self): for path in ( @@ -75,7 +79,7 @@ def test_research_typescript_and_independent_services_skip_agent(self): "apps/maple-auth/vite.config.ts", "apps/maple-research/frontend/src/main.tsx", "apps/maple-research/frontend/src-tauri/src/lib.rs", "sdk/src/lib/index.ts", "sdk/package.json", "sdk/flake.nix", "services/updates/src/index.ts", "services/opensecret/src/main.rs", - ".github/workflows/desktop-pr-build.yml", "scripts/ci/desktop-pr.sh", + ".github/workflows/desktop-build.yml", "scripts/ci/desktop-pr.sh", ): with self.subTest(path=path): self.assertTrue(plan.classified(path)) @@ -95,16 +99,14 @@ def test_docs_and_standalone_dependency_inputs_skip_agent(self): self.assertFalse(agent([path], PUSH)) def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): - self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint", "repo"}) + self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint", "research-rust", "repo"}) for path in ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"): with self.subTest(path=path): - self.assertEqual(selected([path]), {"agent-macos", "repo"}) + self.assertEqual(selected([path]), {"agent-macos", "research-ios", "research-desktop-macos", "repo"}) self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "repo"}) def test_intentional_differences_select_only_the_repo_lane(self): for path in ( - # The Agent flake has no root inputs. - "flake.nix", "flake.lock", # The Research selector; the Agent no longer imports it. "scripts/ci/change_detection.py", # Signing scripts: no ci.yml Agent lane signs anything (agent-desktop-build.yml does). @@ -113,6 +115,9 @@ def test_intentional_differences_select_only_the_repo_lane(self): with self.subTest(path=path): self.assertEqual(selected([path]), {"repo"}) self.assertEqual(selected([path], PUSH), {"repo"}) + for path in ("flake.nix", "flake.lock"): # the Agent flake has no root inputs + with self.subTest(path=path): + self.assertFalse(agent([path]) or agent([path], PUSH)) def test_unknown_roots_and_malformed_paths_select_every_lane(self): for path in ("new-build-config.toml", "newroot/x", "", "/tmp/file", "../file", "docs/../flake.nix", @@ -124,7 +129,7 @@ def test_unknown_roots_and_malformed_paths_select_every_lane(self): def test_mixed_changes_and_empty_diff(self): self.assertEqual(selected([]), set()) self.assertEqual(selected(["README.md", "sdk/rust/README.md"]), set()) - self.assertEqual(selected(["README.md", "proxy/src/proxy.rs"]), AGENT_PR) + self.assertEqual(selected(["README.md", "proxy/src/proxy.rs"]), AGENT_PR | {"proxy", "research-rust"}) def test_explain_reads_null_delimited_names(self): for data, expected in ((b"README.md\0apps/maple-agent/app/assets/a\nspace name\0", "agent-lint"), @@ -137,8 +142,10 @@ def test_explain_reads_null_delimited_names(self): class LaneTableTests(unittest.TestCase): def test_typescript_sdk_never_selects_a_client_lane(self): - self.assertEqual(selected(["sdk/src/index.ts"], PUSH), set()) - self.assertFalse({o for o in owners("sdk/src/index.ts") if o.startswith(("agent-", "research-"))}) + for path in ("sdk/src/index.ts", "sdk/package.json", "sdk/bun.lock"): + with self.subTest(path=path): + self.assertIn("sdk-ts", selected([path], PUSH)) + self.assertFalse({o for o in owners(path) if o.startswith(("agent-", "research-", "proxy"))}) def test_release_metadata_and_publisher_inputs_select_the_repo_lane(self): for path in ("apps/maple-research/frontend/package.json", "apps/maple-research/frontend/src-tauri/Cargo.toml", @@ -148,9 +155,9 @@ def test_release_metadata_and_publisher_inputs_select_the_repo_lane(self): self.assertIn("repo", selected([path])) def test_rust_sdk_tests_and_proxy_container_select_no_agent_lane(self): - self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), set()) - self.assertEqual(selected(["proxy/src/main.rs"]), AGENT_PR) - self.assertEqual(selected(["proxy/Dockerfile"], PUSH), set()) + self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), {"sdk-rust"}) + self.assertEqual(agent_lanes(selected(["proxy/src/main.rs"])), AGENT_PR) + self.assertFalse(agent([".dockerignore", "proxy/Dockerfile"], PUSH)) def test_windows_clippy_runs_on_prs_only_for_windows_paths_or_labels(self): windows = "apps/maple-agent/app/src/host/windows_spawn.rs" @@ -180,10 +187,9 @@ def test_ci_infrastructure_selects_every_active_gate_lane(self): if lane.legacy is None and lane.gate}) def test_legacy_components_are_classified_but_select_nothing(self): - for path in ("services/opensecret/src/main.rs", "apps/maple-research/frontend/src/App.tsx"): - with self.subTest(path=path): - self.assertTrue(plan.classified(path)) - self.assertEqual(selected([path], PUSH), set()) + path = "services/opensecret/src/main.rs" + self.assertTrue(plan.classified(path)) + self.assertEqual(selected([path], PUSH), set()) def test_every_tracked_file_is_classified_and_ci_yml_matches(self): self.assertEqual(plan.lint_problems(plan.tracked_files()), []) @@ -216,7 +222,8 @@ def test_packaging_rehearsal_runs_on_the_narrow_selector_or_a_label(self): self.assertFalse({lane for lane in selected([path], PUSH) if lane.startswith("agent-package")}) self.assertFalse(AGENT_PACKAGE & selected(["apps/maple-agent/app/src/main.rs"])) self.assertFalse({"agent-package-windows"} & selected(["apps/maple-agent/scripts/build-release.sh"])) - self.assertEqual(selected(["docs/x.md"], labels=["ci:package"]), AGENT_PACKAGE | {"agent-package-windows"}) + self.assertEqual(agent_lanes(selected(["docs/x.md"], labels=["ci:package"])), + AGENT_PACKAGE | {"agent-package-windows"}) self.assertEqual(selected(["docs/x.md"], labels=["ci:perf"]), {"agent-perf"}) def test_full_suites_run_on_master_nightly_and_ci_full_only(self): @@ -241,6 +248,53 @@ def test_agent_desktop_selector_covers_the_signed_builds_inputs(self): self.assertFalse(plan.matches(path, lanes.AGENT_DESKTOP)) +class ClientLaneTests(unittest.TestCase): + """The lanes that replaced the SDK, proxy, Research and service workflows.""" + + def test_component_inputs_select_their_lanes(self): + for path, pr, push in ( + ("sdk/rust/tests/x.rs", {"sdk-rust"}, {"sdk-rust"}), + ("proxy/tests/health.rs", {"proxy"}, {"proxy"}), + ("proxy/Dockerfile", {"proxy-container"}, {"proxy-container", "proxy-container-arm64"}), + ("proxy/Cargo.lock", {"proxy", "deps-proxy"}, {"proxy", "deps-proxy", "proxy-container", + "proxy-container-arm64"}), + ("sdk/deny.toml", {"deps-sdk"}, {"deps-sdk"}), + ("apps/maple-research/deny.toml", {"deps-research"}, {"deps-research"}), + ("apps/maple-auth/src/main.tsx", {"auth"}, set()), + ("services/download-tracking/tracking.ts", {"updates", "agent-updates"}, {"updates", "agent-updates"}), + ("services/updates/src/index.ts", {"updates"}, {"updates"}), + ("services/agent-updates/src/index.ts", {"agent-updates"}, {"agent-updates"}), + # Master runs these in frontend-tests.yml, web-build.yml and rust-tests.yml. + ("apps/maple-research/frontend/src/App.tsx", {"research-web"}, set()), + ("apps/maple-research/frontend/src-tauri/src/lib.rs", {"research-rust"}, set()), + ): + with self.subTest(path=path): + self.assertEqual(selected([path]), pr) + self.assertEqual(selected([path], PUSH), push) + self.assertFalse({"deps-research", "deps-sdk", "deps-proxy"} & active(NIGHTLY)) # nightly.yml checks advisories + + def test_research_platform_builds_run_on_native_inputs_or_labels(self): + desktop = {"research-desktop-macos", "research-desktop-linux"} + windows = {"research-desktop-windows", "research-desktop-windows-verify"} + tauri = "apps/maple-research/frontend/src-tauri/" + for path, lanes_ in ((tauri + "gen/apple/project.yml", {"research-ios"}), + (tauri + "gen/android/app/build.gradle.kts", {"research-android"}), + (tauri + "dmg/background.tiff", {"research-desktop-macos", "research-rust"}), + (tauri + "tauri.conf.json", desktop | {"research-rust", "repo"}), + (tauri + "resources/windows/README.md", windows | {"research-rust"}), + ("scripts/ci/desktop-windows-pr.sh", windows | {"repo"}), + ("scripts/ci/research-dev-desktop.sh", {"research-dev-macos", "repo"})): + with self.subTest(path=path): + self.assertEqual(selected([path]), lanes_) + self.assertFalse({lane for lane in selected([path], PUSH) if lane.startswith("research-")}) + self.assertEqual(selected(["docs/x.md"], labels=["ci:ios"]), {"research-ios"}) + self.assertEqual(selected(["docs/x.md"], labels=["ci:windows"]), windows | {"agent-windows"}) + self.assertLessEqual(desktop | windows | {"research-dev-macos"}, + selected(["docs/x.md"], labels=["ci:package"])) + self.assertFalse({lane for lane in selected([".github/workflows/ci.yml"]) if lane.startswith( + ("research-ios", "research-android", "research-desktop", "research-dev"))}) + + class PlannerTests(unittest.TestCase): def github(self, event, payload, **env): with tempfile.TemporaryDirectory() as temporary: @@ -287,12 +341,15 @@ def test_outputs_empty_matrix_and_runner_overrides(self): with mock.patch.dict(os.environ, overrides): result = plan.outputs(dict.fromkeys(ACTIVE, ["x"])) runners = {entry["id"]: entry["runner"] for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])} - linux, mac = "blacksmith-8vcpu-ubuntu-2404", "blacksmith-6vcpu-macos-26" + linux, mac, free = "blacksmith-8vcpu-ubuntu-2404", "blacksmith-6vcpu-macos-26", "ubuntu-latest" self.assertEqual(runners, { "agent-lint": linux, "agent-test": "blacksmith-16vcpu-ubuntu-2404", "agent-macos": mac, "agent-windows": "windows-2025", "agent-full-linux": linux, "agent-full-macos": mac, "agent-full-windows": "windows-2025", "agent-supply-chain": linux, "agent-provider": linux, - "agent-perf": linux, "repo": "ubuntu-latest"}) + "agent-perf": linux, "repo": free, "sdk-ts": free, "sdk-rust": free, "proxy": free, + "proxy-container": free, "proxy-container-arm64": "ubuntu-24.04-arm", "deps-research": free, + "deps-sdk": free, "deps-proxy": free, "research-web": free, "research-android": linux, "auth": free, + "updates": free, "agent-updates": free}) entries = [entry for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])] self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index e19814cc3..61f11019f 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -74,24 +74,10 @@ ("mobile-build.yml", "submit-ios-testflight"), *(("signing-credentials-check.yml", job) for job in ( "desktop-signing", "apple-signing", "android-signing", "windows-signing", "zapstore-publishing")), - ("web-build.yml", "build-web"), ("web-build.yml", "build-web-master"), - ("web-build.yml", "verify-web-master-artifact"), - # Deleted, or made push-only, by the client lanes. - ("android-pr-build.yml", "build-android"), ("mobile-pr-build.yml", "build-ios"), - *(("desktop-pr-build.yml", job) for job in ( - "build-macos", "build-research-dev-macos", "build-linux", "build-windows", "verify-windows-artifacts")), - ("frontend-tests.yml", "test-frontend"), ("rust-tests.yml", "rust-tests"), }), "permissions": frozenset({"desktop-build.yml", "android-build.yml"}), - # The client lanes delete these PR workflows or make them push-only. - "pr-cache-writes": frozenset({ - ("android-pr-build.yml", "build-android"), ("desktop-pr-build.yml", "build-macos"), - ("desktop-pr-build.yml", "build-research-dev-macos"), ("desktop-pr-build.yml", "build-linux"), - ("mobile-pr-build.yml", "build-ios"), ("frontend-tests.yml", "test-frontend"), - ("rust-tests.yml", "rust-tests"), ("web-build.yml", "build-web"), ("proxy-rust.yml", "proxy-rust"), - ("proxy-rust.yml", "proxy-native-release"), ("sdk-rust.yml", "sdk-rust"), - ("sdk-typescript.yml", "sdk-typescript"), ("updates-tests.yml", "test-updates-worker"), - }), + # The OpenSecret lanes replace this workflow. + "github-cache": frozenset({("opensecret-ci.yml", "rust")}), } @@ -267,7 +253,6 @@ def test_9_runner_labels_are_github_hosted_or_declared(self): self.assertTrue(GITHUB_HOSTED.fullmatch(label) or label in declared, label) def test_pull_requests_never_save_caches_or_read_other_runs_artifacts(self): - writers = set() for name, config in workflows().items(): pr = bool({"pull_request", "merge_group"} & triggers(config)) for job_id, job in config["jobs"].items(): @@ -275,11 +260,20 @@ def test_pull_requests_never_save_caches_or_read_other_runs_artifacts(self): self.assertNotIn("run-id", step.get("with", {}), (name, job_id)) guard = " ".join(str(value) for value in ( job.get("if", ""), step.get("if", ""), step.get("with", {}).get("save-if", ""))) - if pr and cache_writer(step) and not re.search( - r"refs/heads/master|github\.event_name == 'push'", guard): - writers.add((name, job_id)) - self.assertEqual(writers, TEMPORARY["pr-cache-writes"]) + if pr and cache_writer(step): + self.assertRegex(guard, r"refs/heads/master|github\.event_name == 'push'", (name, job_id)) + def test_only_windows_signing_fills_githubs_cache_store(self): + # GitHub's cache store is small; heavy caches live on Blacksmith, whose + # runners cannot read GitHub's. The release and the Nix installer are the other users. + writers = set() + for name, job_id, job in jobs(): + for step in job.get("steps", []): + if (name not in SACRED and cache_writer(step) + and "startsWith(matrix.runner, 'blacksmith-')" not in str(step["with"].get("save-if")) + and any(GITHUB_HOSTED.fullmatch(label) for label in runner_labels(name, job_id, job))): + writers.add((name, job_id)) + self.assertEqual(writers, {("desktop-build.yml", "build-windows")} | TEMPORARY["github-cache"]) if __name__ == "__main__": unittest.main() diff --git a/sdk/.githooks/pre-commit b/sdk/.githooks/pre-commit index 84c5b8895..e8d22179b 100755 --- a/sdk/.githooks/pre-commit +++ b/sdk/.githooks/pre-commit @@ -1,9 +1,9 @@ #!/bin/sh # SDK pre-commit checks. Runs from sdk/ inside the SDK flake; the orchestrator # sets MAPLE_HOOK_SELECTED to sdk_rust and/or sdk_ts. -# CI equivalents: sdk-rust.yml and sdk-typescript.yml. Integration suites -# (sdk-integration.yml, sdk/src/lib/test/integration/, sdk/rust/tests/) need a -# live backend and never run here. +# CI equivalents: the ci.yml sdk-rust and sdk-ts lanes (scripts/ci/build-sdk-publish.sh). +# Integration suites (sdk-integration.yml, sdk/src/lib/test/integration/, +# sdk/rust/tests/) need a live backend and never run here. set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" diff --git a/sdk/README.md b/sdk/README.md index b524e4f96..5026ad892 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -17,8 +17,9 @@ and tests. platform client. - `rust/` — the `maple-sdk` crate, imported as `maple_sdk` by native clients. - `docs/PLATFORM.md` — internal developer/platform API notes. -- repository-root `.github/workflows/sdk-*.yml` — path-scoped TypeScript, Rust, - and supply-chain validation for this directory. +- the `sdk-ts`, `sdk-rust`, and `deps-sdk` lanes in the root + `scripts/ci/lanes.py` — TypeScript, Rust, and supply-chain validation for + this directory, run by the root `ci.yml` workflow. Maple's own Rust consumers (`proxy`, `apps/maple-agent`, the Research native shell) build the in-tree Rust SDK through `path` dependencies, so SDK changes @@ -247,7 +248,8 @@ bun run build ``` For tests without a configured backend, use the credential-free selection in -the root [`sdk-typescript.yml`](../.github/workflows/sdk-typescript.yml) workflow. +[`build-sdk-publish.sh`](../scripts/ci/build-sdk-publish.sh), which CI runs as +the `sdk-ts` lane (`just lane sdk-ts` from the repository root). An unfiltered `bun test` also collects integration tests and requires the fixtures described below. diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 6c5ffca6a..dcbd8360d 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -381,8 +381,8 @@ nix develop --no-update-lock-file -c cargo test --locked \ These tests need no existing backend or account credentials. The full format, Clippy, library-test, and rustdoc gates are in the -[SDK development instructions](../README.md#rust-sdk) and -[`sdk-rust.yml`](../../.github/workflows/sdk-rust.yml). +[SDK development instructions](../README.md#rust-sdk) and CI's `sdk-rust` lane +(`just lane sdk-rust` from the repository root). Integration tests are a separate selection. Follow [`sdk-integration.yml`](../../.github/workflows/sdk-integration.yml) for its diff --git a/services/updates/.githooks/pre-commit b/services/updates/.githooks/pre-commit index c90314ed7..ae4f88949 100755 --- a/services/updates/.githooks/pre-commit +++ b/services/updates/.githooks/pre-commit @@ -1,7 +1,7 @@ #!/bin/sh # Updates Worker pre-commit checks. Runs from the repository root inside the # root flake's ci shell. -# CI equivalent: updates-tests.yml (`bun run check`), minus the wrangler +# CI equivalent: the ci.yml updates lane (`bun run check`), minus the wrangler # dry-run deploy. set -u From 4184bdf7a3f72144711875ee7b67477c2eb77b4e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:38:43 +0000 Subject: [PATCH 05/21] ci: run OpenSecret checks and the PR EIF candidate from ci.yml lanes Co-Authored-By: Claude Opus 5.5 --- .../skills/develop-opensecret-sdk/SKILL.md | 10 +- .../review-opensecret-security/SKILL.md | 4 +- .../validate-opensecret/references/checks.md | 14 +- .github/workflows/ci.yml | 130 ++++++++++++- .github/workflows/nightly.yml | 21 +- .../workflows/opensecret-change-detection.yml | 116 ----------- .github/workflows/opensecret-ci.yml | 181 ------------------ .github/workflows/opensecret-eif.yml | 88 +++------ .github/workflows/sdk-integration.yml | 162 ---------------- docs/opensecret-import.md | 10 +- docs/repository-workflows.md | 21 +- scripts/ci/lanes.py | 54 ++++-- scripts/ci/opensecret-helpers.sh | 30 +++ scripts/ci/opensecret-sdk-e2e.sh | 112 +++++++++++ scripts/ci/opensecret_change_detection.py | 8 + scripts/ci/plan.py | 87 +++++++-- scripts/ci/test_contracts.py | 65 +++++-- .../ci/test_opensecret_change_detection.py | 34 ++-- scripts/ci/test_plan.py | 95 ++++++--- scripts/ci/test_workflow_policy.py | 4 +- sdk/.githooks/pre-commit | 2 +- sdk/README.md | 8 +- sdk/rust/README.md | 4 +- services/opensecret/.githooks/pre-commit | 6 +- services/opensecret/README.md | 10 +- services/opensecret/docs/nitro-deploy.md | 2 +- 26 files changed, 637 insertions(+), 641 deletions(-) delete mode 100644 .github/workflows/opensecret-change-detection.yml delete mode 100644 .github/workflows/opensecret-ci.yml delete mode 100644 .github/workflows/sdk-integration.yml create mode 100755 scripts/ci/opensecret-helpers.sh create mode 100755 scripts/ci/opensecret-sdk-e2e.sh diff --git a/.agents/skills/develop-opensecret-sdk/SKILL.md b/.agents/skills/develop-opensecret-sdk/SKILL.md index 075142d21..6154cb5f0 100644 --- a/.agents/skills/develop-opensecret-sdk/SKILL.md +++ b/.agents/skills/develop-opensecret-sdk/SKILL.md @@ -61,7 +61,8 @@ nix develop --no-update-lock-file -c bun run build For tests without service fixtures, use the credential-free selection in `scripts/ci/build-sdk-publish.sh`. An unfiltered `bun test` also collects integration tests that require a configured backend and test identities; use -`sdk-integration.yml` for that complete local-stack setup. +`scripts/ci/opensecret-sdk-e2e.sh` (the `os-sdk-e2e` lane) for that complete +local-stack setup. For Rust work: @@ -84,9 +85,10 @@ when SDK files are staged: the Rust format, Clippy, `--lib` tests, and docs, and the TypeScript format, build, and top-level unit tests. It never runs the integration suites or cargo-deny. When the change reaches backend behavior, authentication, or the encrypted wire -contract, also match `sdk-integration.yml`. It starts disposable PostgreSQL and -the in-tree `services/opensecret/` backend from the same checkout, then tests -both SDK implementations without a hosted development server. Inspect backend +contract, also match the `os-sdk-e2e` lane (`scripts/ci/opensecret-sdk-e2e.sh`). +It migrates a disposable PostgreSQL, starts the in-tree `services/opensecret/` +backend from the same checkout, then tests both SDK implementations without a +hosted development server. Inspect backend changes and run its component validation when the compatibility contract reaches them; there is no separate integration revision to advance. diff --git a/.agents/skills/review-opensecret-security/SKILL.md b/.agents/skills/review-opensecret-security/SKILL.md index 5db750451..fd5bc2531 100644 --- a/.agents/skills/review-opensecret-security/SKILL.md +++ b/.agents/skills/review-opensecret-security/SKILL.md @@ -132,8 +132,8 @@ Load `$validate-opensecret` and run the tiers reached by the diff. Report local, database, provider, client, build/artifact, and live evidence separately. Local artifact builds and read-only PCR comparison are validation when in -scope. Root backend CI validates Rust, Nix checks/default binary, and SDK -compatibility. A separate ARM64 workflow compares dev/prod EIF +scope. Root backend CI validates Rust, Nix checks, and SDK compatibility; the +nightly workflow builds the default binary. A separate ARM64 workflow compares dev/prod EIF measurements on explicit approved-PCR JSON edits in PRs, relevant master changes, and manual runs. Do not require ordinary backend PRs to update approvals, and do not suppress meaningful master mismatches. Master push/manual diff --git a/.agents/skills/validate-opensecret/references/checks.md b/.agents/skills/validate-opensecret/references/checks.md index 3f7f3fe98..ddd10702b 100644 --- a/.agents/skills/validate-opensecret/references/checks.md +++ b/.agents/skills/validate-opensecret/references/checks.md @@ -21,8 +21,8 @@ not completion evidence. ## Tier 1: reproduce Rust CI Initialize submodules when building or testing, then run the exact inner gates -from monorepo-root `.github/workflows/opensecret-ci.yml` through the pinned, -side-effect-disabled component Nix environment: +of the `os-rust` lane (`scripts/ci/lanes.py`; `just lane os-rust` from the +monorepo root) through the pinned, side-effect-disabled component Nix environment: ```sh git -C ../.. submodule update --init --recursive -- services/opensecret/privatemode-public @@ -134,9 +134,9 @@ manifest and lockfile select its SDK version and source; published pins are the default, with local links supported under the [consumer version policy](../../../../docs/sdk-publishing.md#consumer-version-policy). Verify that selection before using an application build as evidence for an SDK -source edit. Root `sdk-integration.yml` runs both in-tree SDKs against -the backend in the same checkout with disposable PostgreSQL and loopback -configuration. That deterministic gate does not prove an application or live +source edit. The root `os-sdk-e2e` lane (`scripts/ci/opensecret-sdk-e2e.sh`) +runs both in-tree SDKs against the backend in the same checkout with disposable +PostgreSQL and loopback configuration. That deterministic gate does not prove an application or live provider flow. Test browser Research and affected native paths independently; an unavailable client runtime leaves that layer unverified. @@ -156,8 +156,8 @@ nix build --no-link --no-write-lock-file '.?submodules=1#default' PCR reference/history updates remain operator-controlled release work. Read-only EIF construction and PCR comparison are validation when in scope. -Root backend CI runs applicable Nix checks and builds the -default backend binary. The separate root EIF approval workflow builds dev/prod +Root backend CI runs applicable Nix checks, and the nightly workflow builds +the default backend binary. The separate root EIF approval workflow builds dev/prod and compares generated measurements on PRs that explicitly edit the four approved PCR JSON files, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not require new PCR approvals; master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 194243428..d4e9f8f37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,9 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + # OpenSecret's flake reads its submodule, which Nix fetches only with full history. + submodules: ${{ matrix.submodules }} + fetch-depth: ${{ matrix.fetch_depth }} - uses: ./.github/actions/setup-nix - name: Select pinned Xcode if: runner.os == 'macOS' @@ -455,12 +458,137 @@ jobs: if-no-files-found: error retention-days: 5 + # Both SDKs against the in-tree OpenSecret backend and a disposable PostgreSQL. + os-sdk-e2e: + name: os-sdk-e2e + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['os-sdk-e2e'] + runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + timeout-minutes: 45 + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: opensecret + POSTGRES_PASSWORD: password + POSTGRES_USER: opensecret_user + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U opensecret_user -d opensecret" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: recursive + # Nix requires revCount for recursively fetched Git inputs. + fetch-depth: 0 + - uses: ./.github/actions/setup-nix + - name: Migrate, start OpenSecret and run both SDKs' integration suites + run: python3 scripts/ci/plan.py run os-sdk-e2e + + # OpenSecret's parent-side helper images (they run beside the enclave, not in + # the EIF): built for ARM64 and tested offline, never published, then scanned. + os-helpers: + name: os-helpers (${{ matrix.helper }}) + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['os-helpers'] + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + helper: [credential-requester, enclave-logging] + env: + HELPER: ${{ matrix.helper }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Build the Linux ARM64 image and run its tests offline + run: python3 scripts/ci/plan.py run os-helpers + - name: Scan finished image for fixable high and critical vulnerabilities + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: v0.75.0 + image-ref: ${{ matrix.helper }}:ci + format: table + exit-code: '1' + ignore-unfixed: true + severity: HIGH,CRITICAL + scanners: vuln + cache: "false" # pull requests save no caches + + # The PR EIF candidate that opensecret-eif.yml built, with its runner, + # permissions and steps unchanged. The planner selects it only for + # same-repository PRs whose diff has EIF inputs and edits no PCR approval + # (opensecret-eif.yml compares approval edits); because it mints an OIDC token + # for the FlakeHub cache, the event and head repository are checked here too. + os-eif: + name: os-eif (${{ matrix.mode }}) + needs: plan + if: >- + ${{ + fromJSON(needs.plan.outputs.lanes)['os-eif'] && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + }} + permissions: + contents: read + id-token: write + runs-on: blacksmith-8vcpu-ubuntu-2404-arm + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + mode: [dev, prod] + env: + EIF_MODE: ${{ matrix.mode }} + OPENSECRET_DEV_POSTGRES: "0" + OPENSECRET_DEV_ENV: "0" + OPENSECRET_DEV_CONTAINERS: "0" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: recursive + fetch-depth: 0 + + - name: Install pinned Determinate Nix for FlakeHub authentication + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + + - name: Reuse FlakeHub with an Actions cache fallback + uses: DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea # v3 + with: + diff-store: true + + # No Actions artifact, attestation, signing, or release is produced here. + # The Nix cache is only a build cache, not an approved deployment handoff. + - name: Build PR candidate without signing or publication + shell: bash + run: | + echo 'PR diagnostic only: source_sha is the PR merge ref, not a release source commit. No approved artifact is uploaded.' >> "$GITHUB_STEP_SUMMARY" + bash scripts/ci/build_opensecret_eif.sh "$EIF_MODE" "$RUNNER_TEMP/eif-candidate-$EIF_MODE" + + - name: Inspect exact PR candidate user ramdisk CA bundle + shell: bash + run: python3 scripts/ci/verify_opensecret_eif_rootfs.py "$EIF_MODE" "$RUNNER_TEMP/eif-candidate-$EIF_MODE" "$GITHUB_SHA" + ci-ok: name: ci-ok if: always() needs: [plan, nix, windows, agent-package-linux, agent-package-macos, agent-package-windows, research-rust, research-ios, research-desktop-macos, research-desktop-linux, research-desktop-windows, - research-desktop-windows-verify, research-dev-macos] + research-desktop-windows-verify, research-dev-macos, os-sdk-e2e, os-helpers, os-eif] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9af2fb95c..d2410e7e2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,5 +1,6 @@ # Nightly checks of what drifts without a commit: advisories, pinned upstream -# sources, the SQLCipher provider suite on every OS and the store benchmark. +# sources, the SQLCipher provider suite on every OS and the store benchmark, +# plus OpenSecret's x86_64 package, which no pull request builds. # One `ci-nightly` issue reports failures here and in ci.yml's scheduled run; # a green night closes it. Nothing here gates a pull request. name: Nightly @@ -131,9 +132,25 @@ jobs: if-no-files-found: warn retention-days: 14 + opensecret-package: + name: OpenSecret x86_64 package + # The shipped EIF is aarch64 (ci.yml's os-eif); nothing consumes this build. + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: recursive + # Nix requires revCount for recursively fetched Git inputs. + fetch-depth: 0 + - uses: ./.github/actions/setup-nix + - name: Build the application package + run: nix build --no-link --no-update-lock-file './services/opensecret?submodules=1#default' + report: name: Report - needs: [advisories, agent-supply-chain, bun-audit, agent-provider, store-bench] + needs: [advisories, agent-supply-chain, bun-audit, agent-provider, store-bench, opensecret-package] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/opensecret-change-detection.yml b/.github/workflows/opensecret-change-detection.yml deleted file mode 100644 index 34894c949..000000000 --- a/.github/workflows/opensecret-change-detection.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Select OpenSecret checks - -on: - workflow_call: - outputs: - rust: - description: Whether backend Rust checks are needed - value: ${{ jobs.detect.outputs.rust }} - nix: - description: Whether backend Nix checks are needed - value: ${{ jobs.detect.outputs.nix }} - integration: - description: Whether SDK and backend compatibility checks are needed - value: ${{ jobs.detect.outputs.integration }} - audit: - description: Whether backend dependency auditing is needed - value: ${{ jobs.detect.outputs.audit }} - eif: - description: Whether EIF build inputs or approval references changed - value: ${{ jobs.detect.outputs.eif }} - helpers: - description: Whether parent-side Nitro helper images changed - value: ${{ jobs.detect.outputs.helpers }} - pcr_approvals: - description: Whether the diff explicitly changes an approved dev/prod PCR JSON file - value: ${{ jobs.detect.outputs.pcr_approvals }} - -permissions: - contents: read - -jobs: - detect: - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - rust: ${{ steps.classify.outputs.rust }} - nix: ${{ steps.classify.outputs.nix }} - integration: ${{ steps.classify.outputs.integration }} - audit: ${{ steps.classify.outputs.audit }} - eif: ${{ steps.classify.outputs.eif }} - helpers: ${{ steps.classify.outputs.helpers }} - pcr_approvals: ${{ steps.classify.outputs.pcr_approvals }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Classify changed paths - id: classify - env: - BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - run: | - set -euo pipefail - enable_all() { - for output in rust nix integration audit eif helpers; do - printf '%s=true\n' "$output" >> "$GITHUB_OUTPUT" - done - # Unknown/manual input must not masquerade as an approval edit. - printf 'pcr_approvals=false\n' >> "$GITHUB_OUTPUT" - } - selection_failed() { - if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then - echo "::error::Cannot determine the PR's approved-PCR changes; refusing an ambiguous approval check." - exit 1 - fi - enable_all - } - case "$GITHUB_EVENT_NAME" in - pull_request) separator='...' ;; - push) - separator='..' - if [[ "$BASE_SHA" =~ ^0+$ ]]; then - enable_all - exit 0 - fi - ;; - schedule) - # Scheduled advisories change independently of repository files. - printf 'rust=false\nnix=false\nintegration=false\naudit=true\neif=false\nhelpers=false\npcr_approvals=false\n' >> "$GITHUB_OUTPUT" - exit 0 - ;; - *) enable_all; exit 0 ;; - esac - if ! git cat-file -e "${BASE_SHA}^{commit}" || ! git cat-file -e "${HEAD_SHA}^{commit}"; then - selection_failed - exit 0 - fi - changed_paths="$(mktemp)" - selected_checks="$(mktemp)" - trap 'rm -f "$changed_paths" "$selected_checks"' EXIT - if ! git diff --no-renames --name-only -z "${BASE_SHA}${separator}${HEAD_SHA}" > "$changed_paths"; then - selection_failed - elif ! python3 scripts/ci/opensecret_change_detection.py < "$changed_paths" > "$selected_checks"; then - selection_failed - elif ! python3 - "$selected_checks" <<'PY' - from pathlib import Path - import sys - from scripts.ci.opensecret_change_detection import OUTPUTS - - lines = Path(sys.argv[1]).read_text().splitlines() - expected = set(OUTPUTS) - keys = [line.partition("=")[0] for line in lines] - valid = ( - len(lines) == len(expected) - and set(keys) == expected - and all(line.partition("=")[2] in {"true", "false"} for line in lines) - ) - sys.exit(0 if valid else 1) - PY - then - selection_failed - else - cat "$selected_checks" >> "$GITHUB_OUTPUT" - fi diff --git a/.github/workflows/opensecret-ci.yml b/.github/workflows/opensecret-ci.yml deleted file mode 100644 index c40281cfc..000000000 --- a/.github/workflows/opensecret-ci.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: OpenSecret backend CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - schedule: - - cron: "17 6 * * *" - workflow_dispatch: - -# Fork PRs run only on fresh hosted runners with read-only repository access. -# This workflow never signs PCRs, builds/publishes an EIF, or deploys a service. -permissions: - contents: read - -concurrency: - group: opensecret-ci-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: -D warnings - OPENSECRET_DEV_POSTGRES: "0" - OPENSECRET_DEV_ENV: "0" - OPENSECRET_DEV_CONTAINERS: "0" - -defaults: - run: - shell: bash - working-directory: services/opensecret - -jobs: - changes: - uses: ./.github/workflows/opensecret-change-detection.yml - - rust: - name: OpenSecret format, lint, and tests - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.rust != 'false') }} - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: recursive - # Nix requires revCount for recursively fetched Git inputs. - fetch-depth: 0 - - - name: Install pinned Nix environment - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Cache backend Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: services/opensecret -> target - add-rust-environment-hash-key: "false" - key: opensecret-${{ runner.os }}-${{ hashFiles('services/opensecret/Cargo.lock', 'services/opensecret/rust-toolchain.toml', 'services/opensecret/flake.lock') }} - save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} - - - name: Check formatting - run: nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo fmt --all -- --check - - - name: Lint all backend targets and features - run: nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo clippy --locked --all-targets --all-features -- -D warnings - - - name: Run backend tests - run: nix develop --no-update-lock-file '.?submodules=1#ci' -c cargo test --locked --all-features - - nix: - name: OpenSecret Nix checks and application package - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.nix != 'false') }} - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: recursive - # Nix requires revCount for recursively fetched Git inputs. - fetch-depth: 0 - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - working-directory: . - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Check the component flake on this host - run: nix flake check --no-update-lock-file --print-build-logs '.?submodules=1' - - - name: Build the application package - run: nix build --no-link --no-update-lock-file '.?submodules=1#default' - - audit: - name: OpenSecret RustSec advisories and malicious crates - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.audit != 'false') }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Check inherited OpenSecret advisory and incident policy - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 - with: - rust-version: 1.91.1 - manifest-path: services/opensecret/Cargo.toml - command: check advisories bans - arguments: --config services/opensecret/deny.toml --all-features --locked - - helpers: - name: Parent helper image (${{ matrix.name }}) - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.helpers != 'false') }} - runs-on: ubuntu-24.04-arm - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - name: credential-requester - context: nitro-toolkit/credential_requester - - name: enclave-logging - context: nitro-toolkit/logging - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - # Build and exercise the deployable parent-side image without publishing it. - # Synthetic tests do not need AWS credentials, IMDS, or a VSOCK device. - - name: Build Linux ARM64 image - env: - IMAGE: ${{ matrix.name }}:ci - CONTEXT: ${{ matrix.context }} - run: docker build --platform linux/arm64 --tag "$IMAGE" "$CONTEXT" - - - name: Check image architecture and runtime packages - env: - IMAGE: ${{ matrix.name }}:ci - run: | - test "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMAGE")" = linux/arm64 - docker run --rm --network none --entrypoint python "$IMAGE" -c \ - 'import boto3, botocore, urllib3, importlib.util; assert importlib.util.find_spec("pip") is None; assert importlib.util.find_spec("ensurepip") is None' - - - name: Run offline helper tests inside the image - env: - IMAGE: ${{ matrix.name }}:ci - CONTEXT: ${{ matrix.context }} - run: | - docker run --rm --network none \ - --mount "type=bind,src=$PWD/$CONTEXT,dst=/tests,readonly" \ - --entrypoint python "$IMAGE" \ - -B -m unittest discover -s /tests -p 'test_*.py' -v - - - name: Scan finished image for fixable high and critical vulnerabilities - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - version: v0.75.0 - image-ref: ${{ matrix.name }}:ci - format: table - exit-code: '1' - ignore-unfixed: true - severity: HIGH,CRITICAL - scanners: vuln diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml index f9fc6827a..e66dd0238 100644 --- a/.github/workflows/opensecret-eif.yml +++ b/.github/workflows/opensecret-eif.yml @@ -5,6 +5,15 @@ on: branches: [master] pull_request: branches: [master] + # PRs compare measurements only when they edit an approval file; ci.yml's + # os-eif lane builds the other EIF candidates. The selector's own files + # also run this workflow, so a change to them is proven before master. + paths: + - services/opensecret/** + - .github/workflows/opensecret-eif.yml + - scripts/ci/opensecret_change_detection.py + - scripts/ci/plan.py + - scripts/ci/lanes.py workflow_dispatch: # Cache writes are not PCR approval, release publication, or deployment. @@ -13,73 +22,34 @@ permissions: contents: read concurrency: - group: opensecret-eif-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true + # PRs replace superseded runs; master and manual runs are grouped per SHA and + # never cancelled, so every master commit gets its approval verdict. + group: opensecret-eif-${{ github.event_name }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # opensecret_change_detection.py's outputs for this diff. A PR whose diff + # cannot be classified fails here rather than guessing an approval edit. changes: - uses: ./.github/workflows/opensecret-change-detection.yml - - eif-candidate: - name: EIF candidate build (PR, ${{ matrix.mode }}) - needs: changes - # Build measured-input changes before merge without requiring or changing - # PCR approvals. Only same-repository PRs may use the trusted cache. - if: >- - ${{ - always() && !cancelled() && - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - needs.changes.result == 'success' && - needs.changes.outputs.eif == 'true' && - needs.changes.outputs.pcr_approvals == 'false' - }} - permissions: - contents: read - id-token: write - runs-on: blacksmith-8vcpu-ubuntu-2404-arm - timeout-minutes: 180 - strategy: - fail-fast: false - matrix: - mode: [dev, prod] - env: - EIF_MODE: ${{ matrix.mode }} - OPENSECRET_DEV_POSTGRES: "0" - OPENSECRET_DEV_ENV: "0" - OPENSECRET_DEV_CONTAINERS: "0" + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + rust: ${{ steps.classify.outputs.rust }} + nix: ${{ steps.classify.outputs.nix }} + integration: ${{ steps.classify.outputs.integration }} + audit: ${{ steps.classify.outputs.audit }} + eif: ${{ steps.classify.outputs.eif }} + helpers: ${{ steps.classify.outputs.helpers }} + pcr_approvals: ${{ steps.classify.outputs.pcr_approvals }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - persist-credentials: false - submodules: recursive fetch-depth: 0 + persist-credentials: false - - name: Install pinned Determinate Nix for FlakeHub authentication - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Reuse FlakeHub with an Actions cache fallback - uses: DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea # v3 - with: - diff-store: true - - # No Actions artifact, attestation, signing, or release is produced here. - # The Nix cache is only a build cache, not an approved deployment handoff. - - name: Build PR candidate without signing or publication - shell: bash - run: | - echo 'PR diagnostic only: source_sha is the PR merge ref, not a release source commit. No approved artifact is uploaded.' >> "$GITHUB_STEP_SUMMARY" - bash scripts/ci/build_opensecret_eif.sh "$EIF_MODE" "$RUNNER_TEMP/eif-candidate-$EIF_MODE" - - - name: Inspect exact PR candidate user ramdisk CA bundle - shell: bash - run: python3 scripts/ci/verify_opensecret_eif_rootfs.py "$EIF_MODE" "$RUNNER_TEMP/eif-candidate-$EIF_MODE" "$GITHUB_SHA" + - name: Classify changed paths + id: classify + run: python3 scripts/ci/plan.py opensecret eif: name: EIF/PCR approval match (${{ matrix.mode }}) diff --git a/.github/workflows/sdk-integration.yml b/.github/workflows/sdk-integration.yml deleted file mode 100644 index 4c3624ebe..000000000 --- a/.github/workflows/sdk-integration.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: SDK OpenSecret integration CI - -permissions: - contents: read - -on: - push: - branches: [master] - pull_request: - branches: [master] - workflow_dispatch: - -concurrency: - group: sdk-integration-${{ github.ref }} - cancel-in-progress: true - -env: - APP_MODE: local - DATABASE_URL: postgres://opensecret_user:password@127.0.0.1:5432/opensecret - ENCLAVE_SECRET_MOCK: "1111111111111111111111111111111111111111111111111111111111111111" - JWT_SECRET: "2222222222222222222222222222222222222222222222222222222222222222" - OPENAI_API_BASE: http://127.0.0.1:9 - OPENSECRET_BIND_ADDR: 127.0.0.1:3000 - OPENSECRET_DEV_CONTAINERS: "0" - OPENSECRET_DEV_DATABASE_URL: postgres://opensecret_user:password@127.0.0.1:5432/opensecret - OPENSECRET_DEV_ENV: "0" - OPENSECRET_DEV_POSTGRES: "0" - RUST_LOG: opensecret=info - TINFOIL_API_KEY: sdk-integration-placeholder - VITE_OPEN_SECRET_API_URL: http://127.0.0.1:3000 - VITE_OPEN_SECRET_PCR_ENVIRONMENT: development - VITE_TEST_CLIENT_ID: ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 - VITE_TEST_DEVELOPER_EMAIL: sdk-ci-developer@local.test - VITE_TEST_DEVELOPER_INVITE_CODE: 11111111-1111-4111-8111-111111111111 - VITE_TEST_DEVELOPER_NAME: SDK CI Developer - VITE_TEST_DEVELOPER_PASSWORD: Maple-sdk-ci-test-1234 - VITE_TEST_EMAIL: sdk-ci-user@local.test - VITE_TEST_NAME: SDK CI User - VITE_TEST_PASSWORD: Maple-sdk-ci-test-1234 - -jobs: - changes: - uses: ./.github/workflows/opensecret-change-detection.yml - - sdk-integration: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.integration != 'false') }} - runs-on: ubuntu-latest - timeout-minutes: 45 - services: - postgres: - image: postgres:17 - env: - POSTGRES_DB: opensecret - POSTGRES_PASSWORD: password - POSTGRES_USER: opensecret_user - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U opensecret_user -d opensecret" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - name: Checkout Maple, SDK, and the in-tree backend - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 - with: - persist-credentials: false - submodules: recursive - # Nix requires revCount for recursively fetched Git inputs. - fetch-depth: 0 - - - name: Install Nix - uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 - with: - source-tag: v3.23.3 - github-token: "" - - - name: Verify pinned Nix runtime - run: bash scripts/ci/verify-nix-runtime.sh - - - name: Migrate the disposable database and build OpenSecret - run: | - nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c bash -lc ' - set -euo pipefail - cd services/opensecret - diesel migration run - cargo build --locked - ' - nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c \ - psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 \ - -f sdk/test/integration/bootstrap.sql - - - name: Verify OAuth persistence and encrypted callback flows - run: | - nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c bash -lc ' - set -euo pipefail - cd services/opensecret - bash ../../.agents/skills/validate-opensecret/scripts/disposable_db_tests.sh - ' - - - name: Start and verify OpenSecret - run: | - nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c \ - services/opensecret/target/debug/opensecret \ - > "${RUNNER_TEMP}/opensecret.log" 2>&1 & - echo "$!" > "${RUNNER_TEMP}/opensecret.pid" - - for _ in $(seq 1 60); do - if curl --fail --silent --show-error --max-time 2 \ - "${VITE_OPEN_SECRET_API_URL}/health-check" >/dev/null; then - echo "OpenSecret is ready." - exit 0 - fi - if ! kill -0 "$(cat "${RUNNER_TEMP}/opensecret.pid")" 2>/dev/null; then - echo "OpenSecret exited before becoming ready." >&2 - tail -n 200 "${RUNNER_TEMP}/opensecret.log" >&2 - exit 1 - fi - sleep 2 - done - - echo "OpenSecret did not become ready." >&2 - tail -n 200 "${RUNNER_TEMP}/opensecret.log" >&2 - exit 1 - - - name: Run TypeScript SDK integration tests - run: | - nix develop --no-update-lock-file ./sdk -c bash -lc ' - set -euo pipefail - cd sdk - bun install --frozen-lockfile --ignore-scripts - bun test src/lib/test/integration/api.test.ts --timeout 30000 - bun test src/lib/test/integration/ai.test.ts --timeout 30000 - bun test src/lib/test/integration/apiKeys.test.ts --timeout 30000 - bun test src/lib/test/integration/bip85.test.ts --timeout 30000 - bun test src/lib/test/integration/developer.test.ts --timeout 30000 - bun test src/lib/test/integration/oauthRedirect.test.ts --timeout 30000 - bun test src/lib/test/integration/signing.test.ts --timeout 30000 - ' - - - name: Run Rust SDK integration tests - run: | - nix develop --no-update-lock-file ./sdk -c bash -lc ' - set -euo pipefail - cd sdk/rust - cargo test --locked --all-features --tests - cargo test --locked --all-features --lib \ - client::tests::native_handoff_full_stack_round_trip \ - -- --ignored --exact --nocapture - ' - - - name: Show OpenSecret log on failure - if: ${{ failure() }} - run: tail -n 300 "${RUNNER_TEMP}/opensecret.log" || true - - - name: Stop OpenSecret - if: ${{ always() }} - run: | - if [[ -f "${RUNNER_TEMP}/opensecret.pid" ]]; then - kill "$(cat "${RUNNER_TEMP}/opensecret.pid")" 2>/dev/null || true - fi diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index 5306031e2..5c2394479 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -44,7 +44,8 @@ shortcuts are `just opensecret-check` and `just opensecret-pcr-check`. ## CI and local development Root workflows replace the backend's inactive nested workflows. Backend CI -checks Rust, the ordinary Nix package/checks, and dependency policy. +(the `os-*` and `deps-opensecret` lanes of `ci.yml`) checks Rust, the ordinary +Nix checks, and dependency policy; `nightly.yml` builds the Nix package. The SDK integration lane starts the backend from this same checkout against a disposable database; it no longer fetches an external backend revision. This advances that lane from the former pinned commit @@ -52,9 +53,10 @@ This advances that lane from the former pinned commit PR jobs have read-only repository contents permission and use hosted runners, without signing/deployment credentials. Ordinary backend CI has no EIF -publisher or deployment step. Signed-PCR validation remains available through -the lightweight monorepo-root `pcr-compatibility` Nix check and the manual -publication procedure, not a standalone backend dev-shell job. A separate +publisher or deployment step. Signed-PCR validation is the lightweight +monorepo-root `pcr-compatibility` Nix check (the `os-pcr` lane, when PCR tooling +or approvals change) and the manual publication procedure, not a standalone +backend dev-shell job. A separate ARM64 EIF workflow compares dev/prod measurements on PRs explicitly editing approved PCR JSON, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not fail merely because approvals have not been diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 7ba9711d4..8806405d9 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -34,16 +34,22 @@ source its own manifest selects, so changing that manifest (a pin bump or a local link) is what selects its lanes. Tests, docs, container-only inputs, and standalone component lockfiles retain their independent lanes. Update the classifier and its table-driven tests when the dependency graph or component layout changes. -The backend has its own root `opensecret-ci.yml` workflow and change selector; -`sdk-integration.yml` tests both SDKs against `services/opensecret/` from the -same checkout. Backend changes do not imply Research or Agent packaging. +OpenSecret's `ci.yml` lanes are selected by its own classifier, +`scripts/ci/opensecret_change_detection.py`, which `plan.py` imports unchanged: +`os-rust`, `os-flake-check`, `os-helpers` (the parent helper images), +`deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check, for PCR tooling +and approval edits) and `os-sdk-e2e`, which tests both SDKs against +`services/opensecret/` from the same checkout (`scripts/ci/opensecret-sdk-e2e.sh`). +`nightly.yml` builds the x86_64 package. Backend changes do not imply Research +or Agent packaging. Agent desktop packages build on master pushes whose own diff changes Agent inputs (`plan.py only agent-desktop`), and on every manual master dispatch. Packaging-input PRs rehearse unsigned Dev packages on Linux and macOS in `ci.yml` (Windows with the `ci:package` label); signing stays confined to trusted master. -The separate `opensecret-eif.yml` builds dev/prod EIF candidates on -same-repository PRs changing measured inputs without editing PCR approvals. +The `os-eif` lane builds dev/prod EIF candidates on same-repository PRs +changing measured inputs without editing PCR approvals, on the release builder's +`blacksmith-8vcpu-ubuntu-2404-arm` class. Each diagnostic build explicitly realizes the user ramdisk selected by its EIF derivation, even when Nix substitutes a cached final EIF. The entrypoint's CA bundle must be present in that archive and omit the affected emSign roots. If @@ -51,8 +57,9 @@ the source CA store path is also present, its bytes must match the archive. The check records whether retained Comodo AAA is excluded from ordinary TLS PEMs and explicitly rejected by OpenSSL for server trust. It records the archived bundle and EIF hashes with the PCRs in the job summary; it does not upload -an artifact. The workflow compares measurements -on PRs editing approved PCR JSON, relevant master changes, and manual runs. +an artifact. `opensecret-eif.yml` compares measurements +on PRs editing approved PCR JSON, relevant master changes, and manual runs; that +approval status is not part of `ci-ok`. Preserve ordinary backend PRs without fresh approvals and meaningful master mismatches; these checks never change approvals, sign, release, or authorize deployment. Master and same-repository PR EIF builds receive OIDC for FlakeHub caching. diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 6541d0853..fccfb3003 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -4,9 +4,9 @@ are repository-relative: `**` spans directories, `*` stays within one path segment, and a leading `!` excludes paths from the globs it is listed with. -A lane with `legacy` set is still run by that workflow. Its paths are -classified here, so they never count as unknown, but ci.yml selects nothing for -it until its migration clears `legacy` and fills in how it runs. +OpenSecret lanes have no globs: opensecret_change_detection.py, imported +verbatim, selects them from each changed path that is not inert here +(`opensecret` names its output). """ from __future__ import annotations @@ -107,7 +107,8 @@ class Lane: cache_files: Tuple[str, ...] = () # hashed into the cache key timeout: int = 15 gate: bool = True # selected by CI_INFRA edits - legacy: Optional[str] = None # the workflow that still runs this lane + opensecret: Optional[str] = None # the opensecret_change_detection.py output that selects it + submodules: bool = False # check out submodules, with the full history Nix needs AGENT_DIR = dict( @@ -159,6 +160,15 @@ def deny(manifest: str, component: str) -> str: "scripts/ci/desktop-windows-*.sh", "scripts/ci/canonical-windows-*.py") WINDOWS_LABELS = ("ci:windows", "ci:package", "ci:full") +# The paths only OpenSecret lanes consume; plan.py counts them as classified. +OPENSECRET = ("services/opensecret/**", "sdk/test/**", ".gitmodules", + ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh") +OPENSECRET_SHELL = "./services/opensecret?submodules=1#ci" +OPENSECRET_ENV = (("OPENSECRET_DEV_POSTGRES", "0"), ("OPENSECRET_DEV_ENV", "0"), ("OPENSECRET_DEV_CONTAINERS", "0"), + ("RUSTFLAGS", "-D warnings")) +# Every EIF build runs on opensecret-eif-release.yml's runner class (test_contracts C5). +EIF_BUILDER = Runner("blacksmith-8vcpu-ubuntu-2404-arm") + LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", @@ -303,13 +313,31 @@ def deny(manifest: str, component: str) -> str: "flake.lock"), runner=GITHUB, flake=".#ci", run="./scripts/ci/agent-updates.sh"), - # Legacy lanes: OpenSecret will route through opensecret_change_detection.py. - Lane("deps-opensecret", ("services/opensecret/deny.toml", "services/opensecret/Cargo.*"), - legacy="opensecret-ci.yml"), - Lane("os-rust", ("services/opensecret/**", ".gitmodules"), legacy="opensecret-ci.yml"), - Lane("os-sdk-e2e", ("sdk/test/**", ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh"), - also=RUST_SDK + SDK_TS, legacy="sdk-integration.yml"), - Lane("os-eif", ("scripts/ci/build_opensecret_eif.sh", "scripts/ci/check_opensecret_eif.sh", - "scripts/ci/verify_opensecret_eif_rootfs.py", "scripts/ci/fixtures/eif/**"), - legacy="opensecret-eif.yml"), + # OpenSecret, selected by its own selector (see the module docstring). The PR + # EIF candidate builds only for same-repository PRs that edit no approval, on + # the release builder's runner class; opensecret-eif.yml compares approvals. + Lane("os-rust", (), opensecret="rust", flake=OPENSECRET_SHELL, workdir="services/opensecret", + run="cargo fmt --all -- --check && cargo clippy --locked --all-targets --all-features -- -D warnings" + " && cargo test --locked --all-features", + cache="os-rust", workspaces="services/opensecret -> target", cache_files=tuple( + "services/opensecret/" + name for name in ("Cargo.lock", "rust-toolchain.toml", "flake.lock")), + submodules=True, timeout=30, env=OPENSECRET_ENV), + Lane("os-sdk-e2e", (), opensecret="integration", job="os-sdk-e2e", run="./scripts/ci/opensecret-sdk-e2e.sh", + timeout=45), + Lane("os-flake-check", (), opensecret="nix", runner=GITHUB, workdir="services/opensecret", + run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'", submodules=True, + timeout=30, env=OPENSECRET_ENV), + Lane("os-helpers", (), opensecret="helpers", job="os-helpers", runner=Runner("ubuntu-24.04-arm"), + run="./scripts/ci/opensecret-helpers.sh", timeout=30), + Lane("os-pcr", (), opensecret="pcr", runner=GITHUB, timeout=10, + run='nix build --no-update-lock-file --no-link' + ' ".#checks.$(nix eval --impure --raw --expr builtins.currentSystem).pcr-compatibility"'), + Lane("deps-opensecret", (), opensecret="audit", flake=OPENSECRET_SHELL, submodules=True, env=OPENSECRET_ENV, + run=deny("services/opensecret/Cargo.toml", "services/opensecret"), **DEPS), + Lane("os-eif", (), opensecret="eif", labels=(), events=frozenset({PR}), job="os-eif", runner=EIF_BUILDER, + # ci.yml runs the former opensecret-eif.yml candidate steps verbatim; this is that build. + run='mode="${EIF_MODE:?dev or prod}" && out="$(mktemp -d)/eif-candidate-$mode"' + ' && bash scripts/ci/build_opensecret_eif.sh "$mode" "$out"' + ' && python3 scripts/ci/verify_opensecret_eif_rootfs.py "$mode" "$out" "$(git rev-parse HEAD)"', + env=OPENSECRET_ENV, timeout=180, gate=False), ) diff --git a/scripts/ci/opensecret-helpers.sh b/scripts/ci/opensecret-helpers.sh new file mode 100755 index 000000000..c3c955782 --- /dev/null +++ b/scripts/ci/opensecret-helpers.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# ci.yml's os-helpers lane: build OpenSecret's parent-side helper images for +# Linux ARM64 and run their tests offline, without publishing them (ci.yml then +# scans each image). HELPER picks one image; the default is both. The steps are +# the former opensecret-ci.yml `helpers` job's, unchanged. +set -euo pipefail +cd "$(dirname "$0")/../../services/opensecret" + +for helper in ${HELPER:-credential-requester enclave-logging}; do + case "$helper" in + credential-requester) CONTEXT=nitro-toolkit/credential_requester ;; + enclave-logging) CONTEXT=nitro-toolkit/logging ;; + *) echo "Unknown helper image: $helper" >&2; exit 2 ;; + esac + IMAGE="$helper:ci" + + # Build Linux ARM64 image + docker build --platform linux/arm64 --tag "$IMAGE" "$CONTEXT" + + # Check image architecture and runtime packages + test "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMAGE")" = linux/arm64 + docker run --rm --network none --entrypoint python "$IMAGE" -c \ + 'import boto3, botocore, urllib3, importlib.util; assert importlib.util.find_spec("pip") is None; assert importlib.util.find_spec("ensurepip") is None' + + # Run offline helper tests inside the image + docker run --rm --network none \ + --mount "type=bind,src=$PWD/$CONTEXT,dst=/tests,readonly" \ + --entrypoint python "$IMAGE" \ + -B -m unittest discover -s /tests -p 'test_*.py' -v +done diff --git a/scripts/ci/opensecret-sdk-e2e.sh b/scripts/ci/opensecret-sdk-e2e.sh new file mode 100755 index 000000000..8bb73726a --- /dev/null +++ b/scripts/ci/opensecret-sdk-e2e.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# ci.yml's os-sdk-e2e lane: both in-tree SDKs against the in-tree OpenSecret +# backend and a disposable PostgreSQL on 127.0.0.1:5432 (a ci.yml service; the +# backend's dev shell uses the same database URL). The environment and steps are +# the former sdk-integration.yml's, unchanged; its two final steps are the trap. +set -euo pipefail +cd "$(dirname "$0")/../.." + +export APP_MODE=local +export DATABASE_URL=postgres://opensecret_user:password@127.0.0.1:5432/opensecret +export ENCLAVE_SECRET_MOCK=1111111111111111111111111111111111111111111111111111111111111111 +export JWT_SECRET=2222222222222222222222222222222222222222222222222222222222222222 +export OPENAI_API_BASE=http://127.0.0.1:9 +export OPENSECRET_BIND_ADDR=127.0.0.1:3000 +export OPENSECRET_DEV_CONTAINERS=0 +export OPENSECRET_DEV_DATABASE_URL=postgres://opensecret_user:password@127.0.0.1:5432/opensecret +export OPENSECRET_DEV_ENV=0 +export OPENSECRET_DEV_POSTGRES=0 +export RUST_LOG=opensecret=info +export TINFOIL_API_KEY=sdk-integration-placeholder +export VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 +export VITE_OPEN_SECRET_PCR_ENVIRONMENT=development +export VITE_TEST_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 +export VITE_TEST_DEVELOPER_EMAIL=sdk-ci-developer@local.test +export VITE_TEST_DEVELOPER_INVITE_CODE=11111111-1111-4111-8111-111111111111 +export VITE_TEST_DEVELOPER_NAME='SDK CI Developer' +export VITE_TEST_DEVELOPER_PASSWORD=Maple-sdk-ci-test-1234 +export VITE_TEST_EMAIL=sdk-ci-user@local.test +export VITE_TEST_NAME='SDK CI User' +export VITE_TEST_PASSWORD=Maple-sdk-ci-test-1234 +RUNNER_TEMP="${RUNNER_TEMP:-$(mktemp -d)}" + +finish() { + status=$? + # Show OpenSecret log on failure + if [[ $status -ne 0 ]]; then + tail -n 300 "${RUNNER_TEMP}/opensecret.log" || true + fi + # Stop OpenSecret + if [[ -f "${RUNNER_TEMP}/opensecret.pid" ]]; then + kill "$(cat "${RUNNER_TEMP}/opensecret.pid")" 2>/dev/null || true + fi +} +trap finish EXIT + +# Migrate the disposable database and build OpenSecret +nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c bash -lc ' + set -euo pipefail + cd services/opensecret + diesel migration run + cargo build --locked +' +nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c \ + psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 \ + -f sdk/test/integration/bootstrap.sql + +# Verify OAuth persistence and encrypted callback flows +nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c bash -lc ' + set -euo pipefail + cd services/opensecret + bash ../../.agents/skills/validate-opensecret/scripts/disposable_db_tests.sh +' + +# Start and verify OpenSecret +( + nix develop --no-update-lock-file './services/opensecret?submodules=1#ci' -c \ + services/opensecret/target/debug/opensecret \ + > "${RUNNER_TEMP}/opensecret.log" 2>&1 & + echo "$!" > "${RUNNER_TEMP}/opensecret.pid" + + for _ in $(seq 1 60); do + if curl --fail --silent --show-error --max-time 2 \ + "${VITE_OPEN_SECRET_API_URL}/health-check" >/dev/null; then + echo "OpenSecret is ready." + exit 0 + fi + if ! kill -0 "$(cat "${RUNNER_TEMP}/opensecret.pid")" 2>/dev/null; then + echo "OpenSecret exited before becoming ready." >&2 + tail -n 200 "${RUNNER_TEMP}/opensecret.log" >&2 + exit 1 + fi + sleep 2 + done + + echo "OpenSecret did not become ready." >&2 + tail -n 200 "${RUNNER_TEMP}/opensecret.log" >&2 + exit 1 +) + +# Run TypeScript SDK integration tests +nix develop --no-update-lock-file ./sdk -c bash -lc ' + set -euo pipefail + cd sdk + bun install --frozen-lockfile --ignore-scripts + bun test src/lib/test/integration/api.test.ts --timeout 30000 + bun test src/lib/test/integration/ai.test.ts --timeout 30000 + bun test src/lib/test/integration/apiKeys.test.ts --timeout 30000 + bun test src/lib/test/integration/bip85.test.ts --timeout 30000 + bun test src/lib/test/integration/developer.test.ts --timeout 30000 + bun test src/lib/test/integration/oauthRedirect.test.ts --timeout 30000 + bun test src/lib/test/integration/signing.test.ts --timeout 30000 +' + +# Run Rust SDK integration tests +nix develop --no-update-lock-file ./sdk -c bash -lc ' + set -euo pipefail + cd sdk/rust + cargo test --locked --all-features --tests + cargo test --locked --all-features --lib \ + client::tests::native_handoff_full_stack_round_trip \ + -- --ignored --exact --nocapture +' diff --git a/scripts/ci/opensecret_change_detection.py b/scripts/ci/opensecret_change_detection.py index 534848d78..3f76c605b 100644 --- a/scripts/ci/opensecret_change_detection.py +++ b/scripts/ci/opensecret_change_detection.py @@ -115,6 +115,14 @@ def classify_paths(paths: Iterable[str]) -> dict[str, bool]: return {name: name in selected for name in OUTPUTS} +def classify_pcr(path: str) -> bool: + """The `pcr` output for ci.yml's os-pcr lane: the offline PCR tooling and the + approvals it verifies. Kept out of OUTPUTS, so the approval workflow's outputs + and every classify_path route stay as they are.""" + relative = path.removeprefix(BACKEND_PREFIX) + return path.startswith(BACKEND_PREFIX) and (relative in PCR_INPUTS or relative in APPROVED_PCR_FILES) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--all", action="store_true", help="Select all checks without claiming approvals changed") diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index 1f7902d31..7c42c79a1 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -4,6 +4,7 @@ github ci.yml's plan job: write the `lanes` and matrix outputs and a summary run LANE run one lane's command in its flake, as CI does (`just lane LANE`) only NAME another workflow's push selector: print selected=true|false + opensecret opensecret-eif.yml's changes job: the OpenSecret selector's outputs explain show what changed paths select (stdin, or --base/--head) check-rust the Agent flake's rustc must equal the Windows rustup pin lint the table classifies every tracked file and matches ci.yml @@ -26,12 +27,15 @@ from typing import Dict, Iterable, List, Optional, Sequence from lanes import (AGENT_WINDOWS_RUST, CI_INFRA, DISPATCH, INERT, LANES, MATRIX_JOBS, NIGHTLY, NOT_YET_TRACKED, - PR, PR_EVENTS, PUSH, QUEUE, SELECTORS, Lane) + OPENSECRET, PR, PR_EVENTS, PUSH, QUEUE, SELECTORS, Lane) ROOT = Path(__file__).resolve().parents[2] CI_WORKFLOW = ROOT / ".github/workflows/ci.yml" WINDOWS_SETUP = ROOT / ".github/actions/setup-agent-windows/action.yml" Selection = Dict[str, List[str]] # lane id -> why it was selected +# opensecret-eif.yml's `changes` outputs: the selector's OUTPUTS, named here so a +# broken selector still yields the fail-safe outputs. +OPENSECRET_OUTPUTS = ("rust", "nix", "integration", "audit", "eif", "helpers", "pcr_approvals") @functools.lru_cache(maxsize=None) @@ -61,13 +65,20 @@ def inert(path: str) -> bool: def classified(path: str) -> bool: - """Owned by some lane, active or legacy.""" + """Owned by some lane.""" return not malformed(path) and ( - matches(path, CI_INFRA) or any(matches(path, lane_globs(lane)) for lane in LANES)) + matches(path, CI_INFRA + OPENSECRET) or any(matches(path, lane_globs(lane)) for lane in LANES)) + + +def opensecret_checks(path: str) -> frozenset: + """The opensecret_change_detection.py outputs that `path` selects, plus `pcr`.""" + import opensecret_change_detection as selector # here, so a broken selector is a selection failure + + return selector.classify_path(path) | ({"pcr"} if selector.classify_pcr(path) else frozenset()) def active(event: str) -> List[Lane]: - return [lane for lane in LANES if lane.legacy is None and event in lane.events] + return [lane for lane in LANES if event in lane.events] def everything(event: str, why: str) -> Selection: @@ -78,9 +89,12 @@ def select(event: str, paths: Iterable[str], labels: Sequence[str] = ()) -> Sele """Active lanes that the changed paths and PR labels select for `event`.""" lanes = active(event) chosen: Selection = {} + approvals = False for path in paths: if inert(path): continue + checks = opensecret_checks(path) + approvals = approvals or "pcr_approvals" in checks if not classified(path): # Fail safe: an unknown path may be an input of anything. for lane in lanes: @@ -88,12 +102,14 @@ def select(event: str, paths: Iterable[str], labels: Sequence[str] = ()) -> Sele continue infra = matches(path, CI_INFRA) for lane in lanes: - if (infra and lane.gate) or matches(path, lane_globs(lane, event)): + if (infra and lane.gate) or lane.opensecret in checks or matches(path, lane_globs(lane, event)): chosen.setdefault(lane.id, []).append(path) for label in labels: for lane in lanes: if label in lane.labels: chosen.setdefault(lane.id, []).append(f"label {label}") + if approvals: # opensecret-eif.yml compares the edited approvals instead + chosen.pop("os-eif", None) return chosen @@ -136,10 +152,12 @@ def plan_event(event: str, payload: dict) -> Selection: if event == NIGHTLY: return everything(event, "nightly") labels: List[str] = [] + same_repository = False if event == PR: pull = payload["pull_request"] base, head = pull["base"]["sha"], pull["head"]["sha"] labels = pr_labels(os.environ["GITHUB_REPOSITORY"], pull["number"]) + same_repository = (pull["head"].get("repo") or {}).get("full_name") == os.environ["GITHUB_REPOSITORY"] elif event == QUEUE: base, head = payload["merge_group"]["base_sha"], payload["merge_group"]["head_sha"] elif event == PUSH: @@ -148,8 +166,14 @@ def plan_event(event: str, payload: dict) -> Selection: raise ValueError(f"unsupported event: {event}") paths = changed_paths(base, head, merge_base=event == PR) if paths is None: - return everything(event, f"no usable diff for {base[:12]}..{head[:12]}") - return select(event, paths, labels) + selected = everything(event, f"no usable diff for {base[:12]}..{head[:12]}") + else: + selected = select(event, paths, labels) + # The EIF candidate mints an OIDC token for the FlakeHub cache, so it builds only + # a same-repository head, and only when the diff shows that no approval changed. + if paths is None or not same_repository: + selected.pop("os-eif", None) + return selected def cache_key(lane: Lane) -> str: @@ -161,7 +185,7 @@ def cache_key(lane: Lane) -> str: def outputs(selected: Selection) -> Dict[str, str]: """`lanes` maps every active lane to whether it runs; each matrix job gets its entries.""" - result = {"lanes": {lane.id: lane.id in selected for lane in LANES if lane.legacy is None}} + result = {"lanes": {lane.id: lane.id in selected for lane in LANES}} for job in MATRIX_JOBS: result[job] = [{ "id": lane.id, @@ -171,6 +195,8 @@ def outputs(selected: Selection) -> Dict[str, str]: "cache": lane.cache, "cache_key": cache_key(lane) if lane.cache else "", "workspaces": lane.workspaces, + "submodules": "recursive" if lane.submodules else "false", + "fetch_depth": 0 if lane.submodules else 1, } for lane in LANES if lane.id in selected and lane.job == job] return {name: json.dumps(value, separators=(",", ":")) for name, value in result.items()} @@ -229,10 +255,6 @@ def explain(event: str, base: Optional[str], head: Optional[str]) -> int: unknown = [p for p in paths if not inert(p) and not classified(p)] if unknown: print(" unclassified, so every lane runs: " + ", ".join(unknown)) - legacy = sorted({f"{lane.id} ({lane.legacy})" for path in paths for lane in LANES - if lane.legacy and not inert(path) and matches(path, lane_globs(lane))}) - if legacy: - print(" still run by their own workflows: " + "; ".join(legacy)) if not selected: print(" no ci.yml lane") return 0 @@ -274,6 +296,38 @@ def only(name: str) -> int: return 0 +def opensecret() -> int: + """opensecret-eif.yml's changes job: opensecret_change_detection.py's outputs for this diff. + + A PR whose selection fails is an error rather than a guessed approval edit; on + any other event, or without a usable diff, every check runs and no approval + edit is claimed.""" + event = os.environ["GITHUB_EVENT_NAME"] + result = {name: name != "pcr_approvals" for name in OPENSECRET_OUTPUTS} + try: + if event in (PR, PUSH): + payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + base, head = ((payload["pull_request"]["base"]["sha"], payload["pull_request"]["head"]["sha"]) + if event == PR else (payload["before"], payload["after"])) + paths = changed_paths(base, head, merge_base=event == PR) + if paths is None: + raise ValueError(f"no usable diff for {base[:12]}..{head[:12]}") + import opensecret_change_detection as selector + + selected = selector.classify_paths(paths) + result = {name: bool(selected[name]) for name in OPENSECRET_OUTPUTS} + except Exception as error: # noqa: BLE001 - every failure is handled the same way + if event == PR: + print("::error::Cannot determine the PR's approved-PCR changes; refusing an ambiguous approval check.") + return 1 + print(f"::warning::OpenSecret selection failed ({error!r}); selecting every check") + lines = [f"{name}={str(value).lower()}\n" for name, value in result.items()] + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.writelines(lines) + print("".join(lines), end="") + return 0 + + def check_rust(expected: str = AGENT_WINDOWS_RUST) -> int: reported = subprocess.run(["rustc", "--version"], check=True, capture_output=True, text=True).stdout version = reported.split()[1] @@ -294,7 +348,7 @@ def tracked_files() -> List[str]: def lint_problems(files: Sequence[str]) -> List[str]: problems = [] - globs = ({g.lstrip("!") for g in INERT + CI_INFRA + sum(SELECTORS.values(), ())} + globs = ({g.lstrip("!") for g in INERT + CI_INFRA + OPENSECRET + sum(SELECTORS.values(), ())} | {g.lstrip("!") for lane in LANES for g in lane_globs(lane)}) problems += [f"matches no tracked file: {g}" for g in sorted(globs - NOT_YET_TRACKED) if not any(_pattern(g).fullmatch(f) for f in files)] @@ -303,12 +357,14 @@ def lint_problems(files: Sequence[str]) -> List[str]: problems += [f"duplicate lane: {i}" for i in sorted({i for i in ids if ids.count(i) > 1})] workflow = CI_WORKFLOW.read_text() jobs = set(re.findall(r"^ ([A-Za-z0-9_-]+):", workflow.split("\njobs:\n", 1)[1], re.M)) - expected = {"plan", "ci-ok", *MATRIX_JOBS} | {lane.job for lane in LANES if lane.legacy is None} + expected = {"plan", "ci-ok", *MATRIX_JOBS} | {lane.job for lane in LANES} if jobs != expected: problems.append(f"ci.yml jobs {sorted(jobs)} != plan, ci-ok, matrix and special lane jobs {sorted(expected)}") if f"toolchain: {AGENT_WINDOWS_RUST}\n" not in WINDOWS_SETUP.read_text(): problems.append(f"setup-agent-windows does not pin Rust {AGENT_WINDOWS_RUST}") declared = set(re.findall(r"^\s+- (\S+)$", (ROOT / ".github/actionlint.yaml").read_text(), re.M)) + problems += [f"{lane.id}: {lane.opensecret} is no opensecret_change_detection.py output" for lane in LANES + if lane.opensecret and lane.opensecret not in OPENSECRET_OUTPUTS + ("pcr",)] for lane in LANES: if lane.runner.label not in declared and not re.match(r"(ubuntu|macos|windows)-", lane.runner.label): problems.append(f"{lane.id}: runner {lane.runner.label} is not in .github/actionlint.yaml") @@ -321,6 +377,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: modes.add_parser("github") modes.add_parser("run").add_argument("lane") modes.add_parser("only").add_argument("name", choices=sorted(SELECTORS)) + modes.add_parser("opensecret") explain_parser = modes.add_parser("explain") explain_parser.add_argument("--event", default=PR, choices=(PR, QUEUE, PUSH)) explain_parser.add_argument("--base") @@ -334,6 +391,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return run(args.lane) if args.mode == "only": return only(args.name) + if args.mode == "opensecret": + return opensecret() if args.mode == "explain": return explain(args.event, args.base, args.head) if args.mode == "check-rust": diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index 130dd8caf..a3f39fce1 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -10,6 +10,7 @@ import importlib.util import itertools import json +import os from pathlib import Path import re import unittest @@ -48,14 +49,14 @@ def module(name: str, path: str): ("desktop-build.yml", "build-macos"), ("desktop-build.yml", "build-linux"), ("desktop-build.yml", "build-windows"), ("dev-credential-requester-image.yml", "build"), ("ios-dev-testflight.yml", "build-ios-dev"), ("mobile-build.yml", "build-ios"), ("opensecret-eif-release.yml", "build"), - ("opensecret-eif.yml", "eif-candidate"), ("opensecret-eif.yml", "eif-trusted"), + ("ci.yml", "os-eif"), ("opensecret-eif.yml", "eif-trusted"), *(("release.yml", job) for job in ("build-tauri", "build-windows", "build-android", "build-ios", "build-web", "publish-proxy-release-artifacts", "update-latest-json", "publish-release-verification-guide")), ("sdk-publish-npm.yml", "publish"), ("sdk-publish-rust.yml", "publish"), ("web-build.yml", "build-web-master"), }) # The EIF builders that warm FlakeHub for opensecret-eif-release.yml. -FLAKEHUB_JOBS = frozenset({("opensecret-eif.yml", "eif-candidate"), ("opensecret-eif.yml", "eif-trusted"), +FLAKEHUB_JOBS = frozenset({("ci.yml", "os-eif"), ("opensecret-eif.yml", "eif-trusted"), ("opensecret-eif-release.yml", "build")}) # Every job that holds a protected environment. CONSUMERS = { @@ -116,6 +117,18 @@ def cache_steps(): yield name, job_id, step +def expression(job: dict) -> str: + """A job's `if:` as a Python expression with equivalent semantics for these gates.""" + text = job["if"].strip().removeprefix("${{").removesuffix("}}").replace("&&", " and ").replace("||", " or ") + return " ".join(text.replace("!cancelled()", "NOT_CANCELLED").replace("always()", "True").split()) + + +def evaluate(text: str, context: dict) -> bool: + for key, value in context.items(): + text = text.replace(key, repr(value)) + return bool(eval(text, {"__builtins__": {}}, {})) # noqa: S307 - fixed workflow text + + def cancels(concurrency) -> bool: if not isinstance(concurrency, dict): return False @@ -278,13 +291,11 @@ def test_c4_dev_testflight_build_number_is_the_run_number(self): self.assertEqual(identity["env"]["RUN_NUMBER"], "${{ github.run_number }}") def test_c5_eif_event_gate_matches_the_approval_policy(self): - # Evaluate the real GitHub expressions with equivalent Python semantics. - expressions = {} - for job_id in ("eif-candidate", "eif", "eif-trusted"): - text = workflows()["opensecret-eif.yml"]["jobs"][job_id]["if"].strip() - text = text.removeprefix("${{").removesuffix("}}").replace("&&", " and ").replace("||", " or ") - expressions[job_id] = " ".join(text.replace("!cancelled()", "NOT_CANCELLED") - .replace("always()", "True").split()) + # Evaluate the real GitHub expressions with equivalent Python semantics. The + # PR candidate is ci.yml's os-eif: the planner selects it, and its job repeats + # the event and head-repository checks. Its jobs need a successful plan. + approval = {job: expression(workflows()["opensecret-eif.yml"]["jobs"][job]) for job in ("eif", "eif-trusted")} + candidate = expression(workflows()["ci.yml"]["jobs"]["os-eif"]) cases = ( ("pull_request", "refs/pull/1/merge", "success", "true", "false", False), ("pull_request", "refs/pull/1/merge", "success", "true", "true", True), @@ -302,6 +313,7 @@ def test_c5_eif_event_gate_matches_the_approval_policy(self): ("push", "refs/heads/master", "success", "", "", True), ("push", "refs/heads/feature", "success", "true", "true", False), ("push", "refs/tags/master", "success", "true", "true", False), + ("merge_group", "refs/heads/gh-readonly-queue/master/pr-1", "success", "true", "false", False), ("workflow_dispatch", "refs/heads/master", "success", "true", "false", True), ("workflow_dispatch", "refs/heads/master", "failure", "", "", True), ("workflow_dispatch", "refs/heads/feature", "success", "true", "false", True), @@ -322,22 +334,42 @@ def test_c5_eif_event_gate_matches_the_approval_policy(self): "needs.changes.outputs.eif": eif, "needs.changes.outputs.pcr_approvals": approvals, "NOT_CANCELLED": not cancelled, } + # The diff the planner classifies: an EIF input, an approval edit (itself an EIF input), or neither. + paths = (["services/opensecret/src/main.rs"] if eif == "true" else []) + ( + ["services/opensecret/pcrDev.json"] if approvals == "true" else []) + planned = "os-eif" in self.plan(event, head, paths) + # The job's own condition holds the event and fork boundary even if the planner erred. + self.assertEqual(evaluate(candidate, {"fromJSON(needs.plan.outputs.lanes)['os-eif']": True, **context}), + event == "pull_request" and same_repo, (case, head)) selected = [] - for job_id, expression in expressions.items(): - for key, value in context.items(): - expression = expression.replace(key, repr(value)) - if eval(expression, {"__builtins__": {}}, {}): # noqa: S307 - fixed workflow text - selected.append(job_id) + if result == "success" and not cancelled and evaluate(candidate, { + "fromJSON(needs.plan.outputs.lanes)['os-eif']": planned, **context}): + selected.append("os-eif") + selected += [job for job, text in approval.items() if evaluate(text, context)] trusted = ((event == "pull_request" and same_repo) or (event in ("push", "workflow_dispatch") and ref == "refs/heads/master")) wanted = [] if (not cancelled and event == "pull_request" and same_repo and result == "success" and eif == "true" and approvals == "false"): - wanted.append("eif-candidate") + wanted.append("os-eif") if expected and not cancelled: wanted.append("eif-trusted" if trusted else "eif") self.assertEqual(selected, wanted, (case, head, cancelled)) + @staticmethod + def plan(event: str, head, paths: list) -> dict: + """ci.yml's plan job for this event, head repository and diff.""" + payload = {"pull_request": {"number": 1, "base": {"sha": "a" * 40}, "head": { + "sha": "b" * 40, "repo": None if head is None else {"full_name": head}}}, + "merge_group": {"base_sha": "a" * 40, "head_sha": "b" * 40}, "before": "a" * 40, "after": "b" * 40} + with mock.patch.object(plan, "changed_paths", return_value=paths), \ + mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": "MaplePrivacyLabs/Maple"}), \ + mock.patch.dict(os.environ, {"GH_TOKEN": "", "GITHUB_TOKEN": ""}): + try: + return plan.plan_event(event, payload) + except ValueError: # an event ci.yml does not run on + return {} + def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): minted = {(name, job_id) for name, job_id, job in jobs() if permissions(workflows()[name], job).get("id-token") == "write"} @@ -346,8 +378,9 @@ def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): flakehub = {(name, job_id) for name, job_id, job in jobs() for step in job.get("steps", []) if "flakehub-cache-action@" in step.get("uses", "")} self.assertEqual(flakehub, FLAKEHUB_JOBS) - for name, job_id in flakehub: + for name, job_id in flakehub: # every EIF builder, on the release builder's runner class self.assertEqual(workflows()[name]["jobs"][job_id]["runs-on"], builder, job_id) + self.assertEqual(next(lane for lane in plan.LANES if lane.id == "os-eif").runner.label, builder) def test_c5_eif_release_is_manual_reviewed_and_never_tags(self): config = workflows()["opensecret-eif-release.yml"] diff --git a/scripts/ci/test_opensecret_change_detection.py b/scripts/ci/test_opensecret_change_detection.py index 44fb2ba27..53c176053 100644 --- a/scripts/ci/test_opensecret_change_detection.py +++ b/scripts/ci/test_opensecret_change_detection.py @@ -1,5 +1,6 @@ """Backend, client-compatibility, and application-packaging routing regressions.""" +import json import os from pathlib import Path import shutil @@ -10,9 +11,9 @@ from change_detection import classify_path as research_routes from lanes import PUSH -from opensecret_change_detection import CHECK_OUTPUTS, OUTPUTS, classify_paths +from opensecret_change_detection import CHECK_OUTPUTS, OUTPUTS, classify_pcr, classify_paths from test_plan import AGENT_PUSH, selected -from test_workflow_policy import ROOT, load +from test_workflow_policy import ROOT def affects_agent(path): @@ -181,9 +182,20 @@ def test_provider_manifests_only_select_the_flake_check(self): def test_component_hook_scripts_select_nothing(self): self.assert_routes(["services/opensecret/.githooks/pre-commit"]) + def test_pcr_tooling_and_approvals_select_the_offline_pcr_check(self): + for relative in ("pcrPreview.json", "pcrPreviewHistory.json", "pcr_sign.js", "pcr_verify.js", + "scripts/pcr_compatibility.py", "scripts/test_pcr_compatibility.py", + "pcrDev.json", "pcrDevHistory.json", "pcrProd.json", "pcrProdHistory.json"): + with self.subTest(path=relative): + self.assertTrue(classify_pcr("services/opensecret/" + relative)) + for path in ("services/opensecret/src/main.rs", "services/opensecret/docs/pcr-compatibility.md", + "services/opensecret/scripts/ci_sign_pcr.sh", "pcrDev.json", "sdk/pcrDev.json"): + with self.subTest(path=path): + self.assertFalse(classify_pcr(path)) + class OpenSecretDiffSelectionTests(unittest.TestCase): - """The opensecret-change-detection.yml classify step, run against fixture histories.""" + """opensecret-eif.yml's changes job (`plan.py opensecret`), run against fixture histories.""" def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) @@ -194,7 +206,8 @@ def setUp(self): self.git("config", "core.hooksPath", "/dev/null") scripts = self.root / "scripts/ci" scripts.mkdir(parents=True) - shutil.copyfile(ROOT / "scripts/ci/opensecret_change_detection.py", scripts / "opensecret_change_detection.py") + for name in ("plan.py", "lanes.py", "opensecret_change_detection.py"): + shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) self.base = self.commit_file("README.md", "initial\n") def git(self, *arguments): @@ -211,13 +224,13 @@ def commit_file(self, path, text): return self.git("rev-parse", "HEAD") def select(self, event, base, head, *, succeeds=True): - step = next(step for step in load(ROOT / ".github/workflows/opensecret-change-detection.yml") - ["jobs"]["detect"]["steps"] if step.get("id") == "classify") - output = self.root / "output" + output, payload = self.root / "output", self.root / "event.json" output.unlink(missing_ok=True) - env = {**os.environ, "GITHUB_EVENT_NAME": event, "BASE_SHA": base, "HEAD_SHA": head, + payload.write_text(json.dumps({"pull_request": {"base": {"sha": base}, "head": {"sha": head}}} + if event == "pull_request" else {"before": base, "after": head})) + env = {**os.environ, "GITHUB_EVENT_NAME": event, "GITHUB_EVENT_PATH": str(payload), "GITHUB_OUTPUT": str(output)} - result = subprocess.run(["bash", "-c", step["run"]], cwd=self.root, env=env, + result = subprocess.run([sys.executable, "scripts/ci/plan.py", "opensecret"], cwd=self.root, env=env, capture_output=True, text=True) if succeeds: self.assertEqual(result.returncode, 0, result.stderr) @@ -309,9 +322,6 @@ def test_successful_but_incomplete_classifier_output_is_rejected(self): self.assertEqual(self.select("pull_request", self.base, self.base, succeeds=False), {}) self.assertEqual(self.select("push", self.base, self.base), self.expected(*CHECK_OUTPUTS)) - def test_schedule_selects_only_advisory_audit(self): - self.assertEqual(self.select("schedule", "", ""), self.expected("audit")) - if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 75e98f20d..9d47d9ec0 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -14,12 +14,15 @@ import lanes import plan from lanes import DISPATCH, NIGHTLY, PR, PUSH, QUEUE +from opensecret_change_detection import OUTPUTS -ACTIVE = {lane.id for lane in lanes.LANES if lane.legacy is None} +ACTIVE = {lane.id for lane in lanes.LANES} AGENT_PR = {"agent-lint", "agent-test", "agent-macos"} AGENT_PUSH = AGENT_PR | {"agent-windows", "agent-full-linux", "agent-full-macos", "agent-full-windows"} AGENT_PACKAGE = {"agent-package-linux", "agent-package-macos"} +# What opensecret_change_detection.py's "all checks" selects in ci.yml. +OPENSECRET_ALL = {"os-rust", "os-sdk-e2e", "os-flake-check", "os-helpers", "deps-opensecret", "os-eif"} SHA_A, SHA_B = "a" * 40, "b" * 40 @@ -31,10 +34,6 @@ def selected(paths, event=PR, labels=()): return set(plan.select(event, paths, labels)) -def owners(path): - return {lane.id for lane in lanes.LANES if plan.matches(path, plan.lane_globs(lane))} - - def agent_lanes(lanes_): return {lane for lane in lanes_ if lane.startswith("agent-") and lane != "agent-updates"} @@ -143,9 +142,10 @@ def test_explain_reads_null_delimited_names(self): class LaneTableTests(unittest.TestCase): def test_typescript_sdk_never_selects_a_client_lane(self): for path in ("sdk/src/index.ts", "sdk/package.json", "sdk/bun.lock"): - with self.subTest(path=path): - self.assertIn("sdk-ts", selected([path], PUSH)) - self.assertFalse({o for o in owners(path) if o.startswith(("agent-", "research-", "proxy"))}) + for event in (PR, PUSH): + with self.subTest(path=path, event=event): + # The SDK-to-backend suite is the only other consumer. + self.assertEqual(selected([path], event), {"sdk-ts", "os-sdk-e2e"}) def test_release_metadata_and_publisher_inputs_select_the_repo_lane(self): for path in ("apps/maple-research/frontend/package.json", "apps/maple-research/frontend/src-tauri/Cargo.toml", @@ -155,7 +155,7 @@ def test_release_metadata_and_publisher_inputs_select_the_repo_lane(self): self.assertIn("repo", selected([path])) def test_rust_sdk_tests_and_proxy_container_select_no_agent_lane(self): - self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), {"sdk-rust"}) + self.assertEqual(selected(["sdk/rust/tests/x.rs"], PUSH), {"sdk-rust", "os-sdk-e2e"}) self.assertEqual(agent_lanes(selected(["proxy/src/main.rs"])), AGENT_PR) self.assertFalse(agent([".dockerignore", "proxy/Dockerfile"], PUSH)) @@ -167,7 +167,8 @@ def test_windows_clippy_runs_on_prs_only_for_windows_paths_or_labels(self): self.assertNotIn("agent-windows", selected([other])) self.assertIn("agent-windows", selected([other], PUSH)) self.assertIn("agent-windows", selected(["docs/x.md"], labels=["ci:windows"])) - self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), active(PR)) + # Every PR lane but the EIF candidate, which only its own inputs select. + self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), active(PR) - {"os-eif"}) self.assertEqual(selected(["docs/x.md"], labels=["unrelated"]), set()) def test_inert_paths_select_nothing(self): @@ -183,13 +184,7 @@ def test_ci_infrastructure_selects_every_active_gate_lane(self): for path in (".github/workflows/ci.yml", ".github/actions/setup-nix/action.yml", "scripts/ci/plan.py", "scripts/ci/lanes.py"): with self.subTest(path=path): - self.assertEqual(selected([path]), {lane.id for lane in lanes.LANES - if lane.legacy is None and lane.gate}) - - def test_legacy_components_are_classified_but_select_nothing(self): - path = "services/opensecret/src/main.rs" - self.assertTrue(plan.classified(path)) - self.assertEqual(selected([path], PUSH), set()) + self.assertEqual(selected([path]), {lane.id for lane in lanes.LANES if lane.gate}) def test_every_tracked_file_is_classified_and_ci_yml_matches(self): self.assertEqual(plan.lint_problems(plan.tracked_files()), []) @@ -253,7 +248,7 @@ class ClientLaneTests(unittest.TestCase): def test_component_inputs_select_their_lanes(self): for path, pr, push in ( - ("sdk/rust/tests/x.rs", {"sdk-rust"}, {"sdk-rust"}), + ("sdk/rust/tests/x.rs", {"sdk-rust", "os-sdk-e2e"}, {"sdk-rust", "os-sdk-e2e"}), ("proxy/tests/health.rs", {"proxy"}, {"proxy"}), ("proxy/Dockerfile", {"proxy-container"}, {"proxy-container", "proxy-container-arm64"}), ("proxy/Cargo.lock", {"proxy", "deps-proxy"}, {"proxy", "deps-proxy", "proxy-container", @@ -263,7 +258,9 @@ def test_component_inputs_select_their_lanes(self): ("apps/maple-auth/src/main.tsx", {"auth"}, set()), ("services/download-tracking/tracking.ts", {"updates", "agent-updates"}, {"updates", "agent-updates"}), ("services/updates/src/index.ts", {"updates"}, {"updates"}), - ("services/agent-updates/src/index.ts", {"agent-updates"}, {"agent-updates"}), + # The OpenSecret selector (imported verbatim) treats this root as unknown: every check. + ("services/agent-updates/src/index.ts", {"agent-updates"} | OPENSECRET_ALL, + {"agent-updates"} | OPENSECRET_ALL - {"os-eif"}), # Master runs these in frontend-tests.yml, web-build.yml and rust-tests.yml. ("apps/maple-research/frontend/src/App.tsx", {"research-web"}, set()), ("apps/maple-research/frontend/src-tauri/src/lib.rs", {"research-rust"}, set()), @@ -295,6 +292,49 @@ def test_research_platform_builds_run_on_native_inputs_or_labels(self): ("research-ios", "research-android", "research-desktop", "research-dev"))}) +class OpenSecretLaneTests(unittest.TestCase): + """The lanes that replaced opensecret-ci.yml, sdk-integration.yml and the PR EIF candidate.""" + + def test_the_opensecret_selector_routes_each_lane(self): + backend = "services/opensecret/" + for path, pr, push in ( + (backend + "src/main.rs", {"os-rust", "os-sdk-e2e", "os-flake-check", "os-eif"}, + {"os-rust", "os-sdk-e2e", "os-flake-check"}), + (backend + "migrations/2026/up.sql", {"os-rust", "os-sdk-e2e"}, {"os-rust", "os-sdk-e2e"}), + (backend + "deny.toml", {"deps-opensecret"}, {"deps-opensecret"}), + (backend + "nitro-toolkit/logging/Dockerfile", {"os-helpers"}, {"os-helpers"}), + (backend + "nix/kernel-upstream.nix", {"os-flake-check", "os-eif"}, {"os-flake-check"}), + (backend + "scripts/pcr_compatibility.py", {"os-pcr"}, {"os-pcr"}), + # An approval edit is compared by opensecret-eif.yml, never built as a candidate. + (backend + "pcrDev.json", {"os-pcr"}, {"os-pcr"}), + (backend + "docs/nitro-deploy.md", set(), set()), + ("sdk/test/integration/bootstrap.sql", {"os-sdk-e2e"}, {"os-sdk-e2e"}), + ("scripts/ci/build_opensecret_eif.sh", {"os-eif", "repo"}, {"repo"}), + (".gitmodules", OPENSECRET_ALL, OPENSECRET_ALL - {"os-eif"}), + ("scripts/ci/opensecret_change_detection.py", OPENSECRET_ALL | {"repo"}, + OPENSECRET_ALL - {"os-eif"} | {"repo"}), + ): + with self.subTest(path=path): + self.assertEqual(selected([path]), pr) + self.assertEqual(selected([path], PUSH), push) + + def test_an_approval_edit_anywhere_in_the_diff_drops_the_candidate(self): + runtime, approval = "services/opensecret/src/main.rs", "services/opensecret/pcrProdHistory.json" + self.assertIn("os-eif", selected([runtime])) + self.assertNotIn("os-eif", selected([runtime, approval])) + self.assertNotIn("os-eif", selected(["docs/x.md"], labels=["ci:full"])) + self.assertNotIn("os-eif", selected([".github/workflows/ci.yml"])) + self.assertEqual({lane.id for lane in lanes.LANES if lane.events == {PR}}, {"os-eif"}) + self.assertNotIn("deps-opensecret", active(NIGHTLY)) # nightly.yml checks advisories + + def test_eif_builder_and_checkout(self): + entries = {e["id"]: e for e in json.loads(plan.outputs(dict.fromkeys(ACTIVE, ["x"]))["nix"])} + for lane in ("os-rust", "os-flake-check", "deps-opensecret"): + self.assertEqual((entries[lane]["submodules"], entries[lane]["fetch_depth"]), ("recursive", 0)) + self.assertEqual((entries["os-pcr"]["submodules"], entries["os-pcr"]["fetch_depth"]), ("false", 1)) + self.assertEqual(plan.OPENSECRET_OUTPUTS, OUTPUTS) + + class PlannerTests(unittest.TestCase): def github(self, event, payload, **env): with tempfile.TemporaryDirectory() as temporary: @@ -319,11 +359,21 @@ def test_push_without_a_usable_base_selects_everything(self): def test_schedule_and_dispatch(self): _, outputs = self.github(NIGHTLY, {}) self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, - {lane.id for lane in lanes.LANES if lane.legacy is None and NIGHTLY in lane.events}) + {lane.id for lane in lanes.LANES if NIGHTLY in lane.events}) _, outputs = self.github(DISPATCH, {}, LANES_INPUT="agent-test") self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, {"agent-test"}) self.assertEqual([entry["id"] for entry in outputs["nix"]], ["agent-test"]) + def test_pr_without_a_usable_diff_or_from_a_fork_never_builds_an_eif_candidate(self): + pull = {"number": 1, "base": {"sha": SHA_A}, "head": {"sha": SHA_B, "repo": {"full_name": "x/Maple"}}} + code, outputs = self.github(PR, {"pull_request": pull}) + self.assertEqual((code, {lane for lane, on in outputs["lanes"].items() if on}), (0, active(PR) - {"os-eif"})) + with mock.patch.object(plan, "changed_paths", return_value=["services/opensecret/src/main.rs"]): + for head, built in (("MaplePrivacyLabs/Maple", True), ("contributor/Maple", False), (None, False)): + with self.subTest(head=head): + pull["head"]["repo"] = head and {"full_name": head} + self.assertIs(self.github(PR, {"pull_request": pull})[1]["lanes"]["os-eif"], built) + def test_malformed_event_fails_a_pr_and_selects_everything_on_push(self): self.assertEqual(self.github(PR, {"pull_request": {}})[0], 1) self.assertEqual(self.github(QUEUE, {})[0], 1) @@ -331,7 +381,7 @@ def test_malformed_event_fails_a_pr_and_selects_everything_on_push(self): self.assertEqual(code, 0) self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, active(PUSH)) code, outputs = self.github(DISPATCH, {}, LANES_INPUT="no-such-lane") - self.assertEqual((code, all(outputs["lanes"].values())), (0, True)) + self.assertEqual((code, {lane for lane, on in outputs["lanes"].items() if on}), (0, active(DISPATCH))) def test_outputs_empty_matrix_and_runner_overrides(self): empty = plan.outputs({}) @@ -349,7 +399,8 @@ def test_outputs_empty_matrix_and_runner_overrides(self): "agent-perf": linux, "repo": free, "sdk-ts": free, "sdk-rust": free, "proxy": free, "proxy-container": free, "proxy-container-arm64": "ubuntu-24.04-arm", "deps-research": free, "deps-sdk": free, "deps-proxy": free, "research-web": free, "research-android": linux, "auth": free, - "updates": free, "agent-updates": free}) + "updates": free, "agent-updates": free, "os-rust": linux, "os-flake-check": free, "os-pcr": free, + "deps-opensecret": free}) entries = [entry for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])] self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index 61f11019f..3e6770e6d 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -76,8 +76,6 @@ "desktop-signing", "apple-signing", "android-signing", "windows-signing", "zapstore-publishing")), }), "permissions": frozenset({"desktop-build.yml", "android-build.yml"}), - # The OpenSecret lanes replace this workflow. - "github-cache": frozenset({("opensecret-ci.yml", "rust")}), } @@ -273,7 +271,7 @@ def test_only_windows_signing_fills_githubs_cache_store(self): and "startsWith(matrix.runner, 'blacksmith-')" not in str(step["with"].get("save-if")) and any(GITHUB_HOSTED.fullmatch(label) for label in runner_labels(name, job_id, job))): writers.add((name, job_id)) - self.assertEqual(writers, {("desktop-build.yml", "build-windows")} | TEMPORARY["github-cache"]) + self.assertEqual(writers, {("desktop-build.yml", "build-windows")}) if __name__ == "__main__": unittest.main() diff --git a/sdk/.githooks/pre-commit b/sdk/.githooks/pre-commit index e8d22179b..58e2e1ba8 100755 --- a/sdk/.githooks/pre-commit +++ b/sdk/.githooks/pre-commit @@ -2,7 +2,7 @@ # SDK pre-commit checks. Runs from sdk/ inside the SDK flake; the orchestrator # sets MAPLE_HOOK_SELECTED to sdk_rust and/or sdk_ts. # CI equivalents: the ci.yml sdk-rust and sdk-ts lanes (scripts/ci/build-sdk-publish.sh). -# Integration suites (sdk-integration.yml, sdk/src/lib/test/integration/, +# Integration suites (the ci.yml os-sdk-e2e lane, sdk/src/lib/test/integration/, # sdk/rust/tests/) need a live backend and never run here. set -u diff --git a/sdk/README.md b/sdk/README.md index 5026ad892..f1eb933a6 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -253,9 +253,9 @@ the `sdk-ts` lane (`just lane sdk-ts` from the repository root). An unfiltered `bun test` also collects integration tests and requires the fixtures described below. -Integration tests read the variables documented in `.env.example`. Monorepo -[`sdk-integration.yml`](../.github/workflows/sdk-integration.yml) migrates -disposable PostgreSQL, starts the in-tree `services/opensecret/` backend from +Integration tests read the variables documented in `.env.example`. The monorepo +`os-sdk-e2e` lane ([`opensecret-sdk-e2e.sh`](../scripts/ci/opensecret-sdk-e2e.sh)) +migrates disposable PostgreSQL, starts the in-tree `services/opensecret/` backend from the same checkout on loopback, and creates disposable SDK fixtures. It does not depend on the hosted development service or stored test-account credentials. @@ -324,7 +324,7 @@ nix develop --no-update-lock-file -c bash -lc ' These library checks do not require a backend or account credentials. Integration tests use explicit backend fixtures and are separate from this default validation path; see [Rust testing](rust/README.md#testing) and -[`sdk-integration.yml`](../.github/workflows/sdk-integration.yml). +[`opensecret-sdk-e2e.sh`](../scripts/ci/opensecret-sdk-e2e.sh). To dispatch validation from `sdk/`, replace `VERSION` with the exact Rust version committed in `rust/Cargo.toml` on `master`: diff --git a/sdk/rust/README.md b/sdk/rust/README.md index dcbd8360d..28004c02f 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -385,8 +385,8 @@ Clippy, library-test, and rustdoc gates are in the (`just lane sdk-rust` from the repository root). Integration tests are a separate selection. Follow -[`sdk-integration.yml`](../../.github/workflows/sdk-integration.yml) for its -disposable database, same-checkout OpenSecret backend, and fixture setup. It +[`opensecret-sdk-e2e.sh`](../../scripts/ci/opensecret-sdk-e2e.sh) (CI's +`os-sdk-e2e` lane) for its disposable database, same-checkout OpenSecret backend, and fixture setup. It uses loopback and does not require hosted development accounts. Configure the actual test endpoint and port, project UUID, and test identities from that setup rather than assuming a backend is running on `localhost:3000`. diff --git a/services/opensecret/.githooks/pre-commit b/services/opensecret/.githooks/pre-commit index 790852b5d..068f1d4d9 100755 --- a/services/opensecret/.githooks/pre-commit +++ b/services/opensecret/.githooks/pre-commit @@ -2,9 +2,9 @@ # OpenSecret backend pre-commit checks. Runs from services/opensecret inside # its own flake with OPENSECRET_DEV_POSTGRES/ENV/CONTAINERS=0 so the dev shell # does not start PostgreSQL, containers, or rewrite .env. -# CI equivalent: the `rust` job of opensecret-ci.yml, plus the entrypoint flake -# checks from its `nix` job when the entrypoint or its test is staged. The nix -# package build, cargo-deny audit, EIF, and PCR checks stay in CI. +# CI equivalent: ci.yml's os-rust lane, plus the entrypoint flake checks from +# os-flake-check when the entrypoint or its test is staged. The nix package +# build, cargo-deny audit, EIF, and PCR checks stay in CI. set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" diff --git a/services/opensecret/README.md b/services/opensecret/README.md index 68f293cdd..5842fdcb4 100644 --- a/services/opensecret/README.md +++ b/services/opensecret/README.md @@ -100,11 +100,11 @@ The supported EIF outputs are `eif-dev`, `eif-preview`, and `eif-prod`, built with the repository's Nix flake on the appropriate Linux/ARM environment. Build, PCR comparison, deployment, and live trust verification are distinct evidence. -The monorepo-root [`opensecret-ci.yml`](../../.github/workflows/opensecret-ci.yml) -validates Rust, Nix checks and the default backend binary, and dependency policy. -[`sdk-integration.yml`](../../.github/workflows/sdk-integration.yml) exercises -both SDKs against the backend in the same checkout. These workflows do not -build or publish EIFs or deploy the TEE service. Before an authorized dev or +The monorepo-root [`ci.yml`](../../.github/workflows/ci.yml) lanes `os-rust`, +`os-flake-check`, and `deps-opensecret` validate Rust, Nix checks, and dependency +policy; `os-sdk-e2e` exercises both SDKs against the backend in the same +checkout, and the nightly workflow builds the default backend binary. These +lanes do not build or publish EIFs or deploy the TEE service. Before an authorized dev or prod publish/deployment, use the supported Linux/ARM64 release builder to review and deliberately update/verify the target measurements; never update checked-in PCRs solely to clear ordinary pull-request CI. diff --git a/services/opensecret/docs/nitro-deploy.md b/services/opensecret/docs/nitro-deploy.md index 1c7860356..c16fed53b 100644 --- a/services/opensecret/docs/nitro-deploy.md +++ b/services/opensecret/docs/nitro-deploy.md @@ -74,7 +74,7 @@ artifact's `pcr.json`; the deployment verifier rejects anything else. ## CI approval checks -`opensecret-eif.yml` builds dev/prod EIF candidates on Linux ARM64 for +The root `ci.yml` `os-eif` lane builds dev/prod EIF candidates on Linux ARM64 for same-repository PRs that change measured inputs without editing PCR approvals. The candidate job explicitly realizes and inspects the exact user initramfs passed to the EIF builder, including when the final EIF comes from cache, From cac3de774bdcfd9366bfc9291b44640ec70aa98a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:00:45 +0000 Subject: [PATCH 06/21] ci: finish the rebuild: signed-lane hygiene, hook on the planner, CI docs Signed Research workflows get job timeouts and job-level OIDC and attestation scopes; Maple Dev TestFlight builds only for Research web or iOS inputs (dispatch always builds); CodeQL runs on every master push, per SHA, never cancelled. The temporary policy and contract allowlists are gone. The pre-commit hook selects components with `plan.py hook --staged` from the lane table, and the Agent hook runs the agent-lint and agent-test recipes. docs/ci.md is new, docs/repository-workflows.md is rewritten, AGENTS.md gains CI rules for agents, and the unused flake apps.ci-* outputs are removed. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/develop-maple-agent/SKILL.md | 6 +- .agents/skills/release-maple/SKILL.md | 9 +- .githooks/pre-commit | 10 +- .githooks/repo-checks | 16 +- .github/workflows/android-build.yml | 10 +- .github/workflows/app-change-detection.yml | 1 + .github/workflows/codeql.yml | 10 +- .github/workflows/desktop-build.yml | 25 +- .github/workflows/ios-dev-testflight.yml | 16 +- .github/workflows/mobile-build.yml | 4 + .../workflows/signing-credentials-check.yml | 5 + AGENTS.md | 17 +- apps/maple-agent/.githooks/pre-commit | 19 +- apps/maple-agent/CLAUDE.md | 5 +- docs/ci.md | 97 ++++ docs/ios-dev-testflight.md | 9 +- docs/repository-workflows.md | 444 ++++++++---------- flake.nix | 42 -- scripts/ci/hook_change_detection.py | 218 --------- scripts/ci/lanes.py | 35 +- scripts/ci/plan.py | 39 +- scripts/ci/test_contracts.py | 32 +- scripts/ci/test_hook_change_detection.py | 185 -------- scripts/ci/test_plan.py | 125 ++++- scripts/ci/test_workflow_policy.py | 33 +- 25 files changed, 588 insertions(+), 824 deletions(-) create mode 100644 docs/ci.md delete mode 100644 scripts/ci/hook_change_detection.py delete mode 100644 scripts/ci/test_hook_change_detection.py diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 1c6334427..6f18b43a3 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -33,9 +33,9 @@ just ci and warning-denied workspace builds/tests. Use `just release` for optimized build and performance evidence. Root `just agent-check`, `agent-build`, and `agent-dev` enter this component environment. The repository pre-commit hook -runs `cargo fmt`, one workspace Clippy pass, and the workspace tests in this -shell when Agent files are staged; set `MAPLE_HOOK_FULL=1` for the complete -`just ci`. Root `nix flake check +runs `just ci-lint` and `just ci-test` (the `agent-lint` and `agent-test` CI +lanes) in this shell when Agent files are staged; `MAPLE_HOOK_FULL=1` adds +`just ci-extra`, the rest of `just ci`. Root `nix flake check --no-update-lock-file` additionally validates workflow selection and security contracts when CI, Nix, or routing changes. diff --git a/.agents/skills/release-maple/SKILL.md b/.agents/skills/release-maple/SKILL.md index 250b4a110..739344ceb 100644 --- a/.agents/skills/release-maple/SKILL.md +++ b/.agents/skills/release-maple/SKILL.md @@ -19,8 +19,9 @@ commit, external effect, and authority provided by the user. number. Its master-only manual dispatch forces a fresh build even without app changes. After a duplicate-build rejection, dispatch this full workflow from master; retrying only submission reuses the rejected IPA's build number. -- The independent `Maple Dev TestFlight` workflow also builds every master - push and uploads the distinct `cloud.opensecret.maple.dev` application for +- The independent `Maple Dev TestFlight` workflow builds master pushes that + change Research web or iOS inputs, and every master dispatch, and uploads the + distinct `cloud.opensecret.maple.dev` application for internal testing against the existing development services. Manual dispatch accepts master only. Its app identity, credentials, artifact verification, internal group setup, and retry contract are documented in @@ -129,8 +130,8 @@ head_sha="$(printf '%s' "$preflight" | jq -r .head_sha)" The script requires a clean current `master`, exact manifest version parity, a newer version and unused tag, and successful executed master CI jobs. CodeQL must run on the exact release commit; canonical master push, nightly, -or manual CodeQL runs qualify. If the nightly scan has not covered that commit, -run CodeQL manually from master before preflight. Other workflows still require +or manual CodeQL runs qualify. Every master push runs it; if that run is +missing or failed, run CodeQL manually from master before preflight. Other workflows still require master-push evidence. Research test/build evidence may come from an ancestor only when the complete Git tree diff contains exclusively independent Agent, documentation, agent guidance, or release-gate test-harness diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d624512a8..ab90f213c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,8 +1,9 @@ #!/bin/sh # Maple monorepo pre-commit hook. # -# Classifies the staged paths with scripts/ci/hook_change_detection.py and runs -# each selected component's own .githooks/pre-commit inside that component's +# `scripts/ci/plan.py hook --staged` selects components from the ci.yml lane +# table (scripts/ci/lanes.py): a lane's own paths select its `hook` component. +# Each selected component's .githooks/pre-commit runs inside that component's # Nix flake, so the tools match CI. Without Nix the same commands run from PATH # with a warning. Only fast, hermetic checks run here (format, lint, type # checks, unit tests); integration suites, cargo-deny, nix flake check and @@ -48,9 +49,8 @@ for candidate in python3 /usr/bin/python3 /run/current-system/sw/bin/python3; do done [ -n "$PYTHON" ] || hook_fail "python3 is required to classify staged paths (it is in every Maple Nix shell)." -SELECTED=$(git diff --cached --name-only -z --diff-filter=ACMR \ - | "$PYTHON" "$REPO_ROOT/scripts/ci/hook_change_detection.py" --selected) \ - || hook_fail "hook_change_detection.py failed." +SELECTED=$("$PYTHON" "$REPO_ROOT/scripts/ci/plan.py" hook --staged) \ + || hook_fail "scripts/ci/plan.py hook failed." SELECTED=$(printf '%s\n' "$SELECTED" | tr '\n' ' ') if [ -n "${MAPLE_HOOK_ONLY:-}" ]; then diff --git a/.githooks/repo-checks b/.githooks/repo-checks index 337974fad..dfc09f9f7 100755 --- a/.githooks/repo-checks +++ b/.githooks/repo-checks @@ -1,8 +1,8 @@ #!/bin/sh -# Repository-level pre-commit checks: workflow lint and CI script unit tests. -# Runs from the repository root inside the root flake's ci shell. -# CI equivalent: the ci.yml repo lane (nix flake check), minus the slow -# release-gates and toolchain checks. +# Repository-level pre-commit checks: workflow lint and the CI script tests. +# Runs from the repository root inside the root flake's ci shell whenever the +# staged paths select the ci.yml repo lane. That lane runs every root flake +# check (`just lane repo`); the release gates and toolchain checks stay there. set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" @@ -23,9 +23,7 @@ if [ -n "$workflows" ]; then done fi -if [ -n "$(hook_staged_matching '^(scripts/ci/|\.github/workflows/|flake\.(nix|lock)$)')" ]; then - hook_require python3 - hook_run python3 -I -m unittest discover -s scripts/ci -p 'test_*.py' -fi +hook_require python3 +hook_run python3 -I -m unittest discover -s scripts/ci -p 'test_*.py' -hook_log "Reminder: run 'nix flake check --no-update-lock-file' before pushing flake, workflow, or release changes." +hook_log "Reminder: run 'just lane repo' (nix flake check) before pushing flake, workflow, or release changes." diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 0afcf7444..d656200d6 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -2,9 +2,6 @@ name: Android App CI permissions: contents: read - id-token: write - attestations: write - artifact-metadata: write on: push: @@ -19,6 +16,12 @@ jobs: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.android != 'false') }} runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 90 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -107,6 +110,7 @@ jobs: verify-android-artifacts: needs: build-android runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: diff --git a/.github/workflows/app-change-detection.yml b/.github/workflows/app-change-detection.yml index 64367d618..9b758e42c 100644 --- a/.github/workflows/app-change-detection.yml +++ b/.github/workflows/app-change-detection.yml @@ -28,6 +28,7 @@ permissions: jobs: detect: runs-on: ubuntu-latest + timeout-minutes: 10 outputs: frontend: ${{ steps.classify.outputs.frontend }} macos: ${{ steps.classify.outputs.macos }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 187ec5668..5aeab9f87 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,7 +1,11 @@ -# Nightly security analysis stays outside merge CI; manual runs support releases. +# Security analysis of every master commit, outside ci-ok. The release preflight +# (.agents/skills/release-maple/scripts/ci_evidence.py) needs it on the exact +# release commit, so runs are grouped per SHA and never cancelled. name: CodeQL on: + push: + branches: [master] schedule: - cron: "28 5 * * *" workflow_dispatch: @@ -10,8 +14,8 @@ permissions: contents: read concurrency: - group: codeql-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: codeql-${{ github.sha }} + cancel-in-progress: false jobs: analyze: diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 223dfbc88..0aa30777a 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -2,9 +2,6 @@ name: Desktop App CI permissions: contents: read - id-token: write - attestations: write - artifact-metadata: write on: push: @@ -19,6 +16,12 @@ jobs: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.macos != 'false') }} runs-on: blacksmith-6vcpu-macos-26 + timeout-minutes: 120 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -95,6 +98,12 @@ jobs: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.linux != 'false') }} runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 90 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: @@ -176,6 +185,7 @@ jobs: needs: - build-linux runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -205,6 +215,7 @@ jobs: needs: - build-macos runs-on: macos-26 + timeout-minutes: 30 permissions: contents: read steps: @@ -234,7 +245,13 @@ jobs: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.windows != 'false') }} runs-on: windows-2025 + timeout-minutes: 150 environment: windows-signing + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write env: MAPLE_WINDOWS_ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} MAPLE_WINDOWS_ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} @@ -358,6 +375,7 @@ jobs: needs: - build-windows runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -389,6 +407,7 @@ jobs: - verify-macos-desktop-artifacts - verify-windows-desktop-artifacts runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify desktop artifact checks completed run: echo "Desktop artifact verification completed for Linux, macOS, and Windows." diff --git a/.github/workflows/ios-dev-testflight.yml b/.github/workflows/ios-dev-testflight.yml index 56432b4d8..5b7cf75e4 100644 --- a/.github/workflows/ios-dev-testflight.yml +++ b/.github/workflows/ios-dev-testflight.yml @@ -16,11 +16,25 @@ concurrency: queue: max jobs: - build-ios-dev: + # Pushes build only when they change Research web or iOS inputs, as in + # mobile-build.yml; a manual dispatch always builds. Every run takes a run + # number, built or not, so build numbers keep increasing. `changes` also runs + # on dispatch so that no ancestor of the upload job is ever skipped. + changes: if: >- github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/app-change-detection.yml + + build-ios-dev: + needs: changes + if: >- + always() && !cancelled() && + github.repository == 'MaplePrivacyLabs/Maple' && + github.ref == 'refs/heads/master' && + (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && + (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.ios != 'false'))) environment: apple-signing runs-on: blacksmith-6vcpu-macos-26 timeout-minutes: 240 diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index de914c085..4a04c6a33 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -35,6 +35,8 @@ jobs: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.ios != 'false') runs-on: blacksmith-6vcpu-macos-26 + # The ONNX source build and the app build each allow 90 minutes. + timeout-minutes: 240 permissions: contents: read id-token: write @@ -119,6 +121,7 @@ jobs: verify-ios-artifacts: needs: build-ios runs-on: macos-26 + timeout-minutes: 30 permissions: contents: read steps: @@ -154,6 +157,7 @@ jobs: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') needs: verify-ios-artifacts runs-on: macos-26 + timeout-minutes: 45 permissions: contents: read steps: diff --git a/.github/workflows/signing-credentials-check.yml b/.github/workflows/signing-credentials-check.yml index 138e8ba28..52682dbb0 100644 --- a/.github/workflows/signing-credentials-check.yml +++ b/.github/workflows/signing-credentials-check.yml @@ -10,6 +10,7 @@ jobs: desktop-signing: environment: desktop-signing runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify configured credential availability shell: python @@ -33,6 +34,7 @@ jobs: apple-signing: environment: apple-signing runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify configured credential availability shell: python @@ -52,6 +54,7 @@ jobs: android-signing: environment: android-signing runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify configured credential availability shell: python @@ -70,6 +73,7 @@ jobs: windows-signing: environment: windows-signing runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify configured credential availability shell: python @@ -97,6 +101,7 @@ jobs: zapstore-publishing: environment: zapstore-publishing runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify configured credential availability shell: python diff --git a/AGENTS.md b/AGENTS.md index 38c61c425..15c378e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ out of the task. | `proxy/` | [Proxy guide](proxy/README.md); `$develop-maple-proxy` for the separate OpenAI-compatible relay | | `services/opensecret/` | [Backend guide](services/opensecret/AGENTS.md); `$develop-opensecret` and its API/provider/validation/security skills | | `services/updates/` | [Updater guide](services/updates/README.md); preserve deployed identity and installed-client metadata compatibility | -| Shared CI, Nix, hooks, docs | [Repository workflows](docs/repository-workflows.md); root `scripts/`, `.github/`, `.agents/`, flake, Just and repository metadata | +| Shared CI, Nix, hooks, docs | [CI](docs/ci.md) and [repository workflows](docs/repository-workflows.md); root `scripts/`, `.github/`, `.agents/`, flake, Just and repository metadata | ## Choose the development environment @@ -70,9 +70,9 @@ or hook pass does not prove runtime integration. `./setup-hooks.sh` enables component-selected fast gates. Research Rust runs formatting and tests; strict Clippy is a separate optional diagnostic, absent from current Research CI/hook. Hooks do not run integration, cargo-deny, flake -checks, or packaging. Read [hook and CI details](docs/repository-workflows.md#pre-commit-hook) -when those boundaries change; run `nix flake check --no-update-lock-file` for -flake, workflow, CI-script, or release-configuration changes. +checks, or packaging. Read [hook details](docs/repository-workflows.md#pre-commit-hook) +when those boundaries change; run `just lane repo` (the root `nix flake check`) +for flake, workflow, CI-script, or release-configuration changes. Routine development never authorizes signing, releases, store uploads, deployment, or live-service changes. A master push can start signed builds/TestFlight uploads; @@ -81,6 +81,15 @@ authorized release work. EIF/PCR comparison, signed approval, and deployment are separate; preserve [manual PCR compatibility](services/opensecret/docs/pcr-compatibility.md) and installed-client raw URLs. Never change approvals to clear ordinary PR checks. +## CI rules for agents + +- `ci-ok` is the one required check; [CI](docs/ci.md) explains its lanes and labels. +- Add or change checks only as lanes in `scripts/ci/lanes.py`, with a `test_plan.py` case. +- Pull requests never save caches, and master runs are never cancelled. +- Never edit release or publisher workflows, signing environments, or OpenSecret + approval gating to clear a check; a failing contract test means fix the change. +- Run `just lane ` for the lanes your change selects before pushing. + ## Maintaining this guidance Keep this public repository self-contained: implementation contracts, setup, diff --git a/apps/maple-agent/.githooks/pre-commit b/apps/maple-agent/.githooks/pre-commit index 2a7940320..27d02f5e5 100755 --- a/apps/maple-agent/.githooks/pre-commit +++ b/apps/maple-agent/.githooks/pre-commit @@ -1,21 +1,14 @@ #!/bin/sh # GPUI Agent pre-commit checks. Runs from apps/maple-agent inside its own flake. -# CI equivalent: ci.yml's Agent lanes (`just ci`). The default tier runs one clippy -# pass and the workspace tests; MAPLE_HOOK_FULL=1 runs the complete `just ci` -# (every feature-set clippy plus the headless tests). +# CI equivalent: the recipes of ci.yml's agent-lint and agent-test lanes. +# MAPLE_HOOK_FULL=1 adds agent-full-linux's `just ci-extra` (the rest of `just ci`). set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" -hook_require cargo +hook_require cargo just python3 +hook_run just ci-lint +hook_run just ci-test if [ "${MAPLE_HOOK_FULL:-0}" = "1" ]; then - hook_require just - hook_run just ci - exit 0 + hook_run just ci-extra fi - -hook_run cargo fmt --all -- --check -hook_require python3 -hook_run python3 scripts/test_git_metadata.py -hook_run cargo clippy --workspace --all-targets --locked -- -D warnings -hook_run cargo test --workspace --locked diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index b0b388754..7e04621de 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -23,8 +23,9 @@ and release verification together when changing the compiled Auth destination. together (format, clippy with `-D warnings` for every feature set, tests); root `just lane ` runs one lane exactly as CI does. Run `just ci` before a commit. The repository pre-commit hook (`.githooks/pre-commit` here) runs -format, one workspace clippy pass, and the workspace tests when Agent files -are staged; `MAPLE_HOOK_FULL=1` runs the full `just ci`. +`just ci-lint` and `just ci-test`, the recipes of CI's `agent-lint` and +`agent-test` lanes, when Agent files are staged; `MAPLE_HOOK_FULL=1` adds +`just ci-extra`. Build and test through `just` or `nix develop` so this checkout shares Cargo intermediates with other Agent worktrees diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 000000000..1ec17ee4d --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,97 @@ +# CI + +`.github/workflows/ci.yml` runs every check a pull request needs. Its one +verdict, `ci-ok`, is the required status check: it passes when every lane the +planner selected succeeded, and an unselected lane may only be skipped. Signed +builds, publication, caches and the contracts CI keeps are in +[repository workflows](repository-workflows.md). + +## How lanes are chosen + +A lane is one check, defined once in `scripts/ci/lanes.py`: its own paths, the +consumers it fans out to (`also`), events, labels, runner, Nix shell, command +and timeout. `scripts/ci/plan.py` (the `plan` job) selects lanes from the diff: + +- A pull request is judged on its own diff from the merge base with its base + branch, so each layer of a stack gets its own `ci-ok`. A merge-queue group + uses its base and head; a master push uses `before..after`. +- Docs, guides and component hooks select nothing. A path that no lane owns + selects every lane. Editing `ci.yml`, the planner or `.github/actions/` + selects every gate lane. +- If selection fails on a pull request, `plan` and `ci-ok` fail; on master, + nightly and dispatch runs every lane runs instead. +- OpenSecret lanes are selected by `scripts/ci/opensecret_change_detection.py`, + imported unchanged. + +The `plan` job summary lists each selected lane and why. To preview locally: + +```sh +python3 scripts/ci/plan.py explain --base origin/master --head HEAD +``` + +## Tiers + +| When | What runs | +| --- | --- | +| Pull request, merge queue | Selected lanes, including unsigned platform and package rehearsals when their native inputs change. A new push cancels the superseded run. | +| Master push | The selected lanes again (for Agent inputs, also the full Agent suites on Linux, macOS and Windows), the signed builds, the release-evidence workflows and CodeQL. Never cancelled. | +| Nightly | `ci.yml` runs its nightly lanes on the master tip, which also refreshes their caches. `nightly.yml` runs advisories, the SQLCipher provider suite on every OS, the store benchmark and OpenSecret's x86_64 package. Failures open or update one `ci-nightly` issue; a green night closes it. | +| On demand | PR labels (below), or a `ci.yml` dispatch with comma-separated lane ids or `all`. | + +## Labels + +Add the label, then re-run the CI workflow: `plan` reads labels when it runs. + +| Label | Adds | +| --- | --- | +| `ci:full` | Every lane except the OpenSecret EIF candidate | +| `ci:package` | Unsigned Agent and Research desktop packages on every OS | +| `ci:windows` | Agent Windows Clippy and the Research Windows package | +| `ci:ios`, `ci:android` | The Research iOS simulator or Android build | +| `ci:research-dev-macos` | The unsigned Research Dev app | +| `ci:perf` | The Agent store benchmark | + +## Run a lane locally + +`just lane ` (the same as `python3 scripts/ci/plan.py run `) runs the +lane's command in its pinned Nix shell, exactly as `ci.yml` does. macOS lanes +need macOS, Windows lanes need Windows with the toolchain from +`.github/actions/setup-agent-windows`, `os-sdk-e2e` needs a local PostgreSQL +and `os-eif` needs Linux on ARM64. Before pushing a workflow, CI-script or +flake change, run `just lane repo` (every root flake check). The +[pre-commit hook](repository-workflows.md#pre-commit-hook) runs the fast part +of the lanes your staged files select. + +## Add or change a lane + +1. Add or edit one `Lane(...)` in `scripts/ci/lanes.py`. Set `hook` when a + component pre-commit hook runs the same checks. +2. Add a `scripts/ci/test_plan.py` case showing which paths select it. +3. A lane in the generic `nix` or `windows` matrix needs nothing else. Add a + special `ci.yml` job only when its steps cannot be generic (services, OIDC, + artifacts, or a cache another workflow writes). The job id is the lane's + `job`, its real work is a script or recipe, and `ci-ok` needs it. +4. Run `python3 scripts/ci/plan.py lint` (every glob matches a tracked file, + every tracked file is owned or inert, the `ci.yml` jobs match the table) + and `just lane repo`. A new runner label also goes in + `.github/actionlint.yaml`. + +## Rules + +- Never add `paths:` to `ci.yml`; `ci-ok` must report on every pull request. +- Pull requests never save caches. Master and nightly runs save lane caches, + on Blacksmith runners only. +- Never cancel master, signed, evidence or nightly runs. +- Never edit the release or publisher workflows, signing environments or + OpenSecret approval gating to clear a check. A failing contract test means + the workflow change is wrong, not the contract. +- Keep each lane's work in a script or recipe, so it runs outside GitHub + Actions. + +## Runners + +Lanes use Blacksmith runners or free GitHub-hosted runners; the runner +constants are at the top of `scripts/ci/lanes.py`. During an outage, set the +repository variable `CI_LINUX_RUNNER`, `CI_LINUX_HEAVY_RUNNER`, +`CI_MACOS_RUNNER` or `CI_WINDOWS_RUNNER` to another label; no pull request is +needed, and a GitHub-hosted failover never saves a cache. diff --git a/docs/ios-dev-testflight.md b/docs/ios-dev-testflight.md index d6fd81ea2..afb4cba9e 100644 --- a/docs/ios-dev-testflight.md +++ b/docs/ios-dev-testflight.md @@ -68,10 +68,11 @@ and retain hosted-flow-before-native-build ordering for future handoff changes. ## Build and upload workflow -`.github/workflows/ios-dev-testflight.yml` starts for every push to `master`, -including documentation-only pushes. A manual dispatch is also available from -`master`. Other branches, forks, pull requests, tags, and GitHub Releases cannot -enter this workflow's signing or upload jobs. The independent production +`.github/workflows/ios-dev-testflight.yml` builds and uploads on pushes to +`master` that change Research web or iOS inputs, chosen by the same +`app-change-detection.yml` selector as production. A manual dispatch from +`master` always builds. Other branches, forks, pull requests, tags, and GitHub +Releases cannot enter this workflow's signing or upload jobs. The independent production `mobile-build.yml` keeps its change selection, artifact names, and TestFlight destination. Its own serial lane also supports a master-only manual dispatch to force a fresh production build. diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 8806405d9..ffc8bb818 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -1,77 +1,111 @@ # Repository checks and publication boundaries -Read this reference when changing hooks, workflow selection, shared CI, caching, -or publication configuration. Commands and paths are relative to the monorepo -root. For everyday setup, use the owning component guide and skill. +Read this reference when changing hooks, workflows, caches, runners or +publication configuration. [CI](ci.md) explains the pull-request checks: the +single required check `ci-ok`, the tiers, labels, the lane table in +`scripts/ci/lanes.py`, running a lane locally and adding one. Commands and +paths are relative to the monorepo root. ## Pre-commit hook -`./setup-hooks.sh` installs `.githooks/pre-commit`. It classifies staged paths -with `scripts/ci/hook_change_detection.py` and runs each affected component's -own `.githooks/pre-commit` inside that component's Nix flake, so the tools match -CI: the root `.#ci` shell for Research, Auth, `services/updates/`, the independent -Agent delivery checks in `scripts/ci/agent-updates.sh`, and repository -checks; the component flakes for `apps/maple-agent/`, `sdk/`, `proxy/`, and -`services/opensecret?submodules=1`. Without Nix it runs the same commands from -`PATH` and warns that results may differ. It runs each selected component's format, lint, type-check, and unit-test gates; -Research Rust runs formatting and tests, without Clippy. Other Rust components -include their documented Clippy checks; shared -crates do not fan out to their consumers. It never runs integration suites, -cargo-deny, `nix flake check`, or packaging. `MAPLE_HOOK_FULL=1` runs the -slower CI-complete variants (for example the Agent's full `just ci`); -`MAPLE_HOOK_SKIP=1` or `git commit --no-verify` bypasses it. The hook is a fast -local gate before the GitHub Actions cycle, not full CI parity. Keep the -classifier, its table-driven tests, and the component scripts in step with the -workflows when a lane changes. - -`scripts/ci/change_detection.py` routes expensive app packaging. It selects -desktop builds for proxy runtime inputs and native builds for Rust SDK build -inputs (`sdk/rust/Cargo.toml`, `src/`, `build.rs`, `assets/`), because the -proxy, the Agent and the Research native shell build both from the tree as -path dependencies; the proxy and Research lanes in `scripts/ci/lanes.py` list the same inputs. -TypeScript SDK changes never select app builds: each frontend builds the SDK -source its own manifest selects, so changing that manifest (a pin bump or a -local link) is what selects its lanes. Tests, docs, container-only inputs, and -standalone component lockfiles retain their independent lanes. Update the classifier and -its table-driven tests when the dependency graph or component layout changes. -OpenSecret's `ci.yml` lanes are selected by its own classifier, -`scripts/ci/opensecret_change_detection.py`, which `plan.py` imports unchanged: -`os-rust`, `os-flake-check`, `os-helpers` (the parent helper images), -`deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check, for PCR tooling -and approval edits) and `os-sdk-e2e`, which tests both SDKs against -`services/opensecret/` from the same checkout (`scripts/ci/opensecret-sdk-e2e.sh`). -`nightly.yml` builds the x86_64 package. Backend changes do not imply Research -or Agent packaging. -Agent desktop packages build on master pushes whose own diff changes Agent -inputs (`plan.py only agent-desktop`), and on every manual master dispatch. -Packaging-input PRs rehearse unsigned Dev packages on Linux and macOS in -`ci.yml` (Windows with the `ci:package` label); signing stays confined to -trusted master. -The `os-eif` lane builds dev/prod EIF candidates on same-repository PRs -changing measured inputs without editing PCR approvals, on the release builder's -`blacksmith-8vcpu-ubuntu-2404-arm` class. -Each diagnostic build explicitly realizes the user ramdisk selected by its EIF -derivation, even when Nix substitutes a cached final EIF. The entrypoint's CA -bundle must be present in that archive and omit the affected emSign roots. If -the source CA store path is also present, its bytes must match the archive. -The check records whether retained Comodo AAA is excluded from ordinary TLS -PEMs and explicitly rejected by OpenSSL for server trust. It records the -archived bundle and EIF hashes with the PCRs in the job summary; it does not upload -an artifact. `opensecret-eif.yml` compares measurements -on PRs editing approved PCR JSON, relevant master changes, and manual runs; that -approval status is not part of `ci-ok`. -Preserve ordinary backend PRs without fresh approvals and meaningful master -mismatches; these checks never change approvals, sign, release, or authorize -deployment. Master and same-repository PR EIF builds receive OIDC for FlakeHub caching. -Fork PRs and non-master manual runs use GitHub's branch-scoped cache without -OIDC. Preserve that head-repository boundary and verify cache changes on fresh -hosted runners, not just a warm local Nix store. See the [cache policy](../services/opensecret/docs/nitro-deploy.md#binary-caches-and-cold-run-validation). - -For Pages, read [the deployment guide](pages-deployments.md). Preserve -unprivileged preview builds and separate development/production profiles. -Credential-bearing publication executes trusted master. Publishing flags, -protected environments, and native Cloudflare build controls are operator -configuration, not consequences of merging source. +`./setup-hooks.sh` installs `.githooks/pre-commit`. It asks +`scripts/ci/plan.py hook --staged` which components to check: a lane's own +paths select the component in its `hook` field, without CI's fan-out to +consumers, and an unclassified path selects every component. Each selected +component's `.githooks/pre-commit` runs inside its Nix flake: the root `.#ci` +shell for repository checks, Research, Auth, `services/updates/` and +`scripts/ci/agent-updates.sh`; the component flakes for `apps/maple-agent/`, +`sdk/`, `proxy/` and `services/opensecret?submodules=1`. Without Nix the same +commands run from `PATH` with a warning. + +The hook runs the fast part of the selected lanes: format, lint, type checks +and unit tests. The Agent runs its `agent-lint` and `agent-test` recipes; +Research Rust runs formatting and tests, without Clippy; repository changes +run actionlint on staged workflows and the `scripts/ci` tests. Integration +suites, cargo-deny, `nix flake check` and packaging stay in CI. +`MAPLE_HOOK_FULL=1` runs slower variants where they exist, `MAPLE_HOOK_ONLY=a,b` +restricts the components, and `MAPLE_HOOK_SKIP=1` or `git commit --no-verify` +skips the hook. `scripts/ci/test_plan.py` keeps the hook's components and its +branches in step with the lane table. + +## Caches + +- Pull requests restore caches and never save one. +- Lane caches (`Swatinem/rust-cache`, prefix `ci-v1`, keyed by lane and + lockfile hashes) are saved by master pushes and the nightly run on Blacksmith + runners only. Heavy Rust caches therefore live in Blacksmith's store; GitHub's + own store, which is small, holds only what GitHub-hosted jobs need (the + Research Windows signed build, the release and the Nix installer). +- The signed master builds write the keys `release.yml` restores by prefix + (`-sccache-*`, `xcode-cache-*`, `release-onnxruntime-ios-*`, + `-bun-*`, `-gradle-*`). No pull-request job writes these prefixes; + the Research platform lanes only restore them. The iOS ONNX key text is + identical in every workflow that uses it. +- `agent-desktop-build.yml`'s unsigned builders save the exact key + `ci-v1-agent-release--` on master pushes, with + no fallback keys; the Agent packaging lanes only restore it. Signing, + attestation and verification jobs restore nothing. + +## Concurrency + +| Workflow | Group | Cancelled | +| --- | --- | --- | +| `ci.yml` | per PR, or per merge-queue head or SHA | superseded PR runs only | +| `frontend-tests`, `rust-tests`, `web-build`, `desktop-build`, `android-build`, `proxy-rust` | none | never | +| `codeql.yml` | per SHA | never | +| `agent-desktop-build.yml` | per run | never | +| `mobile-build.yml`, `ios-dev-testflight.yml` | one queue each (`queue: max`) | never | +| `opensecret-eif.yml`, `pages-preview-build.yml`, `auth-pages-ci.yml` | per PR or SHA | PR runs only | +| `nightly.yml` | one group | never | +| Publishers | their own serial groups | never | + +## Contracts + +`scripts/ci/test_contracts.py` runs in the `repo` lane and imports the code on +the other side of each contract. A failure means the workflow change is wrong: + +1. `release.yml` and the publishers (`pages-publish`, `proxy-publish`, + `updates-publish`, `zapstore-publish`, `sdk-publish-npm`, `sdk-publish-rust`, + `agent-updates-publish`, `auth-pages-publish`, `opensecret-eif-release`) + change only with owner review. Exactly one workflow is named `Release`, and + every `workflow_run` listener names its producer and checks the head + repository, conclusion and event. +2. `services/agent-updates/publisher/publisher.py` trusts + `agent-desktop-build.yml` by path, job names, artifact names and signer, and + its run number is the Agent build number: never rename that file. +3. The release preflight (`.agents/skills/release-maple/scripts/ci_evidence.py`) + needs the evidence jobs of `frontend-tests`, `rust-tests`, `web-build`, + `desktop-build`, `android-build` and `mobile-build` on master pushes, and + CodeQL on the exact commit. These run on every relevant master push and are + never cancelled. +4. The signed master builds write the release cache keys above, and + `ios-dev-testflight.yml`'s run number is the Maple Dev build number: never + rename that file. +5. OpenSecret EIF builds use the release builder's runner class, and only the + listed jobs receive `id-token: write` or use FlakeHub. +6. Each protected environment has a fixed set of consumer jobs; + `windows-signing` runs only on GitHub-hosted runners. +7. Docs, guides and READMEs name only workflows that exist. + +`scripts/ci/test_workflow_policy.py` holds the rules every workflow keeps: no +`pull_request_target`; actions pinned to commits; checkouts without persisted +credentials; read-only workflow permissions, with write scopes granted to the +jobs that need them; secrets only inside their environment's jobs; a pinned, +verified Nix install; a timeout on every job; `ci-ok` needing every other +`ci.yml` job; and declared runner labels. + +## Signed builds and publication + +Master pushes start signed Research builds (`desktop-build`, `android-build`, +`mobile-build`, `ios-dev-testflight`) when `app-change-detection.yml` and +`scripts/ci/change_detection.py` find Research inputs for that platform; a +manual dispatch always builds. The selector routes proxy runtime and Rust SDK +build inputs to the native builds, because the Research shell builds both from +the tree. TypeScript SDK changes never select app builds: each frontend builds +the SDK its own manifest selects. Agent desktop packages build on master pushes +that change Agent inputs (`plan.py only agent-desktop`) and on every manual +dispatch. Signing jobs keep their protected environments; approving one trusts +its runner with that environment's secrets. Routine development never authorizes releases, signing, store submission, deployment, or live-service changes. Never push to `master` as a validation @@ -80,201 +114,103 @@ artifacts to TestFlight. Creating a GitHub Release starts release builds and downstream publication. Use `$release-maple` only for explicitly requested release work, and report the tag and commit before publishing. -## Workflow checks - -The `repo` lane of `ci.yml` runs `nix flake check` when workflows, `scripts/ci/`, -hooks, the root flake, the release-preflight scripts, the Agent update publisher, -or the Research version files change. Besides actionlint and the release gates, -it runs `scripts/ci/test_workflow_policy.py` (invariants every workflow keeps) -and `scripts/ci/test_contracts.py` (what the release, the publishers, signing and -OpenSecret rely on). A contract failure means fix the workflow, not the contract. +For Pages, read [the deployment guide](pages-deployments.md). Credential-bearing +publication runs from trusted master; publishing flags, protected environments +and Cloudflare build controls are operator configuration, not consequences of +merging source. + +## Agent Windows updater checks + +`agent-windows-updater-tests.yml` runs on `windows-2025` when the updater, the +Windows bootstrap, the app's update and desktop code, the release profiles or +the Windows packaging scripts change, with no signing credentials, protected +environment or cache. `scripts/ci/windows-app-typecheck.ps1` typechecks +`maple-agent-app --all-targets` with `cargo check --locked`, Windows-only test +fixtures included, without code generation. `scripts/ci/windows-updater-tests.ps1` +refuses signing and publication credentials, then runs the native updater, +filesystem, Authenticode-rejection and bootstrap tests; every listed native case +must run, so a runner without the symlink privilege the reparse fixtures need +fails. Each script writes a receipt and its Cargo log, kept as an artifact even +on failure. Neither qualifies an official signature, a signed installed upgrade, +the visible GUI or a paired remote upgrade. + +The manual `agent-windows-signature-probe.yml` takes an exact historical +producer run, attempt and source and the artifact's ZIP and PE digests. It +checks that the trusted-master Windows Prod build, signing and download +verification jobs passed and that the ZIP and package identity match, then runs +the production Authenticode verifier on the PE without launching it. It has no +default reference and nothing depends on it; a pass proves historical signature +interoperability only. + +## OpenSecret checks + +`ci.yml` runs `os-rust`, `os-flake-check`, `os-helpers` (the parent helper +images), `deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check for +PCR tooling and approval edits) and `os-sdk-e2e`, which tests both SDKs against +`services/opensecret/` from the same checkout. The `os-eif` lane builds dev and +prod EIF candidates on same-repository PRs that change measured inputs without +editing PCR approvals, on the release builder's +`blacksmith-8vcpu-ubuntu-2404-arm` class, and reports their PCRs. +`opensecret-eif.yml` compares measurements on PRs that edit approved PCR JSON, +on relevant master changes and on manual runs; that approval status is not +part of `ci-ok`. These checks never change approvals, sign, release, or +authorize deployment. Master and same-repository PR EIF builds receive OIDC for +FlakeHub caching; fork PRs and non-master manual runs never do. Verify cache +changes on fresh hosted runners, not a warm local Nix store. Details: +[CI approval checks](../services/opensecret/docs/nitro-deploy.md#ci-approval-checks) +and the [cache policy](../services/opensecret/docs/nitro-deploy.md#binary-caches-and-cold-run-validation). ## Nix runtime -GitHub Actions jobs use Determinate Nix 3.23.3 through an immutable -`DeterminateSystems/determinate-nix-action` commit and an explicit -`source-tag: v3.23.3`. Keep the action revision and source tag synchronized when -upgrading. `scripts/ci/test_workflow_policy.py` rejects the floating installer, -an unexpected action revision or tag, installation before checkout, or a missing -runtime verification step. Each job checks the installed Determinate and -upstream Nix versions before doing build work. The workflows pass no GitHub -token to the installer; FlakeHub authentication remains limited to jobs that -separately receive `id-token: write`. - -The pinned `actionlint` has a small source patch in -`scripts/ci/patches/actionlint-stream-stdin.patch`: analyzer input streams after -the child starts, so workflow lint cannot fill a pipe before its reader runs. -The Nix package runs a large-input regression for stdout and combined output. -Remove the patch when the pinned upstream includes this behavior. -The macOS validation shell also caps Bash heredoc pipes at the POSIX 512-byte -minimum; larger bodies use its normal temporary-file path. Package regressions -exercise both heredocs and here-strings against the built Bash. Apple’s shell -bootstraps this macOS tool build so the cached Bash cannot block configure. - -## Linux runners - -Research desktop/Android compilation and Agent Linux CI/AppImage builds use -`blacksmith-8vcpu-ubuntu-2404`. Lightweight selectors, web builds, artifact -verification and the separate Agent attestation job remain on GitHub's standard -runners. Signing jobs retain their protected environments. The desktop release -build command only receives Apple environment variables on macOS, but that -condition does not restrict the runner's job secret context: approving a signing -job trusts its runner with the selected environment's secrets. PR builds have -no signing credentials and only restore caches that master writes. No -persistent vendor disks or Docker builders are configured by these workflows. - -Research Linux packaging builds AppImage and DEB through Tauri, then creates -the final RPM once from the normalized DEB payload. Its staging executable is -marked as RPM so Tauri selects the RPM updater entry and installer. Build and -download verifiers check the executable identity in all three package formats; -updater metadata, signing and publication keep their existing flow. - -Research's Unix compiler-cache statistics use `scripts/ci/sccache-stats.sh`, -which shares the build's cache configuration. In Actions the sccache socket -lives under `RUNNER_TEMP`, so separate Nix shells reach the same server despite -their different `TMPDIR` values. Cache archive restoration and compiler hits -remain separate measurements. - -## Agent lanes and caches - -Agent checks are `ci.yml` lanes in `scripts/ci/lanes.py`, reported by `ci-ok`. -PRs run `agent-lint`, `agent-test` and `agent-macos`; `agent-windows` on Windows -paths or the `ci:windows` label; `agent-supply-chain` and `agent-provider` when -dependency inputs change; and `agent-package-linux`/`agent-package-macos` when -packaging inputs change (`ci:package` adds Windows). Master pushes also run -`agent-windows` and `agent-full-{linux,macos,windows}`, never cancelled; -`ci:full` runs every lane on a PR and `ci:perf` the store benchmark. -`nightly.yml` runs the advisories, SQLCipher sources, the provider suite on all -three OSes and the store benchmark, and keeps one `ci-nightly` issue. - -Lane caches use `Swatinem/rust-cache` with exact keys (`ci-v1--` plus a -hash of the Agent `flake.lock` and `Cargo.lock`), written only by master pushes -and the nightly `ci.yml` run, on Blacksmith only. Windows has no Nix: -`.github/actions/setup-agent-windows` pins Rust (`agent-lint` checks it equals -the flake's), MSVC, native Perl and the linker. The unsigned -`agent-desktop-build.yml` builders restore and, on master pushes, save the -exact key `ci-v1-agent-release--` with no fallback; -the PR packaging lanes only restore it, and signing, attestation and verifier -jobs restore nothing. - -`agent-windows-updater-tests.yml` separately runs native updater, filesystem, -Authenticode rejection and application bootstrap component tests on -`windows-2025` for the affected Agent paths. It uses the existing pinned Windows -Rust and linker setup without signing credentials, release environments or -dependency-artifact restoration. Required native cases must execute, including -reparse fixtures; missing fixture privileges fail the job. Its source/run/attempt -receipt and Cargo logs are retained even on failure. The lightweight bootstrap -target does not replace the real Windows application build, official Maple -signature verification, signed installed A-to-B upgrade, visible GUI or paired -remote upgrade qualification. - -The same workflow independently typechecks `maple-agent-app --all-targets` on -Windows with `cargo check --locked`, including Windows-only desktop fixtures. -It runs in parallel with native updater tests and retains its own source/run -receipt and complete compiler log. This catches app/test type errors without -release code generation or waiting for release LTO. A successful typecheck does -not claim test execution, packaging or installed-upgrade qualification. - -The separate manual-only `agent-windows-signature-probe.yml` accepts an exact -historical producer run/attempt/source and artifact ZIP/PE digests. It validates -successful trusted-master Windows Prod build, signing and downloaded-consumer -jobs, bounded ZIP bytes and package identity before reading the selected PE with -the real admission/startup Authenticode verifier. It never launches that PE. -There is no default historical reference or dependency from ordinary PR tests; -expired references must be selected again explicitly. Its result proves only -historical signature interoperability, not the new updater's installed upgrade -or final-source release qualification. - -## Client and service lanes - -SDK, proxy, Research and service checks are `ci.yml` lanes too. `sdk-ts` and -`sdk-rust` run the publishers' own builder (`scripts/ci/build-sdk-publish.sh`); -`proxy` runs `just check`, rustdoc and `cargo machete`; `deps-research`, -`deps-sdk` and `deps-proxy` run cargo-deny advisories and bans when that -dependency graph changes; `proxy-container` builds the image when its recipe -changes, and master builds both architectures. `research-web`, `research-rust` -and `auth` run on PRs only: `frontend-tests.yml`, `web-build.yml`, -`rust-tests.yml` and `auth-pages-ci.yml` cover master, and `rust-tests.yml` -writes the `ci-v1-research-rust-` cache that `research-rust` restores. `updates` -and `agent-updates` run on every event. The unsigned Research platform builds -(`research-desktop-{macos,linux,windows}`, `research-ios`, `research-android`, -`research-dev-macos`) run on PRs that touch their native inputs or carry -`ci:package`, `ci:ios`, `ci:android`, `ci:windows` or `ci:research-dev-macos`. -They restore the caches the signed master builds save and never save one. -`proxy-rust.yml` rehearses the release's native proxy builds. - -## macOS runners and Apple toolchain - -Research desktop/iOS builds, Agent release-binary jobs -and native proxy macOS archives use ARM64 `blacksmith-6vcpu-macos-26` runners. -Artifact verifiers, TestFlight uploads and Agent protected signing use standard -GitHub `macos-26` runners; Agent PR packaging rehearsals use Blacksmith. -Research signed builders retain their existing `desktop-signing` or -`apple-signing` environment, so their provider receives that environment's -secret context. Existing master pushes -can build and upload independent Dev/Prod TestFlight artifacts after verification. -The Agent's final signed-package provenance identifies its GitHub -packaging/signing job; Blacksmith is trusted for prebuilt binary integrity. -Research desktop and iOS builds select stable Xcode 26.5, build `17F42`, from +Jobs install Determinate Nix 3.23.3 through an immutable +`DeterminateSystems/determinate-nix-action` commit with `source-tag: v3.23.3` +(`.github/actions/setup-nix`, or inline in the sacred workflows), then run +`scripts/ci/verify-nix-runtime.sh`. Keep the revision and tag in step when +upgrading. No GitHub token reaches the installer; FlakeHub authentication is +limited to jobs with `id-token: write`. + +The pinned `actionlint` carries +`scripts/ci/patches/actionlint-stream-stdin.patch`, so analyzer input streams +after the child starts; remove it when upstream includes this. The macOS shell +caps Bash heredoc pipes at 512 bytes, and larger bodies use a temporary file. + +## Runners and toolchains + +Compilation runs on Blacksmith runners (ARM64 `blacksmith-6vcpu-macos-26` for +macOS); lane runners are the constants in `scripts/ci/lanes.py`. Artifact +verifiers, TestFlight uploads, Agent attestation and protected signing use +GitHub-hosted runners. Windows signing +and the Windows release stay on GitHub's `windows-2025`; unsigned Agent and +Research Windows builds use `blacksmith-8vcpu-windows-2025`, with Rust, MSVC, +Perl and the linker pinned by `.github/actions/setup-agent-windows` for the +Agent. The Research Windows PR package has no cache, because its signed build's +cache lives in GitHub's store. + +Research desktop and iOS builds select Xcode 26.5 (`17F42`) from [`scripts/ci/apple-toolchain.json`](../scripts/ci/apple-toolchain.json) through -the shared selector, which aligns the runner's active `xcode-select` directory -and `DEVELOPER_DIR`; the root Nix shell exports the same version and build -for its Apple recipes. Tauri mobile tooling discovers the active Xcode directly. -Do not rely on the runner's default Xcode or beta app -names. The independent Agent shell selects a full installed Xcode, and the -SDK/proxy flakes own their Nix compiler and SDK inputs. - -The [iOS ONNX pins](../apps/maple-research/frontend/src-tauri/scripts/onnxruntime-pins.sh) -verify artifacts against the exact Xcode build. An Xcode upgrade requires -rebuilding and reviewing those hashes, keeping cache keys tied to the selected -version/build and artifact inputs, and validating desktop and iOS packaging. -Retiring a CI host image does not raise the shipped client's deployment -targets: Research still supports macOS 13.4 and iOS 16.0. - -iOS builds share the unsigned ONNX dependency cache written by trusted master -and release jobs. PRs only restore it; a miss builds locally and never saves a -PR cache. Every restored artifact is checked against the pinned Xcode and ONNX -hashes before use. Dev and Prod app build caches, signing and publication remain -independent; there is no separate ONNX cache warmer. - -## Windows runner pilot - -Agent Windows x64 release binaries build on `blacksmith-8vcpu-windows-2025` with -pinned Rust/MSVC selection, static CRT linkage and the selected SDK's shader -compiler. Credential-free builders upload the executable and third-party notices. A fresh -master job inspects embedded PE profile/source metadata without executing the -artifact, then reuses `windows-signing` and the existing Azure Artifact Signing -helpers for the app, NSIS uninstaller and installer. Separate native verifiers -check downloaded provenance, payload identity, signatures and isolated -install/uninstall; signing and verification stay on GitHub's `windows-2025`. -The `ci:package` PR label builds an unsigned Dev installer without credentials. -These packages are Actions downloads; Windows automatic updates and built-in -computer-use remain unsupported. - -The Research unsigned Windows PR package (`research-desktop-windows`, on -Windows paths or the `ci:windows`/`ci:package` labels) uses -`blacksmith-8vcpu-windows-2025` (Windows Server 2025, public beta) with the -pinned Bun/Rust, ONNX Runtime, VC runtime DLL staging and NSIS build. It has no -cache: the signed master build writes its cache to GitHub's store, which -Blacksmith runners cannot read. Downloaded package proofs are checked -independently on a standard GitHub runner. Signed Windows master -and release builds remain on GitHub's `windows-2025`; this pilot does not -validate Azure signing, updater signing, or installed application behavior. -Windows master/release artifact verifiers also use standard `ubuntu-latest` -runners rather than larger runners. +`scripts/ci/select-xcode.py`; the root Nix shell exports the same version for +its Apple recipes. Do not rely on a runner's default Xcode. The +[iOS ONNX pins](../apps/maple-research/frontend/src-tauri/scripts/onnxruntime-pins.sh) +verify artifacts against that exact build, so an Xcode upgrade means rebuilding +and reviewing those hashes and validating desktop and iOS packaging. A newer +CI image does not raise the shipped deployment targets: Research supports +macOS 13.4 and iOS 16.0. + +Research Linux packaging builds AppImage and DEB with Tauri, then the RPM from +the normalized DEB payload. `scripts/ci/sccache-stats.sh` reports compiler-cache +statistics with the build's own cache configuration. ## SDK consumption Follow the [consumer version policy](sdk-publishing.md#consumer-version-policy). -Consumers independently select published SDK pins; local links are supported -for development. Inspect each consumer's manifest/lockfile and actual SDK -source. SDK publication and upgrading a consumer are separate decisions. +Consumers select published SDK pins independently; local links are for +development. SDK publication and upgrading a consumer are separate decisions. ## Maintaining guidance Re-check prescriptive language against source, tests, and executed workflows. Correct material workflow, ownership, or validation changes alongside the -implementation. Keep unrelated drift separately scoped and avoid generic or -duplicated policy. Public guides describe implementation, supported setup, -tests, and CI; company credential administration and private operational -procedures belong with their owners outside this repository. +implementation. Keep unrelated drift separately scoped and avoid duplicated +policy. Public guides describe implementation, supported setup, tests, and CI; +credential administration and private operational procedures belong with their +owners outside this repository. diff --git a/flake.nix b/flake.nix index d2a08c5c0..4f53131e1 100644 --- a/flake.nix +++ b/flake.nix @@ -647,27 +647,6 @@ pathShellHook = packages: '' export PATH="${lib.makeBinPath packages}:$PATH" ''; - - mkNixApp = - name: shell: script: - (flake-utils.lib.mkApp { - drv = pkgs.writeShellApplication { - inherit name; - text = '' - # Reuse the Nix that invoked this app. Adding pkgs.nix here would - # silently replace a patched host Nix with the nixpkgs-locked version. - if ! command -v nix >/dev/null 2>&1; then - echo "error: nix must be available on PATH" >&2 - exit 127 - fi - exec nix develop --no-update-lock-file ".#${shell}" -c ${script} "$@" - ''; - }; - }) // { - meta.description = "Run ${script} through nix develop --no-update-lock-file .#${shell}"; - }; - - desktopCiShell = if pkgs.stdenv.isLinux then "desktop-linux" else "ci"; in { devShells = { @@ -820,27 +799,6 @@ touch "$out" ''; }; - - apps = { - ci-frontend = mkNixApp "maple-ci-frontend" "ci" "./scripts/ci/frontend.sh"; - ci-rust = mkNixApp "maple-ci-rust" "ci" "./scripts/ci/rust.sh"; - ci-desktop-pr = mkNixApp "maple-ci-desktop-pr" desktopCiShell "./scripts/ci/desktop-pr.sh"; - ci-desktop-release = mkNixApp "maple-ci-desktop-release" desktopCiShell "./scripts/ci/desktop-release.sh"; - ci-signed-desktop-release-rehearsal = mkNixApp "maple-ci-signed-desktop-release-rehearsal" desktopCiShell "./scripts/ci/signed-release-rehearsal.sh desktop"; - ci-latest-json = mkNixApp "maple-ci-latest-json" "ci" "./scripts/ci/latest-json.sh"; - ci-verify-release-artifacts = mkNixApp "maple-ci-verify-release-artifacts" "ci" "./scripts/ci/verify-release-artifacts.sh"; - } - // lib.optionalAttrs pkgs.stdenv.isLinux { - ci-android-pr = mkNixApp "maple-ci-android-pr" "android" "./scripts/ci/android-pr.sh"; - ci-android-release = mkNixApp "maple-ci-android-release" "android" "./scripts/ci/android-release.sh"; - ci-signed-android-release-rehearsal = mkNixApp "maple-ci-signed-android-release-rehearsal" "android" "./scripts/ci/signed-release-rehearsal.sh android"; - } - // lib.optionalAttrs pkgs.stdenv.isDarwin { - ci-ios-onnxruntime = mkNixApp "maple-ci-ios-onnxruntime" "apple" "./scripts/ci/ios-onnxruntime.sh"; - ci-ios-pr = mkNixApp "maple-ci-ios-pr" "apple" "./scripts/ci/ios-pr.sh"; - ci-ios-release = mkNixApp "maple-ci-ios-release" "apple" "./scripts/ci/ios-release.sh"; - ci-signed-ios-release-rehearsal = mkNixApp "maple-ci-signed-ios-release-rehearsal" "apple" "./scripts/ci/signed-release-rehearsal.sh ios"; - }; } ); } diff --git a/scripts/ci/hook_change_detection.py b/scripts/ci/hook_change_detection.py deleted file mode 100644 index d4fee25ac..000000000 --- a/scripts/ci/hook_change_detection.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -"""Select the component pre-commit checks that staged paths require. - -The hook mirrors the lint/test lanes of the GitHub workflows, not the packaging -lanes. Each component is selected only when its own files are staged; shared -crates do not fan out to their consumers here (CI still covers consumers). -""" - -from __future__ import annotations - -import argparse -import sys -from collections.abc import Iterable - - -OUTPUTS = ( - "research_frontend", - "research_rust", - "auth", - "agent", - "sdk_rust", - "sdk_ts", - "proxy", - "opensecret", - "updates", - "agent_updates", - "repo", -) -ALL_OUTPUTS = frozenset(OUTPUTS) - -# Documentation and hook/skill metadata never select a check. -INERT_SUFFIXES = (".md", ".txt", "LICENSE", "AGENTS.md", "CLAUDE.md", ".gitignore") -INERT_ROOT_FILES = frozenset( - { - ".dockerignore", - ".gitmodules", - ".repo_ignore", - "justfile", - "repo.meta.json", - "setup-hooks.sh", - } -) -INERT_ROOT_PREFIXES = (".agents/", ".githooks/", "docs/") - -FRONTEND_PREFIX = "apps/maple-research/frontend/" -TAURI_PREFIX = "apps/maple-research/frontend/src-tauri/" -RESEARCH_INERT_PREFIXES = ("apps/maple-research/docs/", "apps/maple-research/.githooks/") -RESEARCH_INERT_FILES = frozenset({"apps/maple-research/deny.toml", "apps/maple-research/zapstore.yaml"}) - -AUTH_PREFIX = "apps/maple-auth/" - -AGENT_PREFIX = "apps/maple-agent/" -AGENT_INERT_PREFIXES = ("docs/", ".githooks/") -AGENT_PUBLICATION_INPUTS = frozenset({ - "apps/maple-agent/scripts/release-info.py", - "apps/maple-agent/release-profiles.json", -}) - -SDK_TS_PREFIXES = ("sdk/src/",) -SDK_TS_FILES = frozenset( - { - "sdk/.npmrc", - "sdk/.prettierrc.json", - "sdk/bun.lock", - "sdk/bunfig.toml", - "sdk/eslint.config.js", - "sdk/package.json", - "sdk/tsconfig.build.json", - "sdk/tsconfig.json", - "sdk/vite.config.ts", - } -) -SDK_RUST_PREFIX = "sdk/rust/" -SDK_SHARED_FILES = frozenset({"sdk/flake.nix", "sdk/flake.lock", "sdk/rust-toolchain.toml"}) -SDK_INERT_PREFIXES = ("sdk/docs/", "sdk/test/", "sdk/.githooks/") -SDK_INERT_FILES = frozenset({"sdk/deny.toml", "sdk/justfile"}) - -PROXY_PREFIX = "proxy/" -PROXY_INERT_PREFIXES = ("proxy/.githooks/",) -PROXY_INERT_FILES = frozenset({"proxy/deny.toml", "proxy/Dockerfile", "proxy/docker-compose.yml", "proxy/justfile"}) - -BACKEND_PREFIX = "services/opensecret/" -BACKEND_INERT_PREFIXES = ("docs/", ".agents/", ".github/", ".githooks/", "secretspec/", "nix/", "nitro-toolkit/", "privatemode-public/") -BACKEND_INERT_FILES = frozenset( - { - "deny.toml", - "justfile", - "secretspec.toml", - "continuum-proxy", - "continuum-proxy-x86_64", - "pcrDev.json", - "pcrDevHistory.json", - "pcrProd.json", - "pcrProdHistory.json", - "pcrPreview.json", - "pcrPreviewHistory.json", - "pcr_sign.js", - "pcr_verify.js", - } -) - -UPDATES_PREFIX = "services/updates/" -AGENT_UPDATES_PREFIX = "services/agent-updates/" - -REPO_PREFIXES = (".github/", "scripts/ci/") -REPO_FILES = frozenset({"flake.nix", "flake.lock"}) - - -def _inert_name(path: str) -> bool: - name = path.rsplit("/", 1)[-1] - return name.endswith(INERT_SUFFIXES) or name in {"README", "LICENSE"} - - -def classify_path(path: str) -> frozenset[str]: - """Return the hook components selected by one repository-relative path.""" - - if not path or path.startswith("/") or ".." in path.split("/"): - return ALL_OUTPUTS - if path in INERT_ROOT_FILES or path.startswith(INERT_ROOT_PREFIXES): - return frozenset() - if _inert_name(path): - return frozenset() - - if path in RESEARCH_INERT_FILES or path.startswith(RESEARCH_INERT_PREFIXES): - return frozenset() - if path.startswith(TAURI_PREFIX): - return frozenset({"research_rust"}) - if path.startswith(FRONTEND_PREFIX): - return frozenset({"research_frontend"}) - if path.startswith("apps/maple-research/"): - return frozenset({"research_frontend", "research_rust"}) - - if path.startswith(AUTH_PREFIX): - if path.removeprefix(AUTH_PREFIX).startswith(".githooks/"): - return frozenset() - return frozenset({"auth"}) - - if path.startswith(AGENT_PREFIX): - relative = path.removeprefix(AGENT_PREFIX) - if relative.startswith(AGENT_INERT_PREFIXES): - return frozenset() - if path in AGENT_PUBLICATION_INPUTS: - return frozenset({"agent", "agent_updates"}) - return frozenset({"agent"}) - - if path in SDK_SHARED_FILES: - return frozenset({"sdk_rust", "sdk_ts"}) - if path in SDK_INERT_FILES or path.startswith(SDK_INERT_PREFIXES): - return frozenset() - if path.startswith(SDK_RUST_PREFIX): - return frozenset({"sdk_rust"}) - if path in SDK_TS_FILES or path.startswith(SDK_TS_PREFIXES): - return frozenset({"sdk_ts"}) - if path.startswith("sdk/"): - return frozenset({"sdk_rust", "sdk_ts"}) - - if path in PROXY_INERT_FILES or path.startswith(PROXY_INERT_PREFIXES): - return frozenset() - if path.startswith(PROXY_PREFIX): - return frozenset({"proxy"}) - - if path.startswith(BACKEND_PREFIX): - relative = path.removeprefix(BACKEND_PREFIX) - if relative in BACKEND_INERT_FILES or relative.startswith(BACKEND_INERT_PREFIXES): - return frozenset() - return frozenset({"opensecret"}) - - if path.startswith("services/download-tracking/"): - return frozenset({"updates", "agent_updates"}) - - if path.startswith(UPDATES_PREFIX + ".githooks/"): - return frozenset() - if path.startswith(UPDATES_PREFIX): - return frozenset({"updates"}) - if path.startswith(AGENT_UPDATES_PREFIX + ".githooks/"): - return frozenset() - if path.startswith(AGENT_UPDATES_PREFIX): - return frozenset({"agent_updates"}) - - if path in REPO_FILES or path.startswith(REPO_PREFIXES): - return frozenset({"repo"}) - if path.startswith("scripts/"): - return frozenset() - - # A new, unclassified root gets every check until it is routed explicitly. - return ALL_OUTPUTS - - -def classify_paths(paths: Iterable[str]) -> dict[str, bool]: - selected: set[str] = set() - for path in paths: - selected.update(classify_path(path)) - return {name: name in selected for name in OUTPUTS} - - -def _read_null_delimited_paths() -> list[str]: - raw_paths = sys.stdin.buffer.read().split(b"\0") - return [path.decode("utf-8", errors="surrogateescape") for path in raw_paths if path] - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--all", action="store_true", help="Select every component without reading paths") - parser.add_argument("--selected", action="store_true", help="Print only the selected component names, one per line") - args = parser.parse_args() - - result = {name: True for name in OUTPUTS} if args.all else classify_paths(_read_null_delimited_paths()) - for name in OUTPUTS: - if args.selected: - if result[name]: - print(name) - else: - print(f"{name}={'true' if result[name] else 'false'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index fccfb3003..daf43d4e2 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -7,6 +7,11 @@ OpenSecret lanes have no globs: opensecret_change_detection.py, imported verbatim, selects them from each changed path that is not inert here (`opensecret` names its output). + +`hook` names the .githooks/pre-commit component that runs a lane's checks +locally. The hook selects it from the lane's own paths only, without the CI +fan-out (`also`, CI_INFRA); an OpenSecret lane's own paths are its selector's +routes under services/opensecret/. """ from __future__ import annotations @@ -109,6 +114,7 @@ class Lane: gate: bool = True # selected by CI_INFRA edits opensecret: Optional[str] = None # the opensecret_change_detection.py output that selects it submodules: bool = False # check out submodules, with the full history Nix needs + hook: Optional[str] = None # the pre-commit component that runs these checks AGENT_DIR = dict( @@ -161,7 +167,8 @@ def deny(manifest: str, component: str) -> str: WINDOWS_LABELS = ("ci:windows", "ci:package", "ci:full") # The paths only OpenSecret lanes consume; plan.py counts them as classified. -OPENSECRET = ("services/opensecret/**", "sdk/test/**", ".gitmodules", +OPENSECRET_HOME = "services/opensecret/**" +OPENSECRET = (OPENSECRET_HOME, "sdk/test/**", ".gitmodules", ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh") OPENSECRET_SHELL = "./services/opensecret?submodules=1#ci" OPENSECRET_ENV = (("OPENSECRET_DEV_POSTGRES", "0"), ("OPENSECRET_DEV_ENV", "0"), ("OPENSECRET_DEV_CONTAINERS", "0"), @@ -172,9 +179,9 @@ def deny(manifest: str, component: str) -> str: LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", - cache="agent-lint", timeout=15, **AGENT_FLAKE), + cache="agent-lint", timeout=15, hook="agent", **AGENT_FLAKE), Lane("agent-test", AGENT, also=RUST_SDK + PROXY_RUNTIME, runner=LINUX_HEAVY, - run="just ci-test", cache="agent-test", timeout=20, **AGENT_FLAKE), + run="just ci-test", cache="agent-test", timeout=20, hook="agent", **AGENT_FLAKE), Lane("agent-macos", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json"), runner=MACOS, run="cargo clippy --workspace --all-targets --locked -- -D warnings", @@ -222,20 +229,20 @@ def deny(manifest: str, component: str) -> str: ".agents/skills/release-maple/scripts/**", "services/agent-updates/publisher/**", "scripts/update-version.py", "apps/maple-research/zapstore.yaml", "proxy/Cargo.toml", "proxy/justfile", RESEARCH + "package.json", TAURI + "Cargo.toml", TAURI + "tauri.conf.json", - ), runner=GITHUB, run="nix flake check --no-update-lock-file --print-build-logs", timeout=15), + ), runner=GITHUB, run="nix flake check --no-update-lock-file --print-build-logs", timeout=15, hook="repo"), # The SDKs, proxy and container. build-sdk-publish.sh is the publishers' own builder. Lane("sdk-ts", SDK_TS + ("sdk/.prettierrc.json", "sdk/eslint.config.js", "sdk/justfile", "sdk/.env.example", "sdk/LICENSE.md", "scripts/ci/build-sdk-publish.sh", "scripts/ci/check-sdk-package-consumers.py"), - runner=GITHUB, run='./scripts/ci/build-sdk-publish.sh npm "$(mktemp -d)"'), + runner=GITHUB, run='./scripts/ci/build-sdk-publish.sh npm "$(mktemp -d)"', hook="sdk_ts"), Lane("sdk-rust", ("sdk/rust/**", "sdk/rust-toolchain.toml", "sdk/flake.*", "scripts/ci/build-sdk-publish.sh", "scripts/ci/sdk_crates_upload.py"), - runner=GITHUB, flake=".#pages", run=SDK_CRATE), # .#pages: a Python with tomllib + runner=GITHUB, flake=".#pages", run=SDK_CRATE, hook="sdk_rust"), # .#pages: a Python with tomllib Lane("proxy", ("proxy/**", "!proxy/Dockerfile"), also=RUST_SDK, runner=GITHUB, flake="./proxy", workdir="proxy", run='just check && RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features' " && cargo machete", - env=(("RUSTFLAGS", "-D warnings"),)), + env=(("RUSTFLAGS", "-D warnings"),), hook="proxy"), # PRs build the image only when its recipe changes; master builds both architectures. Lane("proxy-container", PROXY_CONTAINER, pr_paths=("proxy/Dockerfile", ".dockerignore"), runner=GITHUB, run="docker build --platform linux/amd64 -f proxy/Dockerfile .", timeout=20), @@ -256,11 +263,11 @@ def deny(manifest: str, component: str) -> str: Lane("research-web", (RESEARCH + "**", "!" + TAURI + "**", "scripts/prepare-*.sh", "scripts/ci/frontend.sh", "scripts/ci/web.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), events=PR_SIDE, runner=GITHUB, flake=".#ci", - run="./scripts/ci/frontend.sh && MAPLE_WEB_ENVIRONMENT=pr ./scripts/ci/web.sh"), + run="./scripts/ci/frontend.sh && MAPLE_WEB_ENVIRONMENT=pr ./scripts/ci/web.sh", hook="research_frontend"), Lane("research-rust", (TAURI + "**", "!" + TAURI + "gen/**", "scripts/ci/rust.sh", "scripts/ci/verify-local-rust-deps.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), events=PR_SIDE, - job="research-rust", flake=".#ci", timeout=20, + job="research-rust", flake=".#ci", timeout=20, hook="research_rust", run="(cd apps/maple-research/frontend/src-tauri && cargo fmt --check)" " && ./scripts/ci/verify-local-rust-deps.sh && ./scripts/ci/rust.sh"), # Unsigned platform builds for PRs that touch native inputs, or by label; @@ -303,15 +310,15 @@ def deny(manifest: str, component: str) -> str: # Services and the hosted Auth app. auth-pages-ci.yml builds Auth on master. Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", "flake.nix", "flake.lock"), - events=PR_SIDE, runner=GITHUB, flake=".#ci", run="./scripts/ci/auth-ci.sh"), + events=PR_SIDE, runner=GITHUB, flake=".#ci", run="./scripts/ci/auth-ci.sh", hook="auth"), Lane("updates", ("services/updates/**", "services/download-tracking/**", "scripts/ci/updates.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), - runner=GITHUB, flake=".#ci", run="./scripts/ci/updates.sh"), + runner=GITHUB, flake=".#ci", run="./scripts/ci/updates.sh", hook="updates"), Lane("agent-updates", ("services/agent-updates/**", "services/download-tracking/**", "scripts/ci/agent-updates.sh", "scripts/ci/test_agent_updates_workflows.py", "apps/maple-agent/release-profiles.json", "apps/maple-agent/scripts/release-info.py", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), - runner=GITHUB, flake=".#ci", run="./scripts/ci/agent-updates.sh"), + runner=GITHUB, flake=".#ci", run="./scripts/ci/agent-updates.sh", hook="agent_updates"), # OpenSecret, selected by its own selector (see the module docstring). The PR # EIF candidate builds only for same-repository PRs that edit no approval, on @@ -321,12 +328,12 @@ def deny(manifest: str, component: str) -> str: " && cargo test --locked --all-features", cache="os-rust", workspaces="services/opensecret -> target", cache_files=tuple( "services/opensecret/" + name for name in ("Cargo.lock", "rust-toolchain.toml", "flake.lock")), - submodules=True, timeout=30, env=OPENSECRET_ENV), + submodules=True, timeout=30, env=OPENSECRET_ENV, hook="opensecret"), Lane("os-sdk-e2e", (), opensecret="integration", job="os-sdk-e2e", run="./scripts/ci/opensecret-sdk-e2e.sh", timeout=45), Lane("os-flake-check", (), opensecret="nix", runner=GITHUB, workdir="services/opensecret", run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'", submodules=True, - timeout=30, env=OPENSECRET_ENV), + timeout=30, env=OPENSECRET_ENV, hook="opensecret"), # the hook runs its entrypoint checks Lane("os-helpers", (), opensecret="helpers", job="os-helpers", runner=Runner("ubuntu-24.04-arm"), run="./scripts/ci/opensecret-helpers.sh", timeout=30), Lane("os-pcr", (), opensecret="pcr", runner=GITHUB, timeout=10, diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index 7c42c79a1..b008ff3f2 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -6,6 +6,7 @@ only NAME another workflow's push selector: print selected=true|false opensecret opensecret-eif.yml's changes job: the OpenSecret selector's outputs explain show what changed paths select (stdin, or --base/--head) + hook .githooks/pre-commit: the components whose checks the staged paths select check-rust the Agent flake's rustc must equal the Windows rustup pin lint the table classifies every tracked file and matches ci.yml """ @@ -27,7 +28,7 @@ from typing import Dict, Iterable, List, Optional, Sequence from lanes import (AGENT_WINDOWS_RUST, CI_INFRA, DISPATCH, INERT, LANES, MATRIX_JOBS, NIGHTLY, NOT_YET_TRACKED, - OPENSECRET, PR, PR_EVENTS, PUSH, QUEUE, SELECTORS, Lane) + OPENSECRET, OPENSECRET_HOME, PR, PR_EVENTS, PUSH, QUEUE, SELECTORS, Lane) ROOT = Path(__file__).resolve().parents[2] CI_WORKFLOW = ROOT / ".github/workflows/ci.yml" @@ -113,6 +114,23 @@ def select(event: str, paths: Iterable[str], labels: Sequence[str] = ()) -> Sele return chosen +def hook_components(paths: Iterable[str]) -> List[str]: + """The pre-commit components for staged paths: a lane's own paths select its + `hook`, without CI's fan-out. An unclassified path selects every component.""" + components = list(dict.fromkeys(lane.hook for lane in LANES if lane.hook)) + chosen = set() + for path in paths: + if inert(path): + continue + if not classified(path): + chosen.update(components) + continue + checks = opensecret_checks(path) if matches(path, (OPENSECRET_HOME,)) else frozenset() + chosen.update(lane.hook for lane in LANES + if lane.hook and (lane.opensecret in checks or matches(path, lane.paths))) + return [name for name in components if name in chosen] + + def git(*args: str) -> str: return subprocess.run(["git", *args], cwd=ROOT, check=True, capture_output=True, text=True).stdout @@ -243,8 +261,7 @@ def explain(event: str, base: Optional[str], head: Optional[str]) -> int: print(f"no usable diff for {base}..{head}") return 1 else: - data = sys.stdin.read() - paths = [p for p in re.split("\0" if "\0" in data else "\n", data) if p.strip()] + paths = stdin_paths() selected = select(event, paths) print(f"{event}: {len(paths)} changed paths") for lane in LANES: @@ -260,6 +277,19 @@ def explain(event: str, base: Optional[str], head: Optional[str]) -> int: return 0 +def stdin_paths() -> List[str]: + data = sys.stdin.read() + return [p for p in re.split("\0" if "\0" in data else "\n", data) if p.strip()] + + +def hook(staged: bool) -> int: + # Inherits the hook's Git environment, so this reads the index being committed. + paths = (git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0") if staged + else stdin_paths()) + sys.stdout.writelines(f"{name}\n" for name in hook_components(p for p in paths if p)) + return 0 + + def run(name: str) -> int: lane = next((lane for lane in LANES if lane.id == name), None) if lane is None or not lane.run: @@ -382,6 +412,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: explain_parser.add_argument("--event", default=PR, choices=(PR, QUEUE, PUSH)) explain_parser.add_argument("--base") explain_parser.add_argument("--head") + modes.add_parser("hook").add_argument("--staged", action="store_true", help="read the index, not stdin") modes.add_parser("check-rust") modes.add_parser("lint") args = parser.parse_args(argv) @@ -395,6 +426,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return opensecret() if args.mode == "explain": return explain(args.event, args.base, args.head) + if args.mode == "hook": + return hook(args.staged) if args.mode == "check-rust": return check_rust() files = tracked_files() diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index a3f39fce1..8f4218dea 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -90,15 +90,6 @@ def module(name: str, path: str): } DOCS = ("docs/**", "*/docs/**", "*/*/docs/**", ".agents/**", "**/README.md", "**/AGENTS.md", "**/CLAUDE.md") -TEMPORARY = { - # Signed-lane hygiene: CodeQL runs on every master push, never cancelled. - "evidence": frozenset({"codeql.yml"}), - # Signed-lane hygiene moves id-token from the workflow level to the attesting jobs. - "oidc": frozenset({("android-build.yml", "changes"), ("desktop-build.yml", "changes"), - ("desktop-build.yml", "verify-desktop-artifacts")}), -} - - def condition(job: dict) -> str: return " ".join(str(job.get("if", "")).split()) @@ -216,19 +207,18 @@ def expand(text, variant): self.assertEqual(proof["build"], "77") # run_number is the Agent build number def test_c3_release_preflight_finds_its_evidence_jobs_on_master_pushes(self): - broken = set() for name, required in ci_evidence.WORKFLOWS.items(): config = workflows().get(name) - self.assertIsNotNone(config, name) - push = config["on"].get("push") if isinstance(config["on"], dict) else None - names = {job.get("name", job_id) for job_id, job in config["jobs"].items()} - found = set(required) <= names if required else any(n.startswith("Analyze (") for n in names) - self.assertTrue(found, name) - if (not isinstance(push, dict) or "master" not in push.get("branches", ["master"]) - or cancels(config.get("concurrency")) - or any(cancels(job.get("concurrency")) for job in config["jobs"].values())): - broken.add(name) - self.assertEqual(broken, TEMPORARY["evidence"]) + with self.subTest(workflow=name): + self.assertIsNotNone(config) + push = config["on"].get("push") if isinstance(config["on"], dict) else None + names = {job.get("name", job_id) for job_id, job in config["jobs"].items()} + found = set(required) <= names if required else any(n.startswith("Analyze (") for n in names) + self.assertTrue(found) + self.assertIsInstance(push, dict) + self.assertIn("master", push.get("branches", ["master"])) + self.assertFalse(cancels(config.get("concurrency"))) + self.assertFalse(any(cancels(job.get("concurrency")) for job in config["jobs"].values())) def test_c4_signed_lanes_own_the_release_cache_keys(self): steps = list(cache_steps()) @@ -373,7 +363,7 @@ def plan(event: str, head, paths: list) -> dict: def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): minted = {(name, job_id) for name, job_id, job in jobs() if permissions(workflows()[name], job).get("id-token") == "write"} - self.assertEqual(minted - OIDC_JOBS, TEMPORARY["oidc"]) + self.assertEqual(minted, OIDC_JOBS) builder = workflows()["opensecret-eif-release.yml"]["jobs"]["build"]["runs-on"] flakehub = {(name, job_id) for name, job_id, job in jobs() for step in job.get("steps", []) if "flakehub-cache-action@" in step.get("uses", "")} diff --git a/scripts/ci/test_hook_change_detection.py b/scripts/ci/test_hook_change_detection.py deleted file mode 100644 index d6027772f..000000000 --- a/scripts/ci/test_hook_change_detection.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for the pre-commit hook component selector.""" - -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import unittest - -from hook_change_detection import OUTPUTS, classify_paths - - -def enabled(*names: str) -> dict[str, bool]: - selected = set(names) - return {name: name in selected for name in OUTPUTS} - - -class HookChangeDetectionTests(unittest.TestCase): - def assert_selects(self, paths: list[str], *names: str) -> None: - self.assertEqual(classify_paths(paths), enabled(*names)) - - def test_documentation_and_hook_metadata_select_nothing(self) -> None: - for path in ( - "README.md", - "AGENTS.md", - "docs/sdk-publishing.md", - ".agents/skills/develop-maple/SKILL.md", - ".githooks/pre-commit", - ".githooks/lib/common.sh", - "setup-hooks.sh", - "justfile", - "apps/maple-research/AGENTS.md", - "apps/maple-research/.githooks/pre-commit", - "apps/maple-auth/AGENTS.md", - "apps/maple-auth/.githooks/pre-commit", - "apps/maple-agent/AGENTS.md", - "apps/maple-agent/.githooks/pre-commit", - "sdk/README.md", - "sdk/.githooks/pre-commit", - "proxy/README.md", - "proxy/.githooks/pre-commit", - "services/opensecret/AGENTS.md", - "services/opensecret/.githooks/pre-commit", - "services/updates/.githooks/pre-commit", - "services/agent-updates/.githooks/pre-commit", - "services/opensecret/docs/nitro-deploy.md", - "services/opensecret/pcrProd.json", - "scripts/prepare-frontend-deps.sh", - ): - with self.subTest(path=path): - self.assert_selects([path]) - - def test_research_components(self) -> None: - self.assert_selects(["apps/maple-research/frontend/src/App.tsx"], "research_frontend") - self.assert_selects(["apps/maple-research/frontend/package.json"], "research_frontend") - self.assert_selects(["apps/maple-research/frontend/src-tauri/src/lib.rs"], "research_rust") - self.assert_selects(["apps/maple-research/frontend/src-tauri/Cargo.lock"], "research_rust") - self.assert_selects( - ["apps/maple-research/frontend/src/App.tsx", "apps/maple-research/frontend/src-tauri/src/lib.rs"], - "research_frontend", - "research_rust", - ) - - def test_auth_component_does_not_select_research(self) -> None: - for path in ("apps/maple-auth/src/main.tsx", "apps/maple-auth/package.json", - "apps/maple-auth/bun.lock", "apps/maple-auth/vite.config.ts"): - with self.subTest(path=path): - self.assert_selects([path], "auth") - self.assert_selects( - ["apps/maple-auth/src/main.tsx", "apps/maple-research/frontend/src/App.tsx"], - "auth", "research_frontend", - ) - - def test_agent_component(self) -> None: - for path in ("apps/maple-agent/crates/maple-agent/src/agent.rs", "apps/maple-agent/Cargo.lock", "apps/maple-agent/flake.nix"): - with self.subTest(path=path): - self.assert_selects([path], "agent") - - def test_agent_publication_inputs_select_both_consumers_without_research(self) -> None: - for path in ("apps/maple-agent/scripts/release-info.py", "apps/maple-agent/release-profiles.json"): - with self.subTest(path=path): - self.assert_selects([path], "agent", "agent_updates") - - def test_sdk_components(self) -> None: - self.assert_selects(["sdk/rust/src/client.rs"], "sdk_rust") - self.assert_selects(["sdk/rust/tests/crypto.rs"], "sdk_rust") - self.assert_selects(["sdk/src/lib/main.tsx"], "sdk_ts") - self.assert_selects(["sdk/src/lib/test/models.test.ts"], "sdk_ts") - self.assert_selects(["sdk/package.json"], "sdk_ts") - self.assert_selects(["sdk/flake.lock"], "sdk_rust", "sdk_ts") - self.assert_selects(["sdk/test/integration/bootstrap.sql"]) - - def test_proxy_opensecret_and_updates(self) -> None: - self.assert_selects(["proxy/src/main.rs"], "proxy") - self.assert_selects(["proxy/Cargo.toml"], "proxy") - self.assert_selects(["proxy/deny.toml"]) - self.assert_selects(["services/opensecret/src/main.rs"], "opensecret") - self.assert_selects(["services/opensecret/migrations/0001/up.sql"], "opensecret") - self.assert_selects(["services/opensecret/entrypoint.sh"], "opensecret") - self.assert_selects(["services/opensecret/tests/entrypoint_entropy_preflight.sh"], "opensecret") - self.assert_selects(["services/opensecret/nix/eif.nix"]) - self.assert_selects(["services/updates/src/index.ts"], "updates") - - def test_download_tracking_selects_both_workers(self) -> None: - self.assert_selects(["services/download-tracking/tracking.ts"], "updates", "agent_updates") - - def test_agent_updates_component_is_isolated_from_research(self) -> None: - for path in ( - "services/agent-updates/src/worker.ts", - "services/agent-updates/publisher/publisher.py", - "services/agent-updates/publisher/test_publisher.py", - "services/agent-updates/bun.lock", - "services/agent-updates/wrangler.jsonc", - ): - with self.subTest(path=path): - self.assert_selects([path], "agent_updates") - - def test_shared_crates_do_not_fan_out_to_consumers(self) -> None: - self.assert_selects(["proxy/src/proxy.rs", "sdk/rust/src/client.rs"], "proxy", "sdk_rust") - self.assert_selects( - ["proxy/src/proxy.rs", "apps/maple-agent/Cargo.toml"], - "proxy", - "agent", - ) - - def test_repository_inputs(self) -> None: - for path in (".github/workflows/proxy-rust.yml", "scripts/ci/change_detection.py", "flake.nix", "flake.lock"): - with self.subTest(path=path): - self.assert_selects([path], "repo") - - def test_unknown_root_selects_everything(self) -> None: - self.assert_selects(["newservice/main.rs"], *OUTPUTS) - - -class AgentUpdatesHookTests(unittest.TestCase): - def test_service_paths_run_the_bash_ci_entrypoint_in_the_root_shell(self) -> None: - source = Path(__file__).resolve().parents[2] - for use_nix in (True, False): - with self.subTest(nix=use_nix), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / "repository with spaces" - root.mkdir() - for relative in ( - ".githooks/pre-commit", ".githooks/lib/common.sh", - "scripts/ci/hook_change_detection.py", - ): - destination = root / relative - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source / relative, destination) - script = root / "scripts/ci/agent-updates.sh" - script.write_text( - '#!/usr/bin/env bash\nset -euo pipefail\n' - 'selected=("$MAPLE_HOOK_SELECTED")\n' - 'printf "%s\\n%s\\n%s\\n" "${selected[0]}" "$PWD" ' - '"$MAPLE_HOOK_IN_NIX" > hook-result\n' - ) - tools = root / "tools" - tools.mkdir() - nix = tools / "nix" - nix.write_text( - '#!/bin/sh\n' - '[ "$1" = develop ] && [ "$2" = --no-update-lock-file ] ' - '&& [ "$3" = ".#ci" ] && [ "$4" = -c ] || exit 70\n' - 'shift 4\nexec "$@"\n' - ) - nix.chmod(0o755) - changed = root / "services/agent-updates/src/worker.ts" - changed.parent.mkdir(parents=True) - changed.write_text("export default {};\n") - environment = {key: value for key, value in os.environ.items() - if not key.startswith(("GIT_", "MAPLE_HOOK_"))} - environment["PATH"] = f"{tools}{os.pathsep}{environment['PATH']}" - environment["MAPLE_HOOK_NO_NIX"] = "0" if use_nix else "1" - subprocess.run(["git", "init", "-q"], cwd=root, env=environment, check=True) - subprocess.run(["git", "add", str(changed)], cwd=root, env=environment, check=True) - subprocess.run(["sh", ".githooks/pre-commit"], cwd=root, env=environment, - check=True, capture_output=True, text=True) - selected, directory, in_nix = (root / "hook-result").read_text().splitlines() - self.assertEqual(selected.strip(), "agent_updates") - self.assertEqual(Path(directory).resolve(), root.resolve()) - self.assertEqual(in_nix, "1" if use_nix else "0") - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 9d47d9ec0..9b2b7dde5 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -1,10 +1,12 @@ -"""Lane selection: the table, the planner's events, and the Agent selectors it replaced.""" +"""Lane selection: the table, the planner's events and the pre-commit hook.""" import contextlib import io import json import os from pathlib import Path +import re +import shutil import subprocess import sys import tempfile @@ -24,6 +26,7 @@ # What opensecret_change_detection.py's "all checks" selects in ci.yml. OPENSECRET_ALL = {"os-rust", "os-sdk-e2e", "os-flake-check", "os-helpers", "deps-opensecret", "os-eif"} SHA_A, SHA_B = "a" * 40, "b" * 40 +HOOK = list(dict.fromkeys(lane.hook for lane in lanes.LANES if lane.hook)) def active(event): @@ -39,12 +42,12 @@ def agent_lanes(lanes_): def agent(paths, event=PR): - """The decision agent_change_detection.py made: does any Agent check run?""" + """Does any Agent check run?""" return bool(agent_lanes(selected(paths, event))) -class PortedAgentSelectorTests(unittest.TestCase): - """Every case of test_agent_change_detection.py, as an Agent lane decision.""" +class AgentSelectionTests(unittest.TestCase): + """Which changes run Agent checks.""" def test_agent_runtime_build_and_asset_inputs_select_agent(self): for path in ( @@ -191,8 +194,120 @@ def test_every_tracked_file_is_classified_and_ci_yml_matches(self): self.assertIn("unclassified: newroot/x", plan.lint_problems(["newroot/x"])) +class HookTests(unittest.TestCase): + """The pre-commit hook's components: a path selects those of the lanes that own it.""" + + def assert_selects(self, paths, *names): + self.assertEqual(plan.hook_components(paths), [n for n in HOOK if n in names]) + + def test_every_component_has_a_branch_in_the_hook(self): + hook = (Path(plan.ROOT) / ".githooks/pre-commit").read_text() + self.assertEqual(set(re.findall(r"hook_selected (\w+)", hook)), set(HOOK)) + + def test_documentation_and_component_hooks_select_nothing(self): + for path in ( + "README.md", "AGENTS.md", "docs/sdk-publishing.md", ".agents/skills/develop-maple/SKILL.md", + "apps/maple-research/AGENTS.md", "apps/maple-research/.githooks/pre-commit", + "apps/maple-auth/AGENTS.md", "apps/maple-auth/.githooks/pre-commit", "apps/maple-agent/AGENTS.md", + "apps/maple-agent/.githooks/pre-commit", "sdk/README.md", "sdk/.githooks/pre-commit", "proxy/README.md", + "proxy/.githooks/pre-commit", "services/opensecret/AGENTS.md", "services/opensecret/.githooks/pre-commit", + "services/updates/.githooks/pre-commit", "services/agent-updates/.githooks/pre-commit", + "services/opensecret/docs/nitro-deploy.md", "services/opensecret/pcrProd.json", + "sdk/test/integration/bootstrap.sql", "apps/maple-research/deny.toml", + ): + with self.subTest(path=path): + self.assert_selects([path]) + + def test_component_inputs(self): + for paths, names in ( + (["apps/maple-research/frontend/src/App.tsx"], ("research_frontend",)), + (["apps/maple-research/frontend/src-tauri/src/lib.rs"], ("research_rust",)), + (["apps/maple-research/frontend/src-tauri/Cargo.lock"], ("research_rust",)), + (["apps/maple-research/frontend/src/App.tsx", "apps/maple-research/frontend/src-tauri/src/lib.rs"], + ("research_frontend", "research_rust")), + (["apps/maple-auth/src/main.tsx"], ("auth",)), (["apps/maple-auth/bun.lock"], ("auth",)), + (["apps/maple-auth/src/main.tsx", "apps/maple-research/frontend/src/App.tsx"], + ("auth", "research_frontend")), + (["apps/maple-agent/crates/maple-agent/src/agent.rs"], ("agent",)), + (["apps/maple-agent/Cargo.lock"], ("agent",)), (["apps/maple-agent/flake.nix"], ("agent",)), + (["apps/maple-agent/scripts/release-info.py"], ("agent", "agent_updates")), + (["apps/maple-agent/release-profiles.json"], ("agent", "agent_updates")), + (["sdk/rust/src/client.rs"], ("sdk_rust",)), (["sdk/rust/tests/crypto.rs"], ("sdk_rust",)), + (["sdk/src/lib/main.tsx"], ("sdk_ts",)), (["sdk/src/lib/test/models.test.ts"], ("sdk_ts",)), + (["sdk/package.json"], ("sdk_ts",)), (["sdk/flake.lock"], ("sdk_ts", "sdk_rust")), + (["proxy/src/main.rs"], ("proxy",)), + (["services/opensecret/src/main.rs"], ("opensecret",)), + (["services/opensecret/migrations/0001/up.sql"], ("opensecret",)), + (["services/opensecret/entrypoint.sh"], ("opensecret",)), + (["services/opensecret/tests/entrypoint_entropy_preflight.sh"], ("opensecret",)), + (["services/updates/src/index.ts"], ("updates",)), + (["services/agent-updates/src/worker.ts"], ("agent_updates",)), + (["services/agent-updates/bun.lock"], ("agent_updates",)), + (["services/agent-updates/wrangler.jsonc"], ("agent_updates",)), + (["services/agent-updates/publisher/publisher.py"], ("agent_updates", "repo")), + (["services/download-tracking/tracking.ts"], ("updates", "agent_updates")), + ([".github/workflows/desktop-build.yml"], ("repo",)), (["scripts/ci/change_detection.py"], ("repo",)), + ([".github/workflows/ci.yml"], ("repo",)), (["scripts/ci/lanes.py"], ("repo",)), + ): + with self.subTest(paths=paths): + self.assert_selects(paths, *names) + + def test_shared_crates_do_not_fan_out_to_their_consumers(self): + self.assert_selects(["proxy/src/proxy.rs", "sdk/rust/src/client.rs"], "proxy", "sdk_rust") + self.assert_selects(["proxy/src/proxy.rs", "apps/maple-agent/Cargo.toml"], "proxy", "agent") + # The OpenSecret selector's routes outside its own directory are CI fan-out too. + self.assert_selects(["scripts/ci/opensecret_change_detection.py"], "repo") + + def test_shared_inputs_select_every_lane_that_owns_them(self): + # Where the former selector chose one component, the table's owners all run. + for paths, names in ( + ([".githooks/pre-commit"], ("repo",)), (["setup-hooks.sh"], ("repo",)), (["justfile"], ("repo",)), + (["scripts/prepare-frontend-deps.sh"], ("research_frontend",)), + (["proxy/Cargo.toml"], ("proxy", "repo")), (["proxy/deny.toml"], ("proxy",)), + (["apps/maple-research/frontend/package.json"], ("research_frontend", "repo")), + (["flake.nix"], ("repo", "research_frontend", "research_rust", "auth", "updates", "agent_updates")), + (["services/opensecret/nix/eif.nix"], ("opensecret",)), + ): + with self.subTest(paths=paths): + self.assert_selects(paths, *names) + + def test_unknown_roots_select_every_component(self): + for path in ("newservice/main.rs", "../x"): + with self.subTest(path=path): + self.assert_selects([path], *HOOK) + + def test_the_hook_runs_the_selected_component_in_its_flake(self): + source = Path(plan.ROOT) + for use_nix in (True, False): + with self.subTest(nix=use_nix), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "repository with spaces" + for relative in (".githooks/pre-commit", ".githooks/lib/common.sh", "scripts/ci/plan.py", + "scripts/ci/lanes.py", "scripts/ci/opensecret_change_detection.py"): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / relative, root / relative) + (root / "scripts/ci/agent-updates.sh").write_text( + '#!/usr/bin/env bash\nset -euo pipefail\nselected=("$MAPLE_HOOK_SELECTED")\n' + 'printf "%s\\n%s\\n%s\\n" "${selected[0]}" "$PWD" "$MAPLE_HOOK_IN_NIX" > hook-result\n') + nix = root / "tools/nix" + nix.parent.mkdir() + nix.write_text('#!/bin/sh\n[ "$1" = develop ] && [ "$2" = --no-update-lock-file ] ' + '&& [ "$3" = ".#ci" ] && [ "$4" = -c ] || exit 70\nshift 4\nexec "$@"\n') + nix.chmod(0o755) + changed = root / "services/agent-updates/src/worker.ts" + changed.parent.mkdir(parents=True) + changed.write_text("export default {};\n") + env = {key: value for key, value in os.environ.items() if not key.startswith(("GIT_", "MAPLE_HOOK_"))} + env.update(PATH=f"{nix.parent}{os.pathsep}{env['PATH']}", MAPLE_HOOK_NO_NIX="0" if use_nix else "1") + for command in (["git", "init", "-q"], ["git", "add", str(changed)], ["sh", ".githooks/pre-commit"]): + subprocess.run(command, cwd=root, env=env, check=True, capture_output=True) + component, directory, in_nix = (root / "hook-result").read_text().splitlines() + self.assertEqual(component.strip(), "agent_updates") + self.assertEqual(Path(directory).resolve(), root.resolve()) + self.assertEqual(in_nix, "1" if use_nix else "0") + + class AgentLaneTests(unittest.TestCase): - """The lanes that replaced agent-ci.yml, the supply-chain and store workflows, and PR packaging.""" + """Agent supply-chain, full-suite, benchmark and packaging lanes.""" def test_dependency_inputs_select_supply_chain_and_the_provider_suite(self): both = {"agent-supply-chain", "agent-provider"} diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index 3e6770e6d..ca590d577 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -1,9 +1,4 @@ -"""Invariants every GitHub Actions workflow keeps (docs/repository-workflows.md). - -TEMPORARY lists the violations that later commits in this change remove. Each -list must equal the current violations exactly, so an entry is deleted as soon -as its violation is fixed and no new violation can hide behind it. -""" +"""Invariants every GitHub Actions workflow keeps (docs/ci.md).""" from __future__ import annotations @@ -62,23 +57,6 @@ # Actions-cache writers; a key under these prefixes feeds release.yml's fallbacks. CACHE_WRITERS = ("actions/cache@", "actions/cache/save@", "irgaly/xcode-cache@", "Swatinem/rust-cache@") -TEMPORARY = { - # Signed-lane hygiene adds the timeouts and moves OIDC to the attesting jobs. - "timeout": frozenset({ - ("android-build.yml", "build-android"), ("android-build.yml", "verify-android-artifacts"), - ("app-change-detection.yml", "detect"), - *(("desktop-build.yml", job) for job in ( - "build-macos", "build-linux", "verify-linux-desktop-artifacts", "verify-macos-desktop-artifacts", - "build-windows", "verify-windows-desktop-artifacts", "verify-desktop-artifacts")), - ("mobile-build.yml", "build-ios"), ("mobile-build.yml", "verify-ios-artifacts"), - ("mobile-build.yml", "submit-ios-testflight"), - *(("signing-credentials-check.yml", job) for job in ( - "desktop-signing", "apple-signing", "android-signing", "windows-signing", "zapstore-publishing")), - }), - "permissions": frozenset({"desktop-build.yml", "android-build.yml"}), -} - - @functools.lru_cache(maxsize=None) def load(path: Path) -> dict: return json.loads(subprocess.run(["yq", "-o=json", ".", str(path)], check=True, @@ -178,16 +156,15 @@ def test_3_checkouts_never_persist_credentials(self): self.assertIs(step.get("with", {}).get("persist-credentials"), False) def test_4_workflow_permissions_are_read_only(self): - broad = set() + # Write scopes are granted per job, to the jobs that need them. for name, config in workflows().items(): granted = config.get("permissions") with self.subTest(workflow=name): self.assertIsInstance(granted, dict) if name in SACRED: self.assertNotIn("write", granted.values()) - elif granted != {"contents": "read"}: - broad.add(name) - self.assertEqual(broad, TEMPORARY["permissions"]) + else: + self.assertEqual(granted, {"contents": "read"}) def test_5_secrets_are_used_only_inside_their_protected_environment(self): for name, config in workflows().items(): @@ -229,7 +206,7 @@ def test_6_every_nix_install_is_pinned_and_verified(self): def test_7_every_job_has_a_timeout(self): missing = {(name, job_id) for name, job_id, job in jobs() if name not in SACRED and "uses" not in job and "timeout-minutes" not in job} - self.assertEqual(missing, TEMPORARY["timeout"]) + self.assertEqual(missing, set()) def test_8_ci_always_reports_one_verdict(self): config = workflows()["ci.yml"] From 17722c0a8251689be1946a025cccd8052e3b8f3c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:49:03 +0000 Subject: [PATCH 07/21] ci: address review findings - os-sdk-e2e and os-helpers select on their own entry scripts; the OpenSecret selector routes inert paths too, so its routing is verbatim - widen the Agent packaging selector to the scripts packaging calls, the Agent flake and (macOS) the Xcode pin; agent-windows runs the native Windows tests; acp-only and proxy-only clippy move into ci-lint - os-eif: PRs to master only, plus a ci:eif label; os-flake-check builds the x86_64 package again; opensecret-eif.yml runs on every PR to master - ci-ok calls `plan.py verdict`; lint checks special jobs' runners and timeouts; setup-bun saves no PR cache; agent-test on 32 vCPU - ci.yml's nightly run covers deps and supply chain; the reporter needs today's scheduled run - drop PR-only scripts from the signed-build selector; Auth push paths rehearse publisher edits again; docs fixes Co-Authored-By: Claude Opus 5.5 --- .agents/skills/develop-maple-agent/SKILL.md | 8 +- .agents/skills/develop-maple-proxy/SKILL.md | 3 +- .github/actionlint.yaml | 1 + .github/workflows/auth-pages-ci.yml | 6 +- .github/workflows/ci.yml | 36 +++----- .github/workflows/nightly.yml | 69 +-------------- .github/workflows/opensecret-eif.yml | 9 -- apps/maple-agent/docs/desktop-builds.md | 6 +- apps/maple-agent/justfile | 12 +-- docs/ci.md | 36 +++++--- docs/opensecret-import.md | 2 +- docs/repository-workflows.md | 55 +++++++----- scripts/ci/change_detection.py | 6 -- scripts/ci/lanes.py | 75 +++++++++------- scripts/ci/nightly_report.py | 21 +++-- scripts/ci/opensecret-helpers.sh | 3 +- scripts/ci/opensecret-sdk-e2e.sh | 4 +- scripts/ci/plan.py | 56 ++++++++++-- scripts/ci/test_change_detection.py | 10 ++- scripts/ci/test_contracts.py | 31 ++++--- scripts/ci/test_nightly_report.py | 24 ++++-- scripts/ci/test_plan.py | 95 +++++++++++++++------ scripts/ci/test_workflow_policy.py | 12 ++- services/opensecret/README.md | 8 +- 24 files changed, 322 insertions(+), 266 deletions(-) diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 6f18b43a3..a67c06a77 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -45,10 +45,10 @@ the tree, but not for TypeScript SDK changes. Component-only changes should not unnecessarily select Research packaging. Agent lanes live in `scripts/ci/lanes.py`, with fixtures in `scripts/ci/test_plan.py`; root `just lane ` runs one locally. PRs run `agent-lint`, `agent-test` and -`agent-macos`, plus Windows clippy for Windows paths, `agent-supply-chain` and -`agent-provider` for dependency inputs, and an unsigned Dev package rehearsal for -packaging inputs. Labels `ci:windows`, `ci:package`, `ci:perf` and `ci:full` add -lanes; master adds the full macOS and Windows suites. On master, +`agent-macos`, plus Windows tests and clippy for Windows paths, +`agent-supply-chain` and `agent-provider` for dependency inputs, and an unsigned +Dev package rehearsal for packaging inputs. Labels `ci:windows`, `ci:package`, +`ci:perf` and `ci:full` add lanes; master adds the full macOS and Windows suites. On master, `.github/workflows/agent-desktop-build.yml` builds signed packages for pushes that change `AGENT_DESKTOP` inputs and on every manual dispatch. diff --git a/.agents/skills/develop-maple-proxy/SKILL.md b/.agents/skills/develop-maple-proxy/SKILL.md index 5e20a87ec..6280c810f 100644 --- a/.agents/skills/develop-maple-proxy/SKILL.md +++ b/.agents/skills/develop-maple-proxy/SKILL.md @@ -61,7 +61,8 @@ nix develop --no-update-lock-file .#ci -c \ The root `ci.yml` lanes `proxy`, `deps-proxy`, and `proxy-container` (both architectures on `master`) run these checks, cargo-deny, and a non-publishing container build; `just lane proxy` runs the first locally. `proxy-rust.yml` -rehearses the native release builds, and `nightly.yml` re-checks advisories. +rehearses the native release builds, and `ci.yml`'s nightly run re-checks +advisories. `proxy/src/**`, `proxy/Cargo.toml`, and unknown proxy build inputs are desktop application inputs; tests, examples, docs, the standalone lockfile, and container-only files do not by themselves diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index af31490dd..b640b7106 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -5,3 +5,4 @@ self-hosted-runner: - blacksmith-8vcpu-windows-2025 - blacksmith-6vcpu-macos-26 - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-32vcpu-ubuntu-2404 diff --git a/.github/workflows/auth-pages-ci.yml b/.github/workflows/auth-pages-ci.yml index 02b160568..d04e975ee 100644 --- a/.github/workflows/auth-pages-ci.yml +++ b/.github/workflows/auth-pages-ci.yml @@ -18,12 +18,12 @@ on: - "flake.lock" push: branches: [master] + # Publisher edits also run, so master's Dev deployment rehearses them before Prod. paths: - - ".github/workflows/auth-pages-ci.yml" + - ".github/workflows/auth-pages-*.yml" - "apps/maple-auth/**" - "scripts/ci/auth-*.sh" - - "scripts/ci/pages_artifact.py" - - "scripts/ci/pages_auth_build.py" + - "scripts/ci/pages_*.py" - "flake.nix" - "flake.lock" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e9f8f37..a51a40d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,6 +396,7 @@ jobs: - uses: oven-sh/setup-bun@f4d14e03ff726c06358e5557344e1da148b56cf7 # v1 with: bun-version: 1.3.5 + no-cache: true # its post step would save the binary from a pull request - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable action, exact toolchain below with: toolchain: 1.94.1 # desktop-build.yml's Windows toolchain @@ -522,11 +523,12 @@ jobs: scanners: vuln cache: "false" # pull requests save no caches - # The PR EIF candidate that opensecret-eif.yml built, with its runner, - # permissions and steps unchanged. The planner selects it only for - # same-repository PRs whose diff has EIF inputs and edits no PCR approval - # (opensecret-eif.yml compares approval edits); because it mints an OIDC token - # for the FlakeHub cache, the event and head repository are checked here too. + # The PR EIF candidate: dev and prod EIFs built on the release builder's runner + # class, never signed or published. The planner selects it only for + # same-repository PRs to master whose diff has EIF inputs (or the ci:eif label) + # and edits no PCR approval (opensecret-eif.yml compares approval edits); + # because it mints an OIDC token for the FlakeHub cache, the event, base branch + # and head repository are checked here too. os-eif: name: os-eif (${{ matrix.mode }}) needs: plan @@ -534,6 +536,7 @@ jobs: ${{ fromJSON(needs.plan.outputs.lanes)['os-eif'] && github.event_name == 'pull_request' && + github.event.pull_request.base.ref == 'master' && github.event.pull_request.head.repo.full_name == github.repository }} permissions: @@ -592,24 +595,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + sparse-checkout: scripts/ci - name: Every selected lane succeeded env: NEEDS: ${{ toJSON(needs) }} - LANES: ${{ needs.plan.outputs.lanes }} - NIX: ${{ needs.plan.outputs.nix }} - WINDOWS: ${{ needs.plan.outputs.windows }} - run: | - set -euo pipefail - if [ "$(jq -r '.plan.result' <<<"$NEEDS")" != success ]; then - echo "::error::plan did not succeed"; exit 1 - fi - # Selected => success; an unselected job may only be skipped (or succeed). - jq -n -e --argjson needs "$NEEDS" --argjson lanes "$LANES" --argjson nix "$NIX" --argjson windows "$WINDOWS" ' - def ok($job; $selected): - if $selected then $needs[$job].result == "success" - else ($needs[$job].result | IN("skipped", "success")) end; - ok("nix"; ($nix | length) > 0) and ok("windows"; ($windows | length) > 0) - and ([$needs | keys[] | select(IN("plan", "nix", "windows") | not)] - | all(. as $job | ok($job; $lanes[$job] // false))) - ' >/dev/null || { echo "::error::a selected lane did not succeed"; exit 1; } - echo "ci-ok: every selected lane succeeded" + run: python3 scripts/ci/plan.py verdict diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d2410e7e2..3b7892cb9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,6 +1,6 @@ -# Nightly checks of what drifts without a commit: advisories, pinned upstream -# sources, the SQLCipher provider suite on every OS and the store benchmark, -# plus OpenSecret's x86_64 package, which no pull request builds. +# Nightly checks that ci.yml's scheduled run does not cover: Bun advisories, +# the SQLCipher provider suite on every OS and the store benchmark. (ci.yml's +# nightly run re-checks the Rust advisories and pinned sources.) # One `ci-nightly` issue reports failures here and in ci.yml's scheduled run; # a green night closes it. Nothing here gates a pull request. name: Nightly @@ -18,51 +18,6 @@ concurrency: cancel-in-progress: false jobs: - advisories: - name: Advisories (${{ matrix.name }}) - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - name: research - manifest: apps/maple-research/frontend/src-tauri/Cargo.toml - config: apps/maple-research/deny.toml - - name: sdk - manifest: sdk/rust/Cargo.toml - config: sdk/deny.toml - - name: proxy - manifest: proxy/Cargo.toml - config: proxy/deny.toml - - name: opensecret - manifest: services/opensecret/Cargo.toml - config: services/opensecret/deny.toml - rust: 1.91.1 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - name: Check RustSec advisories - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 - with: - rust-version: ${{ matrix.rust }} - manifest-path: ${{ matrix.manifest }} - command: check advisories - arguments: --config ${{ matrix.config }} --all-features --locked - - agent-supply-chain: - name: Agent advisories and SQLCipher sources - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - uses: ./.github/actions/setup-nix - - name: Check advisories and bans per target, SQLCipher sources and notices - run: python3 scripts/ci/plan.py run agent-supply-chain - bun-audit: name: Bun advisories runs-on: ubuntu-latest @@ -132,25 +87,9 @@ jobs: if-no-files-found: warn retention-days: 14 - opensecret-package: - name: OpenSecret x86_64 package - # The shipped EIF is aarch64 (ci.yml's os-eif); nothing consumes this build. - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: recursive - # Nix requires revCount for recursively fetched Git inputs. - fetch-depth: 0 - - uses: ./.github/actions/setup-nix - - name: Build the application package - run: nix build --no-link --no-update-lock-file './services/opensecret?submodules=1#default' - report: name: Report - needs: [advisories, agent-supply-chain, bun-audit, agent-provider, store-bench, opensecret-package] + needs: [bun-audit, agent-provider, store-bench] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml index e66dd0238..4490d1c79 100644 --- a/.github/workflows/opensecret-eif.yml +++ b/.github/workflows/opensecret-eif.yml @@ -5,15 +5,6 @@ on: branches: [master] pull_request: branches: [master] - # PRs compare measurements only when they edit an approval file; ci.yml's - # os-eif lane builds the other EIF candidates. The selector's own files - # also run this workflow, so a change to them is proven before master. - paths: - - services/opensecret/** - - .github/workflows/opensecret-eif.yml - - scripts/ci/opensecret_change_detection.py - - scripts/ci/plan.py - - scripts/ci/lanes.py workflow_dispatch: # Cache writes are not PCR approval, release publication, or deployment. diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index a1f6124bf..f48073e7b 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -4,8 +4,10 @@ `master` push changes Agent inputs, and always on a manual dispatch from `master`. Its `changes` job runs `scripts/ci/plan.py only agent-desktop` on the push's own diff; an unusable diff or a selector error builds. No master run is -cancelled, so every Agent push gets its own packages; a failed run is re-run or -fixed forward. Pull requests that change packaging inputs rehearse unsigned Dev +cancelled, so every Agent push gets its own packages. A later push without Agent +changes does not retry a failed run: re-run its failed jobs, dispatch, or fix +forward. Pull requests that change packaging inputs (the packaging and release +scripts, the Agent flake, and for macOS the Xcode pin) rehearse unsigned Dev packages in `ci.yml` (`agent-package-linux`, `agent-package-macos`); the `ci:package` label adds `agent-package-windows`. diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 3a78f52d3..f737454bb 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -27,13 +27,15 @@ store-bench-ci: python3 crates/store-bench/scripts/evaluate.py target/store-bench-ci.json --profile linux-ci python3 -m unittest discover -s crates/store-bench/tests -p 'test_*.py' -# Run all the checks that CI runs. -ci: fmt-check git-metadata-test clippy test +# Run the checks of CI's Linux Agent lanes (agent-lint, agent-test, agent-full-linux). +ci: ci-lint ci-test ci-extra -# The ci.yml agent-lint lane: format, packaging tests, clippy and the dependency graph. +# The ci.yml agent-lint lane: format, packaging tests, clippy for every feature set and the dependency graph. ci-lint: fmt-check git-metadata-test packaging-test cargo clippy --workspace --all-targets --locked -- -D warnings cargo clippy -p maple-agent-app --all-targets --locked {{headless}} -- -D warnings + cargo clippy -p maple-agent-app --locked --no-default-features --features acp -- -D warnings + cargo clippy -p maple-agent-app --locked --no-default-features --features proxy -- -D warnings metadata="$(mktemp)"; trap 'rm -f "$metadata"' EXIT; \ cargo metadata --locked --format-version 1 > "$metadata"; \ python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata"; \ @@ -43,10 +45,8 @@ ci-lint: fmt-check git-metadata-test packaging-test ci-test: RUSTFLAGS="-D warnings" cargo test --workspace --locked -# The ci.yml agent-full-linux lane: the rest of `just ci` after ci-lint and ci-test. +# The ci.yml agent-full-linux lane: every target built, and the headless tests. ci-extra: - cargo clippy -p maple-agent-app --locked --no-default-features --features acp -- -D warnings - cargo clippy -p maple-agent-app --locked --no-default-features --features proxy -- -D warnings RUSTFLAGS="-D warnings" cargo build --workspace --all-targets --locked RUSTFLAGS="-D warnings" cargo test -p maple-agent-app --locked {{headless}} diff --git a/docs/ci.md b/docs/ci.md index 1ec17ee4d..25b6f552e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -15,13 +15,14 @@ and timeout. `scripts/ci/plan.py` (the `plan` job) selects lanes from the diff: - A pull request is judged on its own diff from the merge base with its base branch, so each layer of a stack gets its own `ci-ok`. A merge-queue group uses its base and head; a master push uses `before..after`. -- Docs, guides and component hooks select nothing. A path that no lane owns - selects every lane. Editing `ci.yml`, the planner or `.github/actions/` - selects every gate lane. -- If selection fails on a pull request, `plan` and `ci-ok` fail; on master, - nightly and dispatch runs every lane runs instead. +- Docs, guides and component hooks select no lane but those the OpenSecret + selector routes them to (below). A path that no lane owns selects every + lane. Editing `ci.yml`, the planner or `.github/actions/` selects every gate + lane. +- If selection fails on a pull request, `plan` and `ci-ok` fail; master, + nightly and dispatch runs select every lane instead. - OpenSecret lanes are selected by `scripts/ci/opensecret_change_detection.py`, - imported unchanged. + imported unchanged and asked about every changed path, docs included. The `plan` job summary lists each selected lane and why. To preview locally: @@ -35,7 +36,7 @@ python3 scripts/ci/plan.py explain --base origin/master --head HEAD | --- | --- | | Pull request, merge queue | Selected lanes, including unsigned platform and package rehearsals when their native inputs change. A new push cancels the superseded run. | | Master push | The selected lanes again (for Agent inputs, also the full Agent suites on Linux, macOS and Windows), the signed builds, the release-evidence workflows and CodeQL. Never cancelled. | -| Nightly | `ci.yml` runs its nightly lanes on the master tip, which also refreshes their caches. `nightly.yml` runs advisories, the SQLCipher provider suite on every OS, the store benchmark and OpenSecret's x86_64 package. Failures open or update one `ci-nightly` issue; a green night closes it. | +| Nightly | `ci.yml` runs its nightly lanes on the master tip, Rust advisories included, which also refreshes their caches. `nightly.yml` runs the Bun advisories, the SQLCipher provider suite on every OS and the store benchmark. Failures open or update one `ci-nightly` issue; a green night closes it. | | On demand | PR labels (below), or a `ci.yml` dispatch with comma-separated lane ids or `all`. | ## Labels @@ -46,17 +47,19 @@ Add the label, then re-run the CI workflow: `plan` reads labels when it runs. | --- | --- | | `ci:full` | Every lane except the OpenSecret EIF candidate | | `ci:package` | Unsigned Agent and Research desktop packages on every OS | -| `ci:windows` | Agent Windows Clippy and the Research Windows package | +| `ci:windows` | Agent Windows tests and Clippy, and the Research Windows package | | `ci:ios`, `ci:android` | The Research iOS simulator or Android build | | `ci:research-dev-macos` | The unsigned Research Dev app | | `ci:perf` | The Agent store benchmark | +| `ci:eif` | The OpenSecret EIF candidate (same-repository PRs to `master` that edit no PCR approval) | ## Run a lane locally `just lane ` (the same as `python3 scripts/ci/plan.py run `) runs the lane's command in its pinned Nix shell, exactly as `ci.yml` does. macOS lanes -need macOS, Windows lanes need Windows with the toolchain from -`.github/actions/setup-agent-windows`, `os-sdk-e2e` needs a local PostgreSQL +need macOS and use its selected Xcode (CI pins it with +`scripts/ci/select-xcode.py`), Windows lanes need Windows with the toolchain +from `.github/actions/setup-agent-windows`, `os-sdk-e2e` needs a local PostgreSQL and `os-eif` needs Linux on ARM64. Before pushing a workflow, CI-script or flake change, run `just lane repo` (every root flake check). The [pre-commit hook](repository-workflows.md#pre-commit-hook) runs the fast part @@ -71,10 +74,15 @@ of the lanes your staged files select. special `ci.yml` job only when its steps cannot be generic (services, OIDC, artifacts, or a cache another workflow writes). The job id is the lane's `job`, its real work is a script or recipe, and `ci-ok` needs it. -4. Run `python3 scripts/ci/plan.py lint` (every glob matches a tracked file, - every tracked file is owned or inert, the `ci.yml` jobs match the table) - and `just lane repo`. A new runner label also goes in - `.github/actionlint.yaml`. +4. A new top-level directory, or a new one under `services/`, is unknown to + the OpenSecret selector, which then runs every OpenSecret lane for it, as + it does for `services/agent-updates/`. Adding its prefix to the selector's + `KNOWN_INDEPENDENT_PREFIXES` is an OpenSecret change: it also changes what + `opensecret-eif.yml` checks. +5. Run `python3 scripts/ci/plan.py lint` (every glob matches a tracked file, + every tracked file is owned or inert, the `ci.yml` jobs and their runners + and timeouts match the table) and `just lane repo`. A new runner label also + goes in `.github/actionlint.yaml`. ## Rules diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index 5c2394479..5a74606cb 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -45,7 +45,7 @@ shortcuts are `just opensecret-check` and `just opensecret-pcr-check`. Root workflows replace the backend's inactive nested workflows. Backend CI (the `os-*` and `deps-opensecret` lanes of `ci.yml`) checks Rust, the ordinary -Nix checks, and dependency policy; `nightly.yml` builds the Nix package. +Nix package/checks, and dependency policy. The SDK integration lane starts the backend from this same checkout against a disposable database; it no longer fetches an external backend revision. This advances that lane from the former pinned commit diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index ffc8bb818..dd659bed2 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -40,7 +40,9 @@ branches in step with the lane table. (`-sccache-*`, `xcode-cache-*`, `release-onnxruntime-ios-*`, `-bun-*`, `-gradle-*`). No pull-request job writes these prefixes; the Research platform lanes only restore them. The iOS ONNX key text is - identical in every workflow that uses it. + identical in every workflow that uses it. A release job reads only the store + of its own runner vendor, so `release.yml`'s `build-web` (GitHub-hosted) + starts with a cold Bun cache; Bun installs in seconds. - `agent-desktop-build.yml`'s unsigned builders save the exact key `ci-v1-agent-release--` on master pushes, with no fallback keys; the Agent packaging lanes only restore it. Signing, @@ -98,14 +100,21 @@ verified Nix install; a timeout on every job; `ci-ok` needing every other Master pushes start signed Research builds (`desktop-build`, `android-build`, `mobile-build`, `ios-dev-testflight`) when `app-change-detection.yml` and -`scripts/ci/change_detection.py` find Research inputs for that platform; a -manual dispatch always builds. The selector routes proxy runtime and Rust SDK -build inputs to the native builds, because the Research shell builds both from -the tree. TypeScript SDK changes never select app builds: each frontend builds -the SDK its own manifest selects. Agent desktop packages build on master pushes -that change Agent inputs (`plan.py only agent-desktop`) and on every manual -dispatch. Signing jobs keep their protected environments; approving one trusts -its runner with that environment's secrets. +`scripts/ci/change_detection.py` find Research inputs for that platform; the +scripts only `ci.yml`'s unsigned lanes run select none. Only `mobile-build` and +`ios-dev-testflight` can also be dispatched, which always builds; desktop and +Android release evidence comes only from master pushes. The selector routes +proxy runtime and Rust SDK build inputs to the native builds, because the +Research shell builds both from the tree. TypeScript SDK changes never select +app builds: each frontend builds the SDK its own manifest selects. Agent desktop +packages build on master pushes that change Agent inputs (`plan.py only +agent-desktop`) and on every manual dispatch. Signing jobs keep their protected +environments; approving one trusts its runner with that environment's secrets. +To rehearse the signed Research release scripts outside CI, run +`scripts/ci/signed-release-rehearsal.sh ` in the root +Nix shell that builds the platform (`.#apple`, `.#android`, and +`.#desktop-linux` on Linux or `.#ci` on macOS); its usage text lists the +signing variables, and nothing is uploaded. Routine development never authorizes releases, signing, store submission, deployment, or live-service changes. Never push to `master` as a validation @@ -144,12 +153,14 @@ interoperability only. ## OpenSecret checks -`ci.yml` runs `os-rust`, `os-flake-check`, `os-helpers` (the parent helper -images), `deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check for -PCR tooling and approval edits) and `os-sdk-e2e`, which tests both SDKs against +`ci.yml` runs `os-rust`, `os-flake-check` (the component flake's checks and +the x86_64 package), `os-helpers` (the parent helper images), +`deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check for PCR tooling +and approval edits) and `os-sdk-e2e`, which tests both SDKs against `services/opensecret/` from the same checkout. The `os-eif` lane builds dev and -prod EIF candidates on same-repository PRs that change measured inputs without -editing PCR approvals, on the release builder's +prod EIF candidates on same-repository PRs to `master` that change measured +inputs (or carry the `ci:eif` label) without editing PCR approvals, on the +release builder's `blacksmith-8vcpu-ubuntu-2404-arm` class, and reports their PCRs. `opensecret-eif.yml` compares measurements on PRs that edit approved PCR JSON, on relevant master changes and on manual runs; that approval status is not @@ -177,14 +188,14 @@ caps Bash heredoc pipes at 512 bytes, and larger bodies use a temporary file. ## Runners and toolchains Compilation runs on Blacksmith runners (ARM64 `blacksmith-6vcpu-macos-26` for -macOS); lane runners are the constants in `scripts/ci/lanes.py`. Artifact -verifiers, TestFlight uploads, Agent attestation and protected signing use -GitHub-hosted runners. Windows signing -and the Windows release stay on GitHub's `windows-2025`; unsigned Agent and -Research Windows builds use `blacksmith-8vcpu-windows-2025`, with Rust, MSVC, -Perl and the linker pinned by `.github/actions/setup-agent-windows` for the -Agent. The Research Windows PR package has no cache, because its signed build's -cache lives in GitHub's store. +macOS); lane runners are the constants in `scripts/ci/lanes.py`. The signed +Research macOS, Linux, Android and iOS builds compile and sign on Blacksmith. +Artifact verifiers, TestFlight submission, and the Agent's signing and +attestation use GitHub-hosted runners. Windows signing and the Windows release +stay on GitHub's `windows-2025`; unsigned Agent and Research Windows builds use +`blacksmith-8vcpu-windows-2025`, with Rust, MSVC, Perl and the linker pinned by +`.github/actions/setup-agent-windows` for the Agent. The Research Windows PR +package has no cache, because its signed build's cache lives in GitHub's store. Research desktop and iOS builds select Xcode 26.5 (`17F42`) from [`scripts/ci/apple-toolchain.json`](../scripts/ci/apple-toolchain.json) through diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index c7df96482..adc06d72c 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -68,14 +68,11 @@ CI_SCRIPT_ROUTES = { "scripts/ci/_common.sh": frozenset({"frontend", *NATIVE_PLATFORMS}), "scripts/ci/apple-toolchain.json": frozenset({"macos", "ios"}), - "scripts/ci/android-pr.sh": frozenset({"android"}), "scripts/ci/android-release.sh": frozenset({"android"}), "scripts/ci/attestation-manifest.sh": NATIVE_PLATFORMS, "scripts/ci/canonical-ios-app-hash.py": frozenset({"ios"}), "scripts/ci/canonical-windows-nsis-payload-hash.py": frozenset({"windows"}), - "scripts/ci/desktop-pr.sh": DESKTOP_PLATFORMS, "scripts/ci/desktop-release.sh": DESKTOP_PLATFORMS, - "scripts/ci/desktop-windows-pr.sh": frozenset({"windows"}), "scripts/ci/desktop-windows-release.sh": frozenset({"windows"}), "scripts/ci/dmg-settings.py": frozenset({"macos"}), "scripts/ci/frontend.sh": frozenset({"frontend"}), @@ -83,11 +80,8 @@ "scripts/ci/install-windows-minisign.ps1": frozenset({"windows"}), "scripts/ci/ios-onnxruntime.sh": frozenset({"ios"}), "scripts/ci/ios-build-profile.py": frozenset({"ios"}), - "scripts/ci/ios-pr.sh": frozenset({"ios"}), "scripts/ci/ios-release.sh": frozenset({"ios"}), "scripts/ci/ios-variant.sh": frozenset({"ios"}), - "scripts/ci/research-dev-desktop.sh": frozenset({"macos"}), - "scripts/ci/research-dev-profile.py": frozenset({"macos"}), "scripts/ci/select-xcode.py": frozenset({"macos", "ios"}), "scripts/ci/verify-release-artifacts.sh": NATIVE_PLATFORMS, "scripts/ci/web.sh": frozenset({"frontend"}), diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index daf43d4e2..4539f10b4 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -4,9 +4,9 @@ are repository-relative: `**` spans directories, `*` stays within one path segment, and a leading `!` excludes paths from the globs it is listed with. -OpenSecret lanes have no globs: opensecret_change_detection.py, imported -verbatim, selects them from each changed path that is not inert here -(`opensecret` names its output). +OpenSecret lanes are selected by opensecret_change_detection.py, imported +verbatim, from every changed path, inert ones included (`opensecret` names its +output). Their only globs are the entry scripts of os-sdk-e2e and os-helpers. `hook` names the .githooks/pre-commit component that runs a lane's checks locally. The hook selects it from the lane's own paths only, without the CI @@ -28,7 +28,7 @@ class Runner: # The override variables are unset; set one to fail over during an outage. LINUX = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_RUNNER") -LINUX_HEAVY = Runner("blacksmith-8vcpu-ubuntu-2404", "CI_LINUX_HEAVY_RUNNER") +LINUX_HEAVY = Runner("blacksmith-32vcpu-ubuntu-2404", "CI_LINUX_HEAVY_RUNNER") MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") GITHUB = Runner("ubuntu-latest") # free GitHub runner for lanes that compile nothing heavy @@ -76,16 +76,19 @@ class Runner: STORE + "paths.rs", STORE + "keys.rs", STORE + "keys/**", STORE + "lifecycle.rs", STORE + "lifecycle/**", "scripts/ci/agent_windows.py", ) +# The packaging scripts and what they call, and the toolchain that builds the bundles. AGENT_PACKAGING = tuple("apps/maple-agent/" + glob for glob in ( - "scripts/build-*", "scripts/package-*", "scripts/verify-*", "release-profiles.json", - "release_profile.rs", "about.toml", "app/build.rs", "app/packaging/**", "flake.lock", + "scripts/build-*", "scripts/package-*", "scripts/verify-*", "scripts/release-info.py", + "scripts/macos-build-info.py", "scripts/macos-release-*", "scripts/linux-*", "scripts/smoke-*", + "scripts/*third*party*notices*", "release-profiles.json", "release_profile.rs", "about.toml", "app/build.rs", + "app/packaging/**", "flake.nix", "flake.lock", )) + (".github/workflows/agent-desktop-build.yml",) +APPLE_TOOLCHAIN = ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py") # What agent-desktop-build.yml builds and signs on master (`plan.py only agent-desktop`). AGENT_DESKTOP = AGENT + RUST_SDK + PROXY_RUNTIME + ( ".github/workflows/agent-desktop-build.yml", ".github/actions/setup-agent-windows/**", - "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json", - "scripts/ci/agent_windows.py", "scripts/ci/install-windows-artifact-signing.ps1", - "scripts/ci/windows-artifact-sign.ps1", + "scripts/ci/verify-agent-rust-deps.py", *APPLE_TOOLCHAIN, "scripts/ci/agent_windows.py", + "scripts/ci/install-windows-artifact-signing.ps1", "scripts/ci/windows-artifact-sign.ps1", ) # Selectors for workflows outside ci.yml, read by `plan.py only NAME`. SELECTORS = {"agent-desktop": AGENT_DESKTOP} @@ -148,14 +151,14 @@ class Lane: def deny(manifest: str, component: str) -> str: - """cargo-deny with the component's own toolchain. The Agent flake's nixpkgs pins - the cargo-deny that agent-supply-chain and nightly.yml's cargo-deny-action run.""" + """cargo-deny with the component's own toolchain, from the Agent flake's nixpkgs + (the cargo-deny that agent-supply-chain runs).""" return ("nix shell --no-update-lock-file --inputs-from ./apps/maple-agent nixpkgs#cargo-deny -c cargo deny" f" --manifest-path {manifest} --config {component}/deny.toml --all-features --locked" " check advisories bans") -DEPS = dict(events=NOT_NIGHTLY, runner=GITHUB, gate=False) +DEPS = dict(runner=GITHUB, gate=False) # Research platform builds: PR side only, never on CI edits (ci:full runs them). PLATFORM = dict(events=PR_SIDE, gate=False) DESKTOP = tuple(TAURI + glob for glob in ("tauri.conf.json", "build.rs", "scripts/onnxruntime-pins.sh", @@ -182,13 +185,14 @@ def deny(manifest: str, component: str) -> str: cache="agent-lint", timeout=15, hook="agent", **AGENT_FLAKE), Lane("agent-test", AGENT, also=RUST_SDK + PROXY_RUNTIME, runner=LINUX_HEAVY, run="just ci-test", cache="agent-test", timeout=20, hook="agent", **AGENT_FLAKE), - Lane("agent-macos", AGENT, - also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/select-xcode.py", "scripts/ci/apple-toolchain.json"), + Lane("agent-macos", AGENT, also=RUST_SDK + PROXY_RUNTIME + APPLE_TOOLCHAIN, runner=MACOS, run="cargo clippy --workspace --all-targets --locked -- -D warnings", cache="agent-macos", timeout=25, **AGENT_FLAKE), - # PRs get Windows clippy only for Windows-specific paths or the ci:windows label. + # PRs get the native Windows tests and Clippy only for Windows-specific paths or the ci:windows label. Lane("agent-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=AGENT_WINDOWS, - labels=("ci:windows", "ci:full"), run="cargo clippy --workspace --all-targets --locked -- -D warnings", + labels=("ci:windows", "ci:full"), + run="python3 scripts/test_windows_build_info.py && python3 scripts/test_windows_host_spawn.py" + " && cargo clippy --workspace --all-targets --locked -- -D warnings", cache="agent-windows", timeout=45, **AGENT_WIN), # The rest of `just ci`, and the native macOS and Windows suites: master, nightly and ci:full. Lane("agent-full-linux", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), run="just ci-extra", @@ -202,7 +206,7 @@ def deny(manifest: str, component: str) -> str: " && cargo test --workspace --locked", cache="agent-full-windows", timeout=60, gate=False, **AGENT_WIN), # Pinned SQLCipher sources, per-target advisories and bans, and notices. - Lane("agent-supply-chain", AGENT_DEPENDENCIES, events=NOT_NIGHTLY, run="just supply-chain", **AGENT_FLAKE), + Lane("agent-supply-chain", AGENT_DEPENDENCIES, run="just supply-chain", **AGENT_FLAKE), # Released rusqlite's own tests against the static SQLCipher provider (nightly.yml: all three OSes). Lane("agent-provider", tuple("apps/maple-agent/" + glob for glob in ( "Cargo.lock", "dependencies/sqlcipher/**", "scripts/test-rusqlite-provider.py", @@ -216,9 +220,9 @@ def deny(manifest: str, component: str) -> str: # Unsigned Dev packages built the way agent-desktop-build.yml builds master's. Lane("agent-package-linux", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, job="agent-package-linux", run="just package-rehearsal", timeout=60, gate=False, **AGENT_FLAKE), - Lane("agent-package-macos", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, - job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, gate=False, - **AGENT_FLAKE), + Lane("agent-package-macos", AGENT_PACKAGING + APPLE_TOOLCHAIN, labels=("ci:package", "ci:full"), + events=PR_SIDE, job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, + gate=False, **AGENT_FLAKE), Lane("agent-package-windows", (), labels=("ci:package", "ci:full"), events=PR_SIDE, job="agent-package-windows", runner=WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=60, gate=False), @@ -249,7 +253,7 @@ def deny(manifest: str, component: str) -> str: Lane("proxy-container-arm64", PROXY_CONTAINER, pr_paths=(), runner=Runner("ubuntu-24.04-arm"), run="docker build --platform linux/arm64 -f proxy/Dockerfile .", timeout=20, gate=False), # Advisories and bans when a dependency graph changes. A new advisory is drift, - # not a CI change, so CI edits skip these; nightly.yml re-checks advisories. + # not a CI change, so CI edits skip these; ci.yml's nightly run re-checks them. Lane("deps-research", ("apps/maple-research/deny.toml", TAURI + "Cargo.toml", TAURI + "Cargo.lock", "proxy/Cargo.toml", "sdk/rust/Cargo.toml"), run=deny(TAURI + "Cargo.toml", "apps/maple-research"), flake=".#ci", **DEPS), @@ -307,9 +311,10 @@ def deny(manifest: str, component: str) -> str: labels=("ci:research-dev-macos", "ci:package", "ci:full"), job="research-dev-macos", runner=MACOS, flake=".#ci", run="./scripts/ci/research-dev-desktop.sh", timeout=45, **PLATFORM), - # Services and the hosted Auth app. auth-pages-ci.yml builds Auth on master. - Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", "flake.nix", - "flake.lock"), + # Services and the hosted Auth app. auth-pages-ci.yml also builds Auth, for the + # Dev deployment on master and a preview artifact on PRs. + Lane("auth", ("apps/maple-auth/**", "scripts/ci/auth-*.sh", "scripts/ci/pages_auth_build.py", + "scripts/ci/pages_artifact.py", "flake.nix", "flake.lock"), events=PR_SIDE, runner=GITHUB, flake=".#ci", run="./scripts/ci/auth-ci.sh", hook="auth"), Lane("updates", ("services/updates/**", "services/download-tracking/**", "scripts/ci/updates.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), @@ -321,28 +326,32 @@ def deny(manifest: str, component: str) -> str: runner=GITHUB, flake=".#ci", run="./scripts/ci/agent-updates.sh", hook="agent_updates"), # OpenSecret, selected by its own selector (see the module docstring). The PR - # EIF candidate builds only for same-repository PRs that edit no approval, on - # the release builder's runner class; opensecret-eif.yml compares approvals. + # EIF candidate builds only for same-repository PRs to master that edit no + # approval, on the release builder's runner class; opensecret-eif.yml compares + # approvals. Lane("os-rust", (), opensecret="rust", flake=OPENSECRET_SHELL, workdir="services/opensecret", run="cargo fmt --all -- --check && cargo clippy --locked --all-targets --all-features -- -D warnings" " && cargo test --locked --all-features", cache="os-rust", workspaces="services/opensecret -> target", cache_files=tuple( "services/opensecret/" + name for name in ("Cargo.lock", "rust-toolchain.toml", "flake.lock")), submodules=True, timeout=30, env=OPENSECRET_ENV, hook="opensecret"), - Lane("os-sdk-e2e", (), opensecret="integration", job="os-sdk-e2e", run="./scripts/ci/opensecret-sdk-e2e.sh", - timeout=45), + Lane("os-sdk-e2e", ("scripts/ci/opensecret-sdk-e2e.sh",), opensecret="integration", job="os-sdk-e2e", + run="./scripts/ci/opensecret-sdk-e2e.sh", timeout=45), + # The component flake's checks and the x86_64 application package. Lane("os-flake-check", (), opensecret="nix", runner=GITHUB, workdir="services/opensecret", - run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'", submodules=True, - timeout=30, env=OPENSECRET_ENV, hook="opensecret"), # the hook runs its entrypoint checks - Lane("os-helpers", (), opensecret="helpers", job="os-helpers", runner=Runner("ubuntu-24.04-arm"), + run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'" + " && nix build --no-link --no-update-lock-file '.?submodules=1#default'", submodules=True, + timeout=60, env=OPENSECRET_ENV, hook="opensecret"), # the hook runs its entrypoint checks + Lane("os-helpers", ("scripts/ci/opensecret-helpers.sh",), opensecret="helpers", job="os-helpers", + runner=Runner("ubuntu-24.04-arm"), run="./scripts/ci/opensecret-helpers.sh", timeout=30), Lane("os-pcr", (), opensecret="pcr", runner=GITHUB, timeout=10, run='nix build --no-update-lock-file --no-link' ' ".#checks.$(nix eval --impure --raw --expr builtins.currentSystem).pcr-compatibility"'), Lane("deps-opensecret", (), opensecret="audit", flake=OPENSECRET_SHELL, submodules=True, env=OPENSECRET_ENV, run=deny("services/opensecret/Cargo.toml", "services/opensecret"), **DEPS), - Lane("os-eif", (), opensecret="eif", labels=(), events=frozenset({PR}), job="os-eif", runner=EIF_BUILDER, - # ci.yml runs the former opensecret-eif.yml candidate steps verbatim; this is that build. + # ci.yml's os-eif job runs these two scripts with the FlakeHub cache; `just lane os-eif` runs them locally. + Lane("os-eif", (), opensecret="eif", labels=("ci:eif",), events=frozenset({PR}), job="os-eif", runner=EIF_BUILDER, run='mode="${EIF_MODE:?dev or prod}" && out="$(mktemp -d)/eif-candidate-$mode"' ' && bash scripts/ci/build_opensecret_eif.sh "$mode" "$out"' ' && python3 scripts/ci/verify_opensecret_eif_rootfs.py "$mode" "$out" "$(git rev-parse HEAD)"', diff --git a/scripts/ci/nightly_report.py b/scripts/ci/nightly_report.py index f6ba78439..3f651ebf7 100644 --- a/scripts/ci/nightly_report.py +++ b/scripts/ci/nightly_report.py @@ -2,8 +2,9 @@ """Keep one open `ci-nightly` issue while a nightly check fails. nightly.yml's report job runs this with its jobs' results in NEEDS. Failures -there, or in ci.yml's latest completed scheduled run, open the issue or comment -on it; a green night closes it. DRY_RUN=true prints instead of writing. +there, or in today's scheduled ci.yml run (also when it is missing or still +running), open the issue or comment on it; a green night closes it. +DRY_RUN=true prints instead of writing. """ from __future__ import annotations @@ -25,14 +26,15 @@ def gh(path: str, method: str = "GET", payload: Optional[dict] = None) -> object return json.loads(result.stdout or "null") -def failures(needs: dict, ci_run: Optional[dict]) -> List[str]: +def failures(needs: dict, ci_run: Optional[dict], today: str) -> List[str]: lines = [f"- `{job}`: {state.get('result')}" for job, state in sorted(needs.items()) if state.get("result") != "success"] - if ci_run is None: - lines.append("- ci.yml: no completed scheduled run on master") + if ci_run is None or ci_run["created_at"][:10] != today: + lines.append(f"- ci.yml: no scheduled run on master on {today}") + elif ci_run.get("status") != "completed": + lines.append(f"- ci.yml scheduled run of {today}: still {ci_run.get('status')} ({ci_run['html_url']})") elif ci_run.get("conclusion") != "success": - lines.append(f"- ci.yml scheduled run of {ci_run['created_at'][:10]}: {ci_run.get('conclusion')} " - f"({ci_run['html_url']})") + lines.append(f"- ci.yml scheduled run of {today}: {ci_run.get('conclusion')} ({ci_run['html_url']})") return lines @@ -48,8 +50,9 @@ def main() -> int: repo = os.environ["GITHUB_REPOSITORY"] server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") run_url = f"{server}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}" - runs = gh(f"repos/{repo}/actions/workflows/ci.yml/runs?event=schedule&branch=master&status=completed&per_page=1") - lines = failures(json.loads(os.environ["NEEDS"]), (runs["workflow_runs"] or [None])[0]) + runs = gh(f"repos/{repo}/actions/workflows/ci.yml/runs?event=schedule&branch=master&per_page=1") + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + lines = failures(json.loads(os.environ["NEEDS"]), (runs["workflow_runs"] or [None])[0], today) open_issues = [item["number"] for item in gh(f"repos/{repo}/issues?labels={LABEL}&state=open&per_page=100") if "pull_request" not in item] issue = min(open_issues) if open_issues else None diff --git a/scripts/ci/opensecret-helpers.sh b/scripts/ci/opensecret-helpers.sh index c3c955782..4e03d887a 100755 --- a/scripts/ci/opensecret-helpers.sh +++ b/scripts/ci/opensecret-helpers.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash # ci.yml's os-helpers lane: build OpenSecret's parent-side helper images for # Linux ARM64 and run their tests offline, without publishing them (ci.yml then -# scans each image). HELPER picks one image; the default is both. The steps are -# the former opensecret-ci.yml `helpers` job's, unchanged. +# scans each image). HELPER picks one image; the default is both. set -euo pipefail cd "$(dirname "$0")/../../services/opensecret" diff --git a/scripts/ci/opensecret-sdk-e2e.sh b/scripts/ci/opensecret-sdk-e2e.sh index 8bb73726a..6f1032d08 100755 --- a/scripts/ci/opensecret-sdk-e2e.sh +++ b/scripts/ci/opensecret-sdk-e2e.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # ci.yml's os-sdk-e2e lane: both in-tree SDKs against the in-tree OpenSecret # backend and a disposable PostgreSQL on 127.0.0.1:5432 (a ci.yml service; the -# backend's dev shell uses the same database URL). The environment and steps are -# the former sdk-integration.yml's, unchanged; its two final steps are the trap. +# backend's dev shell uses the same database URL). The EXIT trap stops the +# backend and, on failure, prints its log. set -euo pipefail cd "$(dirname "$0")/../.." diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index b008ff3f2..10d0721cb 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -6,6 +6,7 @@ only NAME another workflow's push selector: print selected=true|false opensecret opensecret-eif.yml's changes job: the OpenSecret selector's outputs explain show what changed paths select (stdin, or --base/--head) + verdict ci.yml's ci-ok job: every selected lane's job succeeded hook .githooks/pre-commit: the components whose checks the staged paths select check-rust the Agent flake's rustc must equal the Windows rustup pin lint the table classifies every tracked file and matches ci.yml @@ -92,10 +93,13 @@ def select(event: str, paths: Iterable[str], labels: Sequence[str] = ()) -> Sele chosen: Selection = {} approvals = False for path in paths: + checks = opensecret_checks(path) # verbatim, inert paths included + approvals = approvals or "pcr_approvals" in checks if inert(path): + for lane in lanes: + if lane.opensecret in checks: + chosen.setdefault(lane.id, []).append(path) continue - checks = opensecret_checks(path) - approvals = approvals or "pcr_approvals" in checks if not classified(path): # Fail safe: an unknown path may be an input of anything. for lane in lanes: @@ -166,16 +170,19 @@ def plan_event(event: str, payload: dict) -> Selection: unknown = sorted(set(names) - {lane.id for lane in active(event)}) if unknown: raise ValueError(f"not an active lane: {', '.join(unknown)}") + if "research-desktop-windows-verify" in names: # it verifies that lane's artifact + names.append("research-desktop-windows") return {name: ["dispatch"] for name in names} if event == NIGHTLY: return everything(event, "nightly") labels: List[str] = [] - same_repository = False + candidate = False # may the EIF candidate build? if event == PR: pull = payload["pull_request"] base, head = pull["base"]["sha"], pull["head"]["sha"] labels = pr_labels(os.environ["GITHUB_REPOSITORY"], pull["number"]) - same_repository = (pull["head"].get("repo") or {}).get("full_name") == os.environ["GITHUB_REPOSITORY"] + candidate = (pull["base"].get("ref") == "master" + and (pull["head"].get("repo") or {}).get("full_name") == os.environ["GITHUB_REPOSITORY"]) elif event == QUEUE: base, head = payload["merge_group"]["base_sha"], payload["merge_group"]["head_sha"] elif event == PUSH: @@ -188,8 +195,9 @@ def plan_event(event: str, payload: dict) -> Selection: else: selected = select(event, paths, labels) # The EIF candidate mints an OIDC token for the FlakeHub cache, so it builds only - # a same-repository head, and only when the diff shows that no approval changed. - if paths is None or not same_repository: + # a same-repository head of a PR to master, and only when the diff shows that no + # approval changed. + if paths is None or not candidate: selected.pop("os-eif", None) return selected @@ -253,6 +261,26 @@ def github() -> int: return 0 +def verdict() -> int: + """ci-ok: the job of every selected lane succeeded, and every other job was + skipped (or succeeded). NEEDS is ci-ok's `needs` context, plan included.""" + needs = json.loads(os.environ["NEEDS"]) + if needs.get("plan", {}).get("result") != "success": + print("::error::plan did not succeed") + return 1 + selected = json.loads(needs["plan"]["outputs"]["lanes"]) + failed = [] + for job, state in sorted(needs.items()): + wanted = any(selected.get(lane.id) for lane in LANES if lane.job == job) + if state.get("result") not in (("success",) if wanted else ("success", "skipped")): + failed.append(f"{job} ({'selected' if wanted else 'not selected'}): {state.get('result')}") + if failed: + print("::error::a job did not finish as planned: " + "; ".join(failed)) + return 1 + print("ci-ok: every selected lane succeeded") + return 0 + + def explain(event: str, base: Optional[str], head: Optional[str]) -> int: if base and head: paths = changed_paths(git("rev-parse", base).strip(), git("rev-parse", head).strip(), @@ -390,6 +418,19 @@ def lint_problems(files: Sequence[str]) -> List[str]: expected = {"plan", "ci-ok", *MATRIX_JOBS} | {lane.job for lane in LANES} if jobs != expected: problems.append(f"ci.yml jobs {sorted(jobs)} != plan, ci-ok, matrix and special lane jobs {sorted(expected)}") + # A special job repeats its lane's runner (with the override variable) and timeout. + blocks = dict(re.findall(r"^ ([A-Za-z0-9_-]+):\n(.*?)(?=^ \S|\Z)", workflow.split("\njobs:\n", 1)[1], + re.M | re.S)) + for lane in LANES: + if lane.job in MATRIX_JOBS or lane.job not in blocks: + continue + runner = lane.runner.label + if lane.runner.variable: + runner = f"${{{{ vars.{lane.runner.variable} || '{runner}' }}}}" + if not re.search(rf"^ runs-on: {re.escape(runner)}$", blocks[lane.job], re.M): + problems.append(f"ci.yml {lane.job}: runs-on is not {runner}") + if not re.search(rf"^ timeout-minutes: {lane.timeout}\b", blocks[lane.job], re.M): + problems.append(f"ci.yml {lane.job}: timeout-minutes is not {lane.timeout}") if f"toolchain: {AGENT_WINDOWS_RUST}\n" not in WINDOWS_SETUP.read_text(): problems.append(f"setup-agent-windows does not pin Rust {AGENT_WINDOWS_RUST}") declared = set(re.findall(r"^\s+- (\S+)$", (ROOT / ".github/actionlint.yaml").read_text(), re.M)) @@ -413,6 +454,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: explain_parser.add_argument("--base") explain_parser.add_argument("--head") modes.add_parser("hook").add_argument("--staged", action="store_true", help="read the index, not stdin") + modes.add_parser("verdict") modes.add_parser("check-rust") modes.add_parser("lint") args = parser.parse_args(argv) @@ -428,6 +470,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return explain(args.event, args.base, args.head) if args.mode == "hook": return hook(args.staged) + if args.mode == "verdict": + return verdict() if args.mode == "check-rust": return check_rust() files = tracked_files() diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index 80ac74c8d..562927c38 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -166,13 +166,15 @@ def test_apple_toolchain_inputs_select_apple_builds(self) -> None: with self.subTest(path=path): self.assert_routes([path], "macos", "ios") - def test_research_dev_packaging_scripts_select_macos(self) -> None: + def test_pull_request_build_scripts_select_no_signed_build(self) -> None: + # Only ci.yml's unsigned lanes run them; editing one must not start a + # signed build or a TestFlight upload on master. for path in ( - "scripts/ci/research-dev-desktop.sh", - "scripts/ci/research-dev-profile.py", + "scripts/ci/android-pr.sh", "scripts/ci/desktop-pr.sh", "scripts/ci/desktop-windows-pr.sh", + "scripts/ci/ios-pr.sh", "scripts/ci/research-dev-desktop.sh", "scripts/ci/research-dev-profile.py", ): with self.subTest(path=path): - self.assert_routes([path], "macos") + self.assert_routes([path]) def test_mixed_changes_union_their_routes(self) -> None: self.assert_routes( diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index 8f4218dea..26550636b 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -283,7 +283,8 @@ def test_c4_dev_testflight_build_number_is_the_run_number(self): def test_c5_eif_event_gate_matches_the_approval_policy(self): # Evaluate the real GitHub expressions with equivalent Python semantics. The # PR candidate is ci.yml's os-eif: the planner selects it, and its job repeats - # the event and head-repository checks. Its jobs need a successful plan. + # the event, base-branch and head-repository checks. Its jobs need a successful + # plan. opensecret-eif.yml's pull_request trigger accepts only PRs to master. approval = {job: expression(workflows()["opensecret-eif.yml"]["jobs"][job]) for job in ("eif", "eif-trusted")} candidate = expression(workflows()["ci.yml"]["jobs"]["os-eif"]) cases = ( @@ -315,10 +316,14 @@ def test_c5_eif_event_gate_matches_the_approval_policy(self): ) heads = (("MaplePrivacyLabs/Maple", True), ("contributor/Maple", False), ("MaplePrivacyLabs/Maple-fork", False), ("", False), (None, False)) - for case, (head, same_repo), cancelled in itertools.product(cases, heads, (False, True)): + self.assertEqual(workflows()["opensecret-eif.yml"]["on"]["pull_request"], {"branches": ["master"]}) + for case, (head, same_repo), base, cancelled in itertools.product( + cases, heads, ("master", "feature"), (False, True)): event, ref, result, eif, approvals, expected = case + runs = event != "pull_request" or base == "master" # opensecret-eif.yml's trigger context = { "github.event_name": event, "github.ref": ref, + "github.event.pull_request.base.ref": base, "github.event.pull_request.head.repo.full_name": head, "github.repository": "MaplePrivacyLabs/Maple", "needs.changes.result": result, "needs.changes.outputs.eif": eif, "needs.changes.outputs.pcr_approvals": approvals, @@ -327,29 +332,29 @@ def test_c5_eif_event_gate_matches_the_approval_policy(self): # The diff the planner classifies: an EIF input, an approval edit (itself an EIF input), or neither. paths = (["services/opensecret/src/main.rs"] if eif == "true" else []) + ( ["services/opensecret/pcrDev.json"] if approvals == "true" else []) - planned = "os-eif" in self.plan(event, head, paths) - # The job's own condition holds the event and fork boundary even if the planner erred. + planned = "os-eif" in self.plan(event, head, paths, base) + # The job's own condition holds the event, base and fork boundary even if the planner erred. self.assertEqual(evaluate(candidate, {"fromJSON(needs.plan.outputs.lanes)['os-eif']": True, **context}), - event == "pull_request" and same_repo, (case, head)) + event == "pull_request" and base == "master" and same_repo, (case, head, base)) selected = [] if result == "success" and not cancelled and evaluate(candidate, { "fromJSON(needs.plan.outputs.lanes)['os-eif']": planned, **context}): selected.append("os-eif") - selected += [job for job, text in approval.items() if evaluate(text, context)] + selected += [job for job, text in approval.items() if runs and evaluate(text, context)] trusted = ((event == "pull_request" and same_repo) or (event in ("push", "workflow_dispatch") and ref == "refs/heads/master")) wanted = [] - if (not cancelled and event == "pull_request" and same_repo and result == "success" - and eif == "true" and approvals == "false"): + if (not cancelled and event == "pull_request" and base == "master" and same_repo + and result == "success" and eif == "true" and approvals == "false"): wanted.append("os-eif") - if expected and not cancelled: + if expected and not cancelled and runs: wanted.append("eif-trusted" if trusted else "eif") - self.assertEqual(selected, wanted, (case, head, cancelled)) + self.assertEqual(selected, wanted, (case, head, base, cancelled)) @staticmethod - def plan(event: str, head, paths: list) -> dict: - """ci.yml's plan job for this event, head repository and diff.""" - payload = {"pull_request": {"number": 1, "base": {"sha": "a" * 40}, "head": { + def plan(event: str, head, paths: list, base: str = "master") -> dict: + """ci.yml's plan job for this event, base branch, head repository and diff.""" + payload = {"pull_request": {"number": 1, "base": {"sha": "a" * 40, "ref": base}, "head": { "sha": "b" * 40, "repo": None if head is None else {"full_name": head}}}, "merge_group": {"base_sha": "a" * 40, "head_sha": "b" * 40}, "before": "a" * 40, "after": "b" * 40} with mock.patch.object(plan, "changed_paths", return_value=paths), \ diff --git a/scripts/ci/test_nightly_report.py b/scripts/ci/test_nightly_report.py index 5a1fb1db8..74d330c22 100644 --- a/scripts/ci/test_nightly_report.py +++ b/scripts/ci/test_nightly_report.py @@ -9,19 +9,26 @@ import nightly_report -GREEN = {"conclusion": "success", "created_at": "2026-10-26T04:17:00Z", "html_url": "https://ci/1"} +DAY = "2026-10-26" +GREEN = {"status": "completed", "conclusion": "success", "created_at": DAY + "T04:17:00Z", "html_url": "https://ci/1"} class NightlyReportTests(unittest.TestCase): - def test_failures_list_failed_jobs_and_a_red_or_missing_scheduled_ci_run(self): + def test_failures_list_failed_jobs_and_a_red_stale_running_or_missing_scheduled_ci_run(self): needs = {"store-bench": {"result": "success"}, "agent-provider": {"result": "failure"}, - "advisories": {"result": "cancelled"}} - self.assertEqual(nightly_report.failures(needs, GREEN), - ["- `advisories`: cancelled", "- `agent-provider`: failure"]) + "bun-audit": {"result": "cancelled"}} + self.assertEqual(nightly_report.failures(needs, GREEN, DAY), + ["- `agent-provider`: failure", "- `bun-audit`: cancelled"]) red = dict(GREEN, conclusion="failure") - self.assertEqual(nightly_report.failures({}, red), + self.assertEqual(nightly_report.failures({}, red, DAY), ["- ci.yml scheduled run of 2026-10-26: failure (https://ci/1)"]) - self.assertEqual(len(nightly_report.failures({}, None)), 1) + running = dict(GREEN, status="in_progress", conclusion=None) + self.assertEqual(nightly_report.failures({}, running, DAY), + ["- ci.yml scheduled run of 2026-10-26: still in_progress (https://ci/1)"]) + # Yesterday's green run says nothing about tonight's. + for stale in (GREEN, None): + self.assertEqual(nightly_report.failures({}, stale, "2026-10-27"), + ["- ci.yml: no scheduled run on master on 2026-10-27"]) def test_one_issue_is_created_commented_or_closed(self): self.assertEqual(nightly_report.decide(["- x"], None, "u")[0], "create") @@ -35,7 +42,8 @@ def run_main(self, dry_run, open_issues, needs): def gh(path, method="GET", payload=None): calls.append((method, path, payload)) if "/actions/workflows/ci.yml/runs" in path: - return {"workflow_runs": [GREEN]} + return {"workflow_runs": [dict(GREEN, created_at=nightly_report.datetime.now( + nightly_report.timezone.utc).strftime("%Y-%m-%dT04:17:00Z"))]} return [{"number": number} for number in open_issues] + [{"number": 1, "pull_request": {}}] env = {"GITHUB_REPOSITORY": "o/r", "GITHUB_RUN_ID": "9", "NEEDS": json.dumps(needs), "DRY_RUN": dry_run} diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 9b2b7dde5..2c91eefaf 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -104,12 +104,13 @@ def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): self.assertEqual(selected(["scripts/ci/verify-agent-rust-deps.py"]), {"agent-lint", "research-rust", "repo"}) for path in ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py"): with self.subTest(path=path): - self.assertEqual(selected([path]), {"agent-macos", "research-ios", "research-desktop-macos", "repo"}) + self.assertEqual(selected([path]), {"agent-macos", "agent-package-macos", "research-ios", + "research-desktop-macos", "repo"}) self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "repo"}) def test_intentional_differences_select_only_the_repo_lane(self): for path in ( - # The Research selector; the Agent no longer imports it. + # The Research selector, which only the signed Research builds use. "scripts/ci/change_detection.py", # Signing scripts: no ci.yml Agent lane signs anything (agent-desktop-build.yml does). "scripts/ci/windows-artifact-sign.ps1", "scripts/ci/install-windows-artifact-signing.ps1", @@ -130,7 +131,7 @@ def test_unknown_roots_and_malformed_paths_select_every_lane(self): def test_mixed_changes_and_empty_diff(self): self.assertEqual(selected([]), set()) - self.assertEqual(selected(["README.md", "sdk/rust/README.md"]), set()) + self.assertEqual(selected(["README.md", "proxy/README.md"]), set()) self.assertEqual(selected(["README.md", "proxy/src/proxy.rs"]), AGENT_PR | {"proxy", "research-rust"}) def test_explain_reads_null_delimited_names(self): @@ -183,6 +184,12 @@ def test_inert_paths_select_nothing(self): self.assertFalse(plan.inert(evidence)) self.assertEqual(selected([evidence]), {"repo"}) + def test_the_opensecret_selector_also_routes_inert_paths(self): + # Its routing is verbatim: these notes still select what it selects for them. + self.assertEqual(selected(["sdk/rust/README.md"]), {"os-sdk-e2e"}) + self.assertEqual(selected(["services/agent-updates/README.md"]), OPENSECRET_ALL) + self.assertEqual(selected(["services/opensecret/README.md", "services/opensecret/docs/x.md"]), set()) + def test_ci_infrastructure_selects_every_active_gate_lane(self): for path in (".github/workflows/ci.yml", ".github/actions/setup-nix/action.yml", "scripts/ci/plan.py", "scripts/ci/lanes.py"): @@ -259,7 +266,7 @@ def test_shared_crates_do_not_fan_out_to_their_consumers(self): self.assert_selects(["scripts/ci/opensecret_change_detection.py"], "repo") def test_shared_inputs_select_every_lane_that_owns_them(self): - # Where the former selector chose one component, the table's owners all run. + # A path that several lanes own selects each of their components. for paths, names in ( ([".githooks/pre-commit"], ("repo",)), (["setup-hooks.sh"], ("repo",)), (["justfile"], ("repo",)), (["scripts/prepare-frontend-deps.sh"], ("research_frontend",)), @@ -320,17 +327,26 @@ def test_dependency_inputs_select_supply_chain_and_the_provider_suite(self): with self.subTest(path=path): self.assertEqual(selected([path]) & both, lanes_) self.assertEqual(selected([path], PUSH) & both, lanes_) - self.assertEqual(selected([path], NIGHTLY) & both, set()) # nightly.yml runs them + self.assertIn("agent-supply-chain", active(NIGHTLY)) + self.assertNotIn("agent-provider", active(NIGHTLY)) # nightly.yml runs it on every OS def test_packaging_rehearsal_runs_on_the_narrow_selector_or_a_label(self): for path in ("apps/maple-agent/scripts/build-release.sh", "apps/maple-agent/flake.lock", - "apps/maple-agent/app/packaging/macos-entitlements.plist", + "apps/maple-agent/flake.nix", "apps/maple-agent/app/packaging/macos-entitlements.plist", "apps/maple-agent/scripts/verify-linux-bundle-docker.sh", + # What package-release.sh and verify-release.sh call. + "apps/maple-agent/scripts/macos-release-app.sh", "apps/maple-agent/scripts/release-info.py", + "apps/maple-agent/scripts/linux-release-bundle.py", "apps/maple-agent/scripts/linux-install.sh", + "apps/maple-agent/scripts/smoke-macos-release.py", + "apps/maple-agent/scripts/stage-third-party-notices.py", ".github/workflows/agent-desktop-build.yml"): with self.subTest(path=path): self.assertLessEqual(AGENT_PACKAGE, selected([path])) self.assertFalse({lane for lane in selected([path], PUSH) if lane.startswith("agent-package")}) - self.assertFalse(AGENT_PACKAGE & selected(["apps/maple-agent/app/src/main.rs"])) + self.assertEqual(AGENT_PACKAGE & selected(["scripts/ci/apple-toolchain.json"]), {"agent-package-macos"}) + for path in ("apps/maple-agent/app/src/main.rs", "apps/maple-agent/scripts/macos-debug-app.sh"): + with self.subTest(path=path): + self.assertFalse(AGENT_PACKAGE & selected([path])) self.assertFalse({"agent-package-windows"} & selected(["apps/maple-agent/scripts/build-release.sh"])) self.assertEqual(agent_lanes(selected(["docs/x.md"], labels=["ci:package"])), AGENT_PACKAGE | {"agent-package-windows"}) @@ -342,7 +358,7 @@ def test_full_suites_run_on_master_nightly_and_ci_full_only(self): self.assertLessEqual(full, selected(["apps/maple-agent/app/src/main.rs"], PUSH)) self.assertLessEqual(full, selected(["docs/x.md"], labels=["ci:full"])) self.assertLessEqual(full | {"agent-windows"}, active(NIGHTLY)) - self.assertFalse(({"agent-perf", "agent-supply-chain", "agent-provider"} | AGENT_PACKAGE) & active(NIGHTLY)) + self.assertFalse(({"agent-perf", "agent-provider"} | AGENT_PACKAGE) & active(NIGHTLY)) # nightly.yml runs these self.assertFalse(full & selected([".github/workflows/ci.yml"])) # CI edits prove the gate lanes only def test_agent_desktop_selector_covers_the_signed_builds_inputs(self): @@ -359,7 +375,7 @@ def test_agent_desktop_selector_covers_the_signed_builds_inputs(self): class ClientLaneTests(unittest.TestCase): - """The lanes that replaced the SDK, proxy, Research and service workflows.""" + """The SDK, proxy, Research and service lanes.""" def test_component_inputs_select_their_lanes(self): for path, pr, push in ( @@ -383,7 +399,7 @@ def test_component_inputs_select_their_lanes(self): with self.subTest(path=path): self.assertEqual(selected([path]), pr) self.assertEqual(selected([path], PUSH), push) - self.assertFalse({"deps-research", "deps-sdk", "deps-proxy"} & active(NIGHTLY)) # nightly.yml checks advisories + self.assertLessEqual({"deps-research", "deps-sdk", "deps-proxy"}, active(NIGHTLY)) # new advisories def test_research_platform_builds_run_on_native_inputs_or_labels(self): desktop = {"research-desktop-macos", "research-desktop-linux"} @@ -408,7 +424,7 @@ def test_research_platform_builds_run_on_native_inputs_or_labels(self): class OpenSecretLaneTests(unittest.TestCase): - """The lanes that replaced opensecret-ci.yml, sdk-integration.yml and the PR EIF candidate.""" + """The OpenSecret lanes and the PR EIF candidate.""" def test_the_opensecret_selector_routes_each_lane(self): backend = "services/opensecret/" @@ -428,6 +444,9 @@ def test_the_opensecret_selector_routes_each_lane(self): (".gitmodules", OPENSECRET_ALL, OPENSECRET_ALL - {"os-eif"}), ("scripts/ci/opensecret_change_detection.py", OPENSECRET_ALL | {"repo"}, OPENSECRET_ALL - {"os-eif"} | {"repo"}), + # Each lane's own entry script. + ("scripts/ci/opensecret-sdk-e2e.sh", {"os-sdk-e2e", "repo"}, {"os-sdk-e2e", "repo"}), + ("scripts/ci/opensecret-helpers.sh", {"os-helpers", "repo"}, {"os-helpers", "repo"}), ): with self.subTest(path=path): self.assertEqual(selected([path]), pr) @@ -439,8 +458,11 @@ def test_an_approval_edit_anywhere_in_the_diff_drops_the_candidate(self): self.assertNotIn("os-eif", selected([runtime, approval])) self.assertNotIn("os-eif", selected(["docs/x.md"], labels=["ci:full"])) self.assertNotIn("os-eif", selected([".github/workflows/ci.yml"])) + # Its own label builds it, for example to prove an edit of its ci.yml job. + self.assertEqual(selected([".github/workflows/ci.yml"], labels=["ci:eif"]) & {"os-eif"}, {"os-eif"}) + self.assertNotIn("os-eif", selected([approval], labels=["ci:eif"])) self.assertEqual({lane.id for lane in lanes.LANES if lane.events == {PR}}, {"os-eif"}) - self.assertNotIn("deps-opensecret", active(NIGHTLY)) # nightly.yml checks advisories + self.assertIn("deps-opensecret", active(NIGHTLY)) # new advisories def test_eif_builder_and_checkout(self): entries = {e["id"]: e for e in json.loads(plan.outputs(dict.fromkeys(ACTIVE, ["x"]))["nix"])} @@ -478,15 +500,23 @@ def test_schedule_and_dispatch(self): _, outputs = self.github(DISPATCH, {}, LANES_INPUT="agent-test") self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, {"agent-test"}) self.assertEqual([entry["id"] for entry in outputs["nix"]], ["agent-test"]) + # A verifier brings the job whose artifact it verifies. + _, outputs = self.github(DISPATCH, {}, LANES_INPUT="research-desktop-windows-verify") + self.assertEqual({lane for lane, on in outputs["lanes"].items() if on}, + {"research-desktop-windows", "research-desktop-windows-verify"}) - def test_pr_without_a_usable_diff_or_from_a_fork_never_builds_an_eif_candidate(self): - pull = {"number": 1, "base": {"sha": SHA_A}, "head": {"sha": SHA_B, "repo": {"full_name": "x/Maple"}}} + def test_eif_candidate_builds_only_same_repository_prs_to_master_with_a_usable_diff(self): + pull = {"number": 1, "base": {"sha": SHA_A, "ref": "master"}, + "head": {"sha": SHA_B, "repo": {"full_name": "MaplePrivacyLabs/Maple"}}} code, outputs = self.github(PR, {"pull_request": pull}) self.assertEqual((code, {lane for lane, on in outputs["lanes"].items() if on}), (0, active(PR) - {"os-eif"})) with mock.patch.object(plan, "changed_paths", return_value=["services/opensecret/src/main.rs"]): - for head, built in (("MaplePrivacyLabs/Maple", True), ("contributor/Maple", False), (None, False)): - with self.subTest(head=head): + for head, base, built in (("MaplePrivacyLabs/Maple", "master", True), + ("MaplePrivacyLabs/Maple", "stacked-branch", False), + ("contributor/Maple", "master", False), (None, "master", False)): + with self.subTest(head=head, base=base): pull["head"]["repo"] = head and {"full_name": head} + pull["base"]["ref"] = base self.assertIs(self.github(PR, {"pull_request": pull})[1]["lanes"]["os-eif"], built) def test_malformed_event_fails_a_pr_and_selects_everything_on_push(self): @@ -506,16 +536,10 @@ def test_outputs_empty_matrix_and_runner_overrides(self): with mock.patch.dict(os.environ, overrides): result = plan.outputs(dict.fromkeys(ACTIVE, ["x"])) runners = {entry["id"]: entry["runner"] for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])} - linux, mac, free = "blacksmith-8vcpu-ubuntu-2404", "blacksmith-6vcpu-macos-26", "ubuntu-latest" - self.assertEqual(runners, { - "agent-lint": linux, "agent-test": "blacksmith-16vcpu-ubuntu-2404", "agent-macos": mac, - "agent-windows": "windows-2025", "agent-full-linux": linux, "agent-full-macos": mac, - "agent-full-windows": "windows-2025", "agent-supply-chain": linux, "agent-provider": linux, - "agent-perf": linux, "repo": free, "sdk-ts": free, "sdk-rust": free, "proxy": free, - "proxy-container": free, "proxy-container-arm64": "ubuntu-24.04-arm", "deps-research": free, - "deps-sdk": free, "deps-proxy": free, "research-web": free, "research-android": linux, "auth": free, - "updates": free, "agent-updates": free, "os-rust": linux, "os-flake-check": free, "os-pcr": free, - "deps-opensecret": free}) + self.assertEqual(runners, {lane.id: overrides.get(lane.runner.variable or "", lane.runner.label) + for lane in lanes.LANES if lane.job in lanes.MATRIX_JOBS}) + self.assertEqual((runners["agent-test"], runners["agent-windows"], runners["agent-lint"]), + ("blacksmith-16vcpu-ubuntu-2404", "windows-2025", lanes.LINUX.label)) entries = [entry for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])] self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) @@ -535,6 +559,25 @@ def test_run_executes_the_lane_command_in_its_flake(self): self.assertEqual(run.call_args.args[0][:2], ["C:/Git/usr/bin/bash.exe", "-euo"]) self.assertEqual(run.call_args.kwargs["env"]["RUSTFLAGS"], "-D warnings") + def verdict(self, lanes_on, **results): + needs = {"plan": {"result": "success", "outputs": {"lanes": json.dumps(dict.fromkeys(lanes_on, True))}}} + needs.update({job: {"result": result} for job, result in results.items()}) + with mock.patch.dict(os.environ, {"NEEDS": json.dumps(needs)}), contextlib.redirect_stdout(io.StringIO()): + return plan.verdict() + + def test_verdict_needs_every_selected_job_to_succeed_and_the_rest_skipped(self): + # nix carries agent-lint; agent-package-linux and os-eif are special jobs. + self.assertEqual(self.verdict(["agent-lint"], nix="success", windows="skipped", **{ + "agent-package-linux": "skipped", "os-eif": "skipped"}), 0) + self.assertEqual(self.verdict(["agent-lint"], nix="failure", windows="skipped"), 1) + self.assertEqual(self.verdict(["agent-lint"], nix="skipped"), 1) + self.assertEqual(self.verdict(["os-eif"], nix="skipped", **{"os-eif": "cancelled"}), 1) + self.assertEqual(self.verdict([], nix="skipped", **{"os-eif": "failure"}), 1) + self.assertEqual(self.verdict([], nix="skipped", **{"os-eif": "success"}), 0) + needs = {"plan": {"result": "failure", "outputs": {}}} + with mock.patch.dict(os.environ, {"NEEDS": json.dumps(needs)}), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(plan.verdict(), 1) + def test_check_rust_compares_the_flake_compiler_with_the_windows_pin(self): for reported, expected in ((f"rustc {lanes.AGENT_WINDOWS_RUST} (abc 2026-01-01)\n", 0), ("rustc 1.99.0 (abc 2026-01-01)\n", 1)): diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index ca590d577..57696c140 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -55,7 +55,9 @@ "sdk-crates": frozenset({"CRATES_BOOTSTRAP_TOKEN"}), } # Actions-cache writers; a key under these prefixes feeds release.yml's fallbacks. -CACHE_WRITERS = ("actions/cache@", "actions/cache/save@", "irgaly/xcode-cache@", "Swatinem/rust-cache@") +# setup-bun's post step saves the Bun binary unless `no-cache` is set. +CACHE_WRITERS = ("actions/cache@", "actions/cache/save@", "irgaly/xcode-cache@", "Swatinem/rust-cache@", + "oven-sh/setup-bun@") @functools.lru_cache(maxsize=None) def load(path: Path) -> dict: @@ -125,8 +127,9 @@ def runner_labels(name: str, job_id: str, job: dict) -> set: def cache_writer(step: dict) -> bool: - uses = step.get("uses", "") - return uses.startswith(CACHE_WRITERS) and str(step.get("with", {}).get("save-if", "true")) != "false" + settings = step.get("with", {}) + return (step.get("uses", "").startswith(CACHE_WRITERS) and str(settings.get("save-if", "true")) != "false" + and str(settings.get("no-cache", "false")).lower() != "true") class WorkflowPolicyTests(unittest.TestCase): @@ -215,6 +218,9 @@ def test_8_ci_always_reports_one_verdict(self): verdict = config["jobs"]["ci-ok"] self.assertEqual(verdict["if"], "always()") self.assertEqual(set(verdict["needs"]), set(config["jobs"]) - {"ci-ok"}) + # nightly.yml's report likewise needs every other nightly job. + nightly = workflows()["nightly.yml"]["jobs"] + self.assertEqual(set(nightly["report"]["needs"]), set(nightly) - {"report"}) def test_9_runner_labels_are_github_hosted_or_declared(self): declared = set(load(ROOT / ".github/actionlint.yaml")["self-hosted-runner"]["labels"]) diff --git a/services/opensecret/README.md b/services/opensecret/README.md index 5842fdcb4..9a74f0a55 100644 --- a/services/opensecret/README.md +++ b/services/opensecret/README.md @@ -101,10 +101,10 @@ with the repository's Nix flake on the appropriate Linux/ARM environment. Build, PCR comparison, deployment, and live trust verification are distinct evidence. The monorepo-root [`ci.yml`](../../.github/workflows/ci.yml) lanes `os-rust`, -`os-flake-check`, and `deps-opensecret` validate Rust, Nix checks, and dependency -policy; `os-sdk-e2e` exercises both SDKs against the backend in the same -checkout, and the nightly workflow builds the default backend binary. These -lanes do not build or publish EIFs or deploy the TEE service. Before an authorized dev or +`os-flake-check`, and `deps-opensecret` validate Rust, Nix checks and the +default backend binary, and dependency policy. Its `os-sdk-e2e` lane exercises +both SDKs against the backend in the same checkout. These lanes do not +build or publish EIFs or deploy the TEE service. Before an authorized dev or prod publish/deployment, use the supported Linux/ARM64 release builder to review and deliberately update/verify the target measurements; never update checked-in PCRs solely to clear ordinary pull-request CI. From 822745864fb59ad44172a19e020dedd557a9dd62 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:02:08 +0000 Subject: [PATCH 08/21] ci: fix validation Keep services/opensecret/ byte-identical to master, as the OpenSecret port requires: restore its README, nitro-deploy.md and hook comment. The C7 docs contract skips that subtree; its docs move with OpenSecret PRs. Co-Authored-By: Claude Opus 5.5 --- scripts/ci/test_contracts.py | 3 ++- services/opensecret/.githooks/pre-commit | 6 +++--- services/opensecret/README.md | 8 ++++---- services/opensecret/docs/nitro-deploy.md | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index 26550636b..54eee1498 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -409,7 +409,8 @@ def test_c7_docs_name_only_existing_workflows_and_files(self): names = {Path(path).name for path in files} dangling = [] for path in files: - if not (path.endswith(".md") and plan.matches(path, DOCS)): + # CI changes never edit services/opensecret/ (PCR inputs); its docs move with OpenSecret PRs. + if not (path.endswith(".md") and plan.matches(path, DOCS)) or path.startswith("services/opensecret/"): continue text = (ROOT / path).read_text(encoding="utf-8", errors="replace") for match in re.finditer(r"(? Date: Tue, 27 Oct 2026 03:47:29 +0000 Subject: [PATCH 09/21] ci: route agent-updates away from OpenSecret and drop the unused x86 package build - opensecret_change_detection.py lists services/agent-updates/ as an independent root. Since #1214 every agent-updates change selected every OpenSecret check, including both EIF builds. Unknown roots still select every check. - os-flake-check runs only the component `nix flake check`. Nothing consumed the x86_64 `#default` package: the shipped EIF is aarch64, and os-rust, os-sdk-e2e and the EIF lanes compile and test the same code. - deps-opensecret follows its own Cargo.toml, Cargo.lock and deny.toml instead of the selector's `audit` output, which CI files also trigger. CI edits no longer select any deps lane, so new RustSec advisories surface in the nightly run instead of failing unrelated PRs. Co-Authored-By: Claude Opus 5.5 --- .../review-opensecret-security/SKILL.md | 4 +- .../validate-opensecret/references/checks.md | 4 +- docs/ci.md | 11 +++-- docs/opensecret-import.md | 4 +- docs/repository-workflows.md | 11 ++--- scripts/ci/lanes.py | 14 ++++--- scripts/ci/opensecret_change_detection.py | 4 +- .../ci/test_opensecret_change_detection.py | 10 +++++ scripts/ci/test_plan.py | 41 ++++++++++++++++--- 9 files changed, 75 insertions(+), 28 deletions(-) diff --git a/.agents/skills/review-opensecret-security/SKILL.md b/.agents/skills/review-opensecret-security/SKILL.md index fd5bc2531..6c25c3571 100644 --- a/.agents/skills/review-opensecret-security/SKILL.md +++ b/.agents/skills/review-opensecret-security/SKILL.md @@ -132,8 +132,8 @@ Load `$validate-opensecret` and run the tiers reached by the diff. Report local, database, provider, client, build/artifact, and live evidence separately. Local artifact builds and read-only PCR comparison are validation when in -scope. Root backend CI validates Rust, Nix checks, and SDK compatibility; the -nightly workflow builds the default binary. A separate ARM64 workflow compares dev/prod EIF +scope. Root backend CI validates Rust, Nix checks, and SDK compatibility. A +separate ARM64 workflow compares dev/prod EIF measurements on explicit approved-PCR JSON edits in PRs, relevant master changes, and manual runs. Do not require ordinary backend PRs to update approvals, and do not suppress meaningful master mismatches. Master push/manual diff --git a/.agents/skills/validate-opensecret/references/checks.md b/.agents/skills/validate-opensecret/references/checks.md index ddd10702b..7ea818d14 100644 --- a/.agents/skills/validate-opensecret/references/checks.md +++ b/.agents/skills/validate-opensecret/references/checks.md @@ -156,8 +156,8 @@ nix build --no-link --no-write-lock-file '.?submodules=1#default' PCR reference/history updates remain operator-controlled release work. Read-only EIF construction and PCR comparison are validation when in scope. -Root backend CI runs applicable Nix checks, and the nightly workflow builds -the default backend binary. The separate root EIF approval workflow builds dev/prod +Root backend CI runs the component flake checks but does not build the +default package. The separate root EIF approval workflow builds dev/prod and compares generated measurements on PRs that explicitly edit the four approved PCR JSON files, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not require new PCR approvals; master diff --git a/docs/ci.md b/docs/ci.md index 25b6f552e..be3e683bf 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -23,6 +23,10 @@ and timeout. `scripts/ci/plan.py` (the `plan` job) selects lanes from the diff: nightly and dispatch runs select every lane instead. - OpenSecret lanes are selected by `scripts/ci/opensecret_change_detection.py`, imported unchanged and asked about every changed path, docs included. +- Dependency audits (`deps-*`, cargo-deny) run only when their manifest, + lockfile or `deny.toml` changes, with `ci:full`, and nightly. CI edits never + select them, so a newly published advisory cannot turn an unrelated PR red; + the nightly run reports it. The `plan` job summary lists each selected lane and why. To preview locally: @@ -75,10 +79,9 @@ of the lanes your staged files select. artifacts, or a cache another workflow writes). The job id is the lane's `job`, its real work is a script or recipe, and `ci-ok` needs it. 4. A new top-level directory, or a new one under `services/`, is unknown to - the OpenSecret selector, which then runs every OpenSecret lane for it, as - it does for `services/agent-updates/`. Adding its prefix to the selector's - `KNOWN_INDEPENDENT_PREFIXES` is an OpenSecret change: it also changes what - `opensecret-eif.yml` checks. + the OpenSecret selector, which then runs every OpenSecret lane for it. + Adding its prefix to the selector's `KNOWN_INDEPENDENT_PREFIXES` is an + OpenSecret change: it also changes what `opensecret-eif.yml` checks. 5. Run `python3 scripts/ci/plan.py lint` (every glob matches a tracked file, every tracked file is owned or inert, the `ci.yml` jobs and their runners and timeouts match the table) and `just lane repo`. A new runner label also diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index 5a74606cb..d107001f0 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -44,8 +44,8 @@ shortcuts are `just opensecret-check` and `just opensecret-pcr-check`. ## CI and local development Root workflows replace the backend's inactive nested workflows. Backend CI -(the `os-*` and `deps-opensecret` lanes of `ci.yml`) checks Rust, the ordinary -Nix package/checks, and dependency policy. +(the `os-*` and `deps-opensecret` lanes of `ci.yml`) checks Rust, the +component Nix checks, and dependency policy. The SDK integration lane starts the backend from this same checkout against a disposable database; it no longer fetches an external backend revision. This advances that lane from the former pinned commit diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index dd659bed2..3b9c15d37 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -153,11 +153,12 @@ interoperability only. ## OpenSecret checks -`ci.yml` runs `os-rust`, `os-flake-check` (the component flake's checks and -the x86_64 package), `os-helpers` (the parent helper images), -`deps-opensecret`, `os-pcr` (the root `pcr-compatibility` check for PCR tooling -and approval edits) and `os-sdk-e2e`, which tests both SDKs against -`services/opensecret/` from the same checkout. The `os-eif` lane builds dev and +`ci.yml` runs `os-rust`, `os-flake-check` (the component flake's checks), +`os-helpers` (the parent helper images), `deps-opensecret` (when its +manifest, lockfile or `deny.toml` changes), `os-pcr` (the root +`pcr-compatibility` check for PCR tooling and approval edits) and +`os-sdk-e2e`, which tests both SDKs against `services/opensecret/` from the +same checkout. The `os-eif` lane builds dev and prod EIF candidates on same-repository PRs to `master` that change measured inputs (or carry the `ci:eif` label) without editing PCR approvals, on the release builder's diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 4539f10b4..ebb6c4fb9 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -7,6 +7,8 @@ OpenSecret lanes are selected by opensecret_change_detection.py, imported verbatim, from every changed path, inert ones included (`opensecret` names its output). Their only globs are the entry scripts of os-sdk-e2e and os-helpers. +deps-opensecret is the exception: like every deps lane, only its manifest, +lockfile and deny.toml select it. `hook` names the .githooks/pre-commit component that runs a lane's checks locally. The hook selects it from the lane's own paths only, without the CI @@ -337,18 +339,20 @@ def deny(manifest: str, component: str) -> str: submodules=True, timeout=30, env=OPENSECRET_ENV, hook="opensecret"), Lane("os-sdk-e2e", ("scripts/ci/opensecret-sdk-e2e.sh",), opensecret="integration", job="os-sdk-e2e", run="./scripts/ci/opensecret-sdk-e2e.sh", timeout=45), - # The component flake's checks and the x86_64 application package. + # The component flake's checks. Lane("os-flake-check", (), opensecret="nix", runner=GITHUB, workdir="services/opensecret", - run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'" - " && nix build --no-link --no-update-lock-file '.?submodules=1#default'", submodules=True, - timeout=60, env=OPENSECRET_ENV, hook="opensecret"), # the hook runs its entrypoint checks + run="nix flake check --no-update-lock-file --print-build-logs '.?submodules=1'", submodules=True, + timeout=30, env=OPENSECRET_ENV, hook="opensecret"), # the hook runs its entrypoint checks Lane("os-helpers", ("scripts/ci/opensecret-helpers.sh",), opensecret="helpers", job="os-helpers", runner=Runner("ubuntu-24.04-arm"), run="./scripts/ci/opensecret-helpers.sh", timeout=30), Lane("os-pcr", (), opensecret="pcr", runner=GITHUB, timeout=10, run='nix build --no-update-lock-file --no-link' ' ".#checks.$(nix eval --impure --raw --expr builtins.currentSystem).pcr-compatibility"'), - Lane("deps-opensecret", (), opensecret="audit", flake=OPENSECRET_SHELL, submodules=True, env=OPENSECRET_ENV, + # Not the selector's `audit` output, which CI files also trigger: like the other + # deps lanes, only the dependency graph's own inputs select it. + Lane("deps-opensecret", tuple("services/opensecret/" + name for name in ("Cargo.toml", "Cargo.lock", "deny.toml")), + flake=OPENSECRET_SHELL, submodules=True, env=OPENSECRET_ENV, run=deny("services/opensecret/Cargo.toml", "services/opensecret"), **DEPS), # ci.yml's os-eif job runs these two scripts with the FlakeHub cache; `just lane os-eif` runs them locally. Lane("os-eif", (), opensecret="eif", labels=("ci:eif",), events=frozenset({PR}), job="os-eif", runner=EIF_BUILDER, diff --git a/scripts/ci/opensecret_change_detection.py b/scripts/ci/opensecret_change_detection.py index 3f76c605b..b869f74e1 100644 --- a/scripts/ci/opensecret_change_detection.py +++ b/scripts/ci/opensecret_change_detection.py @@ -44,8 +44,8 @@ ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh", }) KNOWN_INDEPENDENT_PREFIXES = ( - "apps/", "proxy/", "services/updates/", "services/download-tracking/", ".agents/", ".github/", - ".githooks/", "docs/", "scripts/", + "apps/", "proxy/", "services/updates/", "services/agent-updates/", "services/download-tracking/", + ".agents/", ".github/", ".githooks/", "docs/", "scripts/", ) KNOWN_INDEPENDENT_FILES = frozenset({ "AGENTS.md", "README.md", "LICENSE", ".gitignore", ".dockerignore", diff --git a/scripts/ci/test_opensecret_change_detection.py b/scripts/ci/test_opensecret_change_detection.py index 53c176053..2f11f1f68 100644 --- a/scripts/ci/test_opensecret_change_detection.py +++ b/scripts/ci/test_opensecret_change_detection.py @@ -116,6 +116,16 @@ def test_independent_components_and_docs_skip_backend_checks(self): with self.subTest(path=path): self.assert_routes([path]) + def test_update_services_are_independent_but_a_new_service_selects_every_check(self): + for path in ("services/agent-updates/src/worker.ts", "services/agent-updates/publisher/publisher.py", + "services/agent-updates/wrangler.jsonc", "services/agent-updates/README.md", + "services/download-tracking/tracking.ts", "services/updates/wrangler.jsonc"): + with self.subTest(path=path): + self.assert_routes([path]) + for path in ("services/new-service/src/main.ts", "services/agent-updates-v2/src/worker.ts"): + with self.subTest(path=path): + self.assert_routes([path], *CHECK_OUTPUTS) + def test_disposable_database_harness_selects_integration_without_app_packaging(self): path = ".agents/skills/validate-opensecret/scripts/disposable_db_tests.sh" self.assert_routes([path], "integration") diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 2c91eefaf..4834f47ac 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -23,8 +23,9 @@ AGENT_PR = {"agent-lint", "agent-test", "agent-macos"} AGENT_PUSH = AGENT_PR | {"agent-windows", "agent-full-linux", "agent-full-macos", "agent-full-windows"} AGENT_PACKAGE = {"agent-package-linux", "agent-package-macos"} -# What opensecret_change_detection.py's "all checks" selects in ci.yml. -OPENSECRET_ALL = {"os-rust", "os-sdk-e2e", "os-flake-check", "os-helpers", "deps-opensecret", "os-eif"} +# What opensecret_change_detection.py's "all checks" selects in ci.yml (deps-opensecret follows its manifests). +OPENSECRET_ALL = {"os-rust", "os-sdk-e2e", "os-flake-check", "os-helpers", "os-eif"} +DEPS = {"deps-research", "deps-sdk", "deps-proxy", "deps-opensecret"} SHA_A, SHA_B = "a" * 40, "b" * 40 HOOK = list(dict.fromkeys(lane.hook for lane in lanes.LANES if lane.hook)) @@ -187,7 +188,10 @@ def test_inert_paths_select_nothing(self): def test_the_opensecret_selector_also_routes_inert_paths(self): # Its routing is verbatim: these notes still select what it selects for them. self.assertEqual(selected(["sdk/rust/README.md"]), {"os-sdk-e2e"}) - self.assertEqual(selected(["services/agent-updates/README.md"]), OPENSECRET_ALL) + self.assertEqual(selected(["services/agent-updates/README.md"]), set()) + # A new service root is unknown to it: every OpenSecret check, and every lane if not inert. + self.assertEqual(selected(["services/new-service/README.md"]), OPENSECRET_ALL) + self.assertEqual(selected(["services/new-service/src/main.ts"]), active(PR)) self.assertEqual(selected(["services/opensecret/README.md", "services/opensecret/docs/x.md"]), set()) def test_ci_infrastructure_selects_every_active_gate_lane(self): @@ -389,9 +393,7 @@ def test_component_inputs_select_their_lanes(self): ("apps/maple-auth/src/main.tsx", {"auth"}, set()), ("services/download-tracking/tracking.ts", {"updates", "agent-updates"}, {"updates", "agent-updates"}), ("services/updates/src/index.ts", {"updates"}, {"updates"}), - # The OpenSecret selector (imported verbatim) treats this root as unknown: every check. - ("services/agent-updates/src/index.ts", {"agent-updates"} | OPENSECRET_ALL, - {"agent-updates"} | OPENSECRET_ALL - {"os-eif"}), + ("services/agent-updates/src/index.ts", {"agent-updates"}, {"agent-updates"}), # Master runs these in frontend-tests.yml, web-build.yml and rust-tests.yml. ("apps/maple-research/frontend/src/App.tsx", {"research-web"}, set()), ("apps/maple-research/frontend/src-tauri/src/lib.rs", {"research-rust"}, set()), @@ -401,6 +403,31 @@ def test_component_inputs_select_their_lanes(self): self.assertEqual(selected([path], PUSH), push) self.assertLessEqual({"deps-research", "deps-sdk", "deps-proxy"}, active(NIGHTLY)) # new advisories + def test_dependency_audits_follow_only_their_manifests_labels_and_nightly(self): + # A new advisory is drift, not a CI change: no CI edit selects a deps lane. + for path in (".github/workflows/ci.yml", ".github/actions/setup-nix/action.yml", "scripts/ci/plan.py", + "scripts/ci/lanes.py", "scripts/ci/opensecret_change_detection.py", ".gitmodules", + ".github/workflows/opensecret-eif.yml", "scripts/ci/check_opensecret_eif.sh", + "services/opensecret/flake.lock", "services/opensecret/rust-toolchain.toml", + "services/opensecret/src/main.rs", "apps/maple-agent/flake.lock"): + for event in (PR, QUEUE, PUSH): + with self.subTest(path=path, event=event): + self.assertFalse(selected([path], event) & DEPS) + for lane, paths in ( + ("deps-opensecret", ("services/opensecret/Cargo.toml", "services/opensecret/Cargo.lock", + "services/opensecret/deny.toml")), + ("deps-research", ("apps/maple-research/frontend/src-tauri/Cargo.lock", + "apps/maple-research/deny.toml", "sdk/rust/Cargo.toml")), + ("deps-sdk", ("sdk/rust/Cargo.lock", "sdk/deny.toml")), + ("deps-proxy", ("proxy/Cargo.lock", "proxy/deny.toml"))): + for path in paths: + with self.subTest(lane=lane, path=path): + self.assertIn(lane, selected([path])) + self.assertIn(lane, selected([path], PUSH)) + self.assertLessEqual(DEPS, selected(["docs/x.md"], labels=["ci:full"])) + self.assertLessEqual(DEPS, active(NIGHTLY)) + self.assertFalse(selected(["docs/x.md"], labels=["ci:package", "ci:eif", "ci:windows"]) & DEPS) + def test_research_platform_builds_run_on_native_inputs_or_labels(self): desktop = {"research-desktop-macos", "research-desktop-linux"} windows = {"research-desktop-windows", "research-desktop-windows-verify"} @@ -442,6 +469,8 @@ def test_the_opensecret_selector_routes_each_lane(self): ("sdk/test/integration/bootstrap.sql", {"os-sdk-e2e"}, {"os-sdk-e2e"}), ("scripts/ci/build_opensecret_eif.sh", {"os-eif", "repo"}, {"repo"}), (".gitmodules", OPENSECRET_ALL, OPENSECRET_ALL - {"os-eif"}), + (backend + "Cargo.lock", OPENSECRET_ALL | {"deps-opensecret"}, OPENSECRET_ALL - {"os-eif"} | { + "deps-opensecret"}), ("scripts/ci/opensecret_change_detection.py", OPENSECRET_ALL | {"repo"}, OPENSECRET_ALL - {"os-eif"} | {"repo"}), # Each lane's own entry script. From 1b222782c4a95914446321457463c26dd36db7d6 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:57:17 +0000 Subject: [PATCH 10/21] ci(opensecret): report EIF approval mismatches on master as pending, not failed check_opensecret_eif.sh exits 3 for a PCR mismatch and prints the built pcr.json; usage, build and comparison failures keep their non-3 statuses (a Nix build failure is now always 1, never 3). opensecret-eif.yml's compare step, identical in eif and eif-trusted: - master push or dispatch: a mismatch is a notice, with the built PCRs in the step summary, and the job passes; - pull_request: a mode must match only if the PR's own diff (merge base to head, SHAs through env) edits that mode's pcr.json or pcrHistory.json; otherwise its mismatch is a notice; - dispatch outside master: a mismatch fails, as before; - any other non-zero status fails. Job conditions, runners, permissions, cache steps and the matrix are unchanged; opensecret-eif-release.yml does not call the script. services/opensecret/README.md and docs/nitro-deploy.md now name ci.yml instead of the deleted workflows and describe the new verdicts, so C7 no longer skips services/opensecret/. The eif-dev and eif-prod derivations are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../review-opensecret-security/SKILL.md | 3 +- .../validate-opensecret/references/checks.md | 11 +- .github/workflows/opensecret-eif.yml | 104 +++++++++++++++++- docs/opensecret-import.md | 3 +- docs/repository-workflows.md | 11 +- scripts/ci/check_opensecret_eif.sh | 19 +++- scripts/ci/test_contracts.py | 96 +++++++++++++++- scripts/ci/test_opensecret_eif_scripts.py | 39 +++++-- services/opensecret/README.md | 10 +- services/opensecret/docs/nitro-deploy.md | 27 +++-- 10 files changed, 280 insertions(+), 43 deletions(-) diff --git a/.agents/skills/review-opensecret-security/SKILL.md b/.agents/skills/review-opensecret-security/SKILL.md index 6c25c3571..a672e69d4 100644 --- a/.agents/skills/review-opensecret-security/SKILL.md +++ b/.agents/skills/review-opensecret-security/SKILL.md @@ -136,7 +136,8 @@ scope. Root backend CI validates Rust, Nix checks, and SDK compatibility. A separate ARM64 workflow compares dev/prod EIF measurements on explicit approved-PCR JSON edits in PRs, relevant master changes, and manual runs. Do not require ordinary backend PRs to update -approvals, and do not suppress meaningful master mismatches. Master push/manual +approvals. A master mismatch is a pending-approval notice with the built PCRs; +do not hide it or turn it into a pass without that notice. Master push/manual runs and same-repository PR comparisons receive OIDC for FlakeHub caching; fork PRs and non-master manual refs use GitHub's branch-scoped cache without OIDC. Review event/ref guards and the PR head-repository equality check, diff --git a/.agents/skills/validate-opensecret/references/checks.md b/.agents/skills/validate-opensecret/references/checks.md index 7ea818d14..2ce1298b4 100644 --- a/.agents/skills/validate-opensecret/references/checks.md +++ b/.agents/skills/validate-opensecret/references/checks.md @@ -160,10 +160,13 @@ Root backend CI runs the component flake checks but does not build the default package. The separate root EIF approval workflow builds dev/prod and compares generated measurements on PRs that explicitly edit the four approved PCR JSON files, relevant backend/TEE or approval changes to master, -and manual runs. Ordinary backend PRs do not require new PCR approvals; master -mismatches intentionally signal that the revision does not match its current -approvals. This repository's CI signs only in the reviewer-gated `OpenSecret EIF release` -workflow, never creates GitHub Releases, and never deploys the TEE service. +and manual runs. Ordinary backend PRs do not require new PCR approvals. A PR +fails on a mismatch only in an environment whose approval files it edits; a +master mismatch passes with a notice and the built PCRs, meaning the revision +awaits approval. The check script exits 3 for a mismatch and with another +non-zero status for a build failure. This repository's CI signs only in the +reviewer-gated `OpenSecret EIF release` workflow, never creates GitHub +Releases, and never deploys the TEE service. Ordinary pull-request completion does not update PCR references. Do not copy or sign values just to clear a validation failure; distinguish an EIF build failure from a PCR mismatch. Use `docs/pcr-compatibility.md` for the diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml index 4490d1c79..6778650a7 100644 --- a/.github/workflows/opensecret-eif.yml +++ b/.github/workflows/opensecret-eif.yml @@ -94,9 +94,59 @@ jobs: use-flakehub: disabled use-gha-cache: enabled + # A PCR mismatch (exit 3) fails a PR that edits this mode's approval files + # and a manual run outside master. On master it is pending approval: a + # notice, with the built PCRs in the step summary. - name: Build EIF and compare approved measurements shell: bash - run: bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + log="$RUNNER_TEMP/eif-$EIF_MODE.out" + status=0 + bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" > "$log" || status=$? + cat -- "$log" + if [[ $status -ne 3 ]]; then + exit "$status" + fi + case "$EIF_MODE" in + dev) approvals=(services/opensecret/pcrDev.json services/opensecret/pcrDevHistory.json) ;; + prod) approvals=(services/opensecret/pcrProd.json services/opensecret/pcrProdHistory.json) ;; + *) exit 1 ;; + esac + required="" # why this mismatch fails the job; empty while it awaits approval + if [[ $GITHUB_EVENT_NAME == pull_request ]]; then + # The PR's own diff from the merge base, as the changes job reads it. + if [[ -z $PR_BASE_SHA || -z $PR_HEAD_SHA ]] || + ! edited=$(git diff --name-only --no-renames "$PR_BASE_SHA...$PR_HEAD_SHA" -- "${approvals[@]}"); then + echo "::error::Cannot tell whether this PR edits the $EIF_MODE approval files." + exit 1 + fi + if [[ -n $edited ]]; then + required="this PR edits the $EIF_MODE approval files" + fi + elif [[ $GITHUB_REF != refs/heads/master ]]; then + required="a manual run outside master must match" + fi + verdict=pending + if [[ -n $required ]]; then + verdict=mismatch + fi + { + echo "### EIF/PCR approval $verdict ($EIF_MODE)" + echo + echo "The built measurements differ from \`${approvals[0]}\`." + echo + echo '```text' + cat -- "$log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [[ -n $required ]]; then + echo "::error::EIF/PCR approval mismatch ($EIF_MODE): $required." + exit 3 + fi + echo "::notice title=EIF/PCR approval pending ($EIF_MODE)::This revision's $EIF_MODE measurements are not approved yet. Approval is the manual release process, not a CI failure." eif-trusted: name: EIF/PCR approval match (trusted, ${{ matrix.mode }}) @@ -155,6 +205,56 @@ jobs: # Cache substituted paths without forcing a second cache upload. diff-store: true + # A PCR mismatch (exit 3) fails a PR that edits this mode's approval files + # and a manual run outside master. On master it is pending approval: a + # notice, with the built PCRs in the step summary. - name: Build EIF and compare approved measurements shell: bash - run: bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + log="$RUNNER_TEMP/eif-$EIF_MODE.out" + status=0 + bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" > "$log" || status=$? + cat -- "$log" + if [[ $status -ne 3 ]]; then + exit "$status" + fi + case "$EIF_MODE" in + dev) approvals=(services/opensecret/pcrDev.json services/opensecret/pcrDevHistory.json) ;; + prod) approvals=(services/opensecret/pcrProd.json services/opensecret/pcrProdHistory.json) ;; + *) exit 1 ;; + esac + required="" # why this mismatch fails the job; empty while it awaits approval + if [[ $GITHUB_EVENT_NAME == pull_request ]]; then + # The PR's own diff from the merge base, as the changes job reads it. + if [[ -z $PR_BASE_SHA || -z $PR_HEAD_SHA ]] || + ! edited=$(git diff --name-only --no-renames "$PR_BASE_SHA...$PR_HEAD_SHA" -- "${approvals[@]}"); then + echo "::error::Cannot tell whether this PR edits the $EIF_MODE approval files." + exit 1 + fi + if [[ -n $edited ]]; then + required="this PR edits the $EIF_MODE approval files" + fi + elif [[ $GITHUB_REF != refs/heads/master ]]; then + required="a manual run outside master must match" + fi + verdict=pending + if [[ -n $required ]]; then + verdict=mismatch + fi + { + echo "### EIF/PCR approval $verdict ($EIF_MODE)" + echo + echo "The built measurements differ from \`${approvals[0]}\`." + echo + echo '```text' + cat -- "$log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [[ -n $required ]]; then + echo "::error::EIF/PCR approval mismatch ($EIF_MODE): $required." + exit 3 + fi + echo "::notice title=EIF/PCR approval pending ($EIF_MODE)::This revision's $EIF_MODE measurements are not approved yet. Approval is the manual release process, not a CI failure." diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index d107001f0..31931459c 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -60,7 +60,8 @@ backend dev-shell job. A separate ARM64 EIF workflow compares dev/prod measurements on PRs explicitly editing approved PCR JSON, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not fail merely because approvals have not been -updated; master mismatches are an intentional deployment-approval signal. +updated. A master mismatch is reported as pending approval (a notice with the +built PCRs), not a failure. Master and same-repository PR EIF checks receive OIDC for FlakeHub Cache and also populate GitHub's branch-scoped Nix cache. Fork PRs and non-master manual runs have no OIDC and use only GitHub caching; master runs warm the diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 3b9c15d37..6072f508e 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -84,7 +84,9 @@ the other side of each contract. A failure means the workflow change is wrong: `ios-dev-testflight.yml`'s run number is the Maple Dev build number: never rename that file. 5. OpenSecret EIF builds use the release builder's runner class, and only the - listed jobs receive `id-token: write` or use FlakeHub. + listed jobs receive `id-token: write` or use FlakeHub. An EIF approval + mismatch fails only where that environment's approval files are edited + (or a manual run outside `master`); on master it is a notice. 6. Each protected environment has a fixed set of consumer jobs; `windows-signing` runs only on GitHub-hosted runners. 7. Docs, guides and READMEs name only workflows that exist. @@ -165,8 +167,11 @@ release builder's `blacksmith-8vcpu-ubuntu-2404-arm` class, and reports their PCRs. `opensecret-eif.yml` compares measurements on PRs that edit approved PCR JSON, on relevant master changes and on manual runs; that approval status is not -part of `ci-ok`. These checks never change approvals, sign, release, or -authorize deployment. Master and same-repository PR EIF builds receive OIDC for +part of `ci-ok`. A mismatch fails a manual run outside `master`, and a PR only +in an environment whose approval files it edits. On master it is pending +approval: the job passes with a notice and the built PCRs in its summary. Build +and comparison errors always fail. These checks never change approvals, sign, +release, or authorize deployment. Master and same-repository PR EIF builds receive OIDC for FlakeHub caching; fork PRs and non-master manual runs never do. Verify cache changes on fresh hosted runners, not a warm local Nix store. Details: [CI approval checks](../services/opensecret/docs/nitro-deploy.md#ci-approval-checks) diff --git a/scripts/ci/check_opensecret_eif.sh b/scripts/ci/check_opensecret_eif.sh index 22bb560ae..3f29ee7ab 100644 --- a/scripts/ci/check_opensecret_eif.sh +++ b/scripts/ci/check_opensecret_eif.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash # Build and compare only. Never copy, sign, or publish approval files. +# Exit 0: the build matches its approval. Exit 3: it does not (a PCR mismatch); +# the built pcr.json is printed. Any other status is a usage, build or output +# failure. set -euo pipefail if [[ $# -ne 1 ]]; then @@ -29,8 +32,11 @@ fi # A fresh temporary link never replaces an operator's existing result symlink. output_dir=$(mktemp -d "${TMPDIR:-/tmp}/opensecret-eif-${mode}.XXXXXX") trap 'rm -rf -- "$output_dir"' EXIT -nix build --no-update-lock-file --print-build-logs \ - --out-link "$output_dir/result" ".?submodules=1#eif-$mode" +if ! nix build --no-update-lock-file --print-build-logs \ + --out-link "$output_dir/result" ".?submodules=1#eif-$mode"; then + echo "EIF build failed ($mode)." >&2 + exit 1 +fi if [[ ! -f "$output_dir/result/image.eif" || ! -s "$output_dir/result/image.eif" || ! -f "$output_dir/result/pcr.json" || ! -s "$output_dir/result/pcr.json" ]]; then @@ -38,9 +44,16 @@ if [[ ! -f "$output_dir/result/image.eif" || ! -s "$output_dir/result/image.eif" exit 1 fi -if ! diff -u -- "$reference" "$output_dir/result/pcr.json"; then +compared=0 +diff -u -- "$reference" "$output_dir/result/pcr.json" || compared=$? +if [[ $compared -eq 1 ]]; then echo "EIF/PCR approval mismatch ($mode): this build does not match $reference." >&2 echo "Review the measurements through the manual approval process; CI will not update or sign them." >&2 + echo "Built measurements ($mode, pcr.json):" + cat -- "$output_dir/result/pcr.json" + exit 3 +elif [[ $compared -ne 0 ]]; then + echo "Could not compare $reference with the built pcr.json." >&2 exit 1 fi echo "EIF/PCR approval match ($mode). This does not authorize deployment." diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index 54eee1498..b012d68db 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -13,6 +13,9 @@ import os from pathlib import Path import re +import shutil +import subprocess +import tempfile import unittest from unittest import mock @@ -365,6 +368,96 @@ def plan(event: str, head, paths: list, base: str = "master") -> dict: except ValueError: # an event ci.yml does not run on return {} + def test_c5_eif_mismatch_fails_only_where_its_approval_is_edited(self): + # Both approval jobs' compare step, run with a stand-in for check_opensecret_eif.sh + # (exit 3 is a PCR mismatch). A mismatch fails a PR that edits that mode's + # approval files, or a manual run outside master; on master it is a notice. Every + # other failure fails. + jobs_ = workflows()["opensecret-eif.yml"]["jobs"] + name = "Build EIF and compare approved measurements" + step, trusted = (next(s for s in jobs_[job]["steps"] if s.get("name") == name) for job in ("eif", "eif-trusted")) + self.assertEqual(step, trusted) + self.assertEqual(step["env"], {"PR_BASE_SHA": "${{ github.event.pull_request.base.sha }}", + "PR_HEAD_SHA": "${{ github.event.pull_request.head.sha }}"}) + self.assertNotIn("${{", step["run"]) # event data reaches the script only through env + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(HOME=str(root), RUNNER_TEMP=str(root), GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=os.devnull) + + def git(*arguments): + return subprocess.run(["git", "-c", "user.name=CI fixture", "-c", "user.email=ci@example.invalid", + "-c", "core.hooksPath=/dev/null", *arguments], cwd=root, env=env, + check=True, capture_output=True, text=True).stdout.strip() + + def commit(start, label, *edits): # a distinct tree per label, so no two heads share a commit + git("checkout", "-q", "--detach", start) + for path in edits: + (root / path).write_text(f"{label}\n") + git("add", "-A") + git("commit", "-qm", label) + return git("rev-parse", "HEAD") + + git("init", "-q") + component = root / "services/opensecret" + component.mkdir(parents=True) + for file in ("pcrDev.json", "pcrDevHistory.json", "pcrProd.json", "pcrProdHistory.json", "main.rs"): + (component / file).write_text("approved\n") + stub = root / "scripts/ci/check_opensecret_eif.sh" + stub.parent.mkdir(parents=True) + stub.write_text('echo "Built measurements ($1, pcr.json):"; echo \'{"PCR0": "built"}\'; exit "$STATUS"\n') + git("add", "-A") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + # Master moves on with its own prod approval; only the PR's own edits count. + master = commit(base, "master", "services/opensecret/pcrProd.json") + heads = {label: commit(base, label, path) for label, path in ( + ("dev", "services/opensecret/pcrDevHistory.json"), ("prod", "services/opensecret/pcrProd.json"), + ("code", "services/opensecret/main.rs"))} + git("checkout", "-q", "--detach", base) + git("rm", "-q", "services/opensecret/pcrDev.json") + git("commit", "-qm", "delete") + heads["deleted dev"] = git("rev-parse", "HEAD") + self.assertEqual(len({master, *heads.values()}), 5) + master_push, pr = ("push", "refs/heads/master", "", ""), ("pull_request", "refs/pull/1/merge", master) + cases = [ # (event, ref, base, head), mode, outcome of a mismatch + (master_push, "dev", "pending"), (master_push, "prod", "pending"), + (("workflow_dispatch", "refs/heads/master", "", ""), "prod", "pending"), + (("workflow_dispatch", "refs/heads/feature", "", ""), "dev", "fails"), + (("workflow_dispatch", "refs/tags/v1", "", ""), "prod", "fails"), + ((*pr, heads["dev"]), "dev", "fails"), ((*pr, heads["dev"]), "prod", "pending"), + ((*pr, heads["prod"]), "prod", "fails"), ((*pr, heads["prod"]), "dev", "pending"), + ((*pr, heads["code"]), "dev", "pending"), ((*pr, heads["deleted dev"]), "dev", "fails"), + (("pull_request", "refs/pull/1/merge", "", heads["dev"]), "prod", "error"), + (("pull_request", "refs/pull/1/merge", "f" * 40, heads["dev"]), "prod", "error"), + ] + script = root / "step.sh" + script.write_text(step["run"]) + for (event, ref, pr_base, pr_head), mode, outcome in cases: + for status in (0, 1, 2, 3, 42): + summary = root / "summary.md" + summary.write_text("") + result = subprocess.run( + [shutil.which("bash"), "--noprofile", "--norc", "-eo", "pipefail", str(script)], cwd=root, + env={**env, "GITHUB_EVENT_NAME": event, "GITHUB_REF": ref, "PR_BASE_SHA": pr_base, + "PR_HEAD_SHA": pr_head, "EIF_MODE": mode, "STATUS": str(status), + "GITHUB_STEP_SUMMARY": str(summary)}, capture_output=True, text=True) + with self.subTest(event=event, ref=ref, head=pr_head, mode=mode, status=status): + self.assertIn('{"PCR0": "built"}', result.stdout) # the check's output stays in the log + if status != 3: + self.assertEqual(result.returncode, status) + self.assertEqual(summary.read_text(), "") + continue + expected = {"pending": 0, "fails": 3, "error": 1}[outcome] + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + self.assertEqual("::notice title=EIF/PCR approval pending" in result.stdout, + outcome == "pending") + if outcome != "error": + self.assertIn(f"### EIF/PCR approval {'pending' if expected == 0 else 'mismatch'}" + f" ({mode})", summary.read_text()) + self.assertIn('{"PCR0": "built"}', summary.read_text()) + def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): minted = {(name, job_id) for name, job_id, job in jobs() if permissions(workflows()[name], job).get("id-token") == "write"} @@ -409,8 +502,7 @@ def test_c7_docs_name_only_existing_workflows_and_files(self): names = {Path(path).name for path in files} dangling = [] for path in files: - # CI changes never edit services/opensecret/ (PCR inputs); its docs move with OpenSecret PRs. - if not (path.endswith(".md") and plan.matches(path, DOCS)) or path.startswith("services/opensecret/"): + if not (path.endswith(".md") and plan.matches(path, DOCS)): continue text = (ROOT / path).read_text(encoding="utf-8", errors="replace") for match in re.finditer(r"(? Date: Tue, 27 Oct 2026 04:35:24 +0000 Subject: [PATCH 11/21] ci: address follow-up review findings - opensecret-eif.yml: the EIF compare step accepts only full 40-hex PR base and head SHAs before it diffs the approval files. Anything else fails the job, so an option-like or abbreviated value cannot make an edited mode's mismatch look neutral. Both approval jobs keep the same step; test_contracts covers option-like and abbreviated SHAs. - docs/ci.md: the advisory rule is scoped to the deps-* lanes, and names the two gate lanes that still audit advisories (agent-supply-chain, sdk-ts). - services/opensecret/.githooks/pre-commit: the comment names ci.yml's os-rust and os-flake-check lanes and drops the removed package build. Comment lines only; the eif-dev, eif-prod and eif-preview drvPaths are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/opensecret-eif.yml | 6 ++++-- docs/ci.md | 7 +++++-- scripts/ci/test_contracts.py | 5 +++++ services/opensecret/.githooks/pre-commit | 6 +++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml index 6778650a7..5bca126bd 100644 --- a/.github/workflows/opensecret-eif.yml +++ b/.github/workflows/opensecret-eif.yml @@ -118,7 +118,8 @@ jobs: required="" # why this mismatch fails the job; empty while it awaits approval if [[ $GITHUB_EVENT_NAME == pull_request ]]; then # The PR's own diff from the merge base, as the changes job reads it. - if [[ -z $PR_BASE_SHA || -z $PR_HEAD_SHA ]] || + # Both ends must be full commit ids, never options or abbreviations. + if ! [[ $PR_BASE_SHA =~ ^[0-9a-f]{40}$ && $PR_HEAD_SHA =~ ^[0-9a-f]{40}$ ]] || ! edited=$(git diff --name-only --no-renames "$PR_BASE_SHA...$PR_HEAD_SHA" -- "${approvals[@]}"); then echo "::error::Cannot tell whether this PR edits the $EIF_MODE approval files." exit 1 @@ -229,7 +230,8 @@ jobs: required="" # why this mismatch fails the job; empty while it awaits approval if [[ $GITHUB_EVENT_NAME == pull_request ]]; then # The PR's own diff from the merge base, as the changes job reads it. - if [[ -z $PR_BASE_SHA || -z $PR_HEAD_SHA ]] || + # Both ends must be full commit ids, never options or abbreviations. + if ! [[ $PR_BASE_SHA =~ ^[0-9a-f]{40}$ && $PR_HEAD_SHA =~ ^[0-9a-f]{40}$ ]] || ! edited=$(git diff --name-only --no-renames "$PR_BASE_SHA...$PR_HEAD_SHA" -- "${approvals[@]}"); then echo "::error::Cannot tell whether this PR edits the $EIF_MODE approval files." exit 1 diff --git a/docs/ci.md b/docs/ci.md index be3e683bf..3e895a857 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -25,8 +25,11 @@ and timeout. `scripts/ci/plan.py` (the `plan` job) selects lanes from the diff: imported unchanged and asked about every changed path, docs included. - Dependency audits (`deps-*`, cargo-deny) run only when their manifest, lockfile or `deny.toml` changes, with `ci:full`, and nightly. CI edits never - select them, so a newly published advisory cannot turn an unrelated PR red; - the nightly run reports it. + select them, so a newly published advisory in those graphs reaches a PR only + through its own manifests; the nightly run reports it. Two gate lanes still + audit advisories whenever they are selected, CI edits included: + `agent-supply-chain` (cargo-deny for the Agent targets) and `sdk-ts` + (`bun audit` in the SDK publish build). The `plan` job summary lists each selected lane and why. To preview locally: diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index b012d68db..ce27a750c 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -431,6 +431,10 @@ def commit(start, label, *edits): # a distinct tree per label, so no two heads ((*pr, heads["code"]), "dev", "pending"), ((*pr, heads["deleted dev"]), "dev", "fails"), (("pull_request", "refs/pull/1/merge", "", heads["dev"]), "prod", "error"), (("pull_request", "refs/pull/1/merge", "f" * 40, heads["dev"]), "prod", "error"), + # Only full commit ids: an option or an abbreviation would make a real edit look absent. + (("pull_request", "refs/pull/1/merge", f"--output={root / 'diff.out'}", heads["prod"]), "prod", "error"), + ((*pr[:2], master[:12], heads["prod"]), "prod", "error"), + ((*pr, f"--output={root / 'diff.out'}"), "prod", "error"), ] script = root / "step.sh" script.write_text(step["run"]) @@ -457,6 +461,7 @@ def commit(start, label, *edits): # a distinct tree per label, so no two heads self.assertIn(f"### EIF/PCR approval {'pending' if expected == 0 else 'mismatch'}" f" ({mode})", summary.read_text()) self.assertIn('{"PCR0": "built"}', summary.read_text()) + self.assertEqual(list(root.glob("diff.out*")), []) # git never read a SHA as an option def test_c5_oidc_and_flakehub_stay_in_their_jobs_on_one_builder_class(self): minted = {(name, job_id) for name, job_id, job in jobs() diff --git a/services/opensecret/.githooks/pre-commit b/services/opensecret/.githooks/pre-commit index 790852b5d..682992145 100755 --- a/services/opensecret/.githooks/pre-commit +++ b/services/opensecret/.githooks/pre-commit @@ -2,9 +2,9 @@ # OpenSecret backend pre-commit checks. Runs from services/opensecret inside # its own flake with OPENSECRET_DEV_POSTGRES/ENV/CONTAINERS=0 so the dev shell # does not start PostgreSQL, containers, or rewrite .env. -# CI equivalent: the `rust` job of opensecret-ci.yml, plus the entrypoint flake -# checks from its `nix` job when the entrypoint or its test is staged. The nix -# package build, cargo-deny audit, EIF, and PCR checks stay in CI. +# CI equivalent: ci.yml's os-rust lane, plus the entrypoint flake checks from +# its os-flake-check lane when the entrypoint or its test is staged. The +# cargo-deny audit, EIF, and PCR checks stay in CI. set -u . "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" From 36ee67a8f4a80d228fe1fde8a77a0f22bd9bf9d3 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:30:14 +0000 Subject: [PATCH 12/21] fix(agent): allow two intentional Windows-only Clippy lints The new Windows Clippy lane is the first CI job to lint the Agent's cfg(windows) code. Both findings are intentional, so they get a scoped allow with the reason; behaviour is unchanged: - filesystem_kind joins "\" to the path prefix on purpose: joining a rooted path keeps the prefix, so C: becomes C:\ for GetDriveTypeW. - The Windows-only hard-link cleanup test clears the read-only attribute, not Unix mode bits. Co-Authored-By: Claude Opus 5.5 --- .../crates/maple-agent/src/store/attachment_cleanup.rs | 2 ++ apps/maple-agent/crates/maple-agent/src/store/paths.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/maple-agent/crates/maple-agent/src/store/attachment_cleanup.rs b/apps/maple-agent/crates/maple-agent/src/store/attachment_cleanup.rs index 729b3cf58..bb33737e1 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/attachment_cleanup.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/attachment_cleanup.rs @@ -712,6 +712,8 @@ mod tests { let unchanged = fs::metadata(&outside).unwrap().permissions().readonly(); // Restore only the test-owned external fixture for TempDir cleanup. let mut permissions = fs::metadata(&outside).unwrap().permissions(); + // Windows-only test: this clears the read-only attribute, not Unix mode bits. + #[allow(clippy::permissions_set_readonly_false)] permissions.set_readonly(false); fs::set_permissions(&outside, permissions).unwrap(); assert!(result.unwrap().done); diff --git a/apps/maple-agent/crates/maple-agent/src/store/paths.rs b/apps/maple-agent/crates/maple-agent/src/store/paths.rs index 52e2a8008..aba1b2ed4 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/paths.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/paths.rs @@ -435,6 +435,8 @@ fn filesystem_kind(path: &Path) -> StoreResult { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; let prefix = path.components().next().ok_or(StoreError::UnsafePath)?; + // Joining a rooted path keeps the prefix: `C:` -> `C:\`, `\\server\share` -> `\\server\share\`. + #[allow(clippy::join_absolute_paths)] let root = PathBuf::from(prefix.as_os_str()).join("\\"); let root: Vec = root.as_os_str().encode_wide().chain(Some(0)).collect(); Ok(match unsafe { GetDriveTypeW(root.as_ptr()) } { From 417dc9b478ff4d46c764e1bbf43d55c482bffd6e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:30:14 +0000 Subject: [PATCH 13/21] ci: size Agent lane timeouts for cold runs and lint on 32 vCPU The first ci.yml run timed out agent-lint and agent-supply-chain at 15 minutes on a cold cache, and a lane that times out never saves its cache. Timeouts now cover a cold run (lint and test 40, supply chain 30). The lint lane joins agent-test on the 32 vCPU runner, since it is on the Agent PR path whenever a dependency bump misses the cache. Co-Authored-By: Claude Opus 5.5 --- scripts/ci/lanes.py | 8 ++++---- scripts/ci/test_plan.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index ebb6c4fb9..916efed4f 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -183,10 +183,10 @@ def deny(manifest: str, component: str) -> str: LANES: Tuple[Lane, ...] = ( Lane("agent-lint", AGENT, also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), - run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", - cache="agent-lint", timeout=15, hook="agent", **AGENT_FLAKE), + run="just ci-lint && python3 ../../scripts/ci/plan.py check-rust", runner=LINUX_HEAVY, + cache="agent-lint", timeout=40, hook="agent", **AGENT_FLAKE), Lane("agent-test", AGENT, also=RUST_SDK + PROXY_RUNTIME, runner=LINUX_HEAVY, - run="just ci-test", cache="agent-test", timeout=20, hook="agent", **AGENT_FLAKE), + run="just ci-test", cache="agent-test", timeout=40, hook="agent", **AGENT_FLAKE), Lane("agent-macos", AGENT, also=RUST_SDK + PROXY_RUNTIME + APPLE_TOOLCHAIN, runner=MACOS, run="cargo clippy --workspace --all-targets --locked -- -D warnings", cache="agent-macos", timeout=25, **AGENT_FLAKE), @@ -208,7 +208,7 @@ def deny(manifest: str, component: str) -> str: " && cargo test --workspace --locked", cache="agent-full-windows", timeout=60, gate=False, **AGENT_WIN), # Pinned SQLCipher sources, per-target advisories and bans, and notices. - Lane("agent-supply-chain", AGENT_DEPENDENCIES, run="just supply-chain", **AGENT_FLAKE), + Lane("agent-supply-chain", AGENT_DEPENDENCIES, run="just supply-chain", timeout=30, **AGENT_FLAKE), # Released rusqlite's own tests against the static SQLCipher provider (nightly.yml: all three OSes). Lane("agent-provider", tuple("apps/maple-agent/" + glob for glob in ( "Cargo.lock", "dependencies/sqlcipher/**", "scripts/test-rusqlite-provider.py", diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 4834f47ac..4b3c6c4d9 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -567,8 +567,10 @@ def test_outputs_empty_matrix_and_runner_overrides(self): runners = {entry["id"]: entry["runner"] for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])} self.assertEqual(runners, {lane.id: overrides.get(lane.runner.variable or "", lane.runner.label) for lane in lanes.LANES if lane.job in lanes.MATRIX_JOBS}) - self.assertEqual((runners["agent-test"], runners["agent-windows"], runners["agent-lint"]), - ("blacksmith-16vcpu-ubuntu-2404", "windows-2025", lanes.LINUX.label)) + self.assertEqual((runners["agent-test"], runners["agent-lint"], runners["agent-windows"], + runners["agent-supply-chain"]), + ("blacksmith-16vcpu-ubuntu-2404", "blacksmith-16vcpu-ubuntu-2404", "windows-2025", + lanes.LINUX.label)) entries = [entry for job in lanes.MATRIX_JOBS for entry in json.loads(result[job])] self.assertEqual({len(entry["cache_key"]) for entry in entries if entry["cache"]}, {16}) From 17df448fc49eea318260e7018bf15fc1be311f4a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:38:43 +0000 Subject: [PATCH 14/21] ci: faster PR packaging, cached Research git deps, no store benchmark - Agent PR packaging rehearsals build with thin LTO and 16 codegen units. They prove the packaging scripts, not the optimizer; master's signed builds keep fat LTO. A profile change rebuilds every dependency, so the rehearsals keep their own cache, which ci.yml's nightly master run saves. - research-android restores the Research Rust cache rust-tests.yml writes. Cargo fetches every git dependency in the lockfile for any target, so the Android build was spending most of its time fetching Goose and PDFOxide. - The store benchmark leaves CI: no ci:perf lane and no nightly job. The crate and `just store-bench` remain for local runs. - docs/ci.md, AGENTS.md and the Agent skill say when a label is worth adding. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/develop-maple-agent/SKILL.md | 5 +- .github/workflows/ci.yml | 49 ++++++++++++++++--- .github/workflows/nightly.yml | 29 ++--------- AGENTS.md | 2 + apps/maple-agent/crates/store-bench/README.md | 5 +- docs/ci.md | 14 ++++-- scripts/ci/lanes.py | 20 +++++--- scripts/ci/plan.py | 1 + scripts/ci/test_contracts.py | 15 ++++-- scripts/ci/test_nightly_report.py | 2 +- scripts/ci/test_plan.py | 4 +- 11 files changed, 91 insertions(+), 55 deletions(-) diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index a67c06a77..7802526d8 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -47,8 +47,9 @@ changes should not unnecessarily select Research packaging. Agent lanes live in `just lane ` runs one locally. PRs run `agent-lint`, `agent-test` and `agent-macos`, plus Windows tests and clippy for Windows paths, `agent-supply-chain` and `agent-provider` for dependency inputs, and an unsigned -Dev package rehearsal for packaging inputs. Labels `ci:windows`, `ci:package`, -`ci:perf` and `ci:full` add lanes; master adds the full macOS and Windows suites. On master, +Dev package rehearsal for packaging inputs. Labels `ci:windows`, `ci:package` and +`ci:full` add lanes when a change carries Windows or packaging risk its paths do not +show ([labels](../../../docs/ci.md#labels)); master adds the full macOS and Windows suites. On master, `.github/workflows/agent-desktop-build.yml` builds signed packages for pushes that change `AGENT_DESKTOP` inputs and on every manual dispatch. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a51a40d48..bc3e3a8d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,19 @@ jobs: - name: Select pinned Xcode if: runner.os == 'macOS' run: python3 scripts/ci/select-xcode.py + - name: Restore master's Research Rust cache (read-only) + if: matrix.restore == 'research-rust' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + # Exactly rust-tests.yml's key and paths; it is the only writer. Its Cargo git + # database spares the Goose and PDFOxide fetches Cargo makes for every target. + key: ci-v1-research-rust-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock', 'flake.lock') }} + restore-keys: ci-v1-research-rust- + path: | + ~/.cache/sccache + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db - name: Rust cache (restore everywhere, save only on master and on Blacksmith) if: matrix.cache != '' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -153,11 +166,13 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/setup-nix - - name: Restore master's Agent release cache (read-only) + - name: Restore the packaging rehearsal cache + id: package-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - # Exactly agent-desktop-build.yml's builder key; it is the only writer. - key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + # Thin-LTO release artifacts; master's fat-LTO cache cannot serve them. + key: ci-v1-agent-package-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + restore-keys: ci-v1-agent-package-${{ runner.os }}- path: | ~/.cargo/registry/index ~/.cargo/registry/cache @@ -165,6 +180,16 @@ jobs: apps/maple-agent/target - name: Build, package, verify and smoke-test an unsigned Dev bundle run: python3 scripts/ci/plan.py run agent-package-linux + - name: Save the packaging rehearsal cache (nightly, Blacksmith only) + if: ${{ github.ref == 'refs/heads/master' && github.event_name == 'schedule' && startsWith(runner.name, 'blacksmith') && steps.package-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + key: ${{ steps.package-cache.outputs.cache-primary-key }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target - name: Upload the unsigned package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -186,11 +211,13 @@ jobs: - uses: ./.github/actions/setup-nix - name: Select pinned Xcode run: python3 scripts/ci/select-xcode.py - - name: Restore master's Agent release cache (read-only) + - name: Restore the packaging rehearsal cache + id: package-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - # Exactly agent-desktop-build.yml's builder key; it is the only writer. - key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + # Thin-LTO release artifacts; master's fat-LTO cache cannot serve them. + key: ci-v1-agent-package-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} + restore-keys: ci-v1-agent-package-${{ runner.os }}- path: | ~/.cargo/registry/index ~/.cargo/registry/cache @@ -198,6 +225,16 @@ jobs: apps/maple-agent/target - name: Build, package and verify an unsigned Dev app run: python3 scripts/ci/plan.py run agent-package-macos + - name: Save the packaging rehearsal cache (nightly, Blacksmith only) + if: ${{ github.ref == 'refs/heads/master' && github.event_name == 'schedule' && startsWith(runner.name, 'blacksmith') && steps.package-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + key: ${{ steps.package-cache.outputs.cache-primary-key }} + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + apps/maple-agent/target - name: Upload the unsigned package uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3b7892cb9..fb30eb580 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,5 +1,5 @@ -# Nightly checks that ci.yml's scheduled run does not cover: Bun advisories, -# the SQLCipher provider suite on every OS and the store benchmark. (ci.yml's +# Nightly checks that ci.yml's scheduled run does not cover: Bun advisories +# and the SQLCipher provider suite on every OS. (ci.yml's # nightly run re-checks the Rust advisories and pinned sources.) # One `ci-nightly` issue reports failures here and in ci.yml's scheduled run; # a green night closes it. Nothing here gates a pull request. @@ -64,32 +64,9 @@ jobs: - name: Test released rusqlite against the static provider run: python3 scripts/ci/plan.py run agent-provider - store-bench: - name: Agent store benchmark - # The Linux reference was recorded on this runner class. - runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} - timeout-minutes: 45 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - uses: ./.github/actions/setup-nix - - name: Measure the durable store and compare the Linux reference - run: python3 scripts/ci/plan.py run agent-perf - - name: Save measurements and their digest - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: store-bench-linux-ci-${{ github.run_id }}-${{ github.run_attempt }} - path: | - apps/maple-agent/target/store-bench-ci.json - apps/maple-agent/target/store-bench-ci.json.sha256 - if-no-files-found: warn - retention-days: 14 - report: name: Report - needs: [bun-audit, agent-provider, store-bench] + needs: [bun-audit, agent-provider] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/AGENTS.md b/AGENTS.md index 15c378e2d..70110f78f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,8 @@ and installed-client raw URLs. Never change approvals to clear ordinary PR check - Never edit release or publisher workflows, signing environments, or OpenSecret approval gating to clear a check; a failing contract test means fix the change. - Run `just lane ` for the lanes your change selects before pushing. +- Defaults cover ordinary PRs. Add a `ci:*` label when the change carries + platform or packaging risk its paths do not show ([labels](docs/ci.md#labels)). ## Maintaining this guidance diff --git a/apps/maple-agent/crates/store-bench/README.md b/apps/maple-agent/crates/store-bench/README.md index 2b9fe031c..d12c567a5 100644 --- a/apps/maple-agent/crates/store-bench/README.md +++ b/apps/maple-agent/crates/store-bench/README.md @@ -135,10 +135,7 @@ medians only; append tails, checkpoints and conversion throughput remain reported evidence without an invented acceptance threshold. The report's OS does not independently attest the scheduled runner label or hardware. -`nightly.yml` runs the small Linux data every night and uploads the result and -its digest; the `agent-perf` lane runs it on a PR labelled `ci:perf`. The full -macOS profile has no CI run until it has a reference. Neither signs or releases -anything. +CI does not run the benchmark; run it locally when changing the store. ## Seed a stopped Local profile diff --git a/docs/ci.md b/docs/ci.md index 3e895a857..c70c6e9fd 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -43,12 +43,21 @@ python3 scripts/ci/plan.py explain --base origin/master --head HEAD | --- | --- | | Pull request, merge queue | Selected lanes, including unsigned platform and package rehearsals when their native inputs change. A new push cancels the superseded run. | | Master push | The selected lanes again (for Agent inputs, also the full Agent suites on Linux, macOS and Windows), the signed builds, the release-evidence workflows and CodeQL. Never cancelled. | -| Nightly | `ci.yml` runs its nightly lanes on the master tip, Rust advisories included, which also refreshes their caches. `nightly.yml` runs the Bun advisories, the SQLCipher provider suite on every OS and the store benchmark. Failures open or update one `ci-nightly` issue; a green night closes it. | +| Nightly | `ci.yml` runs its nightly lanes on the master tip, Rust advisories included, which also refreshes their caches. `nightly.yml` runs the Bun advisories and the SQLCipher provider suite on every OS. Failures open or update one `ci-nightly` issue; a green night closes it. | | On demand | PR labels (below), or a `ci.yml` dispatch with comma-separated lane ids or `all`. | ## Labels -Add the label, then re-run the CI workflow: `plan` reads labels when it runs. +Most PRs need no label: `plan` already adds a platform or packaging lane when +its own files change. Add a label when a change could break something those +paths do not reveal, for example: + +- `ci:windows` for path, filesystem, process or terminal code that behaves differently on Windows; +- `ci:package` for changes to what ships in the bundle (assets, resources, notices, entitlements, updater metadata) outside the packaging scripts; +- `ci:ios` or `ci:android` for Research changes that reach native mobile code or mobile-only plugins; +- `ci:full` for toolchain, lockfile-wide or other risky cross-cutting changes before merge. + +Add the label, then re-run the CI workflow (or push): `plan` reads labels when it runs. | Label | Adds | | --- | --- | @@ -57,7 +66,6 @@ Add the label, then re-run the CI workflow: `plan` reads labels when it runs. | `ci:windows` | Agent Windows tests and Clippy, and the Research Windows package | | `ci:ios`, `ci:android` | The Research iOS simulator or Android build | | `ci:research-dev-macos` | The unsigned Research Dev app | -| `ci:perf` | The Agent store benchmark | | `ci:eif` | The OpenSecret EIF candidate (same-repository PRs to `master` that edit no PCR approval) | ## Run a lane locally diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 916efed4f..ffcc2d742 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -120,6 +120,7 @@ class Lane: opensecret: Optional[str] = None # the opensecret_change_detection.py output that selects it submodules: bool = False # check out submodules, with the full history Nix needs hook: Optional[str] = None # the pre-commit component that runs these checks + restore: str = "" # read-only restore of a cache another workflow writes AGENT_DIR = dict( @@ -129,6 +130,9 @@ class Lane: ) AGENT_FLAKE = dict(flake="./apps/maple-agent", **AGENT_DIR) AGENT_WIN = dict(job="windows", runner=WINDOWS, **AGENT_DIR) # pinned rustup, no flake +FAST_RELEASE = (("CARGO_PROFILE_RELEASE_LTO", "thin"), ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "16")) +AGENT_PACKAGE_FLAKE = dict(AGENT_FLAKE, env=AGENT_DIR["env"] + FAST_RELEASE) +PACKAGE_EVENTS = PR_SIDE | {NIGHTLY} # nightly refreshes the rehearsal's own cache AGENT_DEPENDENCIES = tuple("apps/maple-agent/" + glob for glob in ( "Cargo.lock", "**/Cargo.toml", "deny.toml", "about.toml", "flake.nix", "flake.lock", "dependencies/**", "scripts/*third*party*notices*", "scripts/audit-sqlcipher-dependencies.py", "scripts/verify-sqlcipher-build.py", @@ -217,14 +221,14 @@ def deny(manifest: str, component: str) -> str: " && cargo metadata --locked --format-version 1 > target/provider-metadata.json" " && python3 scripts/test-rusqlite-provider.py target/provider-metadata.json", timeout=20, **AGENT_FLAKE), - Lane("agent-perf", (), labels=("ci:perf", "ci:full"), events=PR_SIDE, run="just store-bench-ci", - timeout=30, gate=False, **AGENT_FLAKE), - # Unsigned Dev packages built the way agent-desktop-build.yml builds master's. - Lane("agent-package-linux", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, - job="agent-package-linux", run="just package-rehearsal", timeout=60, gate=False, **AGENT_FLAKE), + # Unsigned Dev packages through the scripts agent-desktop-build.yml uses on master. + # The rehearsal proves packaging, not the optimizer: thin LTO with parallel codegen + # instead of master's fat LTO, from its own cache that the nightly run refreshes. + Lane("agent-package-linux", AGENT_PACKAGING, labels=("ci:package", "ci:full"), events=PACKAGE_EVENTS, + job="agent-package-linux", run="just package-rehearsal", timeout=60, gate=False, **AGENT_PACKAGE_FLAKE), Lane("agent-package-macos", AGENT_PACKAGING + APPLE_TOOLCHAIN, labels=("ci:package", "ci:full"), - events=PR_SIDE, job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, - gate=False, **AGENT_FLAKE), + events=PACKAGE_EVENTS, job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, + gate=False, **AGENT_PACKAGE_FLAKE), Lane("agent-package-windows", (), labels=("ci:package", "ci:full"), events=PR_SIDE, job="agent-package-windows", runner=WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=60, gate=False), @@ -291,7 +295,7 @@ def deny(manifest: str, component: str) -> str: TAURI + "capabilities/mobile-android.json", TAURI + "scripts/*android*", "scripts/ci/android-*.sh", "scripts/ci/_common.sh"), labels=("ci:android", "ci:full"), flake=".#android", run="./scripts/ci/android-pr.sh", timeout=30, - **PLATFORM), + restore="research-rust", **PLATFORM), Lane("research-desktop-macos", DESKTOP + (TAURI + "tauri.macos.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", TAURI + "dmg/**", TAURI + "scripts/*macos*", "scripts/ci/dmg-settings.py", "scripts/ci/test-canonical-macho.py", diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py index 10d0721cb..97d459e3e 100644 --- a/scripts/ci/plan.py +++ b/scripts/ci/plan.py @@ -223,6 +223,7 @@ def outputs(selected: Selection) -> Dict[str, str]: "workspaces": lane.workspaces, "submodules": "recursive" if lane.submodules else "false", "fetch_depth": 0 if lane.submodules else 1, + "restore": lane.restore, } for lane in LANES if lane.id in selected and lane.job == job] return {name: json.dumps(value, separators=(",", ":")) for name, value in result.items()} diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index ce27a750c..69973def0 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -42,9 +42,12 @@ def module(name: str, path: str): "maple-dev-xcode", "release-onnxruntime-ios-", "${{ runner.os }}-bun-", "${{ runner.os }}-gradle-", ) ONNX_CACHE = "apps/maple-research/frontend/src-tauri/onnxruntime-ios" -# The unsigned Agent builders that write the exact-key release cache; ci.yml's packaging lanes read it. +# The unsigned Agent builders that write the exact-key release cache; ci.yml's Windows packaging lane reads it. AGENT_RELEASE_WRITERS = frozenset(("agent-desktop-build.yml", job) for job in ("macos-build", "windows-build", "linux")) -AGENT_RELEASE_READERS = frozenset(("ci.yml", f"agent-package-{os}") for os in ("linux", "macos", "windows")) +AGENT_RELEASE_READERS = frozenset({("ci.yml", "agent-package-windows")}) +# The Linux and macOS packaging rehearsals build thin LTO into their own cache, saved +# only by ci.yml's nightly master run. +AGENT_PACKAGE_JOBS = frozenset(("ci.yml", f"agent-package-{os}") for os in ("linux", "macos")) # Jobs that may mint an OIDC token: attestation, signing identity, FlakeHub, registries. OIDC_JOBS = frozenset({ ("agent-desktop-build.yml", "macos"), ("agent-desktop-build.yml", "windows"), @@ -256,7 +259,13 @@ def test_c4_ci_restores_only_what_a_master_workflow_saves(self): saved = {(step["with"]["key"], step["with"].get("path", "").strip()) for name, _, step in cache_steps() if cache_writer(step) and triggers(workflows()[name]) <= {"push", "workflow_dispatch"}} for name, job_id, step in cache_steps(): - if name == "ci.yml": + if (name, job_id) in AGENT_PACKAGE_JOBS: + with self.subTest(job=job_id): + if step["uses"].startswith("actions/cache/save@"): + self.assertIn("refs/heads/master' && github.event_name == 'schedule'", step["if"]) + else: + self.assertTrue(step["with"]["key"].startswith("ci-v1-agent-package-${{ runner.os }}-")) + elif name == "ci.yml": with self.subTest(job=job_id): self.assertTrue(step["uses"].startswith("actions/cache/restore@")) if not step["with"]["key"].startswith("ci-v1-agent-release-"): # the next test diff --git a/scripts/ci/test_nightly_report.py b/scripts/ci/test_nightly_report.py index 74d330c22..e55e5c8e2 100644 --- a/scripts/ci/test_nightly_report.py +++ b/scripts/ci/test_nightly_report.py @@ -15,7 +15,7 @@ class NightlyReportTests(unittest.TestCase): def test_failures_list_failed_jobs_and_a_red_stale_running_or_missing_scheduled_ci_run(self): - needs = {"store-bench": {"result": "success"}, "agent-provider": {"result": "failure"}, + needs = {"agent-provider": {"result": "failure"}, "bun-audit": {"result": "cancelled"}} self.assertEqual(nightly_report.failures(needs, GREEN, DAY), ["- `agent-provider`: failure", "- `bun-audit`: cancelled"]) diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 4b3c6c4d9..a5612acd8 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -354,7 +354,6 @@ def test_packaging_rehearsal_runs_on_the_narrow_selector_or_a_label(self): self.assertFalse({"agent-package-windows"} & selected(["apps/maple-agent/scripts/build-release.sh"])) self.assertEqual(agent_lanes(selected(["docs/x.md"], labels=["ci:package"])), AGENT_PACKAGE | {"agent-package-windows"}) - self.assertEqual(selected(["docs/x.md"], labels=["ci:perf"]), {"agent-perf"}) def test_full_suites_run_on_master_nightly_and_ci_full_only(self): full = {"agent-full-linux", "agent-full-macos", "agent-full-windows"} @@ -362,7 +361,8 @@ def test_full_suites_run_on_master_nightly_and_ci_full_only(self): self.assertLessEqual(full, selected(["apps/maple-agent/app/src/main.rs"], PUSH)) self.assertLessEqual(full, selected(["docs/x.md"], labels=["ci:full"])) self.assertLessEqual(full | {"agent-windows"}, active(NIGHTLY)) - self.assertFalse(({"agent-perf", "agent-provider"} | AGENT_PACKAGE) & active(NIGHTLY)) # nightly.yml runs these + self.assertFalse({"agent-provider"} & active(NIGHTLY)) # nightly.yml runs it on three OSes + self.assertLessEqual(AGENT_PACKAGE, active(NIGHTLY)) # refreshes the rehearsal's own cache self.assertFalse(full & selected([".github/workflows/ci.yml"])) # CI edits prove the gate lanes only def test_agent_desktop_selector_covers_the_signed_builds_inputs(self): From d32ae1ccdf6a7b3e9ca44ee2bf637fb8afd74770 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:21:13 +0000 Subject: [PATCH 15/21] ci: align with #1316 #1316 (signed Windows Agent updates) landed while this branch moved the Windows jobs. Carry its contracts over: - agent-package-windows replaces the deleted PR-only windows-unsigned job, so it now exports MAPLE_PACKAGE_BUILD_NUMBER, which windows-release.py requires: the Actions run number, or 1 outside Actions (as package-release.sh does), so `just lane agent-package-windows` still runs locally. - test_contracts C2 asks the publisher to trust a run with Windows too, and pins MAPLE_PACKAGE_BUILD_NUMBER to github.run_number on every job that packages or verifies Windows (the assertion #1316 added to the deleted test_agent_workflows.py). - The manual signature probe checks that each Windows Prod job ran on windows-2025. The unsigned build now runs on Blacksmith, so the build job may also report blacksmith-8vcpu-windows-2025 (signing and verification still must be GitHub's). A new C2 contract runs the probe's producer check against this workflow's jobs and runners. Co-Authored-By: Claude Opus 5.5 --- docs/repository-workflows.md | 5 +- scripts/ci/lanes.py | 5 +- scripts/ci/test_contracts.py | 62 +++++++++++++++---- .../ci/test_windows_signature_reference.py | 8 +++ scripts/ci/windows_signature_reference.py | 7 ++- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 6072f508e..e25338b76 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -74,7 +74,10 @@ the other side of each contract. A failure means the workflow change is wrong: repository, conclusion and event. 2. `services/agent-updates/publisher/publisher.py` trusts `agent-desktop-build.yml` by path, job names, artifact names and signer, and - its run number is the Agent build number: never rename that file. + its run number is the Agent build number: never rename that file. The + Windows packaging and verification jobs set `MAPLE_PACKAGE_BUILD_NUMBER` to + that run number. `scripts/ci/windows_signature_reference.py` (the manual + signature probe) trusts the same file's Windows Prod jobs and their runners. 3. The release preflight (`.agents/skills/release-maple/scripts/ci_evidence.py`) needs the evidence jobs of `frontend-tests`, `rust-tests`, `web-build`, `desktop-build`, `android-build` and `mobile-build` on master pushes, and diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index ffcc2d742..aa1afff90 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -140,7 +140,10 @@ class Lane: "crates/maple-agent/src/store/keys/eff-large-wordlist.txt", )) WINDOWS_PACKAGE = ( - "./scripts/build-release.sh dev && python3 scripts/windows-release.py prepare dev" + # windows-release.py requires a build number; like package-release.sh, use the + # Actions run number, or 1 outside Actions. + 'export MAPLE_PACKAGE_BUILD_NUMBER="${GITHUB_RUN_NUMBER:-1}"' + " && ./scripts/build-release.sh dev && python3 scripts/windows-release.py prepare dev" " && python3 scripts/windows-release.py package dev --unsigned" # The stock image exports Azure CLI paths that the fail-closed signing guard rejects. " && env -u AZURE_CONFIG_DIR -u AZURE_DEVOPS_CACHE_DIR -u AZURE_EXTENSION_DIR" diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index 69973def0..d1c5265d8 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -20,6 +20,7 @@ from unittest import mock import plan +import windows_signature_reference as signature_probe from test_workflow_policy import (ENVIRONMENTS, GITHUB_HOSTED, LISTENERS, ROOT, cache_writer, environment, jobs, load, permissions, runner_labels, triggers, workflows) @@ -107,6 +108,22 @@ def variants(job: dict) -> list: return sorted({value for array in re.findall(r"\[[^\]]*\]", matrix) for value in json.loads(array)}) +def desktop_run(config: dict, run_id: int): + """The job inventory (with runner labels) and artifacts of one successful agent-desktop-build.yml run.""" + def expand(text, variant): + return text.replace("${{ matrix.variant }}", str(variant)).replace("${{ github.run_id }}", str(run_id)) + + inventory, artifacts = [], [] + for job_id, job in config["jobs"].items(): + for variant in variants(job): + inventory.append({"name": expand(job.get("name", job_id), variant), "status": "completed", + "conclusion": "success", "labels": [job["runs-on"]]}) + artifacts += [{"name": expand(step["with"]["name"], variant), "expired": False} + for step in job.get("steps", []) + if step.get("uses", "").startswith("actions/upload-artifact@")] + return inventory, artifacts + + def cache_steps(): for name, job_id, job in jobs(): for step in job.get("steps", []): @@ -183,18 +200,8 @@ def test_c2_agent_publisher_accepts_this_workflows_jobs_and_artifacts(self): self.assertIn("master", config["on"]["push"]["branches"]) self.assertIn("workflow_dispatch", config["on"]) run_id = 4242 - - def expand(text, variant): - return text.replace("${{ matrix.variant }}", str(variant)).replace("${{ github.run_id }}", str(run_id)) - - inventory, artifacts = [], [] + inventory, artifacts = desktop_run(config, run_id) for job_id, job in config["jobs"].items(): - for variant in variants(job): - inventory.append({"name": expand(job.get("name", job_id), variant), - "status": "completed", "conclusion": "success"}) - artifacts += [{"name": expand(step["with"]["name"], variant), "expired": False} - for step in job.get("steps", []) - if step.get("uses", "").startswith("actions/upload-artifact@")] attests = any(step.get("uses", "").startswith("actions/attest@") for step in job.get("steps", [])) if attests: # the publisher verifies with --deny-self-hosted-runners self.assertTrue(GITHUB_HOSTED.fullmatch(job["runs-on"]), job_id) @@ -209,8 +216,39 @@ def expand(text, variant): "artifacts": {"total_count": len(artifacts), "artifacts": artifacts}} with mock.patch.object(publisher, "gh_json", lambda path: responses[re.sub(r".*/runs/\d+/?([a-z]*).*", r"\1", path)]): - proof = publisher.trust_run(run_id, linux=True) + proof = publisher.trust_run(run_id, linux=True, windows=True) self.assertEqual(proof["build"], "77") # run_number is the Agent build number + # The publisher checks the Windows package's build against that run number. + windows = [job for job in config["jobs"].values() + if re.search(r"windows-release\.py (?:package|verify)", json.dumps(job.get("steps", [])))] + self.assertTrue(windows) + for job in windows: + self.assertEqual(job.get("env", {}).get("MAPLE_PACKAGE_BUILD_NUMBER"), "${{ github.run_number }}", + job.get("name")) + + def test_c2_windows_signature_probe_accepts_this_workflows_prod_run(self): + self.assertEqual(signature_probe.PRODUCER, publisher.WORKFLOW) + self.assertEqual(signature_probe.REPOSITORY, publisher.REPOSITORY) + run_id, source = 4242, "a" * 40 + inventory, artifacts = desktop_run(load(ROOT / publisher.WORKFLOW), run_id) + signed = f"maple-agent-prod-windows-x86_64-{run_id}" + self.assertIn(signed, [artifact["name"] for artifact in artifacts]) + jobs_ = [dict(job, id=index, started_at="2026-01-01T00:00:00Z", completed_at="2026-01-01T00:10:00Z") + for index, job in enumerate(inventory, 1)] + run = {"id": run_id, "run_attempt": 1, "head_sha": source, "head_branch": "master", "event": "push", + "path": publisher.WORKFLOW, "repository": {"full_name": publisher.REPOSITORY, "id": 1}, + "head_repository": {"full_name": publisher.REPOSITORY}, "status": "completed", + "conclusion": "success"} + artifact = {"id": 7, "expired": False, "name": signed, "size_in_bytes": 1000, "digest": "sha256:" + "b" * 64, + "created_at": "2026-01-01T00:05:00Z", "workflow_run": { + "id": run_id, "head_sha": source, "repository_id": 1, "head_repository_id": 1}} + selected = signature_probe.selection({ + "MAPLE_PROBE_RUN": str(run_id), "MAPLE_PROBE_ATTEMPT": "1", "MAPLE_PROBE_ARTIFACT": "7", + "MAPLE_PROBE_ZIP_BYTES": "1000", "MAPLE_PROBE_SOURCE": source, "MAPLE_PROBE_ZIP_SHA": "b" * 64, + "MAPLE_PROBE_PE_MEMBER": "setup.exe", "MAPLE_PROBE_PE_SHA": "c" * 64}) + chosen = signature_probe.validate_producer( + selected, run, artifact, {"total_count": len(jobs_), "jobs": jobs_}) + self.assertEqual([job["name"] for job in chosen], list(signature_probe.JOBS)) def test_c3_release_preflight_finds_its_evidence_jobs_on_master_pushes(self): for name, required in ci_evidence.WORKFLOWS.items(): diff --git a/scripts/ci/test_windows_signature_reference.py b/scripts/ci/test_windows_signature_reference.py index 01f47a9e7..fdaf767f5 100644 --- a/scripts/ci/test_windows_signature_reference.py +++ b/scripts/ci/test_windows_signature_reference.py @@ -73,12 +73,20 @@ def test_exact_successful_producer_and_three_native_jobs(self): lambda r, a, j: a.update(created_at="2026-10-27T00:03:00Z"), lambda r, a, j: j["jobs"][1].update(conclusion="failure"), lambda r, a, j: j["jobs"][2].update(conclusion="skipped"), + lambda r, a, j: j["jobs"][0].update(labels=["self-hosted", "windows"]), + lambda r, a, j: j["jobs"][1].update(labels=["blacksmith-8vcpu-windows-2025"]), + lambda r, a, j: j["jobs"][2].update(labels=["blacksmith-8vcpu-windows-2025"]), ): values = copy.deepcopy(metadata()) mutate(*values) with self.assertRaises(ValueError): probe.validate_producer(selected(), *values) + def test_the_unsigned_build_may_run_on_blacksmith(self): + run, artifact, jobs = metadata() + jobs["jobs"][0]["labels"] = ["blacksmith-8vcpu-windows-2025"] + self.assertEqual(len(probe.validate_producer(selected(), run, artifact, jobs)), 3) + def test_selectors_cannot_be_paths_urls_or_unbounded_sizes(self): for key, value in (("RUN", "123/other"), ("ATTEMPT", "0"), ("ZIP_BYTES", str(probe.MAX_ZIP + 1)), diff --git a/scripts/ci/windows_signature_reference.py b/scripts/ci/windows_signature_reference.py index aad3cfb8a..3ea89dcef 100644 --- a/scripts/ci/windows_signature_reference.py +++ b/scripts/ci/windows_signature_reference.py @@ -17,6 +17,9 @@ PRODUCER = ".github/workflows/agent-desktop-build.yml" JOBS = ("Build Windows binary (prod)", "Signed Windows (prod)", "Verify downloaded Windows (prod)") +# The runner each job may report. Signing and verification run on GitHub's +# image; the unsigned build runs on Blacksmith's (on GitHub's before CI moved it). +RUNNERS = ({"windows-2025", "blacksmith-8vcpu-windows-2025"}, {"windows-2025"}, {"windows-2025"}) MAX_ZIP = 64 * 1024 * 1024 @@ -95,11 +98,11 @@ def validate_producer(selected, run, artifact, jobs): if jobs["total_count"] > 100: raise ValueError("Reference jobs exceed the bounded one-page inventory") chosen = [] - for name in JOBS: + for name, runners in zip(JOBS, RUNNERS): matches = [job for job in jobs["jobs"] if job["name"] == name] if (len(matches) != 1 or matches[0]["status"] != "completed" or matches[0]["conclusion"] != "success" - or "windows-2025" not in matches[0]["labels"]): + or not runners & set(matches[0]["labels"])): raise ValueError("Exact native Prod build/sign/download job did not pass") chosen.append(matches[0]) signing = chosen[1] From 4ed120aa730d47ef681af5f87079c625ac72c629 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:27:03 +0000 Subject: [PATCH 16/21] ci: fold the Windows updater tests into ci.yml lanes agent-windows-updater-tests.yml (from #1316) becomes the agent-windows-updater lane, reported by ci-ok: - Selected by the workflow's own paths on pull requests, the merge queue and master pushes alike, by ci:windows and ci:full, by CI edits, and nightly. Only the deleted workflow's own path is dropped. - It runs scripts/ci/windows-app-typecheck.ps1, then scripts/ci/windows-updater-tests.ps1, unchanged, with pwsh from the repository root and the workflow's environment (RUSTFLAGS -D warnings, CARGO_TERM_COLOR never, no incremental build, no debug info). Both always run, as the two parallel jobs did, and either failure fails the lane. - A special ci.yml job keeps both receipts and Cargo logs as one artifact even on failure. It uses setup-agent-windows, the pinned Rust, MSVC, native Perl and linker the workflow set up by hand, and restores no cache. The fxc selection is not carried over: GPUI compiles shaders only in builds without debug assertions, so a dev-profile cargo check never runs fxc. - It runs on the Windows lane runner (Blacksmith, CI_WINDOWS_RUNNER to fail over) instead of GitHub's windows-2025. The native reparse fixtures need the symlink privilege, so this PR's own run is the first proof on Blacksmith. test_windows_updater_workflow.py now tests the lane, the job and the scripts instead of the workflow's YAML: no authority, the pinned toolchain and no cache, selection through the planner, both scripts running (with a stub pwsh) and the retained evidence. The script assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../workflows/agent-windows-updater-tests.yml | 154 ------------ .github/workflows/ci.yml | 37 ++- docs/ci.md | 5 +- docs/repository-workflows.md | 17 +- scripts/ci/lanes.py | 18 ++ scripts/ci/test_plan.py | 15 +- scripts/ci/test_windows_updater_workflow.py | 230 ++++++++++-------- 7 files changed, 211 insertions(+), 265 deletions(-) delete mode 100644 .github/workflows/agent-windows-updater-tests.yml diff --git a/.github/workflows/agent-windows-updater-tests.yml b/.github/workflows/agent-windows-updater-tests.yml deleted file mode 100644 index 88fbace76..000000000 --- a/.github/workflows/agent-windows-updater-tests.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: Agent Windows Updater Tests - -# Native component tests run without the signing/publishing environments. A -# green result is not qualification of an official signed installed upgrade. -permissions: - contents: read - -on: - pull_request: - paths: - - ".github/workflows/agent-windows-updater-tests.yml" - - "scripts/ci/windows-updater-tests.ps1" - - "scripts/ci/windows-app-typecheck.ps1" - - "scripts/ci/test_windows_updater_workflow.py" - - "scripts/ci/agent_windows.py" - - "apps/maple-agent/Cargo.toml" - - "apps/maple-agent/Cargo.lock" - - "apps/maple-agent/app/Cargo.toml" - - "apps/maple-agent/crates/maple-agent-updater/**" - - "apps/maple-agent/release_profile.rs" - - "apps/maple-agent/release-profiles.json" - - "apps/maple-agent/app/src/main.rs" - - "apps/maple-agent/app/src/profile.rs" - - "apps/maple-agent/app/src/update.rs" - - "apps/maple-agent/app/src/desktop.rs" - - "apps/maple-agent/app/src/desktop/**" - - "apps/maple-agent/app/src/windows_bootstrap.rs" - - "apps/maple-agent/scripts/windows-release.py" - - "apps/maple-agent/scripts/windows-installer.nsi" - - "apps/maple-agent/scripts/release-info.py" - push: - branches: [master] - paths: - - ".github/workflows/agent-windows-updater-tests.yml" - - "scripts/ci/windows-updater-tests.ps1" - - "scripts/ci/windows-app-typecheck.ps1" - - "scripts/ci/test_windows_updater_workflow.py" - - "scripts/ci/agent_windows.py" - - "apps/maple-agent/Cargo.toml" - - "apps/maple-agent/Cargo.lock" - - "apps/maple-agent/app/Cargo.toml" - - "apps/maple-agent/crates/maple-agent-updater/**" - - "apps/maple-agent/release_profile.rs" - - "apps/maple-agent/release-profiles.json" - - "apps/maple-agent/app/src/main.rs" - - "apps/maple-agent/app/src/profile.rs" - - "apps/maple-agent/app/src/update.rs" - - "apps/maple-agent/app/src/desktop.rs" - - "apps/maple-agent/app/src/desktop/**" - - "apps/maple-agent/app/src/windows_bootstrap.rs" - - "apps/maple-agent/scripts/windows-release.py" - - "apps/maple-agent/scripts/windows-installer.nsi" - - "apps/maple-agent/scripts/release-info.py" - workflow_dispatch: - -concurrency: - group: agent-windows-updater-tests-${{ github.ref }} - cancel-in-progress: true - -jobs: - app-typecheck: - name: Windows app and test target typecheck - runs-on: windows-2025 - timeout-minutes: 30 - env: - CARGO_TERM_COLOR: never - CARGO_INCREMENTAL: 0 - CARGO_PROFILE_DEV_DEBUG: 0 - RUSTFLAGS: -D warnings - steps: - - name: Checkout the tested source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - name: Install the repository's pinned Windows Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 - with: - toolchain: 1.98.0 - - name: Select Windows MSVC and SDK - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c - with: - arch: x64 - - name: Select native Windows Perl for the static crypto build - shell: bash - run: | - native_perl=/c/Strawberry/perl/bin/perl.exe - if [ ! -x "$native_perl" ]; then - echo 'Native Windows Perl is missing from the runner' >&2 - exit 1 - fi - "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' - native_perl_windows="$(cygpath -m "$native_perl")" - printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" - - name: Pin and smoke-test the selected Windows linker - working-directory: apps/maple-agent - shell: pwsh - run: python3 ../../scripts/ci/agent_windows.py - - name: Select the SDK shader compiler for GPUI - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if (-not $env:WindowsSdkVerBinPath) { throw 'Selected Windows SDK binary path is missing' } - $fxc = Join-Path $env:WindowsSdkVerBinPath 'x64/fxc.exe' - if (-not (Test-Path -LiteralPath $fxc -PathType Leaf)) { throw 'Selected SDK shader compiler is missing' } - "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Typecheck the Windows application and every app test target - shell: pwsh - run: ./scripts/ci/windows-app-typecheck.ps1 - - name: Retain the typecheck receipt and complete Cargo log - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: windows-app-typecheck-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/maple-windows-app-typecheck/ - if-no-files-found: error - retention-days: 14 - - native-updater: - name: Native Windows updater component tests - runs-on: windows-2025 - timeout-minutes: 35 - env: - CARGO_TERM_COLOR: never - CARGO_INCREMENTAL: 0 - CARGO_PROFILE_DEV_DEBUG: 0 - RUSTFLAGS: -D warnings - steps: - - name: Checkout the tested source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - persist-credentials: false - - name: Install the repository's pinned Windows Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 - with: - toolchain: 1.98.0 - - name: Select Windows MSVC and SDK - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c - with: - arch: x64 - - name: Pin and smoke-test the selected Windows linker - working-directory: apps/maple-agent - shell: pwsh - run: python3 ../../scripts/ci/agent_windows.py - - name: Exercise the native updater and bootstrap - shell: pwsh - run: ./scripts/ci/windows-updater-tests.ps1 - - name: Retain the native test receipt and logs - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: windows-updater-native-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/maple-windows-updater-tests/ - if-no-files-found: error - retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3e3a8d3..ac37e9faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,36 @@ jobs: LANE: ${{ matrix.id }} run: python3 scripts/ci/plan.py run "$LANE" + # Native Windows updater tests and the app typecheck, compiled from source with + # no cache, credential or environment. Their receipts and Cargo logs are kept + # as an artifact even when a script fails. + agent-windows-updater: + name: agent-windows-updater + needs: plan + if: fromJSON(needs.plan.outputs.lanes)['agent-windows-updater'] + runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-agent-windows + - name: Typecheck the Windows app and run the native updater tests + run: python3 scripts/ci/plan.py run agent-windows-updater + - name: Keep the receipts and Cargo logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: agent-windows-updater-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/maple-windows-app-typecheck/ + ${{ runner.temp }}/maple-windows-updater-tests/ + if-no-files-found: error + retention-days: 14 + # Unsigned Dev packages built like agent-desktop-build.yml's master builds, from # their master-written cache. Required when the packaging selector fires. agent-package-linux: @@ -626,9 +656,10 @@ jobs: ci-ok: name: ci-ok if: always() - needs: [plan, nix, windows, agent-package-linux, agent-package-macos, agent-package-windows, research-rust, - research-ios, research-desktop-macos, research-desktop-linux, research-desktop-windows, - research-desktop-windows-verify, research-dev-macos, os-sdk-e2e, os-helpers, os-eif] + needs: [plan, nix, windows, agent-windows-updater, agent-package-linux, agent-package-macos, + agent-package-windows, research-rust, research-ios, research-desktop-macos, research-desktop-linux, + research-desktop-windows, research-desktop-windows-verify, research-dev-macos, os-sdk-e2e, os-helpers, + os-eif] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/docs/ci.md b/docs/ci.md index c70c6e9fd..b301d5ac0 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -63,7 +63,7 @@ Add the label, then re-run the CI workflow (or push): `plan` reads labels when i | --- | --- | | `ci:full` | Every lane except the OpenSecret EIF candidate | | `ci:package` | Unsigned Agent and Research desktop packages on every OS | -| `ci:windows` | Agent Windows tests and Clippy, and the Research Windows package | +| `ci:windows` | Agent Windows tests and Clippy, the Windows updater tests, and the Research Windows package | | `ci:ios`, `ci:android` | The Research iOS simulator or Android build | | `ci:research-dev-macos` | The unsigned Research Dev app | | `ci:eif` | The OpenSecret EIF candidate (same-repository PRs to `master` that edit no PCR approval) | @@ -74,7 +74,8 @@ Add the label, then re-run the CI workflow (or push): `plan` reads labels when i lane's command in its pinned Nix shell, exactly as `ci.yml` does. macOS lanes need macOS and use its selected Xcode (CI pins it with `scripts/ci/select-xcode.py`), Windows lanes need Windows with the toolchain -from `.github/actions/setup-agent-windows`, `os-sdk-e2e` needs a local PostgreSQL +from `.github/actions/setup-agent-windows` (`agent-windows-updater` also needs +the Actions variables its scripts check), `os-sdk-e2e` needs a local PostgreSQL and `os-eif` needs Linux on ARM64. Before pushing a workflow, CI-script or flake change, run `just lane repo` (every root flake check). The [pre-commit hook](repository-workflows.md#pre-commit-hook) runs the fast part diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index e25338b76..8a75d9d7b 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -135,18 +135,21 @@ merging source. ## Agent Windows updater checks -`agent-windows-updater-tests.yml` runs on `windows-2025` when the updater, the -Windows bootstrap, the app's update and desktop code, the release profiles or -the Windows packaging scripts change, with no signing credentials, protected -environment or cache. `scripts/ci/windows-app-typecheck.ps1` typechecks +The `agent-windows-updater` lane runs on pull requests and master pushes that +change the updater, the Windows bootstrap, the app's update and desktop code, +the release profiles or the Windows packaging scripts, and with `ci:windows` or +`ci:full`. It compiles from source, with no cache, signing credential or +protected environment. `scripts/ci/windows-app-typecheck.ps1` typechecks `maple-agent-app --all-targets` with `cargo check --locked`, Windows-only test fixtures included, without code generation. `scripts/ci/windows-updater-tests.ps1` refuses signing and publication credentials, then runs the native updater, filesystem, Authenticode-rejection and bootstrap tests; every listed native case must run, so a runner without the symlink privilege the reparse fixtures need -fails. Each script writes a receipt and its Cargo log, kept as an artifact even -on failure. Neither qualifies an official signature, a signed installed upgrade, -the visible GUI or a paired remote upgrade. +fails. Both scripts run even when the first fails, and both need the Actions +context (`RUNNER_TEMP`, `GITHUB_SHA`, the run id and attempt). Their receipts +and Cargo logs are kept as an artifact even on failure. Neither qualifies an +official signature, a signed installed upgrade, the visible GUI or a paired +remote upgrade. The manual `agent-windows-signature-probe.yml` takes an exact historical producer run, attempt and source and the artifact's ZIP and PE digests. It diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index aa1afff90..50ceccff2 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -78,6 +78,16 @@ class Runner: STORE + "paths.rs", STORE + "keys.rs", STORE + "keys/**", STORE + "lifecycle.rs", STORE + "lifecycle/**", "scripts/ci/agent_windows.py", ) +# What the native Windows updater tests and the Windows app typecheck cover, on PRs and master alike. +AGENT_WINDOWS_UPDATER = ( + "scripts/ci/windows-updater-tests.ps1", "scripts/ci/windows-app-typecheck.ps1", + "scripts/ci/test_windows_updater_workflow.py", "scripts/ci/agent_windows.py", +) + tuple("apps/maple-agent/" + glob for glob in ( + "Cargo.toml", "Cargo.lock", "app/Cargo.toml", "crates/maple-agent-updater/**", "release_profile.rs", + "release-profiles.json", "app/src/main.rs", "app/src/profile.rs", "app/src/update.rs", "app/src/desktop.rs", + "app/src/desktop/**", "app/src/windows_bootstrap.rs", "scripts/windows-release.py", + "scripts/windows-installer.nsi", "scripts/release-info.py", +)) # The packaging scripts and what they call, and the toolchain that builds the bundles. AGENT_PACKAGING = tuple("apps/maple-agent/" + glob for glob in ( "scripts/build-*", "scripts/package-*", "scripts/verify-*", "scripts/release-info.py", @@ -203,6 +213,14 @@ def deny(manifest: str, component: str) -> str: run="python3 scripts/test_windows_build_info.py && python3 scripts/test_windows_host_spawn.py" " && cargo clippy --workspace --all-targets --locked -- -D warnings", cache="agent-windows", timeout=45, **AGENT_WIN), + # The Windows app typecheck and the native updater, filesystem, Authenticode and + # bootstrap tests, compiled without a cache. Both scripts always run; each needs the + # Actions context and leaves a receipt and its Cargo log, which the job keeps. + Lane("agent-windows-updater", AGENT_WINDOWS_UPDATER, labels=("ci:windows", "ci:full"), + job="agent-windows-updater", runner=WINDOWS, + run="status=0; pwsh -NoProfile -File scripts/ci/windows-app-typecheck.ps1 || status=1;" + " pwsh -NoProfile -File scripts/ci/windows-updater-tests.ps1 || status=1; exit $status", + env=(("RUSTFLAGS", "-D warnings"), ("CARGO_TERM_COLOR", "never")), timeout=60), # The rest of `just ci`, and the native macOS and Windows suites: master, nightly and ci:full. Lane("agent-full-linux", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), run="just ci-extra", cache="agent-full-linux", timeout=30, gate=False, **AGENT_FLAKE), diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index a5612acd8..063f9e8ed 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -107,7 +107,7 @@ def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): with self.subTest(path=path): self.assertEqual(selected([path]), {"agent-macos", "agent-package-macos", "research-ios", "research-desktop-macos", "repo"}) - self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "repo"}) + self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "agent-windows-updater", "repo"}) def test_intentional_differences_select_only_the_repo_lane(self): for path in ( @@ -176,6 +176,16 @@ def test_windows_clippy_runs_on_prs_only_for_windows_paths_or_labels(self): self.assertEqual(selected(["docs/x.md"], labels=["ci:full"]), active(PR) - {"os-eif"}) self.assertEqual(selected(["docs/x.md"], labels=["unrelated"]), set()) + def test_windows_updater_tests_follow_the_same_paths_on_prs_and_master(self): + # test_windows_updater_workflow.py lists every path; these show the shape. + updater = "apps/maple-agent/crates/maple-agent-updater/src/installer.rs" + for path in (updater, "apps/maple-agent/app/src/update.rs", "apps/maple-agent/scripts/windows-release.py"): + for event in (PR, QUEUE, PUSH): + with self.subTest(path=path, event=event): + self.assertIn("agent-windows-updater", selected([path], event)) + self.assertNotIn("agent-windows-updater", selected(["apps/maple-agent/app/src/ui/x.rs"], PUSH)) + self.assertEqual(selected(["scripts/ci/windows-updater-tests.ps1"]), {"agent-windows-updater", "repo"}) + def test_inert_paths_select_nothing(self): for path in ("apps/maple-agent/docs/x.md", "docs/x.md", ".agents/skills/x/SKILL.md"): with self.subTest(path=path): @@ -443,7 +453,8 @@ def test_research_platform_builds_run_on_native_inputs_or_labels(self): self.assertEqual(selected([path]), lanes_) self.assertFalse({lane for lane in selected([path], PUSH) if lane.startswith("research-")}) self.assertEqual(selected(["docs/x.md"], labels=["ci:ios"]), {"research-ios"}) - self.assertEqual(selected(["docs/x.md"], labels=["ci:windows"]), windows | {"agent-windows"}) + self.assertEqual(selected(["docs/x.md"], labels=["ci:windows"]), + windows | {"agent-windows", "agent-windows-updater"}) self.assertLessEqual(desktop | windows | {"research-dev-macos"}, selected(["docs/x.md"], labels=["ci:package"])) self.assertFalse({lane for lane in selected([".github/workflows/ci.yml"]) if lane.startswith( diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index 89dbb1e53..e1304e741 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -1,126 +1,162 @@ -"""Keep native Windows updater tests unprivileged and separate from releases.""" +"""Keep the native Windows updater tests unprivileged and separate from releases. +They run as ci.yml's agent-windows-updater lane (scripts/ci/lanes.py): the +Windows app typecheck and the native updater tests, from two scripts. +""" + +import contextlib import functools +import io import json +import os from pathlib import Path import re +import stat import subprocess +import tempfile import unittest +from unittest import mock + +import lanes +import plan +from lanes import NIGHTLY, PR, PUSH, QUEUE ROOT = Path(__file__).resolve().parents[2] -WORKFLOW = "agent-windows-updater-tests.yml" +LANE = next(lane for lane in lanes.LANES if lane.id == "agent-windows-updater") +SCRIPTS = ("scripts/ci/windows-app-typecheck.ps1", "scripts/ci/windows-updater-tests.ps1") +REPORTS = ("maple-windows-app-typecheck", "maple-windows-updater-tests") +RELEASE_TOOLS = r"(?:gh\s+(?:release|workflow)|wrangler|publisher\.py|windows-sign-file|artifact-sign|cargo\s+publish)" + + +@functools.lru_cache(maxsize=None) +def load(path): + return json.loads(subprocess.check_output(["yq", "-o=json", ".", str(ROOT / path)], text=True)) + + +def job(): + return load(".github/workflows/ci.yml")["jobs"][LANE.job] -@functools.cache -def workflow(name=WORKFLOW): - return json.loads(subprocess.check_output( - ["yq", "-o=json", ".", str(ROOT / ".github/workflows" / name)], text=True, - )) +def script(name): + return (ROOT / "scripts/ci" / name).read_text() -class WindowsUpdaterWorkflowTests(unittest.TestCase): +class WindowsUpdaterLaneTests(unittest.TestCase): def test_native_tests_have_no_release_or_signing_authority(self): - config = workflow() + config = load(".github/workflows/ci.yml") self.assertEqual(config["permissions"], {"contents": "read"}) - self.assertEqual(set(config["on"]), {"pull_request", "push", "workflow_dispatch"}) - self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertEqual(set(config["jobs"]), {"native-updater", "app-typecheck"}) - for name, job in config["jobs"].items(): - self.assertEqual(job["runs-on"], "windows-2025") - for authority in ("environment", "secrets", "permissions", "strategy", "needs"): - self.assertNotIn(authority, job) - self.assertLessEqual(job["timeout-minutes"], 30 if name == "app-typecheck" else 35) - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertNotRegex(commands, r"(?:gh\s+(?:release|workflow)|wrangler|publisher\.py|windows-sign-file|artifact-sign|cargo\s+publish)") - self.assertNotIn("secrets.", json.dumps(config)) - self.assertNotIn("id-token", json.dumps(config)) - - def test_native_toolchain_matches_existing_windows_pin_and_avoids_artifact_cache(self): - # agent-desktop-build.yml's windows-build takes its toolchain from this action. - setup = json.loads(subprocess.check_output( - ["yq", "-o=json", ".", str(ROOT / ".github/actions/setup-agent-windows/action.yml")], text=True, - )) - existing = next(step for step in setup["runs"]["steps"] - if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) - for job in workflow()["jobs"].values(): - steps = job["steps"] - actions = [step["uses"] for step in steps if "uses" in step] - self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions)) - self.assertFalse(any("cache" in action or "download-artifact" in action for action in actions)) - checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) - self.assertIs(checkout["with"]["persist-credentials"], False) - rust = next(step for step in steps if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) - self.assertEqual(rust["uses"], existing["uses"]) - self.assertEqual(rust["with"]["toolchain"], existing["with"]["toolchain"]) - linker = next(step for step in steps if "agent_windows.py" in step.get("run", "")) - self.assertEqual(linker["working-directory"], "apps/maple-agent") - self.assertEqual(linker["shell"], "pwsh") - steps = workflow()["jobs"]["native-updater"]["steps"] - test = next(step for step in steps if "windows-updater-tests.ps1" in step.get("run", "")) - self.assertEqual(test["shell"], "pwsh") - self.assertNotIn("continue-on-error", test) + for authority in ("environment", "secrets", "permissions", "strategy"): + self.assertNotIn(authority, job()) + self.assertEqual(job()["needs"], "plan") + self.assertNotIn("secrets.", json.dumps(job())) + self.assertNotIn("id-token", json.dumps(job())) + commands = "\n".join([LANE.run] + [step.get("run", "") for step in job()["steps"]]) + self.assertNotRegex(commands, RELEASE_TOOLS) + self.assertIn("windows", LANE.runner.label) + self.assertLessEqual(LANE.timeout, 60) + + def test_native_toolchain_matches_the_windows_pin_and_avoids_artifact_caches(self): + steps = job()["steps"] + self.assertIn("./.github/actions/setup-agent-windows", [step.get("uses") for step in steps]) + setup = load(".github/actions/setup-agent-windows/action.yml")["runs"]["steps"] + rust = next(step for step in setup if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) + self.assertEqual(rust["with"]["toolchain"], lanes.AGENT_WINDOWS_RUST) + # Both scripts refuse any other compiler than the pinned one. + for name in SCRIPTS: + self.assertIn("'(?m)^release: {}$'".format(re.escape(lanes.AGENT_WINDOWS_RUST)), script(Path(name).name)) + # Native Perl for the static crypto build and the smoke-tested linker. + commands = "\n".join(step.get("run", "") for step in setup) + for needed in ("OPENSSL_SRC_PERL", "MSWin32", "agent_windows.py"): + self.assertIn(needed, commands) + # Compiled from source: no build cache or other run's artifact is restored. + actions = [step["uses"] for step in steps if "uses" in step] + self.assertFalse([action for action in actions if "cache" in action or "download-artifact" in action]) + self.assertFalse(LANE.cache or LANE.restore) + self.assertEqual(dict(LANE.env).get("RUSTFLAGS"), "-D warnings") def test_relevant_contract_changes_select_native_tests_without_research_packaging(self): - required = { - ".github/workflows/agent-windows-updater-tests.yml", - "scripts/ci/windows-updater-tests.ps1", - "scripts/ci/windows-app-typecheck.ps1", - "scripts/ci/test_windows_updater_workflow.py", - "apps/maple-agent/crates/maple-agent-updater/**", - "apps/maple-agent/Cargo.toml", "apps/maple-agent/Cargo.lock", - "apps/maple-agent/app/Cargo.toml", + required = ( + "scripts/ci/windows-updater-tests.ps1", "scripts/ci/windows-app-typecheck.ps1", + "scripts/ci/test_windows_updater_workflow.py", "scripts/ci/agent_windows.py", + "apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs", + "apps/maple-agent/crates/maple-agent-updater/tests/windows_bootstrap_native.rs", + "apps/maple-agent/Cargo.toml", "apps/maple-agent/Cargo.lock", "apps/maple-agent/app/Cargo.toml", "apps/maple-agent/release_profile.rs", "apps/maple-agent/release-profiles.json", - "apps/maple-agent/app/src/main.rs", "apps/maple-agent/app/src/update.rs", - "apps/maple-agent/app/src/windows_bootstrap.rs", "apps/maple-agent/app/src/desktop.rs", - "apps/maple-agent/app/src/desktop/**", + "apps/maple-agent/app/src/main.rs", "apps/maple-agent/app/src/profile.rs", + "apps/maple-agent/app/src/update.rs", "apps/maple-agent/app/src/windows_bootstrap.rs", + "apps/maple-agent/app/src/desktop.rs", "apps/maple-agent/app/src/desktop/remote_updates.rs", "apps/maple-agent/scripts/windows-release.py", "apps/maple-agent/scripts/windows-installer.nsi", - } - for event in ("push", "pull_request"): - paths = set(workflow()["on"][event]["paths"]) - self.assertTrue(required <= paths) - self.assertFalse(any(path.startswith(("apps/maple-research/", "services/opensecret/")) for path in paths)) + "apps/maple-agent/scripts/release-info.py", + ) + for path in required: + for event in (PR, QUEUE, PUSH): + with self.subTest(path=path, event=event): + chosen = set(plan.select(event, [path])) + self.assertIn(LANE.id, chosen) + self.assertFalse([lane for lane in chosen if lane.startswith(("research-", "os-"))]) + for path in ("apps/maple-agent/app/src/ui/x.rs", "apps/maple-research/frontend/src/main.tsx", + "services/opensecret/src/main.rs", "docs/x.md"): + with self.subTest(path=path): + self.assertNotIn(LANE.id, plan.select(PR, [path])) + self.assertNotIn(LANE.id, plan.select(PUSH, [path])) + for label in ("ci:windows", "ci:full"): + self.assertIn(LANE.id, plan.select(PR, ["docs/x.md"], [label])) + self.assertIn(LANE.id, {lane.id for lane in plan.active(NIGHTLY)}) + + def test_both_scripts_run_and_either_failure_fails_the_lane(self): + with tempfile.TemporaryDirectory() as temporary: + stub = Path(temporary) / "pwsh" + log = Path(temporary) / "calls" + stub.write_text('#!/bin/sh\nprintf "%s %s %s\\n" "$*" "$CARGO_TERM_COLOR" "$RUSTFLAGS" >> "$PWSH_LOG"\n' + 'case "$*" in *"$PWSH_FAIL"*) [ -n "$PWSH_FAIL" ] && exit 1 ;; esac\nexit 0\n') + stub.chmod(stub.stat().st_mode | stat.S_IXUSR) + for failing, expected in (("", 0), (SCRIPTS[0], 1), (SCRIPTS[1], 1)): + log.write_text("") + environment = {"PATH": f"{temporary}{os.pathsep}{os.environ['PATH']}", + "PWSH_LOG": str(log), "PWSH_FAIL": failing} + with self.subTest(failing=failing), mock.patch.dict(os.environ, environment), \ + mock.patch.object(plan.platform, "system", return_value="Linux"), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(plan.run(LANE.id), expected) + calls = log.read_text().splitlines() + self.assertEqual([call.split()[:3] for call in calls], + [["-NoProfile", "-File", name] for name in SCRIPTS]) + self.assertTrue(all(call.endswith("never -D warnings") for call in calls)) def test_app_typecheck_covers_windows_test_fixtures_without_release_codegen(self): - job = workflow()["jobs"]["app-typecheck"] - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertIn("OPENSSL_SRC_PERL", commands) - self.assertIn("MSWin32", commands) - self.assertIn("GPUI_FXC_PATH", commands) - self.assertIn("x64/fxc.exe", commands) - check = next(step for step in job["steps"] if "windows-app-typecheck.ps1" in step.get("run", "")) - self.assertEqual(check["shell"], "pwsh") - self.assertNotIn("continue-on-error", check) - script = (ROOT / "scripts/ci/windows-app-typecheck.ps1").read_text() - self.assertIn("@('check', '-p', 'maple-agent-app', '--all-targets', '--locked')", script) - self.assertNotRegex(script, r"cargo\s+(?:build|test|run)|--release|--no-default-features") - self.assertIn("$receipt.cargo_exit_code -ne 0", script) - self.assertIn("$receipt.app_all_targets_typechecked = $true", script) + source = script("windows-app-typecheck.ps1") + self.assertIn("@('check', '-p', 'maple-agent-app', '--all-targets', '--locked')", source) + self.assertNotRegex(source, r"cargo\s+(?:build|test|run)|--release|--no-default-features") + self.assertIn("$receipt.cargo_exit_code -ne 0", source) + self.assertIn("$receipt.app_all_targets_typechecked = $true", source) for field in ("runtime_tests_executed", "app_code_generated", "release_package_qualified"): - self.assertRegex(script, rf"{field}\s*=\s*\$false") - self.assertNotRegex(script, rf"{field}\s*=\s*\$true") + self.assertRegex(source, rf"{field}\s*=\s*\$false") + self.assertNotRegex(source, rf"{field}\s*=\s*\$true") for binding in ("GITHUB_SHA", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT"): - self.assertIn(binding, script) - upload = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) - self.assertEqual(upload["if"], "always()") - self.assertEqual(upload["with"]["if-no-files-found"], "error") - self.assertIn("github.run_attempt", upload["with"]["name"]) + self.assertIn(binding, source) def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualification(self): - upload = next(step for step in workflow()["jobs"]["native-updater"]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")) + upload = next(step for step in job()["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) self.assertEqual(upload["if"], "always()") self.assertEqual(upload["with"]["if-no-files-found"], "error") self.assertIn("github.run_attempt", upload["with"]["name"]) - script = (ROOT / "scripts/ci/windows-updater-tests.ps1").read_text() - self.assertIn("[System.PlatformID]::Win32NT", script) - self.assertIn("'--locked'", script) - self.assertIn("'--test', 'windows_bootstrap_native'", script) - self.assertIn("'--test-threads=1'", script) - self.assertIn("'--locked', '--no-fail-fast', '--lib'", script) - self.assertIn("Get-AuthenticodeSignature -LiteralPath $unrelated", script) - self.assertIn("A required native Windows test group is missing", script) - self.assertIn("without skipped tests", script) + for report in REPORTS: + self.assertIn("${{ runner.temp }}/" + report + "/", upload["with"]["path"]) + self.assertIn("Join-Path $env:RUNNER_TEMP '{}'".format(report), "\n".join(map(script, ( + "windows-app-typecheck.ps1", "windows-updater-tests.ps1")))) + run = next(step for step in job()["steps"] if "plan.py run" in step.get("run", "")) + self.assertIn(LANE.id, run["run"]) + self.assertNotIn("continue-on-error", run) + source = script("windows-updater-tests.ps1") + self.assertIn("[System.PlatformID]::Win32NT", source) + self.assertIn("'--locked'", source) + self.assertIn("'--test', 'windows_bootstrap_native'", source) + self.assertIn("'--test-threads=1'", source) + self.assertIn("'--locked', '--no-fail-fast', '--lib'", source) + self.assertIn("Get-AuthenticodeSignature -LiteralPath $unrelated", source) + self.assertIn("A required native Windows test group is missing", source) + self.assertIn("without skipped tests", source) for case in ( "native_nsis_inherited_read_acl_accepts_files_but_rejects_directories", "native_foreign_file_mutation_rights_remain_rejected", @@ -128,13 +164,13 @@ def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualificatio "native_owner_rights_sid_is_not_a_trusted_storage_owner", "native_owner_rights_acl_still_rejects_foreign_writers", ): - self.assertIn("installer::windows_bundle::windows_fs::tests::" + case, script) + self.assertIn("installer::windows_bundle::windows_fs::tests::" + case, source) for field in ( "official_maple_signature_qualified", "signed_installed_a_to_b_qualified", "visible_gui_upgrade_qualified", "paired_remote_upgrade_qualified", ): - self.assertRegex(script, rf"{field}\s*=\s*\$false") - self.assertNotRegex(script, rf"{field}\s*=\s*\$true") + self.assertRegex(source, rf"{field}\s*=\s*\$false") + self.assertNotRegex(source, rf"{field}\s*=\s*\$true") if __name__ == "__main__": From b2a6390ec4de3072f0d11194492cb0f6ff561262 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:11:57 +0000 Subject: [PATCH 17/21] ci: address #1316 integration review - agent-package-windows also runs on its own Windows inputs: the Windows packaging scripts, build-release.sh (its Windows branch), release-info.py, the release profile files and the Windows toolchain. #1316 put Windows on the Agent feed, and on master every Agent PR built, installed and smoke-tested an unsigned Windows package; label-only selection assumed Windows was not published. ci:package and ci:full still add it. - test_contracts C2 evaluates each agent-desktop-build.yml job's `if:` and `needs` for a master push and a dispatch, so a condition that skips a Prod job the publisher requires fails the contract. Bare true, false and null evaluate as GitHub's literals. - A new C2 contract runs every lane that calls windows-release.py package or verify with stand-ins and checks they receive MAPLE_PACKAGE_BUILD_NUMBER: the Actions run number, or 1 outside Actions. - C7 also checks quoted or bold workflow display names in docs; updates.md named the deleted Agent Windows Updater Tests workflow and now names the agent-windows-updater lane. - magic-nix-cache-action counts as a cache writer unless use-gha-cache is disabled. opensecret-eif.yml's eif job (fork PRs that edit approvals, manual runs outside master) is the one exempt pull-request writer: no FlakeHub, no id-token, branch-scoped cache. The workflow is unchanged; the cache docs now name the exception. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/docs/desktop-builds.md | 11 ++- apps/maple-agent/docs/updates.md | 4 +- docs/ci.md | 5 +- docs/repository-workflows.md | 9 +- scripts/ci/lanes.py | 10 ++- scripts/ci/test_contracts.py | 112 ++++++++++++++++++++---- scripts/ci/test_plan.py | 26 +++++- scripts/ci/test_workflow_policy.py | 26 ++++-- 8 files changed, 166 insertions(+), 37 deletions(-) diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index f48073e7b..c2b2af2c9 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -8,8 +8,10 @@ cancelled, so every Agent push gets its own packages. A later push without Agent changes does not retry a failed run: re-run its failed jobs, dispatch, or fix forward. Pull requests that change packaging inputs (the packaging and release scripts, the Agent flake, and for macOS the Xcode pin) rehearse unsigned Dev -packages in `ci.yml` (`agent-package-linux`, `agent-package-macos`); the -`ci:package` label adds `agent-package-windows`. +packages in `ci.yml` (`agent-package-linux`, `agent-package-macos`). +`agent-package-windows` runs when the Windows packaging scripts, +`build-release.sh`, the release profile files or the Windows toolchain change; +the `ci:package` label adds all three. Unrelated master commits have no new Agent artifacts. Existing downloads keep their original source SHA and provenance; they are never relabeled as a newer @@ -24,8 +26,9 @@ per-user NSIS installers requiring Windows 10 version 1703 (build 15063) or late imported by GPUI; see Microsoft's [Windows ICU version history](https://learn.microsoft.com/en-us/windows/win32/intl/international-components-for-unicode--icu-). Trusted master packages use the existing Azure publisher to Authenticode-sign the application, -embedded uninstaller and final installer. The `ci:package` PR label rehearses an -unsigned Dev installer. +embedded uninstaller and final installer. Pull requests that change those +Windows packaging inputs, or carry the `ci:package` label, build, install and +smoke-test an unsigned Dev installer. | Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index babf91d9b..553fe5389 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -342,8 +342,8 @@ signature/notarization checks remain intact. They do not prove Developer ID distribution, deployed service behavior, or a successful real package update. -The focused **Agent Windows Updater Tests** workflow runs the actual updater -and bootstrap component tests on Windows with no signing or publication +The `agent-windows-updater` lane in `ci.yml` runs the actual updater and +bootstrap component tests on Windows with no signing or publication credentials. It requires the named native filesystem, trust rejection, transaction and recovery cases and rejects ignored or empty suites. These fixtures do not establish official Maple signatures, installed A-to-B updates, diff --git a/docs/ci.md b/docs/ci.md index b301d5ac0..38388488f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -102,8 +102,9 @@ of the lanes your staged files select. ## Rules - Never add `paths:` to `ci.yml`; `ci-ok` must report on every pull request. -- Pull requests never save caches. Master and nightly runs save lane caches, - on Blacksmith runners only. +- Pull requests never save caches (one branch-scoped exception, outside + `ci.yml`, is listed under [caches](repository-workflows.md#caches)). Master + and nightly runs save lane caches, on Blacksmith runners only. - Never cancel master, signed, evidence or nightly runs. - Never edit the release or publisher workflows, signing environments or OpenSecret approval gating to clear a check. A failing contract test means diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 8a75d9d7b..25ed7851d 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -30,12 +30,17 @@ branches in step with the lane table. ## Caches -- Pull requests restore caches and never save one. +- Pull requests restore caches and never save one. The one exception is + `opensecret-eif.yml`'s `eif` job (fork PRs that edit PCR approvals, and + manual runs outside `master`): without FlakeHub credentials, its Nix store + goes to the run's branch-scoped Actions cache, which `master` and other + pull requests cannot read. - Lane caches (`Swatinem/rust-cache`, prefix `ci-v1`, keyed by lane and lockfile hashes) are saved by master pushes and the nightly run on Blacksmith runners only. Heavy Rust caches therefore live in Blacksmith's store; GitHub's own store, which is small, holds only what GitHub-hosted jobs need (the - Research Windows signed build, the release and the Nix installer). + Research Windows signed build, the release, the Nix installer and that EIF + check). - The signed master builds write the keys `release.yml` restores by prefix (`-sccache-*`, `xcode-cache-*`, `release-onnxruntime-ios-*`, `-bun-*`, `-gradle-*`). No pull-request job writes these prefixes; diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 50ceccff2..f04aaf969 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -95,6 +95,13 @@ class Runner: "scripts/*third*party*notices*", "release-profiles.json", "release_profile.rs", "about.toml", "app/build.rs", "app/packaging/**", "flake.nix", "flake.lock", )) + (".github/workflows/agent-desktop-build.yml",) +# The Windows packaging lane's Windows-specific inputs: the Windows packaging scripts, +# the build script's Windows branch, the release identity the installer and the +# Windows feed read, and the Windows toolchain. +AGENT_WINDOWS_PACKAGING = tuple("apps/maple-agent/" + glob for glob in ( + "scripts/windows-*", "scripts/build-release.sh", "scripts/release-info.py", "release-profiles.json", + "release_profile.rs", +)) + (".github/actions/setup-agent-windows/**", "scripts/ci/agent_windows.py") APPLE_TOOLCHAIN = ("scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py") # What agent-desktop-build.yml builds and signs on master (`plan.py only agent-desktop`). AGENT_DESKTOP = AGENT + RUST_SDK + PROXY_RUNTIME + ( @@ -250,7 +257,8 @@ def deny(manifest: str, component: str) -> str: Lane("agent-package-macos", AGENT_PACKAGING + APPLE_TOOLCHAIN, labels=("ci:package", "ci:full"), events=PACKAGE_EVENTS, job="agent-package-macos", runner=MACOS, run="just package-rehearsal", timeout=60, gate=False, **AGENT_PACKAGE_FLAKE), - Lane("agent-package-windows", (), labels=("ci:package", "ci:full"), events=PR_SIDE, + # Windows is on the Agent feed, so its own packaging inputs select it too. + Lane("agent-package-windows", AGENT_WINDOWS_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, job="agent-package-windows", runner=WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=60, gate=False), # Every root flake check: actionlint, workflow policy and contracts, CI script diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index d1c5265d8..e853e2753 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -108,19 +108,55 @@ def variants(job: dict) -> list: return sorted({value for array in re.findall(r"\[[^\]]*\]", matrix) for value in json.loads(array)}) -def desktop_run(config: dict, run_id: int): - """The job inventory (with runner labels) and artifacts of one successful agent-desktop-build.yml run.""" +def job_results(config: dict, event: str) -> dict: + """Which agent-desktop-build.yml jobs run on a master `event` that changes Agent + inputs, if every job that runs succeeds: each job's `if:` and `needs`, evaluated.""" + results: dict = {} + pending = dict(config["jobs"]) + while pending: + ready = [job_id for job_id, job in pending.items() + if set(needs_of(job)) <= set(results)] + if not ready: + raise ValueError(f"unresolvable needs: {sorted(pending)}") + for job_id in ready: + job = pending.pop(job_id) + succeeded = all(results[need] == "success" for need in needs_of(job)) + if "if" not in job: + runs = succeeded + else: + text = expression(job) + if not re.search(r"\b(?:always|cancelled|failure|success)\(\)", str(job["if"])): + text = f"{succeeded} and ({text})" # GitHub's implicit success() + context = {"NOT_CANCELLED": True, "github.repository": publisher.REPOSITORY, + "github.ref": "refs/heads/master", "github.event_name": event, + "needs.changes.outputs.agent": "true" if results.get("changes") == "success" else ""} + context.update({f"needs.{need}.result": results[need] for need in needs_of(job)}) + runs = evaluate(text, dict(sorted(context.items(), key=lambda item: -len(item[0])))) + results[job_id] = "success" if runs else "skipped" + return results + + +def needs_of(job: dict) -> list: + value = job.get("needs", []) + return [value] if isinstance(value, str) else list(value) + + +def desktop_run(config: dict, run_id: int, event: str = "push"): + """The job inventory (with runner labels) and artifacts of one agent-desktop-build.yml + run on master in which every job that runs succeeds; a job its `if:` skips is skipped.""" def expand(text, variant): return text.replace("${{ matrix.variant }}", str(variant)).replace("${{ github.run_id }}", str(run_id)) + results = job_results(config, event) inventory, artifacts = [], [] for job_id, job in config["jobs"].items(): for variant in variants(job): inventory.append({"name": expand(job.get("name", job_id), variant), "status": "completed", - "conclusion": "success", "labels": [job["runs-on"]]}) - artifacts += [{"name": expand(step["with"]["name"], variant), "expired": False} - for step in job.get("steps", []) - if step.get("uses", "").startswith("actions/upload-artifact@")] + "conclusion": results[job_id], "labels": [job["runs-on"]]}) + if results[job_id] == "success": + artifacts += [{"name": expand(step["with"]["name"], variant), "expired": False} + for step in job.get("steps", []) + if step.get("uses", "").startswith("actions/upload-artifact@")] return inventory, artifacts @@ -140,7 +176,8 @@ def expression(job: dict) -> str: def evaluate(text: str, context: dict) -> bool: for key, value in context.items(): text = text.replace(key, repr(value)) - return bool(eval(text, {"__builtins__": {}}, {})) # noqa: S307 - fixed workflow text + literals = {"true": True, "false": False, "null": None} # GitHub's literals; quoted strings are untouched + return bool(eval(text, {"__builtins__": {}}, literals)) # noqa: S307 - fixed workflow text def cancels(concurrency) -> bool: @@ -200,7 +237,6 @@ def test_c2_agent_publisher_accepts_this_workflows_jobs_and_artifacts(self): self.assertIn("master", config["on"]["push"]["branches"]) self.assertIn("workflow_dispatch", config["on"]) run_id = 4242 - inventory, artifacts = desktop_run(config, run_id) for job_id, job in config["jobs"].items(): attests = any(step.get("uses", "").startswith("actions/attest@") for step in job.get("steps", [])) if attests: # the publisher verifies with --deny-self-hosted-runners @@ -208,16 +244,19 @@ def test_c2_agent_publisher_accepts_this_workflows_jobs_and_artifacts(self): if attests or environment(job) or job.get("name", "").startswith("Verify"): self.assertFalse([step for step in job.get("steps", []) if step.get("uses", "").startswith( ("actions/cache", "Swatinem/rust-cache@"))], f"{job_id} must restore no cache") - run = {"id": run_id, "run_attempt": 1, "run_number": 77, "path": publisher.WORKFLOW, - "head_branch": "master", "event": "push", "status": "completed", "head_sha": "a" * 40, - "created_at": "2026-01-01T00:00:00Z", "repository": {"full_name": publisher.REPOSITORY}, - "head_repository": {"full_name": publisher.REPOSITORY}} - responses = {"": run, "jobs": {"total_count": len(inventory), "jobs": inventory}, - "artifacts": {"total_count": len(artifacts), "artifacts": artifacts}} - with mock.patch.object(publisher, "gh_json", - lambda path: responses[re.sub(r".*/runs/\d+/?([a-z]*).*", r"\1", path)]): - proof = publisher.trust_run(run_id, linux=True, windows=True) - self.assertEqual(proof["build"], "77") # run_number is the Agent build number + # Each job's `if:` and `needs` decide whether it runs: a Prod job they skip fails the publisher. + for event in ("push", "workflow_dispatch"): + inventory, artifacts = desktop_run(config, run_id, event) + run = {"id": run_id, "run_attempt": 1, "run_number": 77, "path": publisher.WORKFLOW, + "head_branch": "master", "event": event, "status": "completed", "head_sha": "a" * 40, + "created_at": "2026-01-01T00:00:00Z", "repository": {"full_name": publisher.REPOSITORY}, + "head_repository": {"full_name": publisher.REPOSITORY}} + responses = {"": run, "jobs": {"total_count": len(inventory), "jobs": inventory}, + "artifacts": {"total_count": len(artifacts), "artifacts": artifacts}} + with self.subTest(event=event), mock.patch.object( + publisher, "gh_json", lambda path: responses[re.sub(r".*/runs/\d+/?([a-z]*).*", r"\1", path)]): + proof = publisher.trust_run(run_id, linux=True, windows=True) + self.assertEqual(proof["build"], "77") # run_number is the Agent build number # The publisher checks the Windows package's build against that run number. windows = [job for job in config["jobs"].values() if re.search(r"windows-release\.py (?:package|verify)", json.dumps(job.get("steps", [])))] @@ -226,6 +265,37 @@ def test_c2_agent_publisher_accepts_this_workflows_jobs_and_artifacts(self): self.assertEqual(job.get("env", {}).get("MAPLE_PACKAGE_BUILD_NUMBER"), "${{ github.run_number }}", job.get("name")) + def test_c2_windows_packaging_lanes_pass_a_build_number(self): + # windows-release.py package and verify refuse to run without MAPLE_PACKAGE_BUILD_NUMBER. + # A lane passes the Actions run number, or 1 outside Actions. Run each such lane's + # command with stand-ins for the build script and Python, and read what they received. + packaging = [lane for lane in plan.LANES if re.search(r"windows-release\.py (?:package|verify)", lane.run)] + self.assertTrue(packaging) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + stubs = {root / "scripts/build-release.sh": "exit 0\n", + root / "bin/python3": 'printf "%s|%s\\n" "${MAPLE_PACKAGE_BUILD_NUMBER-unset}" "$*" >> "$CALLS"\n'} + for path, body in stubs.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + calls = root / "calls" + for lane, run_number in itertools.product(packaging, ("77", None)): + calls.write_text("") + env = {key: value for key, value in os.environ.items() + if key not in ("GITHUB_RUN_NUMBER", "MAPLE_PACKAGE_BUILD_NUMBER")} + env.update(dict(lane.env), PATH=f"{root / 'bin'}{os.pathsep}{env['PATH']}", CALLS=str(calls)) + if run_number: + env["GITHUB_RUN_NUMBER"] = run_number + result = subprocess.run([shutil.which("bash"), "--noprofile", "--norc", "-euo", "pipefail", "-c", + lane.run], cwd=root, env=env, capture_output=True, text=True) + with self.subTest(lane=lane.id, run_number=run_number): + self.assertEqual(result.returncode, 0, result.stderr) + received = [line.split("|")[0] for line in calls.read_text().splitlines() + if re.search(r"windows-release\.py (?:package|verify) ", line)] + self.assertGreaterEqual(len(received), 2) # package, then verify + self.assertEqual(set(received), {run_number or "1"}) + def test_c2_windows_signature_probe_accepts_this_workflows_prod_run(self): self.assertEqual(signature_probe.PRODUCER, publisher.WORKFLOW) self.assertEqual(signature_probe.REPOSITORY, publisher.REPOSITORY) @@ -552,6 +622,7 @@ def test_c6_protected_environments_have_fixed_consumers(self): def test_c7_docs_name_only_existing_workflows_and_files(self): files = plan.tracked_files() names = {Path(path).name for path in files} + titles = {config.get("name") for config in workflows().values()} # display names dangling = [] for path in files: if not (path.endswith(".md") and plan.matches(path, DOCS)): @@ -563,6 +634,11 @@ def test_c7_docs_name_only_existing_workflows_and_files(self): else reference in names) if not exists: dangling.append(f"{path}: {reference}") + # A quoted or bold name before "workflow" is a display name, unless it is a path or file. + for match in re.finditer(r"(?:`([^`\n]+)`|\*\*([^*\n]+)\*\*)\s+workflows?\b", text): + title = match.group(1) or match.group(2) + if "/" not in title and not re.search(r"\.ya?ml$", title) and title not in titles: + dangling.append(f"{path}: {title}") self.assertEqual(dangling, []) diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py index 063f9e8ed..9c03908e3 100644 --- a/scripts/ci/test_plan.py +++ b/scripts/ci/test_plan.py @@ -107,7 +107,8 @@ def test_shared_ci_tooling_selects_the_agent_lanes_that_use_it(self): with self.subTest(path=path): self.assertEqual(selected([path]), {"agent-macos", "agent-package-macos", "research-ios", "research-desktop-macos", "repo"}) - self.assertEqual(selected(["scripts/ci/agent_windows.py"]), {"agent-windows", "agent-windows-updater", "repo"}) + self.assertEqual(selected(["scripts/ci/agent_windows.py"]), + {"agent-windows", "agent-windows-updater", "agent-package-windows", "repo"}) def test_intentional_differences_select_only_the_repo_lane(self): for path in ( @@ -361,10 +362,31 @@ def test_packaging_rehearsal_runs_on_the_narrow_selector_or_a_label(self): for path in ("apps/maple-agent/app/src/main.rs", "apps/maple-agent/scripts/macos-debug-app.sh"): with self.subTest(path=path): self.assertFalse(AGENT_PACKAGE & selected([path])) - self.assertFalse({"agent-package-windows"} & selected(["apps/maple-agent/scripts/build-release.sh"])) self.assertEqual(agent_lanes(selected(["docs/x.md"], labels=["ci:package"])), AGENT_PACKAGE | {"agent-package-windows"}) + def test_windows_packaging_runs_on_its_own_inputs_or_a_label(self): + # Windows is on the Agent feed: its installer, packaging scripts, release + # identity and toolchain get the native unsigned package and install smoke. + for path in ("apps/maple-agent/scripts/windows-installer.nsi", "apps/maple-agent/scripts/windows-release.py", + "apps/maple-agent/scripts/windows-build-info.py", "apps/maple-agent/scripts/build-release.sh", + "apps/maple-agent/scripts/release-info.py", "apps/maple-agent/release-profiles.json", + "apps/maple-agent/release_profile.rs", ".github/actions/setup-agent-windows/action.yml", + "scripts/ci/agent_windows.py"): + for event in (PR, QUEUE): + with self.subTest(path=path, event=event): + self.assertIn("agent-package-windows", selected([path], event)) + self.assertNotIn("agent-package-windows", selected([path], PUSH)) # master builds it signed + # Linux- and macOS-only packaging, Agent code and CI edits do not. + for path in ("apps/maple-agent/scripts/linux-release-bundle.py", "apps/maple-agent/scripts/macos-release-app.sh", + "apps/maple-agent/flake.lock", "apps/maple-agent/app/packaging/macos-entitlements.plist", + "apps/maple-agent/app/src/main.rs", "apps/maple-agent/crates/maple-agent-updater/src/installer.rs", + ".github/workflows/ci.yml", "scripts/ci/plan.py"): + with self.subTest(path=path): + self.assertNotIn("agent-package-windows", selected([path])) + for label in ("ci:package", "ci:full"): + self.assertIn("agent-package-windows", selected(["docs/x.md"], labels=[label])) + def test_full_suites_run_on_master_nightly_and_ci_full_only(self): full = {"agent-full-linux", "agent-full-macos", "agent-full-windows"} self.assertFalse(full & selected(["apps/maple-agent/app/src/main.rs"])) diff --git a/scripts/ci/test_workflow_policy.py b/scripts/ci/test_workflow_policy.py index 57696c140..40857b9a9 100644 --- a/scripts/ci/test_workflow_policy.py +++ b/scripts/ci/test_workflow_policy.py @@ -55,9 +55,15 @@ "sdk-crates": frozenset({"CRATES_BOOTSTRAP_TOKEN"}), } # Actions-cache writers; a key under these prefixes feeds release.yml's fallbacks. -# setup-bun's post step saves the Bun binary unless `no-cache` is set. +# setup-bun's post step saves the Bun binary unless `no-cache` is set; magic-nix-cache +# saves the Nix store to the Actions cache unless `use-gha-cache` is disabled. CACHE_WRITERS = ("actions/cache@", "actions/cache/save@", "irgaly/xcode-cache@", "Swatinem/rust-cache@", - "oven-sh/setup-bun@") + "oven-sh/setup-bun@", "DeterminateSystems/magic-nix-cache-action@") +# The one pull-request job that saves a cache: the EIF approval check for fork PRs +# that edit approvals and for manual runs outside master. It has no FlakeHub +# credentials, so its Nix store goes to the run's own branch-scoped Actions cache, +# which master and other pull requests cannot read. +BRANCH_SCOPED_NIX = frozenset({("opensecret-eif.yml", "eif")}) @functools.lru_cache(maxsize=None) def load(path: Path) -> dict: @@ -129,7 +135,8 @@ def runner_labels(name: str, job_id: str, job: dict) -> set: def cache_writer(step: dict) -> bool: settings = step.get("with", {}) return (step.get("uses", "").startswith(CACHE_WRITERS) and str(settings.get("save-if", "true")) != "false" - and str(settings.get("no-cache", "false")).lower() != "true") + and str(settings.get("no-cache", "false")).lower() != "true" + and str(settings.get("use-gha-cache", "enabled")).lower() != "disabled") class WorkflowPolicyTests(unittest.TestCase): @@ -234,6 +241,7 @@ def test_9_runner_labels_are_github_hosted_or_declared(self): self.assertTrue(GITHUB_HOSTED.fullmatch(label) or label in declared, label) def test_pull_requests_never_save_caches_or_read_other_runs_artifacts(self): + scoped = set() for name, config in workflows().items(): pr = bool({"pull_request", "merge_group"} & triggers(config)) for job_id, job in config["jobs"].items(): @@ -241,10 +249,16 @@ def test_pull_requests_never_save_caches_or_read_other_runs_artifacts(self): self.assertNotIn("run-id", step.get("with", {}), (name, job_id)) guard = " ".join(str(value) for value in ( job.get("if", ""), step.get("if", ""), step.get("with", {}).get("save-if", ""))) - if pr and cache_writer(step): + if pr and cache_writer(step) and (name, job_id) in BRANCH_SCOPED_NIX: + scoped.add((name, job_id)) + self.assertTrue(step["uses"].startswith("DeterminateSystems/magic-nix-cache-action@")) + self.assertEqual(step["with"].get("use-flakehub"), "disabled") + self.assertNotIn("id-token", json.dumps(job)) + elif pr and cache_writer(step): self.assertRegex(guard, r"refs/heads/master|github\.event_name == 'push'", (name, job_id)) + self.assertEqual(scoped, BRANCH_SCOPED_NIX) # the exemption lapses with its job - def test_only_windows_signing_fills_githubs_cache_store(self): + def test_only_windows_signing_and_the_eif_check_fill_githubs_cache_store(self): # GitHub's cache store is small; heavy caches live on Blacksmith, whose # runners cannot read GitHub's. The release and the Nix installer are the other users. writers = set() @@ -254,7 +268,7 @@ def test_only_windows_signing_fills_githubs_cache_store(self): and "startsWith(matrix.runner, 'blacksmith-')" not in str(step["with"].get("save-if")) and any(GITHUB_HOSTED.fullmatch(label) for label in runner_labels(name, job_id, job))): writers.add((name, job_id)) - self.assertEqual(writers, {("desktop-build.yml", "build-windows")}) + self.assertEqual(writers, {("desktop-build.yml", "build-windows")} | BRANCH_SCOPED_NIX) if __name__ == "__main__": unittest.main() From 039c7b5fff25a2150f1c4200c6698910df6c7fad Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:33:06 +0000 Subject: [PATCH 18/21] ci: run the Agent's native Windows test lanes on GitHub's windows-2025 The first Blacksmith run of agent-windows-updater failed one native test: the updater's storage validation rejects the test image because Blacksmith's workspace inherits a foreign write ACE, which GitHub's workspace does not. The validation is right, and #1316 proved these tests on windows-2025, so agent-windows-updater and agent-full-windows (which runs the same tests) use GitHub's image again. The Windows Clippy lane and the unsigned builds stay on Blacksmith. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++- docs/repository-workflows.md | 7 +++++-- scripts/ci/lanes.py | 7 +++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac37e9faf..0bfbf3531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,8 @@ jobs: name: agent-windows-updater needs: plan if: fromJSON(needs.plan.outputs.lanes)['agent-windows-updater'] - runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} + # GitHub's image, as the native updater tests require (scripts/ci/lanes.py GITHUB_WINDOWS). + runs-on: windows-2025 timeout-minutes: 60 defaults: run: diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 25ed7851d..b0a6b8a82 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -211,8 +211,11 @@ Artifact verifiers, TestFlight submission, and the Agent's signing and attestation use GitHub-hosted runners. Windows signing and the Windows release stay on GitHub's `windows-2025`; unsigned Agent and Research Windows builds use `blacksmith-8vcpu-windows-2025`, with Rust, MSVC, Perl and the linker pinned by -`.github/actions/setup-agent-windows` for the Agent. The Research Windows PR -package has no cache, because its signed build's cache lives in GitHub's store. +`.github/actions/setup-agent-windows` for the Agent. The Agent's native Windows +test lanes (`agent-windows-updater` and `agent-full-windows`) also run on GitHub's +`windows-2025`: Blacksmith's workspace inherits a foreign write ACE that the +updater's storage validation rejects. The Research Windows PR package has no +cache, because its signed build's cache lives in GitHub's store. Research desktop and iOS builds select Xcode 26.5 (`17F42`) from [`scripts/ci/apple-toolchain.json`](../scripts/ci/apple-toolchain.json) through diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index f04aaf969..79ceec9e9 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -34,6 +34,9 @@ class Runner: MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") GITHUB = Runner("ubuntu-latest") # free GitHub runner for lanes that compile nothing heavy +# The Agent updater's native Windows tests need GitHub's runner workspace: Blacksmith's +# inherits a foreign write ACE that the updater's storage validation correctly rejects. +GITHUB_WINDOWS = Runner("windows-2025") # .github/actions/setup-agent-windows pins this toolchain (Windows has no Nix); # agent-lint checks that the Agent flake's rustc still reports it. AGENT_WINDOWS_RUST = "1.98.0" @@ -224,7 +227,7 @@ def deny(manifest: str, component: str) -> str: # bootstrap tests, compiled without a cache. Both scripts always run; each needs the # Actions context and leaves a receipt and its Cargo log, which the job keeps. Lane("agent-windows-updater", AGENT_WINDOWS_UPDATER, labels=("ci:windows", "ci:full"), - job="agent-windows-updater", runner=WINDOWS, + job="agent-windows-updater", runner=GITHUB_WINDOWS, run="status=0; pwsh -NoProfile -File scripts/ci/windows-app-typecheck.ps1 || status=1;" " pwsh -NoProfile -File scripts/ci/windows-updater-tests.ps1 || status=1; exit $status", env=(("RUSTFLAGS", "-D warnings"), ("CARGO_TERM_COLOR", "never")), timeout=60), @@ -238,7 +241,7 @@ def deny(manifest: str, component: str) -> str: Lane("agent-full-windows", AGENT, also=RUST_SDK + PROXY_RUNTIME, pr_paths=(), run="python3 scripts/test_git_metadata.py && cargo build --workspace --all-targets --locked" " && cargo test --workspace --locked", - cache="agent-full-windows", timeout=60, gate=False, **AGENT_WIN), + cache="agent-full-windows", timeout=90, gate=False, **dict(AGENT_WIN, runner=GITHUB_WINDOWS)), # Pinned SQLCipher sources, per-target advisories and bans, and notices. Lane("agent-supply-chain", AGENT_DEPENDENCIES, run="just supply-chain", timeout=30, **AGENT_FLAKE), # Released rusqlite's own tests against the static SQLCipher provider (nightly.yml: all three OSes). From 9d909acf8266d382846a302f631fadce0dd2e028 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:40:42 +0000 Subject: [PATCH 19/21] fix(agent): satisfy Windows Clippy in the updater's Windows bundle code The Windows Clippy lane is the first CI job to lint #1316's Windows-only updater code. Five mechanical fixes, no behaviour change: three collapsible ifs (two as let chains), a range check written with RangeInclusive::contains, and PROCESSENTRY32W built with its size in the initializer. Co-Authored-By: Claude Opus 5.5 --- .../src/installer/windows_bundle.rs | 32 +++++++++---------- .../installer/windows_bundle/windows_fs.rs | 8 ++--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs index e89aafbb0..6fea1c344 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs @@ -556,8 +556,7 @@ fn registered_root(root: &Path, profile: &str) -> Result<()> { ) } != ERROR_SUCCESS || kind != REG_SZ - || length < 4 - || length > 65536 + || !(4..=65536).contains(&length) || length % 2 != 0 { return Err(InstallError::Invalid( @@ -600,10 +599,10 @@ fn validate_destination_for(root: &Path, purpose: IdentityPurpose) -> Result<()> #[cfg(windows)] { windows_fs::private_directory(root)?; - if root.join(MANUAL_INTENT).try_exists()? { - if let Ok((_, _, current)) = generation_for(root, "prod", IdentityPurpose::Startup) { - recover_completed_manual_intent(root, ¤t)?; - } + if root.join(MANUAL_INTENT).try_exists()? + && let Ok((_, _, current)) = generation_for(root, "prod", IdentityPurpose::Startup) + { + recover_completed_manual_intent(root, ¤t)?; } if root.join(MANUAL_INTENT).try_exists()? || root.join(UNINSTALL_INTENT).try_exists()? { return Err(InstallError::Busy); @@ -1254,8 +1253,10 @@ fn installer_owner() -> Result<(u32, u64)> { } } let snapshot = Handle(snapshot); - let mut entry = PROCESSENTRY32W::default(); - entry.dwSize = std::mem::size_of::() as u32; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; let current = unsafe { GetCurrentProcessId() }; let mut more = unsafe { Process32FirstW(snapshot.0, &mut entry) }; while more != 0 { @@ -1468,14 +1469,13 @@ fn validate_uninstall_repair(root: &Path, profile: &str, intent: &UninstallInten } else { None }; - if let Some(current) = ¤t { - if !intent.generations.contains(¤t.generation) - && generation_for(root, profile, IdentityPurpose::Startup).is_err() - { - return Err(InstallError::Invalid( - "Windows missing generation is not in uninstall journal", - )); - } + if let Some(current) = ¤t + && !intent.generations.contains(¤t.generation) + && generation_for(root, profile, IdentityPurpose::Startup).is_err() + { + return Err(InstallError::Invalid( + "Windows missing generation is not in uninstall journal", + )); } let versions = root.join("versions"); if versions.try_exists()? { diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs index 82120ecd0..9967edc4a 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs @@ -215,10 +215,10 @@ fn ancestors(path: &Path, include_leaf: bool) -> io::Result> { for path in paths.iter().take(count) { let file = open_raw(path, FILE_READ_ATTRIBUTES, OPEN_EXISTING, null())?; let info = information(&file)?; - if path != paths.last().unwrap() || !include_leaf { - if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { - return Err(invalid_at("ancestor-not-directory")); - } + if (path != paths.last().unwrap() || !include_leaf) + && info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 + { + return Err(invalid_at("ancestor-not-directory")); } guards.push(file); } From 6f022048ff6785bc5e4b17d1fc6b5d25da21fc31 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:04:01 +0000 Subject: [PATCH 20/21] ci: run the Windows packaging rehearsal on GitHub's windows-2025 Run 5 built, packaged and verified the unsigned Dev installer on Blacksmith, then the installer smoke test (setup.exe /S into a temporary directory) failed there. Master's install smoke for the same installer runs on GitHub's windows-2025 (agent-desktop-build.yml verify-windows), which also passes the updater's native tests, so the PR rehearsal runs there too. It no longer restores master's Blacksmith-written release cache, which a GitHub runner cannot read. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 +++------------ docs/repository-workflows.md | 7 ++++--- scripts/ci/lanes.py | 9 +++++---- scripts/ci/test_contracts.py | 4 ++-- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bfbf3531..2f68f8cf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,8 +278,9 @@ jobs: name: agent-package-windows needs: plan if: fromJSON(needs.plan.outputs.lanes)['agent-package-windows'] - runs-on: ${{ vars.CI_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} - timeout-minutes: 60 + # GitHub's image, where master's install smoke runs (scripts/ci/lanes.py GITHUB_WINDOWS). + runs-on: windows-2025 + timeout-minutes: 90 defaults: run: shell: bash @@ -290,16 +291,6 @@ jobs: - uses: ./.github/actions/setup-agent-windows with: release: "true" - - name: Restore master's Agent release cache (read-only) - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - # Exactly agent-desktop-build.yml's builder key; it is the only writer. - key: ci-v1-agent-release-${{ runner.os }}-${{ hashFiles('apps/maple-agent/flake.lock', 'apps/maple-agent/Cargo.lock') }} - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - apps/maple-agent/target - name: Build, package and verify an unsigned Dev installer run: python3 scripts/ci/plan.py run agent-package-windows - name: Upload the unsigned package diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index b0a6b8a82..c7161fc91 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -212,9 +212,10 @@ attestation use GitHub-hosted runners. Windows signing and the Windows release stay on GitHub's `windows-2025`; unsigned Agent and Research Windows builds use `blacksmith-8vcpu-windows-2025`, with Rust, MSVC, Perl and the linker pinned by `.github/actions/setup-agent-windows` for the Agent. The Agent's native Windows -test lanes (`agent-windows-updater` and `agent-full-windows`) also run on GitHub's -`windows-2025`: Blacksmith's workspace inherits a foreign write ACE that the -updater's storage validation rejects. The Research Windows PR package has no +test lanes (`agent-windows-updater`, `agent-full-windows`) and the Windows +packaging rehearsal also run on GitHub's `windows-2025`: Blacksmith's workspace +inherits a foreign write ACE that the updater's storage validation rejects, and +master's install smoke runs on GitHub's image. The Research Windows PR package has no cache, because its signed build's cache lives in GitHub's store. Research desktop and iOS builds select Xcode 26.5 (`17F42`) from diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 79ceec9e9..47a71159d 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -34,8 +34,9 @@ class Runner: MACOS = Runner("blacksmith-6vcpu-macos-26", "CI_MACOS_RUNNER") WINDOWS = Runner("blacksmith-8vcpu-windows-2025", "CI_WINDOWS_RUNNER") GITHUB = Runner("ubuntu-latest") # free GitHub runner for lanes that compile nothing heavy -# The Agent updater's native Windows tests need GitHub's runner workspace: Blacksmith's -# inherits a foreign write ACE that the updater's storage validation correctly rejects. +# The Agent updater's native Windows tests and the installer smoke test need GitHub's +# runner: Blacksmith's workspace inherits a foreign write ACE the updater rejects, and +# master's Windows install smoke (agent-desktop-build.yml verify-windows) runs there too. GITHUB_WINDOWS = Runner("windows-2025") # .github/actions/setup-agent-windows pins this toolchain (Windows has no Nix); # agent-lint checks that the Agent flake's rustc still reports it. @@ -262,8 +263,8 @@ def deny(manifest: str, component: str) -> str: gate=False, **AGENT_PACKAGE_FLAKE), # Windows is on the Agent feed, so its own packaging inputs select it too. Lane("agent-package-windows", AGENT_WINDOWS_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, - job="agent-package-windows", runner=WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, - env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=60, gate=False), + job="agent-package-windows", runner=GITHUB_WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, + env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=90, gate=False), # Every root flake check: actionlint, workflow policy and contracts, CI script # tests, the release gates, and release metadata (read from the version files). Lane("repo", ( diff --git a/scripts/ci/test_contracts.py b/scripts/ci/test_contracts.py index e853e2753..549b4b5cf 100644 --- a/scripts/ci/test_contracts.py +++ b/scripts/ci/test_contracts.py @@ -43,9 +43,9 @@ def module(name: str, path: str): "maple-dev-xcode", "release-onnxruntime-ios-", "${{ runner.os }}-bun-", "${{ runner.os }}-gradle-", ) ONNX_CACHE = "apps/maple-research/frontend/src-tauri/onnxruntime-ios" -# The unsigned Agent builders that write the exact-key release cache; ci.yml's Windows packaging lane reads it. +# The unsigned Agent builders that write the exact-key release cache; no ci.yml lane reads it. AGENT_RELEASE_WRITERS = frozenset(("agent-desktop-build.yml", job) for job in ("macos-build", "windows-build", "linux")) -AGENT_RELEASE_READERS = frozenset({("ci.yml", "agent-package-windows")}) +AGENT_RELEASE_READERS: frozenset = frozenset() # The Linux and macOS packaging rehearsals build thin LTO into their own cache, saved # only by ci.yml's nightly master run. AGENT_PACKAGE_JOBS = frozenset(("ci.yml", f"agent-package-{os}") for os in ("linux", "macos")) From 3e744868c88cfacd275d3d5aa32c0348e4dd5759 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:47:37 +0000 Subject: [PATCH 21/21] ci: cold-run timeouts for Research Rust and Android; fast Windows rehearsal Run 6 cancelled research-rust at its 20-minute timeout: 14.7 minutes went to Cargo fetching Goose's git history before master's Research cache exists. research-rust and research-android get 45 minutes. The Windows packaging rehearsal uses the same thin-LTO profile as the Linux and macOS ones; master's signed Windows build keeps fat LTO. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- scripts/ci/lanes.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f68f8cf3..ea727600d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,7 +307,7 @@ jobs: needs: plan if: fromJSON(needs.plan.outputs.lanes)['research-rust'] runs-on: ${{ vars.CI_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} - timeout-minutes: 20 + timeout-minutes: 45 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: diff --git a/scripts/ci/lanes.py b/scripts/ci/lanes.py index 47a71159d..8e9ba0e0a 100644 --- a/scripts/ci/lanes.py +++ b/scripts/ci/lanes.py @@ -264,7 +264,7 @@ def deny(manifest: str, component: str) -> str: # Windows is on the Agent feed, so its own packaging inputs select it too. Lane("agent-package-windows", AGENT_WINDOWS_PACKAGING, labels=("ci:package", "ci:full"), events=PR_SIDE, job="agent-package-windows", runner=GITHUB_WINDOWS, workdir="apps/maple-agent", run=WINDOWS_PACKAGE, - env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),), timeout=90, gate=False), + env=(("RUSTFLAGS", "-D warnings -C target-feature=+crt-static"),) + FAST_RELEASE, timeout=90, gate=False), # Every root flake check: actionlint, workflow policy and contracts, CI script # tests, the release gates, and release metadata (read from the version files). Lane("repo", ( @@ -310,7 +310,7 @@ def deny(manifest: str, component: str) -> str: Lane("research-rust", (TAURI + "**", "!" + TAURI + "gen/**", "scripts/ci/rust.sh", "scripts/ci/verify-local-rust-deps.sh", "scripts/ci/_common.sh", "flake.nix", "flake.lock"), also=RUST_SDK + PROXY_RUNTIME + ("scripts/ci/verify-agent-rust-deps.py",), events=PR_SIDE, - job="research-rust", flake=".#ci", timeout=20, hook="research_rust", + job="research-rust", flake=".#ci", timeout=45, hook="research_rust", run="(cd apps/maple-research/frontend/src-tauri && cargo fmt --check)" " && ./scripts/ci/verify-local-rust-deps.sh && ./scripts/ci/rust.sh"), # Unsigned platform builds for PRs that touch native inputs, or by label; @@ -327,7 +327,7 @@ def deny(manifest: str, component: str) -> str: Lane("research-android", (TAURI + "gen/android/**", TAURI + "icons/android/**", TAURI + "capabilities/mobile-android.json", TAURI + "scripts/*android*", "scripts/ci/android-*.sh", "scripts/ci/_common.sh"), - labels=("ci:android", "ci:full"), flake=".#android", run="./scripts/ci/android-pr.sh", timeout=30, + labels=("ci:android", "ci:full"), flake=".#android", run="./scripts/ci/android-pr.sh", timeout=45, restore="research-rust", **PLATFORM), Lane("research-desktop-macos", DESKTOP + (TAURI + "tauri.macos.conf.json", TAURI + "Info.plist", TAURI + "Entitlements.plist", TAURI + "dmg/**", TAURI + "scripts/*macos*",