From dcbe583a4bf62d8aefd6fa25a26132311422ceb3 Mon Sep 17 00:00:00 2001
From: John Smith
Date: Sat, 17 Oct 2026 13:18:59 -0400
Subject: [PATCH] ci: publish the README through the required validate check
Deploy keys are disabled for this org and GitHub Actions cannot be a ruleset bypass actor, so main is protected by requiring a passing validate check on every commit. The README workflow stages its regenerated commit on bot/readme-, dispatches validate there (allowed to touch generated files only for that bot branch), waits for success and fast-forwards main. Adds a catalog policy link to the README header and documents the branch protection.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/generate-readme.yml | 36 +++++++++++++++++++++++----
.github/workflows/validate-pr.yml | 9 ++++++-
docs/policy.md | 6 ++++-
scripts/check-pr.js | 12 ++++++---
templates/README.template.md | 2 ++
5 files changed, 54 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml
index ed16ca3..37da53a 100644
--- a/.github/workflows/generate-readme.yml
+++ b/.github/workflows/generate-readme.yml
@@ -10,8 +10,12 @@ on:
- 'scripts/**'
workflow_dispatch:
+# main requires a passing "validate" check on every commit, so the regenerated README is first pushed
+# to a bot/readme-* branch, validated there, and then fast-forwarded onto main.
permissions:
contents: write
+ actions: write
+ checks: read
concurrency:
group: auto-generate-readme-main
@@ -39,7 +43,9 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts
- - name: Generate and push README/registry
+ - name: Generate, validate and publish README/registry
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -62,14 +68,34 @@ jobs:
git add README.md dist/registry.json
git commit -m "docs: auto-regenerate README and registry data"
+ sha="$(git rev-parse HEAD)"
+ branch="bot/readme-${sha:0:12}"
- if git push origin HEAD:main; then
+ # Stage the commit and run the required "validate" check on it
+ git push --force origin "HEAD:refs/heads/${branch}"
+ gh workflow run validate-pr.yml --ref "${branch}"
+ conclusion=""
+ for i in $(seq 1 60); do
+ sleep 10
+ conclusion="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?check_name=validate" \
+ --jq '[.check_runs[] | select(.status == "completed") | .conclusion][0] // ""')"
+ [ -n "${conclusion}" ] && break
+ done
+
+ if [ "${conclusion}" != "success" ]; then
+ git push origin --delete "${branch}" || true
+ echo "validate on ${sha} finished with '${conclusion:-timeout}'; not publishing" >&2
+ exit 1
+ fi
+
+ if git push origin "${sha}:refs/heads/main"; then
+ git push origin --delete "${branch}" || true
exit 0
fi
- echo "Push raced with another main update; retrying..."
- sleep 2
+ git push origin --delete "${branch}" || true
+ echo "main moved while validating; retrying..."
done
- echo "Failed to push regenerated README/registry after retries" >&2
+ echo "Failed to publish regenerated README/registry after retries" >&2
exit 1
diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml
index a34065b..cb6aaa9 100644
--- a/.github/workflows/validate-pr.yml
+++ b/.github/workflows/validate-pr.yml
@@ -41,7 +41,14 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
- run: node scripts/check-pr.js --base "$BASE_REF"
+ # The README bot stages its regenerated README on bot/readme-* and dispatches this check
+ ALLOW_GENERATED: ${{ github.event_name == 'workflow_dispatch' && github.actor == 'github-actions[bot]' && startsWith(github.ref_name, 'bot/readme-') }}
+ run: |
+ if [ "$ALLOW_GENERATED" = "true" ]; then
+ node scripts/check-pr.js --base "$BASE_REF" --allow-generated
+ else
+ node scripts/check-pr.js --base "$BASE_REF"
+ fi
- name: README and registry still generate
run: |
diff --git a/docs/policy.md b/docs/policy.md
index db24823..809b2e6 100644
--- a/docs/policy.md
+++ b/docs/policy.md
@@ -49,13 +49,17 @@ Removal PRs are reviewed like any other PR; closing one keeps the entries until
A weekly scout searches npm for packages published since V2 shipped that depend on `@opencode/*`, keeps public, licensed, non-archived, non-fork repositories that are not already listed or in an open PR, and ranks them by weekly downloads, then stars. It posts a **Scout** issue; a maintainer ticking a candidate's box makes the bot open a PR for it. Add a package or repo to [`discovery/declined.txt`](../discovery/declined.txt) to stop suggesting it.
+## Branch protection
+
+`main` has a ruleset: every commit that lands on it must have a passing `validate` check, and force-pushes and deletion are blocked. Contributors and maintainers get the check through pull requests; bots get it by dispatching `validate` on their branch. Repository admins can merge a PR whose check is failing or pending (for a broken CI), but cannot push unvalidated commits directly.
+
## Automation map
| Workflow | When | What |
| :--- | :--- | :--- |
| `validate-pr.yml` | every PR | Required `validate` check |
| `triage.yml` | PR opened/updated | Labels and a results comment (base-branch code only) |
-| `generate-readme.yml` | push to main | README.md and dist/registry.json; fails without writing on any invalid entry |
+| `generate-readme.yml` | push to main | README.md and dist/registry.json; fails without writing on any invalid entry. The regenerated commit is staged on `bot/readme-*`, validated, then fast-forwarded onto main |
| `site.yml` | push to main, daily | Search site on GitHub Pages |
| `health.yml` | daily | Health report issue, rename and removal PRs |
| `reprobe.yml` | daily | Re-checks `needs-v2` PRs |
diff --git a/scripts/check-pr.js b/scripts/check-pr.js
index 363b6eb..d19b4d0 100644
--- a/scripts/check-pr.js
+++ b/scripts/check-pr.js
@@ -1,7 +1,9 @@
#!/usr/bin/env node
/**
* The required "validate" check for pull requests.
- * Usage: node scripts/check-pr.js --base [--no-probe]
+ * Usage: node scripts/check-pr.js --base [--no-probe] [--allow-generated]
+ *
+ * --allow-generated is passed only for the README bot's own staged commits (see generate-readme.yml).
*
* Compares HEAD against the merge base with , analyzes the changed files, writes a report to
* $GITHUB_STEP_SUMMARY (when set) and exits 1 on any blocking problem.
@@ -11,7 +13,7 @@ const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { loadCatalog } = require('./utils/catalog');
-const { DATA_FILE_RE, analyzePullRequest, formatReport } = require('./utils/pr-checks');
+const { DATA_FILE_RE, GENERATED_FILES, analyzePullRequest, formatReport } = require('./utils/pr-checks');
const ROOT = path.join(__dirname, '..');
const git = (...args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
@@ -20,7 +22,8 @@ function parseArgs(argv) {
const baseIndex = argv.indexOf('--base');
return {
base: baseIndex !== -1 ? argv[baseIndex + 1] : 'origin/main',
- probe: !argv.includes('--no-probe')
+ probe: !argv.includes('--no-probe'),
+ allowGenerated: argv.includes('--allow-generated')
};
}
@@ -42,7 +45,7 @@ function changedFiles(mergeBase) {
}
async function main() {
- const { base, probe } = parseArgs(process.argv.slice(2));
+ const { base, probe, allowGenerated } = parseArgs(process.argv.slice(2));
const mergeBase = git('merge-base', base, 'HEAD').trim();
const changes = changedFiles(mergeBase);
@@ -61,6 +64,7 @@ async function main() {
const baseEntries = entries.filter(e => !changedPaths.has(e.file));
const result = await analyzePullRequest({ changes, baseEntries, probe });
+ if (allowGenerated) result.errors = result.errors.filter(e => !GENERATED_FILES.includes(e.file));
// Unparseable files anywhere in the catalog break README generation, so they always block
for (const err of loadErrors) {
if (!result.errors.some(e => e.file === err.file)) result.errors.push({ ...err, kind: 'metadata' });
diff --git a/templates/README.template.md b/templates/README.template.md
index e87bd48..40fde77 100644
--- a/templates/README.template.md
+++ b/templates/README.template.md
@@ -45,6 +45,8 @@
🔎 Search & filter the catalog
•
âž• Submit an entry
+ •
+📋 Catalog policy