From 5b58ee5f5db56d3626b65d6496395ae9a67badae Mon Sep 17 00:00:00 2001 From: Alex Zuttre Date: Sun, 11 Oct 2026 11:30:32 -0500 Subject: [PATCH 1/2] [ci] Enforce same-change machine recovery checks --- .github/workflows/ci.yml | 44 +++++ .machine-recovery/README.md | 41 +++++ .../ci_change_scope.cpython-314.pyc | Bin 0 -> 7649 bytes .../changes/2026-10-11-universal-checks.json | 9 ++ .machine-recovery/check.py | 152 ++++++++++++++++++ .machine-recovery/ci_change_scope.py | 147 +++++++++++++++++ .machine-recovery/policy.json | 8 + .machine-recovery/test_check.py | 141 ++++++++++++++++ .machine-recovery/test_ci_change_scope.py | 15 ++ README.md | 6 + 10 files changed, 563 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .machine-recovery/README.md create mode 100644 .machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc create mode 100644 .machine-recovery/changes/2026-10-11-universal-checks.json create mode 100644 .machine-recovery/check.py create mode 100644 .machine-recovery/ci_change_scope.py create mode 100644 .machine-recovery/policy.json create mode 100644 .machine-recovery/test_check.py create mode 100644 .machine-recovery/test_ci_change_scope.py create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e166d17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI +on: + workflow_dispatch: + pull_request: + types: [opened, reopened, ready_for_review] +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + ci-gate: + name: ci-gate + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Validate machine recovery + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RECOVERY_REPOSITORY: ${{ github.repository }} + run: | + python3 .machine-recovery/check.py --event-name "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" --repository "$RECOVERY_REPOSITORY" + python3 .machine-recovery/test_check.py + + - name: Classify changed paths + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 .machine-recovery/ci_change_scope.py --event-name "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" + - name: Validate documentation-only diff + if: steps.scope.outputs.docs_only == 'true' + run: git diff --check "${{ steps.scope.outputs.base_sha }}" "${{ steps.scope.outputs.head_sha }}" -- + - name: Validate full policy contracts + if: steps.scope.outputs.docs_only != 'true' + run: python3 .machine-recovery/test_ci_change_scope.py diff --git a/.machine-recovery/README.md b/.machine-recovery/README.md new file mode 100644 index 0000000..b21e768 --- /dev/null +++ b/.machine-recovery/README.md @@ -0,0 +1,41 @@ +# Recovery maintenance + +Every source change must assess machine replacement impact: installation, runtime +dependencies, credentials, durable state, backups, restore, scheduling and host +placement. Update the authoritative owner procedure in the same change when any +requirement changes. DSM manual changes are outside this gate's scope. + +The offline CI check requires one exact-change disposition under +`.machine-recovery/changes/`. Run: + +```sh +python3 .machine-recovery/check.py --base origin/master --head HEAD --digest +python3 .machine-recovery/test_check.py +python3 .machine-recovery/check.py --base origin/master --head HEAD +``` + +Copy `diff_sha256` into a new JSON record with `repository` set to +`azuttre/try_git`, `disposition` set to `updated` or `not-applicable`, +and a concrete `reason` explaining replacement impact. For `updated`, list changed +authoritative Markdown paths in `owned_records`. Regenerate the digest after every +source edit; only changed committed dispositions count. `--staged --base BASE` +checks staged bytes without accepting an unstaged correction. + +A recovery-affecting update also requires the repository owner's consolidated +machine catalog/runbook update to land before merge. The owner merge guard verifies +that catalog's `repo-try-git` component has `last_change["try_git"]` +equal to this disposition digest. `not-applicable` needs a substantive explanation, +reviewed for accuracy, and does not require a meaningless central prose edit. + +CI runs this stdlib-only check in both documentation and full lanes; no credentials, +private catalog download, live services or DSM inspection. Manual/scheduled checks +validate policy/reference completeness without claiming an exact-change disposition. +The retained generated-data exclusions, if any, are in `policy.json`; its baseline +version governs the diff so a change cannot exempt itself. Configuration changes +require coordinated owner review and policy pin maintenance. + +## Setup and source ownership + +Python 3.10+ and Git suffice. No application runtime or extra package install is +required for this check. Existing owner docs below retain installation, protected +state custody and restore authority. Keep credentials and private evidence outside Git. diff --git a/.machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc b/.machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c788f2407b9dfa91db422919570af8a4f22135d0 GIT binary patch literal 7649 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 literal 0 HcmV?d00001 diff --git a/.machine-recovery/changes/2026-10-11-universal-checks.json b/.machine-recovery/changes/2026-10-11-universal-checks.json new file mode 100644 index 0000000..12897e3 --- /dev/null +++ b/.machine-recovery/changes/2026-10-11-universal-checks.json @@ -0,0 +1,9 @@ +{ + "repository": "azuttre/try_git", + "diff_sha256": "a6f23c9c05ed355a6b8c8ae381989226bdc0ee9b8cbfa77b10a6eadd9ed43f67", + "disposition": "updated", + "reason": "Install and recovery maintenance now require an exact-change disposition, unconditional CI validation and matching consolidated owner documentation before merge.", + "owned_records": [ + ".machine-recovery/README.md" + ] +} diff --git a/.machine-recovery/check.py b/.machine-recovery/check.py new file mode 100644 index 0000000..ef32bd5 --- /dev/null +++ b/.machine-recovery/check.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Portable, offline same-change recovery gate. Canonical source: personal-context. + +Vendored byte-for-byte into .machine-recovery/check.py in owned repositories. +No network, credentials, application imports or live configuration inspection. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys + +POLICY = '.machine-recovery/policy.json' +CHANGES = '.machine-recovery/changes/' + + +def git(root, *args): + return subprocess.check_output(['git', *args], cwd=root, stderr=subprocess.PIPE) + + +def read(root, ref, path): + return git(root, 'show', f'{ref}:{path}') + + +def tree(root, ref): + if ref == '': + rows = git(root, 'ls-files', '--stage', '-z').split(b'\0') + result = {} + for row in filter(None, rows): + metadata, path = row.split(b'\t', 1) + mode, sha, stage = metadata.decode().split() + if stage != '0': + raise ValueError('unmerged index cannot establish recovery evidence') + result[path.decode()] = [mode, sha] + return result + result = {} + for row in filter(None, git(root, 'ls-tree', '-r', '-z', ref).split(b'\0')): + metadata, path = row.split(b'\t', 1) + mode, kind, sha = metadata.decode().split() + result[path.decode()] = [mode, sha] + return result + + +def validate_policy(policy, paths): + if policy.get('schema_version') != 1 or not isinstance(policy.get('repository'), str): + raise ValueError('invalid recovery policy') + records = policy.get('owned_records') + if not isinstance(records, list) or not records: + raise ValueError('policy must name authoritative recovery records') + for path in records: + if not isinstance(path, str) or not path.endswith('.md') or path not in paths: + raise ValueError(f'missing authoritative recovery record: {path}') + for key in ('generated_paths', 'evidence_paths'): + excluded = policy.get(key, []) + if not isinstance(excluded, list) or any(not isinstance(x, str) or not x for x in excluded): + raise ValueError(f'invalid {key.replace("_", "-")} policy') + + +def changed_paths(before, after): + return sorted(p for p in before.keys() | after.keys() if before.get(p) != after.get(p)) + + +def change_digest(before, after, policy): + # Blob IDs, modes and paths are reproducible across diff/CRLF/rename settings. + paths = [p for p in changed_paths(before, after) + if not p.startswith(CHANGES) + and not any(p.startswith(x) for x in + policy.get('generated_paths', []) + policy.get('evidence_paths', []))] + payload = [[p, before.get(p), after.get(p)] for p in paths] + digest = hashlib.sha256(json.dumps(payload, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest() + return paths, digest + + +def validate_receipt(policy, paths, digest, receipts, changed): + if not paths: + return None + matches = [r for r in receipts if r.get('repository') == policy['repository'] + and r.get('diff_sha256') == digest] + if len(matches) != 1: + raise ValueError(f'exact change requires one recovery disposition: {digest}') + receipt = matches[0] + if not isinstance(receipt.get('reason'), str) or len(receipt['reason'].strip()) < 30: + raise ValueError('disposition requires a concrete recovery-impact explanation') + mode = receipt.get('disposition') + if mode not in {'updated', 'not-applicable'}: + raise ValueError('disposition must be updated or not-applicable') + if mode == 'updated': + records = receipt.get('owned_records', []) + if not records or any(p not in policy['owned_records'] or p not in changed for p in records): + raise ValueError('updated requires changed authoritative recovery documentation') + return receipt + + +def inspect(root, base, head='HEAD', staged=False): + merge_base = git(root, 'merge-base', base, 'HEAD' if staged else head).decode().strip() + ref = '' if staged else head + before, after = tree(root, merge_base), tree(root, ref) + policy = json.loads(read(root, ref, POLICY)) + validate_policy(policy, after) + # Existing policy governs this diff; a PR cannot exempt itself by widening exclusions. + baseline = json.loads(read(root, merge_base, POLICY)) if POLICY in before else policy + validate_policy(baseline, before if POLICY in before else after) + if policy['repository'] != baseline['repository']: + raise ValueError('repository identity changed') + paths, digest = change_digest(before, after, baseline) + changed = changed_paths(before, after) + receipts = [json.loads(read(root, ref, p)) for p in changed + if p.startswith(CHANGES) and p.endswith('.json') and p in after] + return policy, paths, digest, receipts, set(changed), baseline + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo', type=Path, default=Path.cwd()) + parser.add_argument('--base') + parser.add_argument('--head', default='HEAD') + parser.add_argument('--staged', action='store_true') + parser.add_argument('--digest', action='store_true') + parser.add_argument('--event-name') + parser.add_argument('--repository') + args = parser.parse_args(argv) + root = args.repo.resolve() + if args.event_name in {'workflow_dispatch', 'schedule'}: + policy = json.loads(read(root, args.head, POLICY)) + validate_policy(policy, tree(root, args.head)) + print('Recovery policy and owner references passed; no change disposition claimed.') + return 0 + base = args.base + if args.staged and not base: + base = 'origin/' + git(root, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD').decode().strip().split('/', 1)[1] + if not base or set(base) == {'0'}: + raise ValueError('automatic change validation requires a nonempty base commit') + policy, paths, digest, receipts, changed, baseline = inspect(root, base, args.head, args.staged) + if args.repository and policy['repository'] != args.repository: + raise ValueError('workflow repository does not match recovery policy') + if args.digest: + print(json.dumps({'repository':policy['repository'], 'diff_sha256':digest, 'paths':paths}, indent=2)) + return 0 + validate_receipt(policy, paths, digest, receipts, changed) + print('Exact-change recovery disposition and authoritative records passed.') + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as exc: + print(f'machine-recovery: failed closed: {exc}', file=sys.stderr) + raise SystemExit(1) diff --git a/.machine-recovery/ci_change_scope.py b/.machine-recovery/ci_change_scope.py new file mode 100644 index 0000000..385d88d --- /dev/null +++ b/.machine-recovery/ci_change_scope.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Classify a Git diff for the repository's CI workflow.""" + +from __future__ import annotations + +import argparse +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +AUTOMATIC_EVENTS = frozenset({"pull_request", "push"}) +MARKDOWN_SUFFIXES = frozenset({".md", ".markdown"}) +SAFE_DOCS_SUFFIXES = MARKDOWN_SUFFIXES | frozenset( + { + ".adoc", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".pdf", + ".png", + ".rst", + ".svg", + ".txt", + ".webp", + } +) + + +@dataclass(frozen=True) +class Classification: + docs_only: bool + paths: tuple[str, ...] + base_sha: str = "" + head_sha: str = "" + + +def resolve_revision(repo: Path, revision: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", f"{revision}^{{commit}}"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def changed_paths(repo: Path, base: str, head: str) -> list[str]: + result = subprocess.run( + [ + "git", + "-C", + str(repo), + "diff", + "--name-only", + "-z", + "--no-renames", + base, + head, + "--", + ], + check=True, + capture_output=True, + ) + return [ + raw.decode("utf-8", errors="strict") + for raw in result.stdout.split(b"\0") + if raw + ] + + +def is_docs_path(path: str) -> bool: + candidate = Path(path) + suffix = candidate.suffix.lower() + if suffix in MARKDOWN_SUFFIXES: + return True + return path.startswith("docs/") and suffix in SAFE_DOCS_SUFFIXES + + +def is_docs_only(paths: Iterable[str]) -> bool: + changed = tuple(paths) + return bool(changed) and all(is_docs_path(path) for path in changed) + + +def classify( + repo: Path, + *, + event_name: str, + base: str = "", + head: str = "", +) -> Classification: + if event_name not in AUTOMATIC_EVENTS: + return Classification(docs_only=False, paths=()) + if not base or not head: + raise ValueError(f"{event_name} classification requires base and head revisions") + + base_sha = resolve_revision(repo, base) + head_sha = resolve_revision(repo, head) + paths = tuple(changed_paths(repo, base_sha, head_sha)) + return Classification( + docs_only=is_docs_only(paths), + paths=paths, + base_sha=base_sha, + head_sha=head_sha, + ) + + +def write_github_output(destination: Path, result: Classification) -> None: + with destination.open("a", encoding="utf-8") as handle: + handle.write(f"docs_only={'true' if result.docs_only else 'false'}\n") + handle.write(f"change_count={len(result.paths)}\n") + handle.write(f"base_sha={result.base_sha}\n") + handle.write(f"head_sha={result.head_sha}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--event-name", required=True) + parser.add_argument("--base", default="") + parser.add_argument("--head", default="") + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--github-output", type=Path) + args = parser.parse_args() + + result = classify( + args.repo.resolve(), + event_name=args.event_name, + base=args.base, + head=args.head, + ) + print( + f"change scope: event={args.event_name} " + f"docs_only={'true' if result.docs_only else 'false'} " + f"changed_paths={len(result.paths)}" + ) + for path in result.paths: + print(path) + + if args.github_output is not None: + write_github_output(args.github_output, result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.machine-recovery/policy.json b/.machine-recovery/policy.json new file mode 100644 index 0000000..7aad0bc --- /dev/null +++ b/.machine-recovery/policy.json @@ -0,0 +1,8 @@ +{ + "schema_version": 1, + "repository": "azuttre/try_git", + "owned_records": [ + ".machine-recovery/README.md" + ], + "generated_paths": [] +} diff --git a/.machine-recovery/test_check.py b/.machine-recovery/test_check.py new file mode 100644 index 0000000..9dbe8ba --- /dev/null +++ b/.machine-recovery/test_check.py @@ -0,0 +1,141 @@ +"""Hermetic contract tests for the portable recovery gate (stdlib only).""" +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +_folder = Path(__file__).resolve().parent +_source = _folder / ('check.py' if (_folder / 'check.py').exists() else 'machine_recovery_gate.py') +_spec = importlib.util.spec_from_file_location('portable_recovery_gate', _source) +gate = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gate) + + +class RecoveryGateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.git('init', '-q') + self.git('config', 'user.name', 'fixture') + self.git('config', 'user.email', 'fixture@example.invalid') + self.write('restore.md', 'baseline restore\n') + self.write(gate.POLICY, json.dumps({'schema_version':1, 'repository':'owner/repo', + 'owned_records':['restore.md'], 'generated_paths':['data/']})) + self.git('add', '.'); self.git('commit', '-qm', 'baseline') + self.base = self.git('rev-parse', 'HEAD').strip() + + def git(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root).decode() + + def write(self, path, text): + file = self.root / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(text) + + def change(self, path='code.py'): + self.write(path, 'replacement requirement\n') + self.git('add', '.'); self.git('commit', '-qm', 'change') + + def receipt(self, mode='not-applicable', reason=None): + policy, paths, digest, _, changed, baseline = gate.inspect(self.root, self.base) + value = {'repository':'owner/repo', 'diff_sha256':digest, 'disposition':mode, + 'reason':reason or 'Only presentation changed; installation, state and recovery remain identical.', + 'owned_records':['restore.md'] if mode == 'updated' else []} + self.write(gate.CHANGES + 'change.json', json.dumps(value)) + self.git('add', '.'); self.git('commit', '-qm', 'disposition') + return value + + def check(self): + policy, paths, digest, receipts, changed, baseline = gate.inspect(self.root, self.base) + return gate.validate_receipt(policy, paths, digest, receipts, changed) + + def test_source_change_without_disposition_blocks(self): + self.change() + with self.assertRaisesRegex(ValueError, 'one recovery disposition'): + self.check() + + def test_exact_not_applicable_and_receipt_self_exclusion(self): + self.change(); value = self.receipt() + self.assertEqual(self.check(), value) + + def test_later_source_change_invalidates_receipt(self): + self.change(); self.receipt(); self.change('more.py') + with self.assertRaises(ValueError): self.check() + + def test_updated_requires_changed_authoritative_record(self): + self.change(); self.receipt('updated') + with self.assertRaisesRegex(ValueError, 'authoritative'): self.check() + + def test_updated_with_owned_record_passes(self): + self.change('restore.md'); self.receipt('updated') + self.assertEqual(self.check()['disposition'], 'updated') + + def test_vague_waiver_and_duplicate_receipt_block(self): + self.change(); self.receipt(reason='N/A') + with self.assertRaisesRegex(ValueError, 'explanation'): self.check() + self.receipt() + self.write(gate.CHANGES + 'duplicate.json', (self.root / (gate.CHANGES + 'change.json')).read_text()) + self.git('add', '.'); self.git('commit', '-qm', 'duplicate') + with self.assertRaisesRegex(ValueError, 'one recovery disposition'): self.check() + + def test_generated_data_only_needs_no_disposition(self): + self.change('data/history.csv') + self.assertIsNone(self.check()) + + def test_exemption_widening_cannot_hide_own_change(self): + self.change() + p = json.loads((self.root / gate.POLICY).read_text()) + p['generated_paths'] = [''] + self.write(gate.POLICY, json.dumps(p)); self.git('add', '.'); self.git('commit', '-qm', 'invalid policy') + with self.assertRaisesRegex(ValueError, 'generated-path'): self.check() + p['generated_paths'] = ['code.py'] + self.write(gate.POLICY, json.dumps(p)); self.git('add', '.'); self.git('commit', '-qm', 'widen') + self.assertIn('code.py', gate.inspect(self.root, self.base)[1]) + + def test_staged_ignores_unstaged_receipt_and_source(self): + self.change(); self.receipt() + expected = gate.inspect(self.root, self.base) + self.write('code.py', 'unstaged\n') + self.write(gate.CHANGES + 'change.json', '{}') + observed = gate.inspect(self.root, self.base, staged=True) + self.assertEqual(expected[1:4], observed[1:4]) + + def test_deleted_owner_record_and_policy_block(self): + (self.root / 'restore.md').unlink(); self.git('add', '-u'); self.git('commit', '-qm', 'delete owner') + with self.assertRaisesRegex(ValueError, 'missing authoritative'): self.check() + (self.root / gate.POLICY).unlink(); self.git('add', '-u'); self.git('commit', '-qm', 'delete policy') + with self.assertRaises(subprocess.CalledProcessError): self.check() + + def test_digest_independent_of_local_diff_configuration(self): + self.change(); expected = gate.inspect(self.root, self.base)[1:3] + for key, value in [('diff.context','100'),('core.abbrev','7'),('diff.renames','true')]: + self.git('config',key,value) + self.assertEqual(gate.inspect(self.root,self.base)[1:3],expected) + + def test_rename_and_mode_change_are_visible(self): + self.git('mv','restore.md','renamed.md') + self.git('update-index','--chmod=+x','renamed.md') + before, after = gate.tree(self.root,self.base),gate.tree(self.root,'') + paths, _ = gate.change_digest(before,after,{'generated_paths':[]}) + self.assertEqual(paths,['renamed.md','restore.md']) + + def test_bootstrap_and_composed_existing_evidence_have_no_cycle(self): + # Start from the commit before this policy existed, as rollout PRs do. + self.git('rm',gate.POLICY);self.git('commit','-qm','remove for fixture') + base=self.git('rev-parse','HEAD').strip() + p={'schema_version':1,'repository':'owner/repo','owned_records':['new-guide.md'], + 'generated_paths':[],'evidence_paths':['other-assessment/']} + self.write(gate.POLICY,json.dumps(p));self.write('new-guide.md','new restore guide\n') + self.write('other-assessment/record.json','first attestation\n') + self.git('add','.');self.git('commit','-qm','bootstrap') + first=gate.inspect(self.root,base)[1:3] + self.write('other-assessment/record.json','bound final attestation\n') + self.git('add','.');self.git('commit','-qm','assessment') + self.assertEqual(gate.inspect(self.root,base)[1:3],first) + + +if __name__ == '__main__': + unittest.main() diff --git a/.machine-recovery/test_ci_change_scope.py b/.machine-recovery/test_ci_change_scope.py new file mode 100644 index 0000000..e8006a3 --- /dev/null +++ b/.machine-recovery/test_ci_change_scope.py @@ -0,0 +1,15 @@ +import importlib.util +from pathlib import Path +import unittest +source = Path(__file__).with_name("ci_change_scope.py") +spec = importlib.util.spec_from_file_location("scope", source) +module = importlib.util.module_from_spec(spec) +import sys +sys.modules[spec.name] = module +spec.loader.exec_module(module) +class ScopeContract(unittest.TestCase): + def test_conservative_paths(self): + self.assertTrue(module.is_docs_only(["README.md"])) + for paths in [[], ["src/main.py"], ["docs/schema.json"], [".github/workflows/ci.yml"], ["README.md", "test.py"]]: + self.assertFalse(module.is_docs_only(paths)) +if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c178016 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# try_git + +## Recovery maintenance + +Follow [.machine-recovery/README.md](.machine-recovery/README.md) for the +required same-change recovery disposition and CI checks. From eb7c3d53932d5c43290cf98cef284d4dfc8cc473 Mon Sep 17 00:00:00 2001 From: Alex Zuttre Date: Sun, 11 Oct 2026 11:49:27 -0500 Subject: [PATCH 2/2] [ci] Enforce recovery evidence on manual branch runs --- .github/workflows/ci.yml | 2 +- .gitignore | 3 +++ .../ci_change_scope.cpython-314.pyc | Bin 7649 -> 0 bytes .../changes/2026-10-11-universal-checks.json | 2 +- .machine-recovery/check.py | 7 +----- .machine-recovery/test_check.py | 22 ++++++++++++++++++ 6 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 .gitignore delete mode 100644 .machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e166d17..76bca25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Validate machine recovery env: EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || format('origin/{0}', github.event.repository.default_branch) }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} RECOVERY_REPOSITORY: ${{ github.repository }} run: | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..796e191 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +__pycache__/ +*.py[cod] diff --git a/.machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc b/.machine-recovery/__pycache__/ci_change_scope.cpython-314.pyc deleted file mode 100644 index c788f2407b9dfa91db422919570af8a4f22135d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7649 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 diff --git a/.machine-recovery/changes/2026-10-11-universal-checks.json b/.machine-recovery/changes/2026-10-11-universal-checks.json index 12897e3..22a32d2 100644 --- a/.machine-recovery/changes/2026-10-11-universal-checks.json +++ b/.machine-recovery/changes/2026-10-11-universal-checks.json @@ -1,6 +1,6 @@ { "repository": "azuttre/try_git", - "diff_sha256": "a6f23c9c05ed355a6b8c8ae381989226bdc0ee9b8cbfa77b10a6eadd9ed43f67", + "diff_sha256": "a888e9c75fe9013abbbbd92069eaaa276d43270e4d53e316e7e8006bd527f006", "disposition": "updated", "reason": "Install and recovery maintenance now require an exact-change disposition, unconditional CI validation and matching consolidated owner documentation before merge.", "owned_records": [ diff --git a/.machine-recovery/check.py b/.machine-recovery/check.py index ef32bd5..ba90b4a 100644 --- a/.machine-recovery/check.py +++ b/.machine-recovery/check.py @@ -66,7 +66,7 @@ def changed_paths(before, after): def change_digest(before, after, policy): # Blob IDs, modes and paths are reproducible across diff/CRLF/rename settings. paths = [p for p in changed_paths(before, after) - if not p.startswith(CHANGES) + if not (p.startswith(CHANGES) and p.endswith('.json')) and not any(p.startswith(x) for x in policy.get('generated_paths', []) + policy.get('evidence_paths', []))] payload = [[p, before.get(p), after.get(p)] for p in paths] @@ -123,11 +123,6 @@ def main(argv=None): parser.add_argument('--repository') args = parser.parse_args(argv) root = args.repo.resolve() - if args.event_name in {'workflow_dispatch', 'schedule'}: - policy = json.loads(read(root, args.head, POLICY)) - validate_policy(policy, tree(root, args.head)) - print('Recovery policy and owner references passed; no change disposition claimed.') - return 0 base = args.base if args.staged and not base: base = 'origin/' + git(root, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD').decode().strip().split('/', 1)[1] diff --git a/.machine-recovery/test_check.py b/.machine-recovery/test_check.py index 9dbe8ba..a77b443 100644 --- a/.machine-recovery/test_check.py +++ b/.machine-recovery/test_check.py @@ -52,6 +52,23 @@ def check(self): policy, paths, digest, receipts, changed, baseline = gate.inspect(self.root, self.base) return gate.validate_receipt(policy, paths, digest, receipts, changed) + def test_manual_branch_change_requires_exact_disposition(self): + self.change() + with self.assertRaisesRegex(ValueError, 'one recovery disposition'): + gate.main(['--repo', str(self.root), '--base', self.base, + '--event-name', 'workflow_dispatch', '--repository', 'owner/repo']) + self.receipt() + self.assertEqual(gate.main(['--repo', str(self.root), '--base', self.base, + '--event-name', 'workflow_dispatch', '--repository', 'owner/repo']), 0) + + def test_manual_default_head_and_identity(self): + args = ['--repo', str(self.root), '--base', self.base, '--event-name', 'workflow_dispatch'] + self.assertEqual(gate.main(args + ['--repository', 'owner/repo']), 0) + with self.assertRaisesRegex(ValueError, 'repository does not match'): + gate.main(args + ['--repository', 'other/repo']) + with self.assertRaisesRegex(ValueError, 'nonempty base'): + gate.main(['--repo', str(self.root), '--event-name', 'workflow_dispatch']) + def test_source_change_without_disposition_blocks(self): self.change() with self.assertRaisesRegex(ValueError, 'one recovery disposition'): @@ -81,6 +98,11 @@ def test_vague_waiver_and_duplicate_receipt_block(self): self.git('add', '.'); self.git('commit', '-qm', 'duplicate') with self.assertRaisesRegex(ValueError, 'one recovery disposition'): self.check() + def test_non_json_source_in_evidence_folder_requires_disposition(self): + self.change(gate.CHANGES + 'source.py') + with self.assertRaisesRegex(ValueError, 'one recovery disposition'): + self.check() + def test_generated_data_only_needs_no_disposition(self): self.change('data/history.csv') self.assertIsNone(self.check())