From 89a8dc64eb3d79f115e386db5bafd1f4bbf350dc Mon Sep 17 00:00:00 2001 From: rysweet Date: Tue, 28 Jul 2026 00:16:11 +0000 Subject: [PATCH 1/3] test(self-deploy): lock hooks-manifest git-tracked + drift-free invariant (#4914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-deploy checkout wedged every Overseer cycle because the tracked .github/hooks/amplihack-hooks.json drifted, aborting 'git checkout --detach' with 'your local changes would be overwritten'. The reset-before-checkout repair shipped in #4878; this adds the durable CI regression guard for the drift *source*: - hooks_manifest_and_scripts_are_git_tracked: the manifest (and its hook scripts) must stay git-tracked — untracking/gitignoring is NOT the fix (SR-P1-2, supply-chain integrity). - hooks_dir_has_no_untracked_drift_in_a_clean_checkout: a fresh checkout must leave .github/hooks/ pristine, so a reappearing unconditional manifest rewrite turns CI red instead of silently re-wedging self-deploy. Skips cleanly outside a git work tree (vendored/packaged builds). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/self_deploy_hooks_tracked_invariant.rs | 147 +++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/self_deploy_hooks_tracked_invariant.rs diff --git a/tests/self_deploy_hooks_tracked_invariant.rs b/tests/self_deploy_hooks_tracked_invariant.rs new file mode 100644 index 00000000..fe4c2b80 --- /dev/null +++ b/tests/self_deploy_hooks_tracked_invariant.rs @@ -0,0 +1,147 @@ +//! Repo-invariant regression guard for the self-deploy drift root cause (#4914). +//! +//! The self-deploy checkout failed **every** Overseer cycle for hours because a +//! tracked file in the managed clone — canonically +//! `.github/hooks/amplihack-hooks.json` — was left locally modified, so every +//! `git checkout --detach ` aborted with: +//! +//! ```text +//! error: Your local changes to the following files would be overwritten by +//! checkout: .github/hooks/amplihack-hooks.json +//! ``` +//! +//! The behavioural repair (a gated `reset_source_tree` scrub before checkout) +//! lives in `src/self_deploy/source_prep.rs` and is covered by +//! `src/self_deploy/tests_source_prep.rs`. This file locks the *other* half of +//! the fix as a durable CI invariant: the hooks manifest (and every script it +//! authorises) MUST stay **git-tracked** and MUST NOT drift in a clean checkout. +//! +//! Why an invariant test rather than another unit test: +//! +//! * **Untracking / gitignoring the manifest is NOT the fix.** The manifest +//! and hook scripts stay tracked for review and supply-chain integrity +//! (SR-P1-2). If a future change moves `.github/hooks/` out of version +//! control to "solve" the drift, this test goes red and names why. +//! * **A clean checkout must be drift-free.** The manifest writer is +//! write-if-changed: regenerating an identical manifest is a no-op, so a +//! fresh `git checkout` of `main` leaves `.github/hooks/` pristine. If the +//! manifest starts drifting again (an unconditional rewrite reappears), a +//! fresh CI checkout will show the file modified and this test goes red — +//! exactly the signal that was missing while #4914 burned Overseer cycles. +//! +//! See `docs/reference/self-deploy-drift-resilient-checkout.md`. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Repo root — `CARGO_MANIFEST_DIR` is the crate root, which is the git work +/// tree top level for this repo. +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +const HOOKS_DIR: &str = ".github/hooks"; +const MANIFEST_REL: &str = ".github/hooks/amplihack-hooks.json"; + +/// Run `git ` in the repo root, returning `(success, stdout)`. A spawn +/// failure (git absent) returns `None` so the caller can skip rather than fail +/// spuriously in a git-less packaging/vendored build. +fn git(root: &Path, args: &[&str]) -> Option<(bool, String)> { + let out = Command::new("git") + .current_dir(root) + .args(args) + .output() + .ok()?; + Some(( + out.status.success(), + String::from_utf8_lossy(&out.stdout).into_owned(), + )) +} + +/// Whether `root` is inside a real git work tree. Guards the whole suite so a +/// non-git build (packaged crate, offline vendored source) skips instead of +/// failing on the absence of `.git`. +fn is_git_work_tree(root: &Path) -> bool { + matches!( + git(root, &["rev-parse", "--is-inside-work-tree"]), + Some((true, ref s)) if s.trim() == "true" + ) +} + +#[test] +fn hooks_manifest_and_scripts_are_git_tracked() { + let root = repo_root(); + if !is_git_work_tree(&root) { + eprintln!( + "skipping hooks_manifest_and_scripts_are_git_tracked: {} is not a git work tree", + root.display() + ); + return; + } + + // The manifest must physically exist and be tracked. `git ls-files + // --error-unmatch` exits non-zero for an untracked/ignored/missing path, so + // this fails red the moment the manifest is untracked or gitignored — the + // NOT-the-fix path (SR-P1-2). + assert!( + root.join(MANIFEST_REL).exists(), + "{MANIFEST_REL} must exist on disk (the tracked self-deploy hooks manifest)" + ); + let (tracked, _) = git(&root, &["ls-files", "--error-unmatch", "--", MANIFEST_REL]) + .expect("git must be runnable in a work tree"); + assert!( + tracked, + "{MANIFEST_REL} must be git-tracked — untracking/gitignoring it is NOT the #4914 fix; \ + the manifest stays tracked for review and supply-chain integrity (SR-P1-2)" + ); + + // Every hook script the manifest authorises must be tracked too: at least + // the manifest plus one hook script under .github/hooks/ must be listed by + // `git ls-files`, so the directory can never silently become untracked. + let (ok, listed) = + git(&root, &["ls-files", "--", HOOKS_DIR]).expect("git ls-files must run in a work tree"); + assert!(ok, "git ls-files {HOOKS_DIR} must succeed"); + let tracked_files: Vec<&str> = listed.lines().filter(|l| !l.is_empty()).collect(); + assert!( + tracked_files.iter().any(|f| *f == MANIFEST_REL), + "the manifest must appear in `git ls-files {HOOKS_DIR}`" + ); + assert!( + tracked_files.len() >= 2, + "{HOOKS_DIR} must track the manifest AND its hook scripts (found only {tracked_files:?})" + ); +} + +#[test] +fn hooks_dir_has_no_untracked_drift_in_a_clean_checkout() { + let root = repo_root(); + if !is_git_work_tree(&root) { + eprintln!( + "skipping hooks_dir_has_no_untracked_drift_in_a_clean_checkout: {} is not a git work tree", + root.display() + ); + return; + } + + // A fresh checkout of `main` must leave .github/hooks/ pristine. Any + // untracked (and not gitignored) file here is a drift source that can + // re-wedge the self-deploy checkout, exactly the #4914 failure mode. + let (ok, others) = git( + &root, + &[ + "ls-files", + "--others", + "--exclude-standard", + "--", + HOOKS_DIR, + ], + ) + .expect("git ls-files --others must run in a work tree"); + assert!(ok, "git ls-files --others {HOOKS_DIR} must succeed"); + let untracked: Vec<&str> = others.lines().filter(|l| !l.is_empty()).collect(); + assert!( + untracked.is_empty(), + "no untracked files may live under {HOOKS_DIR} in a clean checkout (drift source for #4914); \ + found: {untracked:?}" + ); +} From 60da4d0c0a23e3d2a7f9645919917e56dcb30773 Mon Sep 17 00:00:00 2001 From: rysweet Date: Tue, 28 Jul 2026 00:33:05 +0000 Subject: [PATCH 2/3] docs(self-deploy): accurate drift-resilient checkout reference for #4914 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-deploy checkout wedged every Overseer cycle because a tracked-file drift (.github/hooks/amplihack-hooks.json, rewritten out-of-band each session) aborted 'git checkout --detach' with 'your local changes would be overwritten'. The code repair shipped in #4878 (reset+clean the disposable canonical checkout before checkout, fail-closed canonical-only gate) and its CI regression guard in #4914 (tests/self_deploy_hooks_tracked_invariant.rs). This adds the reference doc for that shipped fix. Zero-BS: the doc describes ONLY code that exists. It documents the shipped reset_source_tree scrub on both prepare paths, the double-canonicalized is_canonical_src_repo gate, the remove_stale_checkout clone-clean recovery, and the git-tracked / drift-free invariant test — and lists only the real tests in src/self_deploy/tests_source_prep.rs and the invariant target. No invented write_manifest_if_changed / checkout_detached_with_retry / gate_uncertain / dirty_retry symbols. Added to mkdocs nav for discoverability. Verified: cargo test --test docs_integrity (green), the 33 tests_source_prep tests (green), and the 2 hooks-tracked invariant tests (green). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/index.md | 1 + .../self-deploy-drift-resilient-checkout.md | 223 ++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 225 insertions(+) create mode 100644 docs/reference/self-deploy-drift-resilient-checkout.md diff --git a/docs/index.md b/docs/index.md index 8f5da04d..b29eca92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,6 +58,7 @@ Terminal sessions and repo-grounded engineer runs now bridge through one explici - [Reference: deterministic self-deploy canary — unit-test gate diagnostics and deterministic test invariants](./reference/deterministic-canary-unit-test-gate.md) — how the `RelaunchGate::UnitTest` canary became diagnosable (full stdout+stderr capture on failure, a sanitized/bounded failing-test-name `error`-level tracing event on `self_relaunch::gate`, fail-closed preserved) and how the load-induced canary redders were fixed **deterministically** — a dispatch peak-concurrency gauge, an `interruptible_sleep` injectable-clock seam, per-test install isolation, and cost-ledger writer durability — with no timing bound widened and no gate removed. Companion to the Overseer-level [red-canary diagnostics](./reference/overseer-deploy-canary-diagnostics.md). - [Reference: self-deploy canary — unit-test gate state-root isolation](./reference/canary-unit-test-gate-state-isolation.md) — how the `RelaunchGate::UnitTest` canary isolates its **STATE ROOT** into a fresh per-run `TempDir` (overriding `SIMARD_STATE_ROOT` / `SIMARD_HOME` and removing `SIMARD_MEMORY_SOCKET` **after** `scrub_gate_env`, last-write-wins) so the canary's `cargo test` never collides with the live daemon's lbug cognitive store + typed-OODA sqlite outcome store — the collision that reddened every self-deploy with `exit status: 101` at test `Drop`. **Fail-closed** (tempdir failure reddens the gate, never a fallback to the live root), absolute isolated root, scoped to the unit-test gate only (rpc-health still dials the live daemon). Supersedes PR #4632 (#4628). - [Reference: hermetic self-deploy canary — scoping the unit-test gate to a curated target](./reference/hermetic-canary-unit-test-scope.md) — how the `RelaunchGate::UnitTest` canary is scoped from the full, ever-shifting `cargo test` lib suite to a dedicated hermetic-by-construction integration target (`cargo test --test self_deploy_canary --features canary-tests`) via the off-by-default `canary-tests` Cargo feature + curated `tests/self_deploy_canary.rs` invariant suite, so stock builds/CI are unaffected and the canary compiles/runs one small target instead of the whole suite. **Fail-closed** preserved (failure/unbuildable-root ⇒ RED, never a live-store fallback), deny-by-default env scrub + #4628 state-root isolation intact, and `verify.yml` pins the exact target name so a rename reddens CI before the live canary. Companion to the deterministic (above) and state-root-isolation (above) canary references (#4622). +- [Drift-resilient self-deploy checkout reference](./reference/self-deploy-drift-resilient-checkout.md) — the #4914 root-cause repair that stops a tracked-file drift in the managed clone (canonically `.github/hooks/amplihack-hooks.json`) from wedging every self-deploy with `git checkout` "your local changes would be overwritten". Covers the fail-closed, canonical-only `reset_source_tree` scrub (`git reset --hard` + `git clean -fd`, never `-x`) that runs before `checkout --detach` on **both** the fetch and skip-fetch prepare paths, the `remove_stale_checkout` clone-clean recovery for an absent/invalid canonical checkout, and the `tests/self_deploy_hooks_tracked_invariant.rs` CI invariant that keeps the manifest git-tracked and a clean checkout drift-free. Extends the [source-prep reference](./reference/self-deploy-source-prep.md) (#4914). - [Concept: deploy-aware done-gate](./concepts/deploy-aware-done-gate.md) — why a goal is complete only with a merged PR, a closed issue, and (for self-affecting changes) a verified deploy; the gate that prevents evidence-free done-claims. See the [completion-evidence gate API](./reference/completion-evidence-gate-api.md) and the [rejected-completion runbook](./howto/diagnose-a-rejected-goal-completion.md). - [Concept: standing/perpetual goals are exempt from the no-progress hard-block](./concepts/perpetual-goal-no-progress-exemption.md) — why a bursty standing goal (the continuous self-research goal) must never be parked "needs human review" by the OODA no-progress safeguard; the runtime exemption plus the load-time self-heal that keep it continuous and self-sustaining without operator unblocking (#2589). See the [no-progress breaker API](./reference/no-progress-breaker-api.md) and the [unblock runbook](./howto/unblock-stuck-ooda-goals.md). - [Concept: the standing research goal never idles — an idle cycle is a fault](./concepts/research-goal-never-idle.md) — why Simard's standing cognition-research goal must produce a concrete NOVEL action **every** cycle (a new external source ingestion OR a new measurable experiment, dedup'd against recent directions) and why an idle cycle for THIS goal is a **fault** the daemon re-orients out of — not the benign perpetual-idle exemption other standing goals keep. Prompt-first (charter + never-idle directive), reinforced by a thin fail-closed breaker rail (`classify_standing_idle` → `research_idle_faults` → re-orient, never block) keyed on `is_standing_research_goal()` (#4399). See the [never-idle rail API](./reference/research-goal-never-idle-rail-api.md) and the [keep-the-research-goal-never-idle how-to](./howto/keep-the-research-goal-never-idle.md). diff --git a/docs/reference/self-deploy-drift-resilient-checkout.md b/docs/reference/self-deploy-drift-resilient-checkout.md new file mode 100644 index 00000000..ed9924cd --- /dev/null +++ b/docs/reference/self-deploy-drift-resilient-checkout.md @@ -0,0 +1,223 @@ +--- +title: Drift-resilient self-deploy checkout reference +description: > + Reference for the self-deploy source-prep hardening that stops a tracked-file + drift in the managed clone (canonically `.github/hooks/amplihack-hooks.json`) + from wedging every self-deploy with `git checkout` "your local changes would + be overwritten". Covers the fail-closed, canonical-only `reset_source_tree` + scrub that runs before every `checkout --detach`, the `remove_stale_checkout` + clone-clean recovery, and the git-tracked / drift-free CI invariant. +last_updated: 2026-07-28 +review_schedule: as-needed +owner: simard +doc_type: reference +status: implemented +related: + - ./self-deploy-source-prep.md + - ./self-deploy-api.md + - ../concepts/reconcile-and-self-deploy.md + - ../howto/converge-a-stuck-red-canary-self-deploy.md + - ../howto/verify-and-roll-back-a-self-deploy.md + - ../../src/self_deploy/source_prep.rs + - ../../src/self_deploy/tests_source_prep.rs + - ../../tests/self_deploy_hooks_tracked_invariant.rs +--- + +# Drift-resilient self-deploy checkout reference + +> **Status: implemented.** The canonical-only `reset_source_tree` scrub, its +> `is_canonical_src_repo` fail-closed gate, and the `remove_stale_checkout` +> clone-clean recovery live in +> [`src/self_deploy/source_prep.rs`](https://github.com/rysweet/Simard/blob/main/src/self_deploy/source_prep.rs). +> The git-tracked / drift-free invariant is asserted by +> [`tests/self_deploy_hooks_tracked_invariant.rs`](https://github.com/rysweet/Simard/blob/main/tests/self_deploy_hooks_tracked_invariant.rs), +> and the reset behaviour by +> [`src/self_deploy/tests_source_prep.rs`](https://github.com/rysweet/Simard/blob/main/src/self_deploy/tests_source_prep.rs). +> Everything here is **additive** and **non-breaking**: the clean-tree happy +> path (`git checkout --detach ` succeeds first try) is unchanged, no error +> variant is renamed, and the [source-prep contract](./self-deploy-source-prep.md) +> keeps its existing signatures. + +This reference extends the +[self-deploy source preparation reference](./self-deploy-source-prep.md). Read +that first: it defines the canonical repo-resolution precedence, the managed +disposable clone at `self_deploy_src_dir()`, and the warm +`self_deploy_target_dir()` this page hardens. + +## Why this exists (#4914) + +The self-deploy checkout failed **every** Overseer cycle for hours and the +running binary fell commits behind merged `main`, so `ProcessHealth` kept +firing `ooda.log tail contains recent ERROR line(s)`. The recurring error was: + +``` +error: Your local changes to the following files would be overwritten by checkout: + .github/hooks/amplihack-hooks.json +Please commit your changes or stash them before you switch branches. +``` + +A prior self-deploy left the disposable canonical checkout at +`self_deploy_src_dir()` with locally-modified **tracked** files (canonically +`.github/hooks/amplihack-hooks.json`, rewritten out-of-band by the amplihack +framework each session) plus untracked cruft. That drift aborts the next +`git checkout --detach `, so the running binary can never adopt the +shipped fix — a self-reinforcing wedge. + +The repair has two independent halves: + +1. **Recover at deploy time.** Reset + clean the disposable canonical checkout + *before* `checkout --detach`, strictly gated so only the throwaway clone can + ever be scrubbed (never a caller-supplied override or the operator cwd). +2. **Keep the drift source honest.** Assert in CI that `.github/hooks/` stays + git-tracked and that a clean checkout carries **no** untracked drift, so a + reappearing unconditional manifest rewrite reds `cargo test` instead of + silently re-wedging self-deploy. + +> **Escalation linkage.** This is the root-cause repair for the symptom that +> escalation PR #4914 tagged. Closing #4914 itself is an operational Overseer +> action, not part of this code change — see +> [operational autonomy model](../concepts/operational-autonomy-model.md). + +## Contents + +- [Design invariants](#design-invariants) +- [1. Reset-before-checkout on both prepare paths](#1-reset-before-checkout-on-both-prepare-paths) +- [2. Fail-closed canonical-only gate](#2-fail-closed-canonical-only-gate) +- [3. Clone-clean recovery for an absent/invalid checkout](#3-clone-clean-recovery-for-an-absentinvalid-checkout) +- [4. Git-tracked / drift-free CI invariant](#4-git-tracked--drift-free-ci-invariant) +- [Observability](#observability) +- [Error surface](#error-surface) +- [Security model](#security-model) +- [Testing](#testing) + +## Design invariants + +| Invariant | Guarantee | +| --- | --- | +| **Additive / non-breaking** | The clean-tree checkout path is unchanged; the scrub only discards drift a prior deploy left in the disposable clone. | +| **Fail-closed scrub** | `reset_source_tree` runs **only** when the double-canonicalized path is *exactly* `self_deploy_src_dir()`. Any canonicalize error or mismatch refuses the reset — it never guesses. | +| **`amplihack-hooks.json` stays tracked** | The manifest and hook scripts remain git-tracked for review and supply-chain integrity. The fix recovers from the drift; it does **not** untrack / gitignore the file. | +| **`clean -fd`, never `-x`** | Ignored files (`.env`, credential caches, warm build artifacts) are preserved. The warm `self_deploy_target_dir()` is a separate dir and is never scrubbed, so self-deploys stay incremental. | +| **Override / cwd never scrubbed** | A `SIMARD_SELF_DEPLOY_REPO` override resolves to a different canonical path, so a dirty override still fails loud at checkout — exactly as before. | +| **Structured observability only** | Detail is routed through `tracing` and `redact_credentials`. No `print!` / `println!` / `eprintln!` in the changed effectful paths. | + +## 1. Reset-before-checkout on both prepare paths + +Both source-prep entry points reset the disposable canonical checkout to a +pristine tree *after* fetch and *before* `checkout --detach`, so a wedged tree +left by a prior deploy cannot abort the checkout: + +- `GitSourcePreparer::prepare` — the effectful deploy path. The reset runs on + **both** the fetch branch and the skip-fetch (commit-already-present) branch, + so a wedged tree whose target is already local cannot slip past. +- `SelfDeploySourcePreparer::prepare_existing_repo` — the autonomous pre-swap + canary path that never clones from cwd. + +`reset_source_tree` runs `git reset --hard` (discard tracked-file edits at +`HEAD`) then `git clean -fd` (remove untracked files/dirs). Both go through the +env-scrubbed `git_capture` (no shell, argv-array exec) so a hostile ambient env +cannot hijack them. `clean` is **`-fd`, never `-x`** — ignored secrets, caches, +and the separate warm `self_deploy_target_dir()` survive. + +## 2. Fail-closed canonical-only gate + +The scrub is destructive, so it is gated twice: + +1. At each call site by `is_canonical_src_repo(&repo)`, which canonicalizes both + `repo` and `self_deploy_src_dir()` and returns `true` only on an exact match. + If either path cannot be canonicalized (e.g. the canonical checkout does not + exist because this is a `SIMARD_SELF_DEPLOY_REPO` override run) the answer is + `false` — **fail closed**: never reset a tree we cannot prove is the + throwaway checkout. +2. Inside `reset_source_tree` as defense in depth: it re-canonicalizes `repo` + and `self_deploy_src_dir()` and returns `CheckoutFailed { detail }` on any + canonicalize error or mismatch **without** running reset/clean. + +A dirty non-canonical override is therefore never scrubbed; it still fails +loudly at `checkout --detach`, preserving the pre-fix behaviour for overrides. + +## 3. Clone-clean recovery for an absent/invalid checkout + +When the canonical checkout is absent or is not a valid git work tree (a clone +killed mid-way, a leftover non-git directory, or a dangling symlink), the +preparer recovers to a **known-clean** tree by re-cloning from origin rather +than by resetting an unverified path: + +- `resolve_repo` returns early when `self_deploy_src_dir()` is already a valid + work tree; otherwise `clone_from_origin` is reached. +- `clone_from_origin` calls the idempotent `remove_stale_checkout` to tear down + the stale path (a missing path is a no-op, never an error), then + transport-validates the origin URL and `git clone`s a pristine tree. +- A freshly cloned tree is pristine by construction, so the fail-closed gate is + never asked to reset an unverified path. The warm `self_deploy_target_dir()` + is untouched, so builds stay incremental. + +## 4. Git-tracked / drift-free CI invariant + +Recovering at deploy time is necessary but not sufficient — the drift *source* +must stay honest. [`tests/self_deploy_hooks_tracked_invariant.rs`](https://github.com/rysweet/Simard/blob/main/tests/self_deploy_hooks_tracked_invariant.rs) +asserts, in a git work tree (it skips cleanly in vendored/packaged builds): + +- **Every file under `.github/hooks/` — including `amplihack-hooks.json` — is + git-tracked.** Untracking / gitignoring the manifest is explicitly **not** the + fix (supply-chain integrity; the manifest and its hook scripts stay + reviewable). +- **A fresh checkout leaves `.github/hooks/` pristine** (no untracked drift), so + a reappearing unconditional manifest rewrite turns CI red instead of silently + re-wedging self-deploy. + +## Observability + +The scrub logs a `tracing::debug!` on `self_deploy` before it runs +(`"resetting disposable self-deploy source checkout before checkout"`, with the +canonical path). Every failure — a refused gate, a failed reset/clean, or a +checkout that still fails — surfaces **loudly** as a `CheckoutFailed { detail }` +error up the deploy stack; the `detail` is routed through `redact_credentials` +so no tokens, env dumps, or credentialed URLs are emitted. There is no silent +degrade: a scrub the gate refuses aborts the deploy rather than resetting an +unverified tree. + +## Error surface + +No new `SafeUpdateError` variants. Failures reuse the existing surface from the +[source-prep reference](./self-deploy-source-prep.md): + +| Variant | When | +| --- | --- | +| `CheckoutFailed { detail }` | SHA validation failed, the gated reset was refused (non-canonical / un-canonicalizable path) or its `reset`/`clean` failed, or `checkout --detach` failed. `detail` is redacted. | +| `SourceResolveFailed { detail }` | The canonical repo could not be resolved and the clone-clean re-clone also failed. | + +## Security model + +| Control | Enforcement | +| --- | --- | +| **Reset only the disposable managed clone** | Double-canonicalized equality with `self_deploy_src_dir()` at the call site (`is_canonical_src_repo`) and again inside `reset_source_tree`; any error/mismatch refuses the reset (fail-closed). A `SIMARD_SELF_DEPLOY_REPO` override resolves to a different canonical path, so its tree is **never** reset — a dirty override still fails loud on checkout. | +| **Keep `.github/hooks/` tracked** | The git-tracked / no-drift invariant test makes untracking or a reappearing unconditional rewrite red. | +| **`clean -fd`, never `-x`** | Ignored secrets/caches and the warm target dir survive the scrub. | +| **No shell, no injection** | Both git invocations use the env-scrubbed argv-array `git_capture`; the validated full SHA is pinned to `checkout --detach` so a skipped fetch can never check out a different tree (SEC-I2). | +| **Forward-only swap intact** | The recovery paths do not bypass the ancestry oracle or the `self_deploy_canary` forward-only swap gates. | + +## Testing + +Covered by +[`src/self_deploy/tests_source_prep.rs`](https://github.com/rysweet/Simard/blob/main/src/self_deploy/tests_source_prep.rs) +and +[`tests/self_deploy_hooks_tracked_invariant.rs`](https://github.com/rysweet/Simard/blob/main/tests/self_deploy_hooks_tracked_invariant.rs): + +| Test | Asserts | +| --- | --- | +| `prepare_resets_dirty_canonical_checkout_before_checking_out_merged_head` | A managed clone with a modified tracked `amplihack-hooks.json` + an untracked stray checks out the merged head cleanly (reset + `clean -fd`). | +| `prepare_resets_before_checkout_even_on_the_skip_fetch_present_commit_branch` | The reset guards the skip-fetch (commit-already-present) branch too — proven with origin destroyed so no fetch is possible. | +| `resetting_the_source_checkout_never_touches_the_warm_target_dir` | The warm `self_deploy_target_dir()` sentinel survives the source reset. | +| `dirty_non_canonical_override_is_not_reset_and_still_fails_loud_at_checkout` | A dirty `SIMARD_SELF_DEPLOY_REPO` override is **not** scrubbed and fails loud at checkout; its local edit + untracked stray survive. | +| `hooks_manifest_and_scripts_are_git_tracked` | Every file under `.github/hooks/` — including `amplihack-hooks.json` — is git-tracked. | +| `hooks_dir_has_no_untracked_drift_in_a_clean_checkout` | A fresh checkout leaves `.github/hooks/` pristine (CI drift ⇒ red). | + +Run: + +```bash +cargo test -p simard self_deploy::tests_source_prep +cargo test --test self_deploy_hooks_tracked_invariant +# canary gate must stay green: +cargo test --test self_deploy_canary --features canary-tests +``` diff --git a/mkdocs.yml b/mkdocs.yml index 625e01eb..965a4346 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -345,6 +345,7 @@ nav: - Multi-Binary Self-Update: reference/multi-binary-self-update.md - Self-Deploy API: reference/self-deploy-api.md - Self-Deploy Source Prep & Warm Target Dir: reference/self-deploy-source-prep.md + - Drift-Resilient Self-Deploy Checkout: reference/self-deploy-drift-resilient-checkout.md - Overseer Deploy Red-Canary Diagnostics: reference/overseer-deploy-canary-diagnostics.md - Canary Gate Isolation & Self-Deploy Convergence: reference/canary-gate-convergence.md - RPC-Health Readiness & Stats Snapshot: reference/rpc-health-stats-snapshot-readiness.md From fba8e4415865c95bc00a5ce39b86abeaa09dfa43 Mon Sep 17 00:00:00 2001 From: rysweet Date: Tue, 28 Jul 2026 00:46:43 +0000 Subject: [PATCH 3/3] fix(self-deploy): satisfy clippy::manual_contains in hooks-tracked invariant (#4914) CI's `clippy --all-targets --all-features` reds on `tracked_files.iter().any(|f| *f == MANIFEST_REL)` (manual_contains); the commit-stage `clippy --no-deps` (lib only) did not cover the test target. Use the idiomatic `Vec::contains` so the branch's #4914 regression guard is clippy-clean under the full CI gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/self_deploy_hooks_tracked_invariant.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/self_deploy_hooks_tracked_invariant.rs b/tests/self_deploy_hooks_tracked_invariant.rs index fe4c2b80..00873267 100644 --- a/tests/self_deploy_hooks_tracked_invariant.rs +++ b/tests/self_deploy_hooks_tracked_invariant.rs @@ -103,7 +103,7 @@ fn hooks_manifest_and_scripts_are_git_tracked() { assert!(ok, "git ls-files {HOOKS_DIR} must succeed"); let tracked_files: Vec<&str> = listed.lines().filter(|l| !l.is_empty()).collect(); assert!( - tracked_files.iter().any(|f| *f == MANIFEST_REL), + tracked_files.contains(&MANIFEST_REL), "the manifest must appear in `git ls-files {HOOKS_DIR}`" ); assert!(