From d1b31029fe31b3b903c2c48f11ed054d349fd194 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:35:01 -0400 Subject: [PATCH 01/18] admin: add protect-next ruleset spec --- .../control-plane/rulesets/protect-next.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github-stars/control-plane/rulesets/protect-next.json diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json new file mode 100644 index 000000000..b50e09ffd --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -0,0 +1,56 @@ +{ + "name": "protect-next", + "target": "branch", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/next" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash", + "rebase" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "gate" + }, + { + "context": "workflow-lint" + }, + { + "context": "build" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "bypass_actors": [] +} From 5dde047c91299c28430bb6fc23cfc01c6e711b71 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:35:13 -0400 Subject: [PATCH 02/18] admin: add protect-main-release-only ruleset spec --- .../rulesets/protect-main-release-only.json | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github-stars/control-plane/rulesets/protect-main-release-only.json diff --git a/.github-stars/control-plane/rulesets/protect-main-release-only.json b/.github-stars/control-plane/rulesets/protect-main-release-only.json new file mode 100644 index 000000000..4473e245c --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -0,0 +1,59 @@ +{ + "name": "protect-main-release-only", + "target": "branch", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash", + "rebase" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "main-release-guard" + }, + { + "context": "gate" + }, + { + "context": "workflow-lint" + }, + { + "context": "build" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "bypass_actors": [] +} From cc06c87c11e6ebdc22a2f1e36a80ebfd19230edb Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:36:43 -0400 Subject: [PATCH 03/18] admin: set github app as sole next ruleset bypass actor --- .github-stars/control-plane/rulesets/protect-next.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index b50e09ffd..7888fa633 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -52,5 +52,11 @@ "type": "deletion" } ], - "bypass_actors": [] + "bypass_actors": [ + { + "actor_id": 3663316, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] } From 64082a56d0bff2b217fb7db82032bdb178835333 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:39:50 -0400 Subject: [PATCH 04/18] admin: remove hardcoded app bypass actor from next ruleset spec --- .github-stars/control-plane/rulesets/protect-next.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 7888fa633..b50e09ffd 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -52,11 +52,5 @@ "type": "deletion" } ], - "bypass_actors": [ - { - "actor_id": 3663316, - "actor_type": "Integration", - "bypass_mode": "always" - } - ] + "bypass_actors": [] } From 140251e9e05abe0fd12c34d12cf6368993723987 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:40:32 -0400 Subject: [PATCH 05/18] admin: add main release branch guard --- .github/workflows/00c-main-release-guard.yml | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/00c-main-release-guard.yml diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml new file mode 100644 index 000000000..504ad121a --- /dev/null +++ b/.github/workflows/00c-main-release-guard.yml @@ -0,0 +1,40 @@ +name: main-release-guard + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + +permissions: + contents: read + +jobs: + main-release-guard: + name: main-release-guard + runs-on: ubuntu-latest + steps: + - name: Require repo-owned next as source branch for main + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + BASE_REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "base=$BASE_REF" + echo "head=$HEAD_REF" + echo "head_repo=$HEAD_REPO" + echo "base_repo=$BASE_REPO" + + if [ "$BASE_REF" = "main" ] && { [ "$HEAD_REF" != "next" ] || [ "$HEAD_REPO" != "$BASE_REPO" ]; }; then + echo "::error::PRs into main must come from the repo-owned next branch. Retarget feature/chore/fork PRs to next first." + exit 1 + fi + + echo "main release guard passed: repo-owned next is the source branch." From c9b060a8347348a98c93ab1e021932be710ec6b6 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:41:25 -0400 Subject: [PATCH 06/18] admin: add github app ruleset check and upsert workflow --- .github/workflows/00e-branch-rulesets.yml | 212 ++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 .github/workflows/00e-branch-rulesets.yml diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml new file mode 100644 index 000000000..288b66430 --- /dev/null +++ b/.github/workflows/00e-branch-rulesets.yml @@ -0,0 +1,212 @@ +name: branch-rulesets + +on: + workflow_dispatch: + inputs: + operation: + description: 'Ruleset operation: check reports drift, upsert creates/updates' + required: true + default: check + type: choice + options: + - check + - upsert + enforcement: + description: 'Ruleset enforcement mode' + required: true + default: disabled + type: choice + options: + - disabled + - active + +permissions: + contents: read + +jobs: + branch-rulesets: + name: branch-rulesets-${{ inputs.operation }} + runs-on: ubuntu-latest + steps: + - name: Checkout tracked ruleset specs + uses: actions/checkout@v6 + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-administration: write + + - name: Check or upsert branch rulesets + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + OPERATION: ${{ inputs.operation }} + ENFORCEMENT: ${{ inputs.enforcement }} + GH_APP_ID: ${{ vars.GH_APP_ID }} + run: | + set -euo pipefail + + if ! [[ "${GH_APP_ID}" =~ ^[0-9]+$ ]]; then + echo "::error::vars.GH_APP_ID must be the numeric GitHub App ID used as the ruleset Integration bypass actor." + exit 1 + fi + + normalize_ruleset() { + jq -S '{ + name, + target, + enforcement, + conditions: { + ref_name: { + include: (.conditions.ref_name.include // []), + exclude: (.conditions.ref_name.exclude // []) + } + }, + rules: [ + .rules[] + | if .type == "pull_request" then { + type, + parameters: { + allowed_merge_methods: (.parameters.allowed_merge_methods // []), + dismiss_stale_reviews_on_push: (.parameters.dismiss_stale_reviews_on_push // false), + require_code_owner_review: (.parameters.require_code_owner_review // false), + require_last_push_approval: (.parameters.require_last_push_approval // false), + required_approving_review_count: (.parameters.required_approving_review_count // 0), + required_review_thread_resolution: (.parameters.required_review_thread_resolution // false) + } + } + elif .type == "required_status_checks" then { + type, + parameters: { + strict_required_status_checks_policy: (.parameters.strict_required_status_checks_policy // false), + required_status_checks: [ + .parameters.required_status_checks[]? + | {context} + ] + } + } + else {type} + end + ] | sort_by(.type), + bypass_actors: [ + .bypass_actors[]? + | { + actor_id, + actor_type, + bypass_mode: (.bypass_mode // "always") + } + ] | sort_by(.actor_type, .actor_id, .bypass_mode) + }' + } + + render_spec() { + local spec="$1" + local rendered="$2" + jq \ + --arg enforcement "${ENFORCEMENT}" \ + --argjson app_id "${GH_APP_ID}" \ + '.enforcement = $enforcement + | .bypass_actors = [ + { + actor_id: $app_id, + actor_type: "Integration", + bypass_mode: "always" + } + ]' \ + "${spec}" > "${rendered}" + } + + upsert_ruleset() { + local name="$1" + local payload="$2" + local id + id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ + --jq ".[] | select(.name == \"${name}\") | .id" \ + | head -n 1 || true) + + if [ -n "${id}" ]; then + echo "Updating ruleset ${name} (${id})" + gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/rulesets/${id}" \ + --input "${payload}" \ + --jq '{id, name, target, enforcement}' + else + echo "Creating ruleset ${name}" + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/rulesets" \ + --input "${payload}" \ + --jq '{id, name, target, enforcement}' + fi + } + + check_ruleset() { + local spec="$1" + local name + local rendered + local desired + local actual + local id + + name=$(jq -r '.name' "${spec}") + rendered=$(mktemp) + desired=$(mktemp) + actual=$(mktemp) + + render_spec "${spec}" "${rendered}" + normalize_ruleset < "${rendered}" > "${desired}" + + id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ + --jq ".[] | select(.name == \"${name}\") | .id" \ + | head -n 1 || true) + + if [ -z "${id}" ]; then + if [ "${OPERATION}" = "upsert" ]; then + upsert_ruleset "${name}" "${rendered}" + id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ + --jq ".[] | select(.name == \"${name}\") | .id" \ + | head -n 1 || true) + else + echo "::error::ruleset missing: ${name}" + return 1 + fi + elif [ "${OPERATION}" = "upsert" ]; then + upsert_ruleset "${name}" "${rendered}" + fi + + gh api "/repos/${REPO}/rulesets/${id}" > "${actual}.raw" + normalize_ruleset < "${actual}.raw" > "${actual}" + + if diff -u "${desired}" "${actual}"; then + echo "ruleset ok: ${name}" + else + echo "::error::ruleset drift after ${OPERATION}: ${name}" + return 1 + fi + } + + check_ruleset ".github-stars/control-plane/rulesets/protect-next.json" + check_ruleset ".github-stars/control-plane/rulesets/protect-main-release-only.json" + + - name: Ruleset summary + if: always() + run: | + { + echo "# Branch rulesets" + echo "" + echo "- Operation: \`${{ inputs.operation }}\`" + echo "- Enforcement: \`${{ inputs.enforcement }}\`" + echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" + echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\`" + echo "- App permission required: \`Administration: write\` on this repository" + } >> "$GITHUB_STEP_SUMMARY" From 30d9c5732c76850826cdc3aad9dd4031ed0e4aa5 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:52:46 -0400 Subject: [PATCH 07/18] admin: add DoNotMergeYet pull request gate --- .github/workflows/00a-do-not-merge-yet.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/00a-do-not-merge-yet.yml diff --git a/.github/workflows/00a-do-not-merge-yet.yml b/.github/workflows/00a-do-not-merge-yet.yml new file mode 100644 index 000000000..4b40f81c2 --- /dev/null +++ b/.github/workflows/00a-do-not-merge-yet.yml @@ -0,0 +1,31 @@ +name: DoNotMergeYet label gate + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - edited + - labeled + - unlabeled + - ready_for_review + +permissions: + contents: read + +jobs: + do-not-merge-yet: + name: do-not-merge-yet + runs-on: ubuntu-latest + steps: + - name: Fail when PR has DoNotMergeYet label + run: | + set -euo pipefail + + if jq -e '.pull_request.labels[]? | select(.name == "DoNotMergeYet")' "$GITHUB_EVENT_PATH" >/dev/null; then + echo "::error::This pull request has the DoNotMergeYet label. Remove the label before merging." + exit 1 + fi + + echo "No DoNotMergeYet label present." From e152ebeb3ab4e65dd61230be8a9152008efdb95f Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:53:24 -0400 Subject: [PATCH 08/18] ci: run gate checks for next branch --- .github/workflows/00-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/00-ci.yml b/.github/workflows/00-ci.yml index 18dc9bc4c..924d7e3d8 100644 --- a/.github/workflows/00-ci.yml +++ b/.github/workflows/00-ci.yml @@ -4,9 +4,11 @@ on: pull_request: branches: - main + - next push: branches: - main + - next permissions: contents: read From 727b8a481aab462811075c972e3449c0eaafe37d Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:53:52 -0400 Subject: [PATCH 09/18] ci: run web gate for next branch --- .github/workflows/00b-web-ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/00b-web-ci.yml b/.github/workflows/00b-web-ci.yml index a5a3ae422..c41a4bdc5 100644 --- a/.github/workflows/00b-web-ci.yml +++ b/.github/workflows/00b-web-ci.yml @@ -2,15 +2,17 @@ name: 'Web CI' on: - # Run on EVERY PR to main (no paths filter) so this can be a required - # status check without leaving non-web PRs stuck on "pending". The - # build is ~15s; the cost is negligible. See `.sisyphus/proofs/02-AUDIT-on-main.md` G2. + # Run on EVERY PR to main/next (no paths filter) so this can be a + # required status check without leaving non-web PRs stuck on "pending". + # The build is ~15s; the cost is negligible. See `.sisyphus/proofs/02-AUDIT-on-main.md` G2. pull_request: branches: - main + - next push: branches: - main + - next paths: - 'web/**' - 'repos.yml' From d148f4e880305937491db5cc24a7e607478fca8d Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:54:15 -0400 Subject: [PATCH 10/18] admin: require DoNotMergeYet label gate on next --- .github-stars/control-plane/rulesets/protect-next.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index b50e09ffd..2100f0cbd 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -30,6 +30,9 @@ "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ + { + "context": "do-not-merge-yet" + }, { "context": "gate" }, From cdab840e735ce10a663ece89728533e504a3274e Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:54:32 -0400 Subject: [PATCH 11/18] admin: require DoNotMergeYet label gate on main --- .../control-plane/rulesets/protect-main-release-only.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github-stars/control-plane/rulesets/protect-main-release-only.json b/.github-stars/control-plane/rulesets/protect-main-release-only.json index 4473e245c..7659c96f9 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -33,6 +33,9 @@ { "context": "main-release-guard" }, + { + "context": "do-not-merge-yet" + }, { "context": "gate" }, From 49b0b606402158b6f86d52ea93c9062a563567f6 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:55:10 -0400 Subject: [PATCH 12/18] admin: harden ruleset workflow lookups and dispatch guard --- .github/workflows/00e-branch-rulesets.yml | 48 +++++++++++++++-------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 288b66430..6bb23b1ad 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -27,6 +27,7 @@ jobs: branch-rulesets: name: branch-rulesets-${{ inputs.operation }} runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' steps: - name: Checkout tracked ruleset specs uses: actions/checkout@v6 @@ -56,6 +57,28 @@ jobs: exit 1 fi + gh_rules_api() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + list_rulesets() { + gh_rules_api --paginate "/repos/${REPO}/rulesets?includes_parents=false" + } + + ruleset_id_by_name() { + local name="$1" + list_rulesets \ + | jq -r --arg name "${name}" ' + if type == "array" then .[] else . end + | select(.name == $name) + | .id + ' \ + | head -n 1 + } + normalize_ruleset() { jq -S '{ name, @@ -87,7 +110,7 @@ jobs: required_status_checks: [ .parameters.required_status_checks[]? | {context} - ] + ] | sort_by(.context) } } else {type} @@ -125,25 +148,19 @@ jobs: local name="$1" local payload="$2" local id - id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ - --jq ".[] | select(.name == \"${name}\") | .id" \ - | head -n 1 || true) + id=$(ruleset_id_by_name "${name}" || true) if [ -n "${id}" ]; then echo "Updating ruleset ${name} (${id})" - gh api \ + gh_rules_api \ --method PUT \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${REPO}/rulesets/${id}" \ --input "${payload}" \ --jq '{id, name, target, enforcement}' else echo "Creating ruleset ${name}" - gh api \ + gh_rules_api \ --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${REPO}/rulesets" \ --input "${payload}" \ --jq '{id, name, target, enforcement}' @@ -166,16 +183,12 @@ jobs: render_spec "${spec}" "${rendered}" normalize_ruleset < "${rendered}" > "${desired}" - id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ - --jq ".[] | select(.name == \"${name}\") | .id" \ - | head -n 1 || true) + id=$(ruleset_id_by_name "${name}" || true) if [ -z "${id}" ]; then if [ "${OPERATION}" = "upsert" ]; then upsert_ruleset "${name}" "${rendered}" - id=$(gh api "/repos/${REPO}/rulesets?includes_parents=false" \ - --jq ".[] | select(.name == \"${name}\") | .id" \ - | head -n 1 || true) + id=$(ruleset_id_by_name "${name}" || true) else echo "::error::ruleset missing: ${name}" return 1 @@ -184,7 +197,7 @@ jobs: upsert_ruleset "${name}" "${rendered}" fi - gh api "/repos/${REPO}/rulesets/${id}" > "${actual}.raw" + gh_rules_api "/repos/${REPO}/rulesets/${id}" > "${actual}.raw" normalize_ruleset < "${actual}.raw" > "${actual}" if diff -u "${desired}" "${actual}"; then @@ -206,6 +219,7 @@ jobs: echo "" echo "- Operation: \`${{ inputs.operation }}\`" echo "- Enforcement: \`${{ inputs.enforcement }}\`" + echo "- Ref guard: \`refs/heads/main\` only" echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\`" echo "- App permission required: \`Administration: write\` on this repository" From 9db6f0e235f067f0024f7ce40f20a0eb2d576167 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:58:56 -0400 Subject: [PATCH 13/18] admin: add GitHub API next sync workflow --- .github/workflows/00f-sync-next-with-main.yml | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/00f-sync-next-with-main.yml diff --git a/.github/workflows/00f-sync-next-with-main.yml b/.github/workflows/00f-sync-next-with-main.yml new file mode 100644 index 000000000..6c698ccc8 --- /dev/null +++ b/.github/workflows/00f-sync-next-with-main.yml @@ -0,0 +1,97 @@ +name: sync-next-with-main + +on: + workflow_dispatch: + inputs: + operation: + description: 'sync updates the next -> main release PR branch; check only reports the target PR' + required: true + default: check + type: choice + options: + - check + - sync + +permissions: + contents: read + +jobs: + sync-next-with-main: + name: sync-next-with-main-${{ inputs.operation }} + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Find and optionally update next -> main pull request branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + OPERATION: ${{ inputs.operation }} + run: | + set -euo pipefail + + owner="${REPO%%/*}" + repo_name="${REPO#*/}" + head_filter="${owner}:next" + + gh_pr_api() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + pr_json=$(gh_pr_api "/repos/${REPO}/pulls?state=open&base=main&head=${head_filter}" \ + | jq 'map(select(.head.ref == "next" and .head.repo.full_name == .base.repo.full_name and .base.ref == "main"))') + + count=$(jq 'length' <<<"${pr_json}") + if [ "${count}" -eq 0 ]; then + echo "::error::No open repo-owned next -> main pull request found. Create that PR first; GitHub's documented update-branch API operates on a pull request." + exit 1 + fi + + if [ "${count}" -gt 1 ]; then + echo "::error::Multiple open next -> main pull requests found; refusing to choose." + jq -r '.[] | "- #\(.number) \(.html_url)"' <<<"${pr_json}" + exit 1 + fi + + pr_number=$(jq -r '.[0].number' <<<"${pr_json}") + head_sha=$(jq -r '.[0].head.sha' <<<"${pr_json}") + html_url=$(jq -r '.[0].html_url' <<<"${pr_json}") + + echo "release_pr=#${pr_number}" + echo "release_pr_url=${html_url}" + echo "head_sha=${head_sha}" + + if [ "${OPERATION}" = "check" ]; then + echo "Check only: next -> main PR exists and can be updated by this workflow." + exit 0 + fi + + gh_pr_api \ + --method PUT \ + "/repos/${REPO}/pulls/${pr_number}/update-branch" \ + -f "expected_head_sha=${head_sha}" + + - name: Sync summary + if: always() + run: | + { + echo "# Sync next with main" + echo "" + echo "- Operation: \`${{ inputs.operation }}\`" + echo "- Ref guard: \`refs/heads/main\` only" + echo "- Mechanism: GitHub REST \`PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch\`" + echo "- Required App permissions: \`Pull requests: write\` and \`Contents: write\` for the head repository" + } >> "$GITHUB_STEP_SUMMARY" From d86636375064bb609922d82d6485a30523989ab8 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 21:01:57 -0400 Subject: [PATCH 14/18] admin: require explicit approval gate for ruleset upsert --- .github/workflows/00e-branch-rulesets.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 6bb23b1ad..4c3e86a9e 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -19,6 +19,11 @@ on: options: - disabled - active + confirm_upsert: + description: 'Required for operation=upsert: type APPLY_RULESETS' + required: false + default: '' + type: string permissions: contents: read @@ -28,10 +33,22 @@ jobs: name: branch-rulesets-${{ inputs.operation }} runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' + environment: ${{ inputs.operation == 'upsert' && 'github-admin' || '' }} steps: - name: Checkout tracked ruleset specs uses: actions/checkout@v6 + - name: Validate upsert authorization + if: inputs.operation == 'upsert' + env: + CONFIRM_UPSERT: ${{ inputs.confirm_upsert }} + run: | + set -euo pipefail + if [ "${CONFIRM_UPSERT}" != "APPLY_RULESETS" ]; then + echo "::error::operation=upsert requires confirm_upsert=APPLY_RULESETS." + exit 1 + fi + - name: Create GitHub App token id: app-token uses: actions/create-github-app-token@v3 @@ -220,6 +237,8 @@ jobs: echo "- Operation: \`${{ inputs.operation }}\`" echo "- Enforcement: \`${{ inputs.enforcement }}\`" echo "- Ref guard: \`refs/heads/main\` only" + echo "- Upsert confirmation: \`confirm_upsert=APPLY_RULESETS\` required" + echo "- Upsert environment: \`github-admin\`" echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\`" echo "- App permission required: \`Administration: write\` on this repository" From d4a8be7899cfe33eb608d53bc2e31db59531c540 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 21:14:58 -0400 Subject: [PATCH 15/18] admin: add repository code owners --- .github/CODEOWNERS | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f2088b3f5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,19 @@ +# GitHub uses the first CODEOWNERS file it finds in .github/, repository root, or docs/. +# Keep this file in .github/ so ownership of repository governance is explicit. + +# Default owner for all repository changes. +* @primeinc + +# Repository governance and automation surfaces. +/.github/ @primeinc +/.github/CODEOWNERS @primeinc +/.github/workflows/ @primeinc +/.github-stars/ @primeinc +/.github-stars/control-plane/ @primeinc + +# Runtime/source surfaces. +/src/ @primeinc +/web/ @primeinc +/repos.yml @primeinc +/package.json @primeinc +/pnpm-lock.yaml @primeinc From db31fd8495a565ef2f2cba405e4530db87f0b7aa Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 21:15:26 -0400 Subject: [PATCH 16/18] admin: require code owner review on next ruleset --- .github-stars/control-plane/rulesets/protect-next.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 2100f0cbd..4692758fe 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -19,9 +19,9 @@ "rebase" ], "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, + "require_code_owner_review": true, "require_last_push_approval": false, - "required_approving_review_count": 0, + "required_approving_review_count": 1, "required_review_thread_resolution": true } }, From e080ac5452e28e5aad66b6fb1f9ab1d97e2538ff Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 21:15:47 -0400 Subject: [PATCH 17/18] admin: require code owner review on main ruleset --- .../control-plane/rulesets/protect-main-release-only.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github-stars/control-plane/rulesets/protect-main-release-only.json b/.github-stars/control-plane/rulesets/protect-main-release-only.json index 7659c96f9..90e015778 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -19,9 +19,9 @@ "rebase" ], "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, + "require_code_owner_review": true, "require_last_push_approval": false, - "required_approving_review_count": 0, + "required_approving_review_count": 1, "required_review_thread_resolution": true } }, From 1d54bb249c6a36af28618b04040fe69155a9eb9d Mon Sep 17 00:00:00 2001 From: primeinc Date: Sun, 10 May 2026 22:45:08 -0400 Subject: [PATCH 18/18] =?UTF-8?q?admin:=20protection-stage=20rework=20?= =?UTF-8?q?=E2=80=94=20drop=20CODEOWNERS,=20native=20bypass,=20behind-main?= =?UTF-8?q?=20+=20auto-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the protection-stage brief, this is one coherent diff that brings admin/ghapp-rulesets to the required final state for the user-owned public repo (no org/team layer, App as the automation/protection actor, main = release lane, next = integration lane). Changes: 1) Delete .github/CODEOWNERS (brief rule #2). Solo-owner CODEOWNERS is fake trust — there is no separate reviewer/team; CODEOWNERS only adds friction without governance. Path-based ownership for a one-actor repo is theatre. 2) Drop require_code_owner_review + required_approving_review_count from both ruleset specs (brief rule #3). Without a separate reviewer the rule is unsatisfiable on every PR; setting review_count=0 + dropping require_code_owner_review reflects the actual governance shape (App + status checks gate, not human approver gate). The App is still the bypass actor for the few cases that need it. 3) Render App bypass with bypass_mode: "pull_request" (brief rule #4). Per the GitHub REST docs: "pull_request means that an actor can only bypass rules on pull requests" and "pull_request is only applicable to branch rulesets." That's strictly tighter than the previous "always" and matches the workflow shape (App bypass exists to close PRs, not to bypass the rule entirely). 4) Add 00d-admin-branch-sync-guard.yml (brief rule #9). Runs on every PR to main; for admin/* heads it queries GET /repos/{owner}/{repo}/compare/{base}...{head} and fails the PR if behind_by > 0. For non-admin heads it passes through (so the check name remains a viable required-status-check on every PR to main without leaving non-admin PRs perpetually pending). Update path: rebase or use the GitHub UI's Update branch button (which calls PUT /pulls/{n}/update-branch with expected_head_sha for the safe path). 5) Add admin-branch-sync-guard to protect-main-release-only.json's required_status_checks. The check is now both wired (workflow exists) and required (ruleset references it). 6) 00f-sync-next-with-main.yml: add `push: branches: [main]` trigger so admin merges to main propagate the next branch automatically via the documented update-branch API (brief rule #10). The existing workflow_dispatch fallback retains check/sync inputs. Operation defaults to `sync` on push; on dispatch the input wins. Removed unused repo_name shell var. What was removed: - .github/CODEOWNERS (entire file; brief rule #2) - require_code_owner_review: true (both rulesets; brief rule #3) - required_approving_review_count: 1 (both rulesets; brief rule #3) - bypass_mode: "always" (replaced with "pull_request"; brief rule #4) - 00f-sync-next-with-main.yml's unused repo_name shell variable Native GitHub primitives used: - Branch rulesets (target: branch) with bypass_actors - bypass_mode: pull_request (App-shaped governance) - required_status_checks rule with strict_required_status_checks_policy - required_linear_history + non_fast_forward + deletion rules - GET /repos/{owner}/{repo}/compare/{base}...{head} for behind-main check - PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch with expected_head_sha for the next-with-main sync (stale-head guard) - create-github-app-token@v3 for short-lived App tokens with minimal scoped permissions per workflow Tracked JSON does not hardcode App IDs; bypass actor is rendered at runtime in 00e-branch-rulesets.yml from vars.GH_APP_ID. Remaining manual repo settings: - Create vars.GH_APP_ID (numeric App ID for the primeinc-github-stars App). The branch-rulesets workflow guards against this with `^[0-9]+$` regex and fails loud. - Configure `github-admin` deployment environment with required reviewers (the brief notes this is the future webhook/custom-deployment-protection-app surface). Until then, upsert is gated only by the workflow's APPLY_RULESETS confirmation and refs/heads/main check. Deferred to a future stage (per brief): external webhook / custom deployment protection app. The github-admin environment is shaped for it; activation requires a separate deployment. Do not merge: brief rule "Do not merge this PR" + "Do not merge PR #79" + "Do not activate live rulesets" all stand. PR #79 unblock condition: this admin PR merges to main, then 00f-sync-next-with-main fires automatically on the push to main and updates the next branch PR's head, then chore/bun-modernization (PR #79's branch) rebases against main + retargets to next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rulesets/protect-main-release-only.json | 7 +- .../control-plane/rulesets/protect-next.json | 4 +- .github/CODEOWNERS | 19 ---- .../workflows/00d-admin-branch-sync-guard.yml | 103 ++++++++++++++++++ .github/workflows/00e-branch-rulesets.yml | 12 +- .github/workflows/00f-sync-next-with-main.yml | 34 +++++- 6 files changed, 149 insertions(+), 30 deletions(-) delete mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/00d-admin-branch-sync-guard.yml diff --git a/.github-stars/control-plane/rulesets/protect-main-release-only.json b/.github-stars/control-plane/rulesets/protect-main-release-only.json index 90e015778..2463e580f 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -19,9 +19,9 @@ "rebase" ], "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": true, + "require_code_owner_review": false, "require_last_push_approval": false, - "required_approving_review_count": 1, + "required_approving_review_count": 0, "required_review_thread_resolution": true } }, @@ -30,6 +30,9 @@ "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ + { + "context": "admin-branch-sync-guard" + }, { "context": "main-release-guard" }, diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 4692758fe..2100f0cbd 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -19,9 +19,9 @@ "rebase" ], "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": true, + "require_code_owner_review": false, "require_last_push_approval": false, - "required_approving_review_count": 1, + "required_approving_review_count": 0, "required_review_thread_resolution": true } }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index f2088b3f5..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,19 +0,0 @@ -# GitHub uses the first CODEOWNERS file it finds in .github/, repository root, or docs/. -# Keep this file in .github/ so ownership of repository governance is explicit. - -# Default owner for all repository changes. -* @primeinc - -# Repository governance and automation surfaces. -/.github/ @primeinc -/.github/CODEOWNERS @primeinc -/.github/workflows/ @primeinc -/.github-stars/ @primeinc -/.github-stars/control-plane/ @primeinc - -# Runtime/source surfaces. -/src/ @primeinc -/web/ @primeinc -/repos.yml @primeinc -/package.json @primeinc -/pnpm-lock.yaml @primeinc diff --git a/.github/workflows/00d-admin-branch-sync-guard.yml b/.github/workflows/00d-admin-branch-sync-guard.yml new file mode 100644 index 000000000..36c486eef --- /dev/null +++ b/.github/workflows/00d-admin-branch-sync-guard.yml @@ -0,0 +1,103 @@ +name: admin-branch-sync-guard + +# Per the protection-stage brief (rule #9): admin/control-plane +# branches must not silently drift behind `main`. This workflow runs +# on every PR that targets `main` from an `admin/*` head branch and +# fails the PR if the head branch is behind main. +# +# Mechanism: GitHub's +# `GET /repos/{owner}/{repo}/compare/{base}...{head}` returns +# `behind_by` — the number of commits in `base` that are not present +# in `head`. Anything > 0 means the admin PR is stale; the operator +# must rebase or use the GitHub UI's "Update branch" button (which +# calls `PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch` +# with the documented `expected_head_sha` for the safe path) before +# the PR can merge. +# +# The 00f-sync-next-with-main.yml workflow uses the same +# update-branch API for the `next -> main` release PR flow; this +# workflow is the analogous read-only check for the admin/* PR +# class. + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - edited + +permissions: + contents: read + pull-requests: read + +jobs: + admin-branch-sync-guard: + name: admin-branch-sync-guard + runs-on: ubuntu-latest + steps: + - name: Compare head to base (admin/* only) + # Pass-through for non-admin heads so this check name remains + # available as a required status check on every PR to main — + # required checks that never run leave the PR perpetually + # pending. + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + echo "base=${BASE_REF}" + echo "head=${HEAD_REF}" + echo "head_sha=${HEAD_SHA}" + + case "${HEAD_REF}" in + admin/*) + echo "Admin/* head detected. Running behind-base check." + ;; + *) + echo "Non-admin head (${HEAD_REF}); admin sync guard does not apply. Pass." + exit 0 + ;; + esac + + # `compare/{base}...{head}` per + # https://docs.github.com/en/rest/commits/commits#compare-two-commits + # — `behind_by` counts commits in base that are not in head. + response=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/compare/${BASE_REF}...${HEAD_SHA}") + + behind_by=$(jq -r '.behind_by' <<<"${response}") + ahead_by=$(jq -r '.ahead_by' <<<"${response}") + status=$(jq -r '.status' <<<"${response}") + + echo "ahead_by=${ahead_by}" + echo "behind_by=${behind_by}" + echo "status=${status}" + + if [ "${behind_by}" -gt 0 ]; then + echo "::error::admin/* PR head is ${behind_by} commit(s) behind ${BASE_REF}. Rebase or use the GitHub UI 'Update branch' button before this PR can merge. The update-branch API takes expected_head_sha=${HEAD_SHA} for the safe path." + exit 1 + fi + + echo "admin/* PR head is up to date with ${BASE_REF} (ahead_by=${ahead_by}, behind_by=0)." + + - name: Sync guard summary + if: always() + run: | + { + echo "# admin-branch-sync-guard" + echo "" + echo "- Trigger: \`pull_request\` to \`main\` from \`admin/*\` head" + echo "- Mechanism: GitHub REST \`GET /repos/{owner}/{repo}/compare/{base}...{head}\`" + echo "- Block condition: \`behind_by > 0\`" + echo "- Unblock: rebase against \`${{ github.event.pull_request.base.ref }}\` or click \"Update branch\" in the PR UI (calls update-branch API with expected_head_sha)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 4c3e86a9e..278f19a5f 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -138,7 +138,7 @@ jobs: | { actor_id, actor_type, - bypass_mode: (.bypass_mode // "always") + bypass_mode: (.bypass_mode // "pull_request") } ] | sort_by(.actor_type, .actor_id, .bypass_mode) }' @@ -147,6 +147,14 @@ jobs: render_spec() { local spec="$1" local rendered="$2" + # `bypass_mode: "pull_request"` per + # https://docs.github.com/en/rest/repos/rules#create-a-repository-ruleset + # — "pull_request means that an actor can only bypass rules + # on pull requests" and "pull_request is only applicable to + # branch rulesets." For an automation App that gates merges + # via PR (next -> main release flow + admin/control-plane PR + # flow), pull_request is strictly tighter than always while + # still letting the App close release PRs. jq \ --arg enforcement "${ENFORCEMENT}" \ --argjson app_id "${GH_APP_ID}" \ @@ -155,7 +163,7 @@ jobs: { actor_id: $app_id, actor_type: "Integration", - bypass_mode: "always" + bypass_mode: "pull_request" } ]' \ "${spec}" > "${rendered}" diff --git a/.github/workflows/00f-sync-next-with-main.yml b/.github/workflows/00f-sync-next-with-main.yml index 6c698ccc8..1c06e5689 100644 --- a/.github/workflows/00f-sync-next-with-main.yml +++ b/.github/workflows/00f-sync-next-with-main.yml @@ -1,6 +1,26 @@ name: sync-next-with-main +# Per protection-stage brief rule #10: after the admin/control-plane +# PR merges to `main`, `next` must absorb latest `main` before any +# downstream PR (e.g. PR #79) can proceed. This workflow: +# +# - on `push` to main: auto-runs in `sync` mode (default), keeping +# the open `next -> main` release PR's head up to date with main +# using GitHub's documented update-branch API +# (PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch) +# with `expected_head_sha` for the stale-head guard. +# - on `workflow_dispatch`: manual fallback; supports `check` and +# `sync` operations for inspection or forced re-run. +# +# The same workflow also fires from refs/heads/main only — push from +# any other branch is ignored — so admin merges to main are the +# trigger, and pushes to next don't loop the operation back on +# itself. + on: + push: + branches: + - main workflow_dispatch: inputs: operation: @@ -17,7 +37,9 @@ permissions: jobs: sync-next-with-main: - name: sync-next-with-main-${{ inputs.operation }} + # Operation defaults to `sync` on push (rule #10), `check` on + # manual dispatch unless the operator explicitly picks `sync`. + name: sync-next-with-main-${{ inputs.operation || 'sync' }} runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: @@ -36,12 +58,12 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - OPERATION: ${{ inputs.operation }} + # On push: default `sync`. On dispatch: use the input. + OPERATION: ${{ inputs.operation || 'sync' }} run: | set -euo pipefail owner="${REPO%%/*}" - repo_name="${REPO#*/}" head_filter="${owner}:next" gh_pr_api() { @@ -90,8 +112,10 @@ jobs: { echo "# Sync next with main" echo "" - echo "- Operation: \`${{ inputs.operation }}\`" + echo "- Trigger: \`${{ github.event_name }}\`" + echo "- Operation: \`${{ inputs.operation || 'sync' }}\` _(default 'sync' on push, dispatch input on workflow_dispatch)_" echo "- Ref guard: \`refs/heads/main\` only" - echo "- Mechanism: GitHub REST \`PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch\`" + echo "- Mechanism: GitHub REST \`PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch\` with \`expected_head_sha\` for the stale-head guard" echo "- Required App permissions: \`Pull requests: write\` and \`Contents: write\` for the head repository" + echo "- Auto-trigger: every push to \`main\` (so admin/control-plane merges propagate to \`next\` immediately)" } >> "$GITHUB_STEP_SUMMARY"