diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76bca25 --- /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 || format('origin/{0}', github.event.repository.default_branch) }} + 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/.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/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/changes/2026-10-11-universal-checks.json b/.machine-recovery/changes/2026-10-11-universal-checks.json new file mode 100644 index 0000000..22a32d2 --- /dev/null +++ b/.machine-recovery/changes/2026-10-11-universal-checks.json @@ -0,0 +1,9 @@ +{ + "repository": "azuttre/try_git", + "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": [ + ".machine-recovery/README.md" + ] +} diff --git a/.machine-recovery/check.py b/.machine-recovery/check.py new file mode 100644 index 0000000..ba90b4a --- /dev/null +++ b/.machine-recovery/check.py @@ -0,0 +1,147 @@ +#!/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 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] + 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() + 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..a77b443 --- /dev/null +++ b/.machine-recovery/test_check.py @@ -0,0 +1,163 @@ +"""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_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'): + 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_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()) + + 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.