pinned-source-validate checked out depth 2. Hermes resolves its own version from release tags merged into HEAD, so the shallow checkout reported 'unknown' and plugins that gate on the host version refused to load (omh, #122578: requires Hermes >=0.21.1). A blobless full clone carries the commit graph and tags for about the cost of the shallow one.
286 lines
14 KiB
YAML
286 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:
|
|
# Full commit graph + tags, blobs on demand: the diff below needs the
|
|
# merge commit's first parent, and Hermes resolves its own version
|
|
# from the release tags merged into HEAD. A shallow checkout reports
|
|
# "unknown", which plugins checking the host version reject (omh).
|
|
fetch-depth: 0
|
|
filter: blob:none
|
|
# `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"
|