Skip to content

Commit ff7c4c5

Browse files
andrewjongclaude
andauthored
feat(ci): registry→trunk catalog sync automation + drift alarm (#408)
* feat(ci): registry→trunk catalog sync automation + drift alarm Module registration is two merges (registry PR + trunk fixture/catalog sync); the docs deploy regenerates the published catalog from the LIVE registry, so a missed half silently dropped the module from the site (happened with mighty, 2026-08-29). Two mechanical guards: - deploy_docs_from_develop: drift alarm step — gen_docs_catalog --check against a fresh registry clone (empty modules-dir, the committed-page variant); on mismatch emits a warning annotation, a job summary, and files one docs-catalog-drift issue. Never fails the deploy. - sync-modules-index (new, daily + dispatch): mirrors registry entries into tests/meta/fixtures/modules_index/, regenerates docs/modules/, bumps VERSION for the increment gate, and opens/refreshes the bot/sync-modules-index PR (scripts/registry_sync.py, locally testable). AGENTS.md + create-module/extract-module skills updated to point at the automation. VERSION 0.20.0-alpha.21. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * sync-modules-index: prefer REGISTRY_SYNC_TOKEN so bot PRs trigger CI Falls back to the workflow token when the secret is absent (close/reopen the bot PR to run checks in that mode). Registry-side dispatch workflow uses the same secret name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 24a80f7 commit ff7c4c5

8 files changed

Lines changed: 290 additions & 4 deletions

File tree

‎.agents/skills/create-module/SKILL.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,12 @@ so only deviations appear in stack files.)
132132
regenerated with `tools/gen_docs_catalog.py`, mkdocs nav updated). The
133133
docs deploy regenerates the catalog from the LIVE registry, so the trunk
134134
PR alone leaves the published catalog without your module — merge the
135-
registry PR first. Full checklist + recovery command: the
136-
[extract-module](../extract-module/SKILL.md) registration step.
135+
registry PR first. The trunk PR is automated: after the registry PR
136+
merges, dispatch the `sync-modules-index` workflow (also runs daily) and
137+
merge the PR it opens; the develop docs deploy files a
138+
`docs-catalog-drift` issue if the two ever disagree. Full checklist +
139+
caveats: the [extract-module](../extract-module/SKILL.md) registration
140+
step.
137141

138142
## References
139143

‎.agents/skills/extract-module/SKILL.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,19 @@ delete, written for the orchestrator of that PR. Include:
193193
with** the trunk PR; if it lands late, re-run the deploy:
194194
`gh workflow run deploy_docs_from_develop.yaml --repo castacks/AirStack --ref develop`.
195195

196+
**Automation (2026-08-29):** the trunk half no longer needs to be
197+
hand-built — merging the registry PR dispatches the `sync-modules-index`
198+
workflow (registry-side `trigger-trunk-sync`; also daily sweep + manual
199+
dispatch), which opens the trunk sync PR (fixture mirror + regenerated
200+
pages + VERSION bump). The develop docs deploy independently raises a
201+
`docs-catalog-drift` issue whenever the committed catalog and the live
202+
registry disagree, so a missed sync can no longer stay silent. Both
203+
automations authenticate with the `REGISTRY_SYNC_TOKEN` secret (same
204+
fine-grained PAT in both repos); if it is missing/expired, the registry
205+
trigger no-ops with a warning (daily sweep covers it) and the bot PR falls
206+
back to the workflow token, whose PRs don't trigger CI — close and reopen
207+
such a PR to run the checks.
208+
196209
## References
197210

198211
- [create-module](../create-module/SKILL.md) — manifest, anatomy, canonical-defaults rule

‎.env‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PROJECT_NAME="airstack"
1212
# If you've run ./airstack.sh setup, then this will auto-generate from the git commit hash every time a change is made
1313
# to a Dockerfile or docker-compose.yaml file. Otherwise this can also be set explicitly to make a release version.
1414
# auto-generated from git commit hash
15-
VERSION="0.20.0-alpha.20"
15+
VERSION="0.20.0-alpha.21"
1616
# Image-tag discriminator ONLY (appears in the image tag suffix, e.g. ..._robot-x86-64_dev).
1717
# No Dockerfile consumes it: "prebuilt" does NOT bake the built ros_ws into the image today —
1818
# a real prebuilt (workspace-baked) stage is future work. Keep "dev" (mounted code, built live).

‎.github/workflows/deploy_docs_from_develop.yaml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ on:
2020
- cron: "17 6 * * 1" # weekly, Mondays 06:17 UTC
2121
permissions:
2222
contents: write
23+
issues: write # drift alarm files an issue when the committed catalog lags the registry
2324
jobs:
2425
deploy:
2526
runs-on: ubuntu-latest
@@ -37,6 +38,40 @@ jobs:
3738
run: |
3839
pip install mkdocs-material mkdocs-same-dir mkdocs-redirects pyyaml
3940
pip install pillow cairosvg mike
41+
# DRIFT ALARM: the committed docs/modules pages + the fixture index
42+
# (tests/meta/fixtures/modules_index/) must mirror the LIVE registry.
43+
# A merged trunk PR with an unmerged registry PR — or vice versa —
44+
# otherwise publishes a catalog that silently disagrees with git
45+
# (this happened with `mighty`, 2026-08-29). Develop deploy only:
46+
# main/release committed pages intentionally lag develop. NEVER fails
47+
# the deploy (the regeneration below publishes the current catalog
48+
# regardless); it just makes the mismatch loud — a warning annotation,
49+
# a job summary, and at most one open issue. The sync-modules-index
50+
# workflow opens the PR that clears it.
51+
- name: Catalog drift alarm (committed pages vs live registry)
52+
env:
53+
GH_TOKEN: ${{ github.token }}
54+
run: |
55+
rm -rf .modules-index-drift
56+
git clone --depth 1 https://github.com/castacks/airstack-modules-index .modules-index-drift \
57+
|| { echo "skipped: registry unreachable — no drift verdict"; exit 0; }
58+
mkdir -p /tmp/no-fetched-modules
59+
if python3 tools/gen_docs_catalog.py --index .modules-index-drift \
60+
--modules-dir /tmp/no-fetched-modules --check 2> drift.txt; then
61+
echo "committed catalog is in sync with the live registry"
62+
exit 0
63+
fi
64+
echo "::warning title=Module catalog drift::committed docs/modules + fixture disagree with the live registry. The published catalog is regenerated and current; trunk needs a sync PR — run the sync-modules-index workflow."
65+
{ echo "## Module catalog drift"; echo; echo '```'; head -c 5000 drift.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
66+
if [ -z "$(gh issue list --label docs-catalog-drift --state open --json number --jq '.[].number')" ]; then
67+
gh label create docs-catalog-drift --color D93F0B \
68+
--description "Committed module catalog disagrees with the live registry" 2>/dev/null || true
69+
gh issue create --title "Module catalog drift: committed docs/modules disagrees with the live registry" \
70+
--label docs-catalog-drift \
71+
--body "$(printf 'Detected by the develop docs deploy: %s/%s/actions/runs/%s\n\nThe committed docs/modules pages + tests/meta/fixtures/modules_index/ do not match regeneration from the live airstack-modules-index (usually a registry PR merged without its trunk sync, or the reverse). The published develop catalog is regenerated live and stays correct; git is what lags.\n\nFix: run the **sync-modules-index** workflow (Actions tab) and merge its PR — or sync manually per the extract-module skill registration step.\n\n```\n%s\n```' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "$(head -c 5000 drift.txt)")"
72+
else
73+
echo "drift issue already open — not filing a duplicate"
74+
fi
4075
# RFC #379 §9: module docs ride the docs deploy. Shallow-clone the
4176
# registry index and each REGISTERED module repo at its registered_ref
4277
# into the gitignored modules/ dir, then regenerate docs/modules/ so
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 Carnegie Mellon University
3+
# SPDX-License-Identifier: BSD-3-Clause-Clear
4+
"""Sync trunk's committed module catalog from a registry checkout.
5+
6+
Module registration is TWO merges (registry PR + trunk PR — see the
7+
extract-module skill). This script produces the trunk half mechanically from
8+
a local checkout of castacks/airstack-modules-index:
9+
10+
1. mirror the registry's ``modules/*.yaml`` and ``stacks/*.yaml`` into the
11+
contract-test fixture ``tests/meta/fixtures/modules_index/`` (deleting
12+
entries the registry no longer has);
13+
2. regenerate the committed ``docs/modules/`` pages with
14+
``tools/gen_docs_catalog.py`` against that fixture and an EMPTY
15+
fetched-modules dir (the committed-page variant the docs-catalog contract
16+
test expects);
17+
3. with ``--bump``, and only when step 1/2 changed anything: bump the ``.env``
18+
VERSION pre-release counter (the check-version-increment gate requires a
19+
strict bump on every PR) and add a release-notes bullet.
20+
21+
Run by ``.github/workflows/sync-modules-index.yml``, which turns a non-empty
22+
diff into the trunk sync PR. Also runnable locally from the repo root:
23+
24+
git clone --depth 1 https://github.com/castacks/airstack-modules-index /tmp/idx
25+
python3 .github/workflows/scripts/registry_sync.py --index /tmp/idx
26+
27+
stdlib only (gen_docs_catalog.py itself needs PyYAML).
28+
"""
29+
from __future__ import annotations
30+
31+
import argparse
32+
import re
33+
import shutil
34+
import subprocess
35+
import sys
36+
import tempfile
37+
from pathlib import Path
38+
39+
TRUNK = Path(__file__).resolve().parents[3]
40+
FIXTURE = TRUNK / "tests" / "meta" / "fixtures" / "modules_index"
41+
GENERATOR = TRUNK / "tools" / "gen_docs_catalog.py"
42+
ENV_FILE = TRUNK / ".env"
43+
RELEASE_NOTES = TRUNK / "docs" / "release_notes" / "index.md"
44+
45+
46+
def mirror_entries(index: Path) -> None:
47+
"""Mirror registry modules/ and stacks/ YAMLs into the fixture."""
48+
for sub in ("modules", "stacks"):
49+
src, dst = index / sub, FIXTURE / sub
50+
if not src.is_dir():
51+
sys.exit(f"registry checkout has no {sub}/ directory: {src}")
52+
if dst.is_dir():
53+
shutil.rmtree(dst)
54+
dst.mkdir(parents=True)
55+
for entry in sorted(src.glob("*.yaml")):
56+
shutil.copy2(entry, dst / entry.name)
57+
58+
59+
def regenerate_catalog() -> None:
60+
with tempfile.TemporaryDirectory() as empty:
61+
subprocess.run(
62+
[sys.executable, str(GENERATOR),
63+
"--index", str(FIXTURE),
64+
"--modules-dir", f"{empty}/no-fetched-modules"],
65+
check=True, cwd=TRUNK,
66+
)
67+
68+
69+
def changed_paths() -> "list[str]":
70+
out = subprocess.run(
71+
["git", "status", "--porcelain", "--",
72+
str(FIXTURE.relative_to(TRUNK)), "docs/modules"],
73+
check=True, capture_output=True, text=True, cwd=TRUNK,
74+
).stdout
75+
return [line[3:].strip() for line in out.splitlines() if line.strip()]
76+
77+
78+
def bump_version() -> "tuple[str, str]":
79+
"""Increment the .env VERSION pre-release counter (or patch)."""
80+
text = ENV_FILE.read_text()
81+
m = re.search(r'^VERSION="([^"]+)"$', text, flags=re.M)
82+
if not m:
83+
sys.exit('.env has no VERSION="..." line')
84+
old = m.group(1)
85+
pre = re.fullmatch(r"(\d+\.\d+\.\d+-(?:alpha|beta|rc)\.)(\d+)", old)
86+
if pre:
87+
new = f"{pre.group(1)}{int(pre.group(2)) + 1}"
88+
else:
89+
rel = re.fullmatch(r"(\d+\.\d+\.)(\d+)", old)
90+
if not rel:
91+
sys.exit(f"unrecognized VERSION format: {old}")
92+
new = f"{rel.group(1)}{int(rel.group(2)) + 1}"
93+
ENV_FILE.write_text(text.replace(f'VERSION="{old}"', f'VERSION="{new}"', 1))
94+
return old, new
95+
96+
97+
def add_release_note(entries: "list[str]") -> None:
98+
"""Insert a bullet under the current '(Unreleased)' section heading."""
99+
lines = RELEASE_NOTES.read_text().splitlines(keepends=True)
100+
names = ", ".join(f"`{e}`" for e in entries) or "registry entries"
101+
bullet = (
102+
"- Module catalog synced from the registry "
103+
f"(`sync-modules-index`): {names}.\n"
104+
)
105+
for i, line in enumerate(lines):
106+
if re.match(r"^## .*\(Unreleased\)", line):
107+
lines.insert(i + 2, bullet)
108+
lines.insert(i + 3, "\n")
109+
RELEASE_NOTES.write_text("".join(lines))
110+
return
111+
print("warning: no '(Unreleased)' release-notes section — bullet skipped",
112+
file=sys.stderr)
113+
114+
115+
def main() -> int:
116+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
117+
parser.add_argument("--index", required=True, type=Path,
118+
help="local checkout of castacks/airstack-modules-index")
119+
parser.add_argument("--bump", action="store_true",
120+
help="on change, bump .env VERSION and add a "
121+
"release-notes bullet (for the sync PR)")
122+
args = parser.parse_args()
123+
124+
mirror_entries(args.index.resolve())
125+
regenerate_catalog()
126+
127+
changed = changed_paths()
128+
if not changed:
129+
print("in-sync: committed catalog already matches the registry")
130+
return 0
131+
132+
print("changed:")
133+
for path in changed:
134+
print(f" {path}")
135+
if args.bump:
136+
# Only the registry-entry files (not the regenerated pages) make a
137+
# readable release note.
138+
entry_names = sorted(
139+
Path(p).stem for p in changed
140+
if "fixtures/modules_index" in p and p.endswith(".yaml")
141+
)
142+
old, new = bump_version()
143+
add_release_note(entry_names)
144+
print(f"VERSION {old} -> {new}; release-notes bullet added")
145+
return 0
146+
147+
148+
if __name__ == "__main__":
149+
sys.exit(main())
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Module registration is TWO merges: the registry PR (castacks/
2+
# airstack-modules-index) and a trunk PR mirroring the entries into the
3+
# contract-test fixture + regenerating the committed docs/modules pages
4+
# (see the extract-module skill). This workflow automates the trunk half:
5+
# it diffs trunk against the live registry and opens/refreshes one sync PR
6+
# when they disagree. The develop docs deploy's drift alarm is the loud
7+
# signal; this is the fix.
8+
#
9+
# Cadence: dispatched by the registry's trigger-trunk-sync workflow on
10+
# every registry merge, plus a daily sweep and manual dispatch. The
11+
# registry itself is cloned read-only.
12+
#
13+
# TOKEN: branch push + PR creation prefer the REGISTRY_SYNC_TOKEN secret
14+
# (a fine-grained PAT on castacks/AirStack with contents+pull-requests
15+
# read/write) so the bot PR triggers CI like any human PR. Without the
16+
# secret it falls back to the workflow token, whose PRs do NOT trigger
17+
# other workflows (unit-tests, check-version-increment) — close and
18+
# reopen such a PR to run them.
19+
name: Sync module catalog from registry
20+
on:
21+
workflow_dispatch:
22+
schedule:
23+
- cron: "43 7 * * *" # daily sweep, 07:43 UTC
24+
permissions:
25+
contents: write
26+
pull-requests: write
27+
jobs:
28+
sync:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
with:
33+
# schedule/workflow_dispatch run on the default branch; the sync
34+
# PR always targets develop.
35+
ref: develop
36+
fetch-depth: 0
37+
# PAT when configured (bot PR triggers CI); workflow token otherwise.
38+
token: ${{ secrets.REGISTRY_SYNC_TOKEN || github.token }}
39+
- uses: actions/setup-python@v4
40+
with:
41+
python-version: 3.10.6
42+
- name: Install Dependencies
43+
run: pip install pyyaml
44+
- name: Sync fixture + committed catalog from the live registry
45+
id: sync
46+
run: |
47+
rm -rf .modules-index
48+
git clone --depth 1 https://github.com/castacks/airstack-modules-index .modules-index
49+
python3 .github/workflows/scripts/registry_sync.py --index .modules-index --bump
50+
rm -rf .modules-index
51+
if [ -n "$(git status --porcelain)" ]; then
52+
echo "changed=true" >> "$GITHUB_OUTPUT"
53+
else
54+
echo "changed=false" >> "$GITHUB_OUTPUT"
55+
fi
56+
- name: Open or refresh the sync PR
57+
if: steps.sync.outputs.changed == 'true'
58+
env:
59+
GH_TOKEN: ${{ secrets.REGISTRY_SYNC_TOKEN || github.token }}
60+
run: |
61+
git config user.name "Registry Sync"
62+
git config user.email "registry.sync@example.co.uk"
63+
git checkout -B bot/sync-modules-index
64+
git add -A
65+
git commit -m "chore(catalog): sync committed module catalog from airstack-modules-index"
66+
git push -f origin bot/sync-modules-index
67+
if [ -z "$(gh pr list --head bot/sync-modules-index --state open --json number --jq '.[].number')" ]; then
68+
gh pr create --base develop --head bot/sync-modules-index \
69+
--title "chore(catalog): sync committed module catalog from the registry" \
70+
--body "$(printf 'Automated trunk half of module registration (see the extract-module skill): mirrors the live [airstack-modules-index](https://github.com/castacks/airstack-modules-index) into tests/meta/fixtures/modules_index/ and regenerates the committed docs/modules pages with tools/gen_docs_catalog.py. VERSION is bumped only to satisfy the check-version-increment gate (docs-only change; publish will retag, not rebuild).\n\nEverything here is deterministic output of already-reviewed registry entries.\n\nIf CI checks did not start on this PR, it was opened with the fallback workflow token (REGISTRY_SYNC_TOKEN secret not configured) — close and reopen it to trigger them. The branch is force-refreshed from develop on each run, so a stale VERSION bump heals itself on the next run.\n\nWorkflow: sync-modules-index (%s/%s/actions/runs/%s)' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID")"
71+
else
72+
echo "sync PR already open — branch refreshed in place"
73+
fi

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ AirStack is transitioning from a monolith to **modules** (thin external repos wi
2727

2828
- Docs: [Module & Stack Catalog](docs/modules/index.md) (marketplace, generated by [`tools/gen_docs_catalog.py`](tools/gen_docs_catalog.py)) · [Modules](docs/development/modules.md) · [Stacks](docs/development/stacks.md) · [Fleets](docs/development/fleets.md) · [Module CI](docs/development/module_ci.md) · [Modular AirStack Walkthrough](docs/getting_started/modular_airstack.md)
2929
- Skills: `create-module`, `create-stack`, `integrate-module-into-layer` (now stack-centric), `configure-multi-robot` (fleets)
30-
- Registry: [castacks/airstack-modules-index](https://github.com/castacks/airstack-modules-index) — one YAML per registered module/stack; DECLARED compat in the entry, VERIFIED compat CI-stamped under `compat/`. **Registering a module is TWO merges**: the registry-repo PR *and* the trunk PR (fixture entries under `tests/meta/fixtures/modules_index/` + regenerated `docs/modules/`). The docs deploy regenerates the catalog from the **live registry**, so a merged trunk PR with an unmerged registry PR silently drops the module from the published site — merge the registry PR first (details + recovery command: the `extract-module` skill's registration step)
30+
- Registry: [castacks/airstack-modules-index](https://github.com/castacks/airstack-modules-index) — one YAML per registered module/stack; DECLARED compat in the entry, VERIFIED compat CI-stamped under `compat/`. **Registering a module is TWO merges**: the registry-repo PR *and* the trunk PR (fixture entries under `tests/meta/fixtures/modules_index/` + regenerated `docs/modules/`). The docs deploy regenerates the catalog from the **live registry**, so a merged trunk PR with an unmerged registry PR silently drops the module from the published site — merge the registry PR first. The trunk half is automated: the daily [`sync-modules-index`](.github/workflows/sync-modules-index.yml) workflow opens the trunk sync PR (dispatch it manually for an immediate sync), and the develop docs deploy raises a `docs-catalog-drift` issue whenever git and the live registry disagree (details: the `extract-module` skill's registration step)
3131

3232
## Repository Architecture
3333

@@ -311,6 +311,7 @@ GitHub Actions workflows live in [`.github/workflows/`](.github/workflows/):
311311
| [`system-tests.yml`](.github/workflows/system-tests.yml) | PR opened/synchronized/reopened, `/pytest` PR comment (write-access only), or `workflow_dispatch` | Runs automatic package builds or selected simulation marks on an ephemeral GPU runner; only complete simulation campaigns are compared in metrics reports |
312312
| [`docker-build.yml`](.github/workflows/docker-build.yml) | Push to `main`/`develop` that changes `.env` (`VERSION=`), or manual dispatch | Builds, pushes, and cosign-signs all compose images on the ephemeral runner |
313313
| [`check-version-increment.yml`](.github/workflows/check-version-increment.yml) | Pull request | Validates `.env` `VERSION=` is valid semver and strictly greater than the base branch |
314+
| [`sync-modules-index.yml`](.github/workflows/sync-modules-index.yml) | Daily schedule, or manual dispatch after merging a registry PR | Mirrors the live [module registry](https://github.com/castacks/airstack-modules-index) into `tests/meta/fixtures/modules_index/` + the committed `docs/modules/` pages and opens the trunk sync PR when they disagree (the develop docs deploy's drift alarm files a `docs-catalog-drift` issue meanwhile) |
314315
| `deploy_docs_from_{main,develop,release}.yaml` | Push to the matching branch (`docs/**`, `mkdocs.yml`, `*.md`) | Publishes versioned MkDocs site via `mike` |
315316

316317
**`/pytest` PR comments** trigger `system-tests.yml` for users with write access (OWNER/MEMBER/COLLABORATOR), pulling args from the first line of the comment (e.g. `/pytest -m liveliness --sim msairsim`). Fork PRs are blocked — same-repo only — to keep arbitrary code off the self-hosted runner.

0 commit comments

Comments
 (0)