Files

184 lines
7.8 KiB
YAML

name: Refresh registry-worker cache
# On every push that changes any registry content, regenerate the two
# in-repo indexes the registry-worker ingests:
# plugins-index.json — daemon-shaped flat plugins array (signed)
# registry-index.json — dict-shaped dashboard payload (unsigned)
# then poke the worker's forced-refresh endpoint so it pulls both
# (3 subrequests total, regardless of registry size — fits Workers
# Free's 50-subrequest budget) and stores them.
#
# Without this, dashboard + daemon would have to wait for the next
# 02:00 UTC cron tick to see content changes (up to ~24h delay).
#
# === SECURITY MODEL ===
#
# REGISTRY_PRIVATE_KEY (Ed25519 PKCS#8) is the trust root for every
# `librefang plugin install <name>` worldwide. Threats and mitigations:
#
# 1. Compromised maintainer pushes a malicious plugins-index.json
# directly to main. Mitigation: GitHub branch protection on `main`
# requires PR review (admin-configured); .github/CODEOWNERS forces
# sign-script and committed-artefact changes through the registry
# owner.
#
# 2. Malicious PR adds a step that exfiltrates the secret. Mitigation:
# .github/CODEOWNERS owns this file; can't be modified without
# registry-owner approval. Action versions are SHA-pinned to block
# action-supply-chain attacks (transitive `uses:` swap).
#
# 3. Sign step writes garbage. Mitigation: post-sign verify step
# validates the signature against the corresponding pubkey before
# committing — a malformed sign-script run is caught here, not
# after publish.
#
# 4. Worker secret REGISTRY_REFRESH_TOKEN leaked. Mitigation: worker
# no longer holds signing material (PR #4600), so a leaked token
# lets an attacker trigger refreshes against existing committed
# bytes — they can't substitute attacker-supplied bytes for the
# worker to sign.
on:
push:
branches: [main]
paths:
- 'plugins/**'
- 'agents/**'
- 'skills/**'
- 'hands/**'
- 'channels/**'
- 'providers/**'
- 'workflows/**'
- 'mcp/**'
- 'scripts/build-plugins-index.mjs'
- 'scripts/build-registry-index.mjs'
- 'scripts/sign-plugins-index.mjs'
workflow_dispatch:
permissions:
contents: write # push the regen to a side branch
pull-requests: write # open the regen PR for maintainer merge
jobs:
refresh:
runs-on: ubuntu-latest
steps:
# SHA-pinned to block action-supply-chain swaps. Update with care
# (read the diff between current and target SHA upstream).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '20'
- name: Rebuild indexes
run: |
node scripts/build-plugins-index.mjs
node scripts/build-registry-index.mjs
# REGISTRY_PRIVATE_KEY is scoped to ONLY this step's env so prior
# build steps and later trigger steps cannot read it. Keep this
# narrow; do NOT lift the env to the job level.
- name: Sign plugins-index.json
env:
REGISTRY_PRIVATE_KEY: ${{ secrets.REGISTRY_PRIVATE_KEY }}
run: node scripts/sign-plugins-index.mjs
# Defense in depth: verify the signature against the committed
# pubkey before the .sig hits main. This catches an *accidental*
# sign-script regression — a malformed signature, an env-leak that
# produces zero bytes, an off-by-one in the JSON canonicalization.
# It does NOT defend against an adversary, because an attacker
# who can edit sign-plugins-index.mjs in a PR can edit this verify
# step and the embedded pubkey in the same diff. The CODEOWNERS
# gate on `/scripts/` and `/.github/workflows/` is what stops
# adversarial edits, not this step.
- name: Verify signature against committed pubkey
env:
REGISTRY_PUBLIC_KEY: joY8IYrUbbACfKRyp2CTcEbcEty8wcBwP1MTxU+vjaM=
run: |
node -e '
const c = require("crypto"), fs = require("fs");
const idx = fs.readFileSync("plugins-index.json");
const sig = Buffer.from(
fs.readFileSync("plugins-index.json.sig", "utf8").trim(),
"base64",
);
const pub = Buffer.from(process.env.REGISTRY_PUBLIC_KEY, "base64");
if (pub.length !== 32) {
console.error("REGISTRY_PUBLIC_KEY is not a raw 32-byte Ed25519 pubkey");
process.exit(1);
}
const spki = Buffer.concat([
Buffer.from("302a300506032b6570032100", "hex"),
pub,
]);
const k = c.createPublicKey({ key: spki, format: "der", type: "spki" });
if (!c.verify(null, idx, k, sig)) {
console.error("plugins-index.json.sig does NOT verify against the committed pubkey");
process.exit(1);
}
console.log("Signature verifies OK against committed pubkey.");
'
# Direct `git push` to `main` is rejected by branch protection
# (GH006: "Changes must be made through a pull request"), which is
# the intended security model documented at the top of this file
# (mitigation #1). Open a PR with the regen instead so the same
# human-review gate applies to bot-authored index updates. The PR
# body links back to the triggering content commit so reviewers can
# eyeball the regen against the source change.
- name: Open PR with regenerated indexes if changed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add plugins-index.json plugins-index.json.sig registry-index.json
if git diff --cached --quiet; then
echo "indexes already up-to-date"
exit 0
fi
branch="automation/refresh-indexes-${GITHUB_SHA::8}"
git switch -c "$branch"
git commit -m "chore: regenerate registry indexes for ${GITHUB_SHA::8}"
git push -u origin "$branch"
gh pr create \
--base main \
--head "$branch" \
--title "chore: regenerate registry indexes for ${GITHUB_SHA::8}" \
--body "Auto-generated by \`.github/workflows/refresh-cache.yml\` after $GITHUB_SHA.
Regenerates the two indexes the registry-worker ingests:
- \`plugins-index.json\` + \`plugins-index.json.sig\` (signed)
- \`registry-index.json\`
The signature was verified against the committed pubkey in the
generating run before this PR was opened (see the run linked
on the commit).
Branch protection on \`main\` blocks direct push (the documented
security model — see the header of \`refresh-cache.yml\`), so
this PR carries the regen for maintainer review and merge.
Squash-merge is safe; \`[skip ci]\` is not needed since the
regen run on the merge commit will be a no-op."
- name: Trigger worker refresh
env:
REGISTRY_REFRESH_TOKEN: ${{ secrets.REGISTRY_REFRESH_TOKEN }}
run: |
if [ -z "$REGISTRY_REFRESH_TOKEN" ]; then
echo "::error::REGISTRY_REFRESH_TOKEN secret is not set on this repo"
exit 1
fi
response=$(curl -fsS -X POST \
-H "Authorization: Bearer $REGISTRY_REFRESH_TOKEN" \
-w "\nHTTP_CODE:%{http_code}" \
https://librefang.ai/api/registry/refresh)
echo "$response"
code=$(echo "$response" | grep -oE 'HTTP_CODE:[0-9]+' | cut -d: -f2)
if [ "$code" != "200" ]; then
echo "::error::worker returned $code"
exit 1
fi