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 ` 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://stats.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