Skip to content
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
8 changes: 6 additions & 2 deletions .agents/skills/create-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,12 @@ so only deviations appear in stack files.)
regenerated with `tools/gen_docs_catalog.py`, mkdocs nav updated). The
docs deploy regenerates the catalog from the LIVE registry, so the trunk
PR alone leaves the published catalog without your module — merge the
registry PR first. Full checklist + recovery command: the
[extract-module](../extract-module/SKILL.md) registration step.
registry PR first. The trunk PR is automated: after the registry PR
merges, dispatch the `sync-modules-index` workflow (also runs daily) and
merge the PR it opens; the develop docs deploy files a
`docs-catalog-drift` issue if the two ever disagree. Full checklist +
caveats: the [extract-module](../extract-module/SKILL.md) registration
step.

## References

Expand Down
13 changes: 13 additions & 0 deletions .agents/skills/extract-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ delete, written for the orchestrator of that PR. Include:
with** the trunk PR; if it lands late, re-run the deploy:
`gh workflow run deploy_docs_from_develop.yaml --repo castacks/AirStack --ref develop`.

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

## References

- [create-module](../create-module/SKILL.md) — manifest, anatomy, canonical-defaults rule
Expand Down
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PROJECT_NAME="airstack"
# If you've run ./airstack.sh setup, then this will auto-generate from the git commit hash every time a change is made
# to a Dockerfile or docker-compose.yaml file. Otherwise this can also be set explicitly to make a release version.
# auto-generated from git commit hash
VERSION="0.20.0-alpha.20"
VERSION="0.20.0-alpha.21"
# Image-tag discriminator ONLY (appears in the image tag suffix, e.g. ..._robot-x86-64_dev).
# No Dockerfile consumes it: "prebuilt" does NOT bake the built ros_ws into the image today —
# a real prebuilt (workspace-baked) stage is future work. Keep "dev" (mounted code, built live).
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/deploy_docs_from_develop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ on:
- cron: "17 6 * * 1" # weekly, Mondays 06:17 UTC
permissions:
contents: write
issues: write # drift alarm files an issue when the committed catalog lags the registry
jobs:
deploy:
runs-on: ubuntu-latest
Expand All @@ -37,6 +38,40 @@ jobs:
run: |
pip install mkdocs-material mkdocs-same-dir mkdocs-redirects pyyaml
pip install pillow cairosvg mike
# DRIFT ALARM: the committed docs/modules pages + the fixture index
# (tests/meta/fixtures/modules_index/) must mirror the LIVE registry.
# A merged trunk PR with an unmerged registry PR — or vice versa —
# otherwise publishes a catalog that silently disagrees with git
# (this happened with `mighty`, 2026-08-29). Develop deploy only:
# main/release committed pages intentionally lag develop. NEVER fails
# the deploy (the regeneration below publishes the current catalog
# regardless); it just makes the mismatch loud — a warning annotation,
# a job summary, and at most one open issue. The sync-modules-index
# workflow opens the PR that clears it.
- name: Catalog drift alarm (committed pages vs live registry)
env:
GH_TOKEN: ${{ github.token }}
run: |
rm -rf .modules-index-drift
git clone --depth 1 https://github.com/castacks/airstack-modules-index .modules-index-drift \
|| { echo "skipped: registry unreachable — no drift verdict"; exit 0; }
mkdir -p /tmp/no-fetched-modules
if python3 tools/gen_docs_catalog.py --index .modules-index-drift \
--modules-dir /tmp/no-fetched-modules --check 2> drift.txt; then
echo "committed catalog is in sync with the live registry"
exit 0
fi
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."
{ echo "## Module catalog drift"; echo; echo '```'; head -c 5000 drift.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
if [ -z "$(gh issue list --label docs-catalog-drift --state open --json number --jq '.[].number')" ]; then
gh label create docs-catalog-drift --color D93F0B \
--description "Committed module catalog disagrees with the live registry" 2>/dev/null || true
gh issue create --title "Module catalog drift: committed docs/modules disagrees with the live registry" \
--label docs-catalog-drift \
--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)")"
else
echo "drift issue already open — not filing a duplicate"
fi
# RFC #379 §9: module docs ride the docs deploy. Shallow-clone the
# registry index and each REGISTERED module repo at its registered_ref
# into the gitignored modules/ dir, then regenerate docs/modules/ so
Expand Down
149 changes: 149 additions & 0 deletions .github/workflows/scripts/registry_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Carnegie Mellon University
# SPDX-License-Identifier: BSD-3-Clause-Clear
"""Sync trunk's committed module catalog from a registry checkout.

Module registration is TWO merges (registry PR + trunk PR — see the
extract-module skill). This script produces the trunk half mechanically from
a local checkout of castacks/airstack-modules-index:

1. mirror the registry's ``modules/*.yaml`` and ``stacks/*.yaml`` into the
contract-test fixture ``tests/meta/fixtures/modules_index/`` (deleting
entries the registry no longer has);
2. regenerate the committed ``docs/modules/`` pages with
``tools/gen_docs_catalog.py`` against that fixture and an EMPTY
fetched-modules dir (the committed-page variant the docs-catalog contract
test expects);
3. with ``--bump``, and only when step 1/2 changed anything: bump the ``.env``
VERSION pre-release counter (the check-version-increment gate requires a
strict bump on every PR) and add a release-notes bullet.

Run by ``.github/workflows/sync-modules-index.yml``, which turns a non-empty
diff into the trunk sync PR. Also runnable locally from the repo root:

git clone --depth 1 https://github.com/castacks/airstack-modules-index /tmp/idx
python3 .github/workflows/scripts/registry_sync.py --index /tmp/idx

stdlib only (gen_docs_catalog.py itself needs PyYAML).
"""
from __future__ import annotations

import argparse
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

TRUNK = Path(__file__).resolve().parents[3]
FIXTURE = TRUNK / "tests" / "meta" / "fixtures" / "modules_index"
GENERATOR = TRUNK / "tools" / "gen_docs_catalog.py"
ENV_FILE = TRUNK / ".env"
RELEASE_NOTES = TRUNK / "docs" / "release_notes" / "index.md"


def mirror_entries(index: Path) -> None:
"""Mirror registry modules/ and stacks/ YAMLs into the fixture."""
for sub in ("modules", "stacks"):
src, dst = index / sub, FIXTURE / sub
if not src.is_dir():
sys.exit(f"registry checkout has no {sub}/ directory: {src}")
if dst.is_dir():
shutil.rmtree(dst)
dst.mkdir(parents=True)
for entry in sorted(src.glob("*.yaml")):
shutil.copy2(entry, dst / entry.name)


def regenerate_catalog() -> None:
with tempfile.TemporaryDirectory() as empty:
subprocess.run(
[sys.executable, str(GENERATOR),
"--index", str(FIXTURE),
"--modules-dir", f"{empty}/no-fetched-modules"],
check=True, cwd=TRUNK,
)


def changed_paths() -> "list[str]":
out = subprocess.run(
["git", "status", "--porcelain", "--",
str(FIXTURE.relative_to(TRUNK)), "docs/modules"],
check=True, capture_output=True, text=True, cwd=TRUNK,
).stdout
return [line[3:].strip() for line in out.splitlines() if line.strip()]


def bump_version() -> "tuple[str, str]":
"""Increment the .env VERSION pre-release counter (or patch)."""
text = ENV_FILE.read_text()
m = re.search(r'^VERSION="([^"]+)"$', text, flags=re.M)
if not m:
sys.exit('.env has no VERSION="..." line')
old = m.group(1)
pre = re.fullmatch(r"(\d+\.\d+\.\d+-(?:alpha|beta|rc)\.)(\d+)", old)
if pre:
new = f"{pre.group(1)}{int(pre.group(2)) + 1}"
else:
rel = re.fullmatch(r"(\d+\.\d+\.)(\d+)", old)
if not rel:
sys.exit(f"unrecognized VERSION format: {old}")
new = f"{rel.group(1)}{int(rel.group(2)) + 1}"
ENV_FILE.write_text(text.replace(f'VERSION="{old}"', f'VERSION="{new}"', 1))
return old, new


def add_release_note(entries: "list[str]") -> None:
"""Insert a bullet under the current '(Unreleased)' section heading."""
lines = RELEASE_NOTES.read_text().splitlines(keepends=True)
names = ", ".join(f"`{e}`" for e in entries) or "registry entries"
bullet = (
"- Module catalog synced from the registry "
f"(`sync-modules-index`): {names}.\n"
)
for i, line in enumerate(lines):
if re.match(r"^## .*\(Unreleased\)", line):
lines.insert(i + 2, bullet)
lines.insert(i + 3, "\n")
RELEASE_NOTES.write_text("".join(lines))
return
print("warning: no '(Unreleased)' release-notes section — bullet skipped",
file=sys.stderr)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--index", required=True, type=Path,
help="local checkout of castacks/airstack-modules-index")
parser.add_argument("--bump", action="store_true",
help="on change, bump .env VERSION and add a "
"release-notes bullet (for the sync PR)")
args = parser.parse_args()

mirror_entries(args.index.resolve())
regenerate_catalog()

changed = changed_paths()
if not changed:
print("in-sync: committed catalog already matches the registry")
return 0

print("changed:")
for path in changed:
print(f" {path}")
if args.bump:
# Only the registry-entry files (not the regenerated pages) make a
# readable release note.
entry_names = sorted(
Path(p).stem for p in changed
if "fixtures/modules_index" in p and p.endswith(".yaml")
)
old, new = bump_version()
add_release_note(entry_names)
print(f"VERSION {old} -> {new}; release-notes bullet added")
return 0


if __name__ == "__main__":
sys.exit(main())
73 changes: 73 additions & 0 deletions .github/workflows/sync-modules-index.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Module registration is TWO merges: the registry PR (castacks/
# airstack-modules-index) and a trunk PR mirroring the entries into the
# contract-test fixture + regenerating the committed docs/modules pages
# (see the extract-module skill). This workflow automates the trunk half:
# it diffs trunk against the live registry and opens/refreshes one sync PR
# when they disagree. The develop docs deploy's drift alarm is the loud
# signal; this is the fix.
#
# Cadence: dispatched by the registry's trigger-trunk-sync workflow on
# every registry merge, plus a daily sweep and manual dispatch. The
# registry itself is cloned read-only.
#
# TOKEN: branch push + PR creation prefer the REGISTRY_SYNC_TOKEN secret
# (a fine-grained PAT on castacks/AirStack with contents+pull-requests
# read/write) so the bot PR triggers CI like any human PR. Without the
# secret it falls back to the workflow token, whose PRs do NOT trigger
# other workflows (unit-tests, check-version-increment) — close and
# reopen such a PR to run them.
name: Sync module catalog from registry
on:
workflow_dispatch:
schedule:
- cron: "43 7 * * *" # daily sweep, 07:43 UTC
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# schedule/workflow_dispatch run on the default branch; the sync
# PR always targets develop.
ref: develop
fetch-depth: 0
# PAT when configured (bot PR triggers CI); workflow token otherwise.
token: ${{ secrets.REGISTRY_SYNC_TOKEN || github.token }}
- uses: actions/setup-python@v4
with:
python-version: 3.10.6
- name: Install Dependencies
run: pip install pyyaml
- name: Sync fixture + committed catalog from the live registry
id: sync
run: |
rm -rf .modules-index
git clone --depth 1 https://github.com/castacks/airstack-modules-index .modules-index
python3 .github/workflows/scripts/registry_sync.py --index .modules-index --bump
rm -rf .modules-index
if [ -n "$(git status --porcelain)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Open or refresh the sync PR
if: steps.sync.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.REGISTRY_SYNC_TOKEN || github.token }}
run: |
git config user.name "Registry Sync"
git config user.email "registry.sync@example.co.uk"
git checkout -B bot/sync-modules-index
git add -A
git commit -m "chore(catalog): sync committed module catalog from airstack-modules-index"
git push -f origin bot/sync-modules-index
if [ -z "$(gh pr list --head bot/sync-modules-index --state open --json number --jq '.[].number')" ]; then
gh pr create --base develop --head bot/sync-modules-index \
--title "chore(catalog): sync committed module catalog from the registry" \
--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")"
else
echo "sync PR already open — branch refreshed in place"
fi
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ AirStack is transitioning from a monolith to **modules** (thin external repos wi

- 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)
- Skills: `create-module`, `create-stack`, `integrate-module-into-layer` (now stack-centric), `configure-multi-robot` (fleets)
- 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)
- 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)

## Repository Architecture

Expand Down Expand Up @@ -311,6 +311,7 @@ GitHub Actions workflows live in [`.github/workflows/`](.github/workflows/):
| [`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 |
| [`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 |
| [`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 |
| [`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) |
| `deploy_docs_from_{main,develop,release}.yaml` | Push to the matching branch (`docs/**`, `mkdocs.yml`, `*.md`) | Publishes versioned MkDocs site via `mike` |

**`/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.
Expand Down
Loading
Loading