Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

__pycache__/
*.py[cod]
41 changes: 41 additions & 0 deletions .machine-recovery/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .machine-recovery/changes/2026-10-11-universal-checks.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
147 changes: 147 additions & 0 deletions .machine-recovery/check.py
Original file line number Diff line number Diff line change
@@ -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)
147 changes: 147 additions & 0 deletions .machine-recovery/ci_change_scope.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading