From c93119180879039c22b371db1d1325a31888a388 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:07:06 +0000 Subject: [PATCH] add org ADR sync manifest --- .../workflows/reusable-org-adr-auto-sync.yaml | 12 ++++- .gitignore | 1 + docs/reference/mirroring.md | 2 +- org-adr-manifest.json | 49 +++++++++++++++++++ tools/check_baseline_manifest.py | 38 ++++++++------ 5 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 org-adr-manifest.json diff --git a/.github/workflows/reusable-org-adr-auto-sync.yaml b/.github/workflows/reusable-org-adr-auto-sync.yaml index 1f5f11f..f2fed30 100644 --- a/.github/workflows/reusable-org-adr-auto-sync.yaml +++ b/.github/workflows/reusable-org-adr-auto-sync.yaml @@ -96,7 +96,11 @@ jobs: source_repo = os.environ["SOURCE_REPO"] source_sha = os.environ["SOURCE_SHA"] - manifest = json.loads((source_dir / "baseline-manifest.json").read_text(encoding="utf-8")) + manifest_path = source_dir / "org-adr-manifest.json" + if not manifest_path.is_file(): + manifest_path = source_dir / "baseline-manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + print(f"using source manifest {manifest_path.name}") entries = [] for item in manifest.get("files", []): source = item.get("source") @@ -142,11 +146,15 @@ jobs: lines = text.splitlines(keepends=True) changed = False source_pattern = re.compile(rf"^\s*source-repo:\s*{re.escape(source_repo)}\s*(?:#.*)?$") + manifest_pattern = re.compile(r"^\s*manifest:\s*org-adr-manifest\.json\s*(?:#.*)?$") ref_pattern = re.compile(r"^(\s*source-ref:\s*)([^\s#]+)(?:\s*#.*)?(\r?\n?)$") for index, line in enumerate(lines): if not source_pattern.match(line): continue - for ref_index in range(index + 1, min(index + 8, len(lines))): + window = lines[index + 1 : min(index + 12, len(lines))] + if not any(manifest_pattern.match(candidate) for candidate in window): + continue + for ref_index in range(index + 1, min(index + 12, len(lines))): match = ref_pattern.match(lines[ref_index]) if match: lines[ref_index] = f"{match.group(1)}{source_sha}{match.group(3) or os.linesep}" diff --git a/.gitignore b/.gitignore index 90eceb5..284d310 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ # Allow baseline manifest so drift-gate has an authoritative org source list. !/baseline-manifest.json +!/org-adr-manifest.json # Allow README so the special repo's operating model stays documented. !/README.md diff --git a/docs/reference/mirroring.md b/docs/reference/mirroring.md index a811035..e47861f 100644 --- a/docs/reference/mirroring.md +++ b/docs/reference/mirroring.md @@ -24,7 +24,7 @@ Org governance is namespace-local. A `NWarila/*` repository mirrors org ADRs and ## Org ADR Auto-Sync -Repositories that already mirror org ADRs should carry a scheduled caller for the namespace-local `reusable-org-adr-auto-sync.yaml`. The caller runs from the adopting repository, so its sync token can only update that repository. It fetches the owning namespace `.github` baseline manifest, copies only `docs/decision-records/org/` targets, removes stale mirrored ADR Markdown files, updates the adopting repository's `org-adr-sync.yaml` source pin when present, and opens or refreshes a PR. +Repositories that already mirror org ADRs should carry a scheduled caller for the namespace-local `reusable-org-adr-auto-sync.yaml`. The caller runs from the adopting repository, so its sync token can only update that repository. It fetches the owning namespace `.github` `org-adr-manifest.json`, copies only `docs/decision-records/org/` targets, removes stale mirrored ADR Markdown files, updates the adopting repository's ADR-only detector source pin when present, and opens or refreshes a PR. The reusable keeps `GITHUB_TOKEN` read-only. Real sync writes require the caller to pass an explicit `sync_token` secret with permission to push the sync branch and open the PR. diff --git a/org-adr-manifest.json b/org-adr-manifest.json new file mode 100644 index 0000000..42036f2 --- /dev/null +++ b/org-adr-manifest.json @@ -0,0 +1,49 @@ +{ + "version": "1", + "files": [ + { + "source": "docs/decision-records/0001-use-architecture-decision-records.md", + "target": "docs/decision-records/org/0001-use-architecture-decision-records.md" + }, + { + "source": "docs/decision-records/0002-adopt-diataxis-documentation-framework.md", + "target": "docs/decision-records/org/0002-adopt-diataxis-documentation-framework.md" + }, + { + "source": "docs/decision-records/0003-use-deny-all-gitignore-strategy.md", + "target": "docs/decision-records/org/0003-use-deny-all-gitignore-strategy.md" + }, + { + "source": "docs/decision-records/0004-use-renovate-for-dependency-updates.md", + "target": "docs/decision-records/org/0004-use-renovate-for-dependency-updates.md" + }, + { + "source": "docs/decision-records/0005-pin-terraform-and-provider-versions-exactly.md", + "target": "docs/decision-records/org/0005-pin-terraform-and-provider-versions-exactly.md" + }, + { + "source": "docs/decision-records/0006-keep-github-control-planes-namespace-local.md", + "target": "docs/decision-records/org/0006-keep-github-control-planes-namespace-local.md" + }, + { + "source": "docs/decision-records/0007-centralize-universal-ci-reusables-within-each-namespace.md", + "target": "docs/decision-records/org/0007-centralize-universal-ci-reusables-within-each-namespace.md" + }, + { + "source": "docs/decision-records/0008-enforce-repo-hygiene-by-repo-type.md", + "target": "docs/decision-records/org/0008-enforce-repo-hygiene-by-repo-type.md" + }, + { + "source": "docs/decision-records/0009-classify-baseline-manifest-byte-identity.md", + "target": "docs/decision-records/org/0009-classify-baseline-manifest-byte-identity.md" + }, + { + "source": "docs/decision-records/0010-keep-ai-attribution-out-of-version-control.md", + "target": "docs/decision-records/org/0010-keep-ai-attribution-out-of-version-control.md" + }, + { + "source": "docs/decision-records/org/.gitkeep", + "target": "docs/decision-records/org/.gitkeep" + } + ] +} diff --git a/tools/check_baseline_manifest.py b/tools/check_baseline_manifest.py index 81501bd..3050ca1 100644 --- a/tools/check_baseline_manifest.py +++ b/tools/check_baseline_manifest.py @@ -8,7 +8,10 @@ ROOT = Path(__file__).resolve().parents[1] -MANIFEST = ROOT / "baseline-manifest.json" +MANIFESTS = ( + ROOT / "baseline-manifest.json", + ROOT / "org-adr-manifest.json", +) ADR_ROOT = ROOT / "docs" / "decision-records" @@ -25,40 +28,40 @@ def manifest_path(field: str, value: Any) -> str: return value -def main() -> None: +def validate_manifest(manifest: Path) -> None: try: - raw = json.loads(MANIFEST.read_text(encoding="utf-8")) + raw = json.loads(manifest.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: - fail(f"{MANIFEST.name} is not valid JSON: {exc}") + fail(f"{manifest.name} is not valid JSON: {exc}") if not isinstance(raw, dict) or set(raw) != {"version", "files"}: - fail("root must contain exactly 'version' and 'files'") + fail(f"{manifest.name}: root must contain exactly 'version' and 'files'") if raw["version"] != "1": - fail(f"unsupported manifest version: {raw['version']!r}") + fail(f"{manifest.name}: unsupported manifest version: {raw['version']!r}") files = raw["files"] if not isinstance(files, list) or not files: - fail("'files' must be a non-empty list") + fail(f"{manifest.name}: 'files' must be a non-empty list") sources: list[str] = [] targets: set[str] = set() for idx, item in enumerate(files): if not isinstance(item, dict) or set(item) != {"source", "target"}: - fail(f"files[{idx}] must contain exactly 'source' and 'target'") - source = manifest_path(f"files[{idx}].source", item["source"]) - target = manifest_path(f"files[{idx}].target", item["target"]) + fail(f"{manifest.name}: files[{idx}] must contain exactly 'source' and 'target'") + source = manifest_path(f"{manifest.name}: files[{idx}].source", item["source"]) + target = manifest_path(f"{manifest.name}: files[{idx}].target", item["target"]) if target in targets: - fail(f"duplicate target path: {target!r}") + fail(f"{manifest.name}: duplicate target path: {target!r}") sources.append(source) targets.add(target) missing = [source for source in sources if not (ROOT / source).is_file()] if missing: - fail(f"sources missing: {missing}") + fail(f"{manifest.name}: sources missing: {missing}") org_adrs = sorted(path.relative_to(ROOT).as_posix() for path in ADR_ROOT.glob("000*.md")) unlisted_adrs = [path for path in org_adrs if path not in sources] if unlisted_adrs: - fail(f"org ADRs missing from baseline manifest: {unlisted_adrs}") + fail(f"{manifest.name}: org ADRs missing from manifest: {unlisted_adrs}") expected_targets = { f"docs/decision-records/org/{Path(source).name}" @@ -66,9 +69,14 @@ def main() -> None: } missing_targets = sorted(expected_targets - targets) if missing_targets: - fail(f"org ADR mirror targets missing from baseline manifest: {missing_targets}") + fail(f"{manifest.name}: org ADR mirror targets missing from manifest: {missing_targets}") + + print(f"{manifest.name} check passed: {len(files)} files") - print(f"baseline manifest check passed: {len(files)} files") + +def main() -> None: + for manifest in MANIFESTS: + validate_manifest(manifest) if __name__ == "__main__":