Files
hermes-agent/.github/workflows/plugin-catalog-ci.yml
teknium1 b909c01d07 fix(ci): scope the catalog data-only guard to entry PRs; diff the merge commit shallowly
- Run the data-only guard once, inside the changed-entries step of the
  pinned-source job, and only when an entry YAML changed. README, policy,
  docs and CI PRs that touch plugin-catalog/ without an entry no longer go
  red.
- Allow contributors/emails/** beside plugin-catalog/**: the attribution
  fix we push onto catalog PR branches is data the gate never executes
  (29 of the last 134 merged catalog PRs carried one).
- Diff GitHub's merge commit against its first parent with fetch-depth: 2
  instead of a full-history clone plus merge-base. The structural job,
  which only validates the whole directory, goes back to a shallow
  checkout with no diff at all.
- `subdir:` with no value means the repo root in both the parse step and
  the structural validator, matching how the runtime loader has always
  read it.
2026-10-01 10:46:45 -07:00

283 lines
14 KiB
YAML

name: Plugin Catalog CI
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
# plugin-catalog/** so it can never go red on unrelated PRs.
#
# Two gates:
# structural — cheap schema check, no hermes install needed
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
# reachable in the entry's repo, and the plugin
# at that exact commit must pass
# `hermes plugins validate`.
on:
pull_request:
paths:
- "plugin-catalog/**"
permissions:
contents: read
jobs:
structural:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
persist-credentials: false # this job is read-only
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
- name: Install ruamel.yaml
uses: ./.github/actions/retry
with:
command: pip install ruamel.yaml==0.18.17
- name: Validate catalog files (structural)
run: |
set -euo pipefail
# Validating the whole directory is simpler than diffing and keeps
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
python3 scripts/validate_plugin_catalog.py plugin-catalog/
pinned-source-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
# The pull_request checkout is GitHub's merge commit; depth 2 brings its
# first parent (the base tip), which is all the diff below needs.
fetch-depth: 2
# `hermes plugins validate` below runs pinned-repo code; keep the
# job's token out of .git/config where that code could read it.
persist-credentials: false
- name: Find changed catalog entries
id: changed
run: |
set -euo pipefail
# HEAD is the PR merge commit: diffing it against its first parent is
# exactly what the PR would land. Refuse anything else rather than
# silently diffing a single commit.
if ! git rev-parse -q --verify 'HEAD^2' >/dev/null; then
echo "::error::expected the pull_request merge commit as HEAD"
exit 1
fi
# Added + modified + renamed + typechanged entry files only; deletions
# and removed.yaml have nothing to clone. A rename or file->symlink
# change that slipped past this list would skip the supply-chain gate.
CHANGED=$(git diff --name-only --diff-filter=AMRT 'HEAD^1' HEAD \
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml')
# The grep can only shrink the list and runs on its own so a git diff
# failure still fails the step instead of looking like "no changes".
CHANGED=$(printf '%s' "$CHANGED" | grep -v '/removed\.yaml$' || true)
if [ -n "$CHANGED" ]; then
# Admission PRs must be data-only: the gate's own enforcement code
# (this workflow, the validator, the composite actions, the built
# hermes) resolves from the PR checkout, so an entry PR that also
# touches tooling could neuter the gate vetting it. Contributor
# email maps are data the gate never executes; README/policy/CI PRs
# with no entry change skip this check.
ALL=$(git diff --name-only 'HEAD^1' HEAD)
OUTSIDE=$(printf '%s' "$ALL" | grep -vE '^(plugin-catalog|contributors/emails)/' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::catalog entry PRs may only touch plugin-catalog/** and contributors/emails/**, found:"
# Filenames are PR-controlled; a newline in one could smuggle a
# workflow command into the log.
printf '%s' "$OUTSIDE" | tr '\0\n' ' '
echo
exit 1
fi
fi
echo "Changed catalog entries:"
echo "${CHANGED:-<none>}"
{
echo 'files<<__EOF__'
echo "$CHANGED"
echo '__EOF__'
} >> "$GITHUB_OUTPUT"
- name: Prepare Hermes from the PR's own checkout
if: steps.changed.outputs.files != ''
uses: ./.github/actions/setup-pm
with:
toolchain: python
extras: '[]'
- name: Clone each entry at its pinned sha and validate
if: steps.changed.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
# A hostile repo URL must never block the job on a credential prompt.
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
FAILED=0
WORKROOT=$(realpath "$(mktemp -d)")
# Entries arrive on fd 3, not stdin: `hermes plugins validate` executes
# plugin-declared install code that could otherwise read stdin and
# silently drain the remaining entry list.
while IFS= read -r entry <&3; do
[ -z "$entry" ] && continue
echo "::group::validate $entry"
# A symlinked or missing entry file is not a catalog entry.
if [ -L "$entry" ] || [ ! -f "$entry" ]; then
echo "::error file=$entry::catalog entry must be a regular file"
FAILED=1; echo "::endgroup::"; continue
fi
# Parse repo / sha / subdir from the entry yaml. These fields are
# PR-controlled, so the entry is rejected here before anything uses
# them -- the clone must be remote-only and the subdir a plain
# relative path, or the pinned sha ends up validating files outside
# the pinned commit. `python3 -I` keeps the PR checkout's own
# modules off sys.path (cwd is the PR tree) so it cannot shadow the
# parser's imports; ruamel.yaml comes from installed packages.
PARSED=$(python3 -I - "$entry" <<'PYEOF'
import re
import shlex
import sys
from ruamel.yaml import YAML
try:
with open(sys.argv[1], encoding="utf-8") as fh:
data = YAML(typ='safe').load(fh)
except Exception:
data = None
repo = str(data.get('repo', '')) if isinstance(data, dict) else ''
sha = data.get('sha', '') if isinstance(data, dict) else ''
subdir_raw = data.get('subdir', '') if isinstance(data, dict) else ''
if subdir_raw is None:
subdir_raw = '' # `subdir:` with no value means the repo root
subdir = subdir_raw if isinstance(subdir_raw, str) else ''
bad = ""
if data is None:
bad = "entry yaml is empty or unparseable"
elif not isinstance(data, dict):
bad = "entry yaml must be a mapping"
elif not re.fullmatch(r"https://\S+", repo):
# No whitespace either: the value is echoed into the log, where a
# newline would smuggle workflow commands like ::error::.
bad = "repo must be an https:// git URL"
elif not isinstance(sha, str):
bad = "sha must be a string"
elif not re.fullmatch(r"[0-9a-f]{40}", sha):
# A ref name would validate the branch tip, not the pin; a leading
# '-' would become a checkout option.
bad = "sha must be exactly 40 lowercase hex characters"
elif not isinstance(subdir_raw, str):
# Never coerce: a falsy non-string (0, false) would silently mean
# the clone root while the structural gate rejects the entry.
bad = "subdir must be a string"
elif subdir and (
not re.fullmatch(r"[A-Za-z0-9._/-]+", subdir)
or any(seg in ("", ".", "..") for seg in subdir.split("/"))
):
bad = "subdir must be a relative path without '.' or '..' segments"
print(f"REPO={shlex.quote(repo)}")
print(f"SHA={shlex.quote(str(sha))}")
print(f"SUBDIR={shlex.quote(subdir)}")
print(f"BAD={shlex.quote(bad)}")
PYEOF
) || PARSED="BAD='catalog entry parser failed'"
# Reset before eval: a killed or truncated parser must not let this
# entry inherit a previous entry's repo/sha and pass on its content.
REPO="" SHA="" SUBDIR="" BAD=""
eval "$PARSED"
# Check before echo: a rejected value must never reach the log, where
# a newline in it would smuggle workflow commands like ::error::.
if [ -n "$BAD" ]; then
echo "::error file=$entry::$BAD"
FAILED=1; echo "::endgroup::"; continue
fi
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
CLONE_DIR=$(realpath "$(mktemp -d -p "$WORKROOT")")
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
# 600s: listed repos reach >1 GB (hermes-jackal-verified 1.43 GB,
# hindsight 741 MB); 120s would false-fail a re-pin on a slow runner.
if ! timeout 600 git clone "$REPO" "$CLONE_DIR" </dev/null; then
echo "::error file=$entry::clone failed for $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# The pin must name a commit object -- a tag-object sha would peel
# to a commit the reviewer never declared as the pin.
if [ "$(git -C "$CLONE_DIR" cat-file -t "$SHA" 2>/dev/null)" != "commit" ]; then
echo "::error file=$entry::pinned sha $SHA is not a commit object in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
if ! git -C "$CLONE_DIR" checkout --detach "$SHA" </dev/null; then
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
# A symlinked directory inside the clone could still point outside
# it, so confine the resolved path under the clone, not the lexical one.
RESOLVED_DIR=$(realpath -m -- "$PLUGIN_DIR")
if [ "$RESOLVED_DIR" != "$CLONE_DIR" ] && [ "${RESOLVED_DIR#"$CLONE_DIR"/}" = "$RESOLVED_DIR" ]; then
echo "::error file=$entry::subdir '$SUBDIR' resolves outside the pinned clone"
FAILED=1; echo "::endgroup::"; continue
fi
# Confinement is per-symlink too, across the whole clone: find does
# not descend into a symlinked directory, so an allowed in-clone
# link like plugin/ext -> ../lib would otherwise hide lib/evil ->
# /outside from a PLUGIN_DIR-only scan while reads through it still
# escape the pin.
if ! find "$CLONE_DIR" -path "$CLONE_DIR/.git" -prune -o -type l -print0 | while IFS= read -r -d '' link; do
target=$(realpath -m -- "$link")
case "$target" in "$CLONE_DIR"|"$CLONE_DIR"/*) ;; *) exit 1 ;; esac
done; then
echo "::error file=$entry::a symlink in the repo resolves outside the pinned clone"
FAILED=1; echo "::endgroup::"; continue
fi
# Native plugin.yaml OR portable Agent Plugins v1 plugin.json
# (#81196; native manifest wins when both exist).
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ] && [ ! -f "$PLUGIN_DIR/plugin.yml" ] && [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
echo "::error file=$entry::no plugin.yaml or plugin.json at subdir '$SUBDIR' of $REPO@$SHA"
FAILED=1; echo "::endgroup::"; continue
fi
# Manifest schema + declared-vs-registered capability check.
if ! timeout 600 hermes plugins validate --install-deps "$PLUGIN_DIR" </dev/null; then
echo "::error file=$entry::hermes plugins validate failed"
FAILED=1; echo "::endgroup::"; continue
fi
# SELF-UPDATER GATE (README rule 3): the pin is the only update path.
# A desktop bundle that both fetches from GitHub AND writes/renames
# plugin files is a self-updater; either half alone is fine (a
# plugin may read the API, or manage its own data files).
# NUL-delimited both ways: committed filenames with spaces or
# newlines must not split into grep options or forged log lines.
SELF_UPDATE=$(grep -rlZE 'releases/latest|releases/download|raw\.githubusercontent\.com|codeload\.github\.com' \
--include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' \
--include='*.jsx' --include='*.tsx' --include='*.mts' --include='*.cts' \
"$PLUGIN_DIR" \
| xargs -r0 grep -lZE 'writeTextFile|renamePath|writeFile\(|writeFileSync|renameSync|copyFileSync|cpSync|createWriteStream' || true)
SELF_UPDATE=$(printf '%s' "$SELF_UPDATE" | tr '\0\n' ' ')
if [ -n "$SELF_UPDATE" ]; then
echo "::error file=$entry::self-updating code in catalog build (fetches GitHub AND writes plugin files): $SELF_UPDATE"
FAILED=1; echo "::endgroup::"; continue
fi
echo "✅ PASS: $entry"
echo "::endgroup::"
done 3<<< "$CHANGED_FILES"
rm -rf "$WORKROOT"
if [ "$FAILED" -ne 0 ]; then
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
exit 1
fi
echo "✅ PASS: all changed catalog entries validated at their pinned shas"