From 9b3fabb44ee0f82d3e32f6b512635535c2fa67ff Mon Sep 17 00:00:00 2001
From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:55:39 -0400
Subject: [PATCH] ci: gate apps/** and catalog/skill-packs.json
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two previously ungated surfaces:
- apps/** had zero CI despite Dependabot opening monthly grouped PRs
against dashboard, mcp-server, and webhook — nothing ever verified
those bumps left an app compiling. New ci-apps workflow: dashboard
(typecheck + its 156 unit tests + `next build`, kept separate per the
documented TS7/Next16 incompatibility), cli (typecheck, borrowing the
dashboard's TypeScript since it has none of its own), mcp-server
(build), webhook (syntax check + `wrangler deploy --dry-run`, no
Cloudflare creds needed). Also added the missing apps/cli entry to
dependabot.yml.
- catalog/skill-packs.json is the one file routinely hand-edited by
outside contributors (every community pack listing), with nothing
validating it. New scripts/validate-skill-packs.mjs + ci-skill-packs
gate: registry shape (JSON, owner/repo format, non-empty unique
skills[], trust_level enum, capabilities against the locked taxonomy
read out of bin/install-skill-pack, secrets_required shape) and
README parity (every entry has a matching table row and vice versa,
skill counts agree, --path flags agree, handles monorepos publishing
multiple packs, checks the "N community skill packs" counter). Also
catches a self-declared trust_level: trusted that isn't backed by
trusted-sources.txt, which is what the installer actually checks.
30 test cases in scripts/tests/test_validate_skill_packs.sh, wired
into ci-tests.
Docs (CONTRIBUTING.md, PULL_REQUEST_TEMPLATE.md, community-skill-packs.md,
apps/cli/README.md) updated to match.
---
.github/CONTRIBUTING.md | 12 +
.github/PULL_REQUEST_TEMPLATE.md | 2 +
.github/dependabot.yml | 16 +
.github/workflows/ci-apps.yml | 142 ++++++++
.github/workflows/ci-skill-packs.yml | 56 ++++
.github/workflows/ci-tests.yml | 2 +
apps/cli/README.md | 12 +
apps/cli/package.json | 3 +
apps/dashboard/package.json | 1 +
apps/mcp-server/package.json | 1 +
docs/community-skill-packs.md | 31 ++
scripts/tests/test_validate_skill_packs.sh | 289 ++++++++++++++++
scripts/validate-skill-packs.mjs | 362 +++++++++++++++++++++
13 files changed, 929 insertions(+)
create mode 100644 .github/workflows/ci-apps.yml
create mode 100644 .github/workflows/ci-skill-packs.yml
create mode 100755 scripts/tests/test_validate_skill_packs.sh
create mode 100644 scripts/validate-skill-packs.mjs
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index f5e1045a51..5af9a677c0 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -184,12 +184,24 @@ protect:
| `ci-packs-json` | `packs.json` matches a fresh `bin/generate-packs-json` |
| `ci-skill-category` | every `SKILL.md` declares a valid `category:` |
| `ci-capabilities-parity` | the capabilities taxonomy stays in sync |
+| `ci-skill-packs` | `catalog/skill-packs.json` is well-formed **and** matches the README's Community Packs table |
+| `ci-apps` | each app in `apps/**` typechecks, tests, and builds |
+| `ci-tests` | the `scripts/tests/` suites pass |
+| `ci-okf` | the knowledge bundle stays OKF-conformant |
+| `ci-agents-md` | `AGENTS.md` is regenerated from `STRATEGY.md` |
Run the checks locally before pushing:
```bash
bash scripts/check-skill-categories.sh
bash scripts/check-capabilities-parity.sh
+node scripts/validate-skill-packs.mjs # listing a community pack
+```
+
+Touching `apps/**`? Run that app's own checks — for the dashboard:
+
+```bash
+cd apps/dashboard && npm ci && npm run typecheck && npm test && npm run build
```
If `ci-skills-json`/`ci-packs-json` fails, you changed a generator input without
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 780323d505..4420c2050e 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -38,6 +38,7 @@
- [ ] Pack has a `skills-pack.json` manifest at its root and a `SKILL.md` per skill
- [ ] Write / onchain / bet skills are `default_enabled: false`
- [ ] This PR adds **both** a README table row and a matching `skill-packs.json` entry
+- [ ] `node scripts/validate-skill-packs.mjs` passes (registry shape + README parity, incl. the pack counter)
- [ ] No monkey-patching of Aeon internals; no private or auth-walled endpoints required to run
### Core fix (dashboard / scripts / workflows / docs)
@@ -45,6 +46,7 @@
- [ ] Change is focused — one concern, no unrelated refactor
- [ ] Touched code follows the existing pattern in the file
- [ ] Relevant CI gates pass locally (e.g. `bash scripts/check-skill-categories.sh`, `bash scripts/check-capabilities-parity.sh`)
+- [ ] Touched `apps/**`? That app typechecks, tests, and builds (dashboard: `npm run typecheck && npm test && npm run build`)
---
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 569b7fe047..22ee454d8f 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -67,6 +67,22 @@ updates:
patterns:
- "*"
+ # CLI — non-interactive control of an Aeon repo; shares apps/dashboard/lib.
+ - package-ecosystem: "npm"
+ directory: "/apps/cli"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 5
+ assignees:
+ - "aeonfun"
+ commit-message:
+ prefix: "chore(deps)"
+ prefix-development: "chore(deps-dev)"
+ groups:
+ cli:
+ patterns:
+ - "*"
+
# Cloudflare Worker that relays Telegram messages to a fork.
- package-ecosystem: "npm"
directory: "/apps/webhook"
diff --git a/.github/workflows/ci-apps.yml b/.github/workflows/ci-apps.yml
new file mode 100644
index 0000000000..9c188195bc
--- /dev/null
+++ b/.github/workflows/ci-apps.yml
@@ -0,0 +1,142 @@
+name: ci-apps
+
+# apps/** was the largest untested surface in the repo: four shipped apps — the
+# Next.js dashboard, the CLI, the MCP server, and the Telegram Worker — with no
+# gate of any kind. The dashboard alone carries 156 committed unit tests
+# (apps/dashboard/lib/**/*.test.ts) that nothing ran, plus a strict-mode
+# TypeScript config nothing typechecked.
+#
+# That gap is not theoretical. Dependabot opens grouped monthly npm PRs against
+# apps/dashboard, apps/mcp-server, and apps/webhook (.github/dependabot.yml) —
+# every one of them landed with zero automated verification that the app still
+# compiles. The TypeScript-7 pin comment in dependabot.yml records a breakage
+# caught by hand: `next build` crashed while `tsc --noEmit` passed. That is why
+# the dashboard job runs BOTH — a typecheck alone would have missed it.
+#
+# One job per app so a red X names the broken surface, and so a slow dashboard
+# build never masks a fast Worker failure. The workflow-level path filter is
+# `apps/**` rather than per-job filters: keeping it native (no third-party
+# paths-filter action) is worth re-running a 30-second Worker job on a
+# dashboard-only PR.
+#
+# No secrets, no network beyond the registry: `next build` is fully static here
+# (every route is dynamic/server-rendered at request time) and `wrangler
+# --dry-run` bundles the Worker without touching a Cloudflare account, so this
+# workflow runs green on fork PRs.
+#
+# Run locally: see each app's README, or the same commands used below.
+
+on:
+ pull_request:
+ paths:
+ - 'apps/**'
+ - '.github/workflows/ci-apps.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'apps/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# A force-push mid-review shouldn't leave a stale build burning a runner.
+concurrency:
+ group: ci-apps-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ dashboard:
+ name: dashboard — typecheck, test, build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/dashboard
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: apps/dashboard/package-lock.json
+ - name: Install deps
+ run: npm ci
+ - name: Typecheck (strict)
+ run: npm run typecheck
+ - name: Unit tests
+ run: npm test
+ # Kept separate from the typecheck on purpose — see the TS7 note above.
+ - name: Production build
+ run: npm run build
+
+ cli:
+ name: cli — typecheck
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: |
+ apps/cli/package-lock.json
+ apps/dashboard/package-lock.json
+ # The CLI deliberately shares one source of truth with the dashboard: its
+ # tsconfig compiles ../dashboard/lib/**/*.ts and borrows that app's
+ # typescript + @types (apps/cli/tsconfig.json `typeRoots`). So the CLI
+ # cannot be typechecked without the dashboard's node_modules — installing
+ # both here is what makes `npm run typecheck` resolve at all.
+ - name: Install dashboard deps (CLI borrows its typescript + @types)
+ run: npm ci
+ working-directory: apps/dashboard
+ - name: Install CLI deps
+ run: npm ci
+ working-directory: apps/cli
+ - name: Typecheck (strict, includes the shared dashboard lib)
+ run: npm run typecheck
+ working-directory: apps/cli
+
+ mcp-server:
+ name: mcp-server — build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/mcp-server
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+ # No package-lock.json is committed for this app, so `npm ci` is not
+ # available and the install floats within the package.json ranges.
+ - name: Install deps
+ run: npm install --no-audit --no-fund
+ # `npm run build` (tsc, emitting to dist/) rather than a bare --noEmit:
+ # dist/index.js is this package's published `bin`, so a build that cannot
+ # emit is a broken install for anyone running `aeon-mcp`.
+ - name: Build
+ run: npm run build
+
+ webhook:
+ name: webhook — worker bundle
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/webhook
+ env:
+ WRANGLER_SEND_METRICS: 'false'
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+ - name: Syntax check the Worker
+ run: node --check src/worker.js
+ # No lockfile committed for this app either — see mcp-server above.
+ - name: Install deps
+ run: npm install --no-audit --no-fund
+ # --dry-run validates wrangler.toml and bundles the Worker without any
+ # Cloudflare credentials, so a broken config or a bad import fails here
+ # instead of at deploy time.
+ - name: Bundle (wrangler --dry-run, no credentials needed)
+ run: npx wrangler deploy --dry-run --outdir "$RUNNER_TEMP/worker-dist"
diff --git a/.github/workflows/ci-skill-packs.yml b/.github/workflows/ci-skill-packs.yml
new file mode 100644
index 0000000000..4627b22b68
--- /dev/null
+++ b/.github/workflows/ci-skill-packs.yml
@@ -0,0 +1,56 @@
+name: ci-skill-packs
+
+# catalog/skill-packs.json is the community pack registry — and the one file in
+# this repo that is routinely hand-edited by people outside it. Every listing
+# arrives as an outside-contributor PR that must touch TWO surfaces in one diff
+# (docs/community-skill-packs.md publishing checklist): a row in the README's
+# Community Packs table and a matching registry entry. Nothing checked either.
+#
+# What shipping a bad edit costs: `bin/install-skill-pack --list` jq's this file
+# directly, and the dashboard's community-packs panel fetches it
+# (apps/dashboard/lib/packs.ts) — so one trailing comma breaks pack discovery for
+# every Aeon fork, not just this repo. A README row with no registry entry is
+# invisible to both. A `--path` flag that disagrees with the registry hands
+# browsers a copy-paste command that installs the wrong subtree.
+#
+# The validator also refuses a self-declared `trust_level: trusted` that is not
+# in skills/security/trusted-sources.txt: `--list` prints its trust badge from
+# the registry but the installer decides the real scan bypass from the trusted
+# file, so an unbacked claim advertises "security scan skipped" without earning
+# it. That check is why bin/install-skill-pack and trusted-sources.txt are
+# trigger paths here — the gate has to re-run when either side moves.
+#
+# Sibling of ci-packs-json.yml (first-party packs) and ci-skills-json.yml
+# (skill catalog); this covers the third, previously ungated, registry.
+#
+# Run locally: node scripts/validate-skill-packs.mjs
+
+on:
+ pull_request:
+ paths:
+ - 'catalog/skill-packs.json'
+ - '.github/README.md'
+ - 'bin/install-skill-pack'
+ - 'skills/security/trusted-sources.txt'
+ - 'scripts/validate-skill-packs.mjs'
+ - '.github/workflows/ci-skill-packs.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'catalog/skill-packs.json'
+ - '.github/README.md'
+ - 'bin/install-skill-pack'
+ - 'skills/security/trusted-sources.txt'
+ - 'scripts/validate-skill-packs.mjs'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: Validate the community pack registry and its README parity
+ run: node scripts/validate-skill-packs.mjs
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 06f7fb19ab..874e1c0846 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -42,6 +42,8 @@ jobs:
run: bash scripts/tests/test_run_actions_summary.sh
- name: skill-pack manifest validation tests
run: bash scripts/tests/test_validate_pack.sh
+ - name: community pack registry validation tests
+ run: bash scripts/tests/test_validate_skill_packs.sh
- name: grok harness runner tests
run: bash scripts/tests/test_run_grok.sh
- name: state reducer tests
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 1abbb3300b..3d9ca46c5d 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -95,3 +95,15 @@ Exit code is non-zero on error.
(git), `auth.ts` (auth flow), `packs.ts` (pack join). Ten routes now wrap these.
- `apps/dashboard/lib/gh.ts` honours `AEON_REPO_ROOT` so the shared lib is
location-independent (unset = the dashboard's original cwd-relative behaviour).
+
+## Typecheck
+
+```bash
+(cd ../dashboard && npm ci) # the CLI borrows the dashboard's typescript + @types
+npm ci && npm run typecheck # strict; compiles src/ plus the shared dashboard lib
+```
+
+Because `tsconfig.json` compiles `../dashboard/lib/**/*.ts` and points
+`typeRoots` at that app, the CLI has no `typescript` dependency of its own and
+cannot be typechecked without the dashboard's `node_modules`. CI does exactly
+the two installs above — see `.github/workflows/ci-apps.yml`.
diff --git a/apps/cli/package.json b/apps/cli/package.json
index f6e5fa2d33..0d719110d3 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -8,6 +8,9 @@
"bin": {
"aeon": "./aeon"
},
+ "scripts": {
+ "typecheck": "../dashboard/node_modules/.bin/tsc --noEmit -p tsconfig.json"
+ },
"dependencies": {
"yaml": "^2.9.0"
},
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 47e955f96f..0eae1269ea 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -7,6 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
+ "typecheck": "tsc --noEmit",
"test": "node --import tsx --test 'lib/**/*.test.mjs' 'lib/**/*.test.ts'"
},
"dependencies": {
diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json
index 3d9c193fbb..106ff41d9d 100644
--- a/apps/mcp-server/package.json
+++ b/apps/mcp-server/package.json
@@ -10,6 +10,7 @@
},
"scripts": {
"build": "tsc",
+ "typecheck": "tsc --noEmit",
"dev": "tsc --watch",
"start": "node dist/index.js"
},
diff --git a/docs/community-skill-packs.md b/docs/community-skill-packs.md
index 85c5d7b5ac..ef230ab96a 100644
--- a/docs/community-skill-packs.md
+++ b/docs/community-skill-packs.md
@@ -233,6 +233,37 @@ The operator is always the trust boundary. The install script does not auto-trus
Pack maintainers update both in the same PR so the two surfaces stay in lockstep.
+### CI validates both (run it before you open the PR)
+
+`ci-skill-packs` gates every PR that touches the registry or the README:
+
+```bash
+node scripts/validate-skill-packs.mjs
+```
+
+It fails the PR on registry shape (unparseable JSON, a `repo` that isn't
+`owner/repo`, an empty or duplicated `skills[]`, a `trust_level` outside
+`trusted|community`, a capability outside the [locked taxonomy](CAPABILITIES.md),
+a `secrets_required` entry that isn't an env var name) and on README parity (an
+entry with no table row or a row with no entry, a skill count that disagrees with
+`skills[]`, a `--path` flag that disagrees with the registry's `path`, and the
+`N community skill packs` counter in the README's Proof of work section).
+
+Two things to know:
+
+- **`trust_level: trusted` must be earned.** `--list` prints its trust badge from
+ this registry, but the installer decides the real scan bypass from
+ `skills/security/trusted-sources.txt`. A `trusted` entry that isn't in that
+ file advertises "security scan skipped" without ever getting it, so the gate
+ rejects it. Community packs use `trust_level: community`.
+- **A subdirectory pack needs its `--path` in the README row.** If your registry
+ entry sets `path`, the table row has to show the matching
+ (`` `--path
` ``) flag — otherwise the command a reader copies out of the
+ README installs the wrong subtree.
+
+Missing recommended fields (`name`, `description`, `author`) and unrecognised
+fields warn rather than fail.
+
---
## Listed packs
diff --git a/scripts/tests/test_validate_skill_packs.sh b/scripts/tests/test_validate_skill_packs.sh
new file mode 100755
index 0000000000..ff057afd6b
--- /dev/null
+++ b/scripts/tests/test_validate_skill_packs.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+# Unit test for scripts/validate-skill-packs.mjs — registry shape, README parity,
+# and the trust/capability checks. No network, no GitHub auth required. Every
+# case runs against throwaway fixtures under /tmp; the last case runs the real
+# committed registry so a drifted main is caught here too.
+set -uo pipefail
+cd "$(dirname "$0")/../.." || exit 1
+
+V="scripts/validate-skill-packs.mjs"
+fail=0
+pass(){ echo "ok - $1"; }
+bad(){ echo "FAIL - $1"; fail=1; }
+
+TMP="$(mktemp -d "${TMPDIR:-/tmp}/validate-skill-packs.XXXXXX")"
+trap 'rm -rf "$TMP"' EXIT
+
+# ── Fixture builders ────────────────────────────────────────────────────────
+# A fixture dir holds the four inputs the validator reads. Callers overwrite
+# whichever one the case is about.
+
+# write_registry
+write_registry() {
+ cat > "$1/skill-packs.json" <
+write_readme() {
+ cat > "$1/README.md" < "$1/install-skill-pack" <<'EOF'
+#!/usr/bin/env bash
+ALLOWED_CAPABILITIES=(
+ read_only
+ external_api
+ writes_external_host
+ onchain_writes
+ agent_messaging
+ sends_notifications
+)
+EOF
+}
+
+write_trusted() {
+ cat > "$1/trusted-sources.txt" <<'EOF'
+# comment line
+goodowner/good-pack
+EOF
+}
+
+ONE_PACK='[{"repo":"goodowner/good-pack","name":"Good Pack","description":"A pack.","author":"goodowner","license":"MIT","homepage":"https://example.com","category":"dev","trust_level":"community","skills":["alpha","beta"]}]'
+ONE_ROW='| [Good Pack](https://github.com/goodowner/good-pack) | 2 | A pack. |'
+
+# new_fixture → prints the dir; baseline is valid and passes.
+new_fixture() {
+ local d="$TMP/$1"
+ mkdir -p "$d"
+ write_registry "$d" "$ONE_PACK"
+ write_readme "$d" 1 "$ONE_ROW"
+ write_installer "$d"
+ write_trusted "$d"
+ echo "$d"
+}
+
+run() {
+ node "$V" --registry "$1/skill-packs.json" --readme "$1/README.md" \
+ --installer "$1/install-skill-pack" --trusted "$1/trusted-sources.txt" 2>&1
+}
+
+# expect_ok