Skip to content

Commit a1dfbd2

Browse files
authored
[sync] add org ADR auto-sync reusable (#24)
## What changed - Adds a namespace-local reusable workflow that syncs org ADR mirror targets from the source baseline manifest. - Wires a smoke job into repo CI so the reusable runtime path is exercised without pushing or opening a PR. - Documents the auto-sync behavior in the mirroring reference. ## Why Org ADR mirrors remain byte-identical, but drift repair should be small and reviewable. This reusable lets adopting repositories open their own sync PRs without cross-namespace writes. ## Validation - `python tools/check_baseline_manifest.py` - `python tools/check_docs_layout.py` - `python tools/check_ai_residue.py` - `git diff --check`
1 parent 8130762 commit a1dfbd2

4 files changed

Lines changed: 227 additions & 0 deletions

File tree

.github/workflows/repo-ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,14 @@ jobs:
159159
# Validates the reusable's wiring/inputs; the release-please action and
160160
# evidence dispatch are skipped (side effects), so no release is made.
161161
smoke: true
162+
163+
org-adr-auto-sync-smoke:
164+
name: Smoke-test reusable-org-adr-auto-sync (self)
165+
permissions:
166+
contents: read
167+
pull-requests: read
168+
uses: ./.github/workflows/reusable-org-adr-auto-sync.yaml
169+
with:
170+
source_repo: NWarila/.github
171+
source_ref: main
172+
smoke: true
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
name: Reusable Org ADR Auto Sync
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
source_repo:
7+
description: "Namespace control-plane repository that owns the org ADRs."
8+
required: true
9+
type: string
10+
source_ref:
11+
description: "Source ref to sync from. Use main for scheduled sync."
12+
required: false
13+
type: string
14+
default: main
15+
target_branch:
16+
description: "Repository branch that receives sync PRs."
17+
required: false
18+
type: string
19+
default: main
20+
branch_name:
21+
description: "Sync branch to create or update in the caller repository."
22+
required: false
23+
type: string
24+
default: sync/org-adrs
25+
pr_title:
26+
description: "Pull request title for ADR mirror sync changes."
27+
required: false
28+
type: string
29+
default: "[sync] refresh org ADR mirrors"
30+
smoke:
31+
description: "Exercise sync logic without committing, pushing, or opening a PR."
32+
required: false
33+
type: boolean
34+
default: false
35+
secrets:
36+
sync_token:
37+
description: "Token for pushing sync branches and opening PRs."
38+
required: false
39+
40+
permissions:
41+
contents: read
42+
pull-requests: read
43+
44+
jobs:
45+
sync:
46+
name: org ADR mirror sync
47+
runs-on: ubuntu-24.04
48+
timeout-minutes: 10
49+
permissions:
50+
contents: read
51+
pull-requests: read
52+
steps:
53+
- name: Checkout caller repository
54+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
55+
with:
56+
fetch-depth: 0
57+
58+
- name: Sync org ADR mirrors
59+
env:
60+
BRANCH_NAME: ${{ inputs.branch_name }}
61+
GH_TOKEN: ${{ secrets.sync_token || github.token }}
62+
PR_TITLE: ${{ inputs.pr_title }}
63+
SMOKE: ${{ inputs.smoke }}
64+
SYNC_TOKEN: ${{ secrets.sync_token }}
65+
SOURCE_REF: ${{ inputs.source_ref }}
66+
SOURCE_REPO: ${{ inputs.source_repo }}
67+
TARGET_BRANCH: ${{ inputs.target_branch }}
68+
run: |
69+
set -euo pipefail
70+
71+
git config user.name "github-actions[bot]"
72+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
73+
74+
git fetch --no-tags origin "${TARGET_BRANCH}"
75+
git checkout -B "${BRANCH_NAME}" "origin/${TARGET_BRANCH}"
76+
77+
source_dir="${RUNNER_TEMP}/org-adr-source"
78+
mkdir -p "${source_dir}"
79+
git -C "${source_dir}" init
80+
git -C "${source_dir}" remote add origin "https://github.com/${SOURCE_REPO}.git"
81+
git -C "${source_dir}" fetch --depth=1 origin "${SOURCE_REF}"
82+
git -C "${source_dir}" checkout --detach FETCH_HEAD
83+
source_sha="$(git -C "${source_dir}" rev-parse HEAD)"
84+
export SOURCE_SHA="${source_sha}"
85+
echo "Resolved ${SOURCE_REPO}@${SOURCE_REF} to ${source_sha}"
86+
87+
python - <<'PY'
88+
import json
89+
import os
90+
import re
91+
import shutil
92+
from pathlib import Path
93+
94+
root = Path.cwd()
95+
source_dir = Path(os.environ["RUNNER_TEMP"]) / "org-adr-source"
96+
source_repo = os.environ["SOURCE_REPO"]
97+
source_sha = os.environ["SOURCE_SHA"]
98+
99+
manifest = json.loads((source_dir / "baseline-manifest.json").read_text(encoding="utf-8"))
100+
entries = []
101+
for item in manifest.get("files", []):
102+
source = item.get("source")
103+
target = item.get("target")
104+
if not isinstance(source, str) or not isinstance(target, str):
105+
continue
106+
if not target.startswith("docs/decision-records/org/"):
107+
continue
108+
if Path(target).name != ".gitkeep" and not Path(target).name.startswith("000"):
109+
continue
110+
entries.append((source, target))
111+
112+
if not entries:
113+
raise SystemExit("source manifest contains no org ADR mirror entries")
114+
115+
expected_targets = {target for _, target in entries}
116+
target_dir = root / "docs" / "decision-records" / "org"
117+
target_dir.mkdir(parents=True, exist_ok=True)
118+
119+
for stale in target_dir.glob("000*.md"):
120+
rel = stale.relative_to(root).as_posix()
121+
if rel not in expected_targets:
122+
stale.unlink()
123+
print(f"removed stale mirror {rel}")
124+
125+
for source, target in entries:
126+
src = source_dir / source
127+
dst = root / target
128+
if not src.is_file():
129+
raise SystemExit(f"manifest source is missing: {source}")
130+
dst.parent.mkdir(parents=True, exist_ok=True)
131+
shutil.copyfile(src, dst)
132+
print(f"synced {target}")
133+
134+
detector = root / ".github" / "workflows" / "org-adr-sync.yaml"
135+
if detector.is_file():
136+
text = detector.read_text(encoding="utf-8")
137+
lines = text.splitlines(keepends=True)
138+
changed = False
139+
source_pattern = re.compile(rf"^\s*source-repo:\s*{re.escape(source_repo)}\s*(?:#.*)?$")
140+
ref_pattern = re.compile(r"^(\s*source-ref:\s*)([^\s#]+)(?:\s*#.*)?(\r?\n?)$")
141+
for index, line in enumerate(lines):
142+
if not source_pattern.match(line):
143+
continue
144+
for ref_index in range(index + 1, min(index + 8, len(lines))):
145+
match = ref_pattern.match(lines[ref_index])
146+
if match:
147+
lines[ref_index] = f"{match.group(1)}{source_sha}{match.group(3) or os.linesep}"
148+
changed = True
149+
break
150+
break
151+
if changed:
152+
detector.write_text("".join(lines), encoding="utf-8")
153+
print(f"updated {detector.relative_to(root).as_posix()} source-ref")
154+
else:
155+
print("org-adr-sync.yaml did not contain a matching source-ref to update")
156+
PY
157+
158+
if [[ "${SMOKE}" == "true" ]]; then
159+
echo "Smoke mode: sync logic completed; not committing, pushing, or opening a PR."
160+
git status --short
161+
exit 0
162+
fi
163+
164+
if git diff --quiet; then
165+
echo "Org ADR mirrors are already current."
166+
exit 0
167+
fi
168+
169+
if [[ -z "${SYNC_TOKEN}" ]]; then
170+
echo "A sync_token secret is required to push sync branches and open PRs."
171+
exit 1
172+
fi
173+
174+
git status --short
175+
git add docs/decision-records/org
176+
if [[ -f .github/workflows/org-adr-sync.yaml ]]; then
177+
git add .github/workflows/org-adr-sync.yaml
178+
fi
179+
git commit -m "sync org ADR mirrors"
180+
git remote set-url origin "https://x-access-token:${SYNC_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
181+
git push --force-with-lease origin "HEAD:${BRANCH_NAME}"
182+
183+
body_file="${RUNNER_TEMP}/org-adr-sync-pr.md"
184+
cat > "${body_file}" <<EOF
185+
Synchronizes org ADR mirrors from \`${SOURCE_REPO}@${source_sha}\`.
186+
187+
This PR was opened by the namespace-local org ADR auto-sync workflow.
188+
It copies only \`docs/decision-records/org/\` mirror targets from the
189+
source baseline manifest and updates the detector workflow source pin
190+
when that workflow exists in this repository.
191+
EOF
192+
193+
existing_pr="$(gh pr list \
194+
--head "${BRANCH_NAME}" \
195+
--base "${TARGET_BRANCH}" \
196+
--json number \
197+
--jq '.[0].number')"
198+
if [[ -n "${existing_pr}" ]]; then
199+
gh pr edit "${existing_pr}" --title "${PR_TITLE}" --body-file "${body_file}"
200+
echo "Updated PR #${existing_pr}"
201+
else
202+
gh pr create \
203+
--title "${PR_TITLE}" \
204+
--body-file "${body_file}" \
205+
--head "${BRANCH_NAME}" \
206+
--base "${TARGET_BRANCH}"
207+
fi

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
!/.github/workflows/reusable-auto-merge.yaml
4646
!/.github/workflows/reusable-codeql.yaml
4747
!/.github/workflows/reusable-iac-security.yaml
48+
!/.github/workflows/reusable-org-adr-auto-sync.yaml
4849
!/.github/workflows/reusable-release-please.yaml
4950
!/.github/workflows/reusable-repo-hygiene.yaml
5051
!/.github/workflows/reusable-scorecard.yaml

docs/reference/mirroring.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ Consumers mirror what they inherit as governance or directly run in their own li
2222

2323
Org governance is namespace-local. A `NWarila/*` repository mirrors org ADRs and community files from `NWarila/.github`. A repository under another namespace mirrors the same categories from that namespace's `.github` control plane. Cross-namespace references remain valid for explicit type-template or tool dependencies, but not for org-control-plane governance.
2424

25+
## Org ADR Auto-Sync
26+
27+
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.
28+
29+
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.
30+
31+
This auto-sync supplements the drift-gate detector; it does not replace review. The detector stays responsible for byte-identity verification, while the auto-sync keeps the repair path small and namespace-scoped.
32+
2533
## Byte-Identity Rule
2634

2735
Use byte identity when local edits would be drift. Do not use byte identity when local edits would be maturity.

0 commit comments

Comments
 (0)