From d1b31029fe31b3b903c2c48f11ed054d349fd194 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Sun, 10 May 2026 20:35:01 -0400 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] =?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" From b23f57ef37663950fc1b2df7aa69c782f3d73a9e Mon Sep 17 00:00:00 2001 From: primeinc Date: Sun, 10 May 2026 23:27:40 -0400 Subject: [PATCH 19/36] admin: pin ghapp/repo-admin as the canonical control-plane lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's correction: the canonical admin/control-plane branch name is `ghapp/repo-admin`, not `ghapp/next`. `next` already means the integration lane; overloading the name on the admin lane makes future humans stupider. Branch renamed remote (admin/ghapp-rulesets -> ghapp/repo-admin) and locally to match. Three workflow changes form one coherent diff: 1) 00c-main-release-guard.yml: rewrite to accept exactly two repo-owned source branches into main: - `next` integration / release lane - `ghapp/repo-admin` control-plane admin lane Anything else fails with both allowed lanes named in the error. Forks fail (BASE_REPO != HEAD_REPO). 2) 00d-admin-branch-sync-guard.yml: tighten head-branch scope from `admin/*` glob to exactly `ghapp/repo-admin` (env ADMIN_LANE_BRANCH). Add a SECOND check — path-scope guard — that fails the PR if any changed file is outside the Medium scope per the brief: - .github/workflows/00*.yml - .github-stars/control-plane/** - AGENTS.md - docs/automation/** - docs/security.md - .github/PULL_REQUEST_TEMPLATE.md Pass-through for non-admin heads so this required-status-check name remains viable on every PR to main. 3) Replace 00f-sync-next-with-main.yml with 00f-sync-protected-branches-with-main.yml. The brief's missing piece: a push-to-main reconciler that brings forward BOTH long- lived lanes when main moves, or marks them stale. - For `next`: prefer GitHub's update-branch API on the open repo-owned next -> main release PR with expected_head_sha. If no release PR is open, fail loud (per release-lane policy — the release PR is the documented surface for next->main update-branch calls). - For `ghapp/repo-admin`: prefer update-branch API if an open admin PR exists. If no open PR, FF-state the branch via compare API; fast-forward via PATCH /git/refs/heads/{branch} (force=false) only when ahead_by=0. Divergent histories fail loud (refuse to blind-push). Up-to-date is recorded. - GitHub App installation token only. No PAT fallback. - Workflow fails red if either lane could not be synced; PR #79 remains blocked until both lanes are caught up. - Summary surfaces main_sha + each lane's before/after SHA or blocker reason. What was removed: - The `admin/*` glob in 00d (replaced by exact `ghapp/repo-admin`) - The single-lane (next-only) restriction in 00c (now allows ghapp/repo-admin too) - 00f-sync-next-with-main.yml entirely (subsumed by the broader protected-branches reconciler) - Implicit acceptance of any path on admin PRs (now path-scoped) Native GitHub primitives used (additions to the prior set): - `gh api --paginate /repos/.../pulls/{n}/files` for path-scope enforcement - bash `extglob`/`globstar` for `**` glob matching against the allow-list - `PATCH /repos/{owner}/{repo}/git/refs/heads/{branch}` with force=false for FF-only branch advancement - `GET /repos/{owner}/{repo}/compare/{branch}...main` to determine FF-state before any branch advancement Operator manual settings (already documented in PR #80 comment): - vars.GH_APP_ID = 3663316 still required - secrets.GH_APP_PRIVATE_KEY still required - github-admin environment still optional (only for live ruleset upsert) Do not merge: brief stands. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/00c-main-release-guard.yml | 32 ++- .../workflows/00d-admin-branch-sync-guard.yml | 153 +++++++--- .github/workflows/00f-sync-next-with-main.yml | 121 -------- .../00f-sync-protected-branches-with-main.yml | 262 ++++++++++++++++++ 4 files changed, 402 insertions(+), 166 deletions(-) delete mode 100644 .github/workflows/00f-sync-next-with-main.yml create mode 100644 .github/workflows/00f-sync-protected-branches-with-main.yml diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index 504ad121a..9f1a0230a 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -1,5 +1,16 @@ name: main-release-guard +# Per the protection-stage brief, `main` accepts exactly two +# repo-owned source branches: +# +# 1. `next` — the integration / release lane +# 2. `ghapp/repo-admin` — the GitHub App / control-plane admin lane +# +# Anything else fails this gate. PRs from forks fail (BASE_REPO != +# HEAD_REPO). Feature/chore work flows through `next` first; admin / +# workflow / ruleset work flows through `ghapp/repo-admin` and is +# additionally path-scoped by `00d-admin-branch-sync-guard.yml`. + on: pull_request: branches: @@ -19,7 +30,7 @@ jobs: name: main-release-guard runs-on: ubuntu-latest steps: - - name: Require repo-owned next as source branch for main + - name: Require repo-owned next or ghapp/repo-admin as source branch for main env: BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_REF: ${{ github.event.pull_request.head.ref }} @@ -32,9 +43,22 @@ jobs: 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." + if [ "$BASE_REF" != "main" ]; then + echo "main-release-guard: base is not main; nothing to enforce." + exit 0 + fi + + if [ "$HEAD_REPO" != "$BASE_REPO" ]; then + echo "::error::PRs into main must come from a repo-owned branch. Forks must PR to next first." exit 1 fi - echo "main release guard passed: repo-owned next is the source branch." + case "$HEAD_REF" in + next|ghapp/repo-admin) + echo "main-release-guard: repo-owned ${HEAD_REF} -> main is allowed." + ;; + *) + echo "::error::PRs into main must come from one of: 'next' (integration lane) or 'ghapp/repo-admin' (control-plane lane). Got '${HEAD_REF}'. Retarget feature/chore/fork PRs to next first; admin/control-plane work flows through ghapp/repo-admin." + exit 1 + ;; + esac diff --git a/.github/workflows/00d-admin-branch-sync-guard.yml b/.github/workflows/00d-admin-branch-sync-guard.yml index 36c486eef..cadee1b7d 100644 --- a/.github/workflows/00d-admin-branch-sync-guard.yml +++ b/.github/workflows/00d-admin-branch-sync-guard.yml @@ -1,23 +1,32 @@ 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. +# Per the protection-stage brief: the canonical admin/control-plane +# lane is exactly one branch — `ghapp/repo-admin`. This guard runs +# on every PR to `main` and enforces two invariants on that branch: # -# 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. +# 1. Behind-main check: `ghapp/repo-admin` must not silently drift +# behind `main`. Implemented via +# GET /repos/{owner}/{repo}/compare/{base}...{head} — `behind_by` +# counts commits in `main` that are not in the PR head. Anything +# > 0 fails the gate. Operator unblocks by rebase or by clicking +# "Update branch" in the PR UI (which calls +# PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch +# with `expected_head_sha` for the stale-head guard). # -# 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. +# 2. Path-scope check: `ghapp/repo-admin -> main` may ONLY modify +# admin/control-plane files. Anything outside the allowed set +# fails the gate so unrelated changes can't ride in on an admin +# PR. Allowed paths (Medium scope per brief): +# - .github/workflows/00*.yml +# - .github-stars/control-plane/** +# - AGENTS.md +# - docs/automation/** +# - docs/security.md +# - .github/PULL_REQUEST_TEMPLATE.md +# +# Pass-through for non-`ghapp/repo-admin` heads so this check name +# remains a viable required-status-check on every PR to main — +# required checks that never run leave the PR perpetually pending. on: pull_request: @@ -34,61 +43,121 @@ permissions: contents: read pull-requests: read +env: + ADMIN_LANE_BRANCH: ghapp/repo-admin + 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. + - name: Compare head to base + path-scope (ghapp/repo-admin only) 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 }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail echo "base=${BASE_REF}" echo "head=${HEAD_REF}" echo "head_sha=${HEAD_SHA}" + echo "admin_lane=${ADMIN_LANE_BRANCH}" + + if [ "${HEAD_REF}" != "${ADMIN_LANE_BRANCH}" ]; then + echo "Non-admin head (${HEAD_REF}); admin sync guard does not apply. Pass." + exit 0 + fi - 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 + echo "Admin lane head detected. Running behind-base check + path-scope check." + # ----- behind-main check ----- # `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 \ + # — `behind_by` counts commits in base not in head. + compare=$(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}") + behind_by=$(jq -r '.behind_by' <<<"${compare}") + ahead_by=$(jq -r '.ahead_by' <<<"${compare}") + status=$(jq -r '.status' <<<"${compare}") 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." + echo "::error::ghapp/repo-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 "behind-main: OK (ahead_by=${ahead_by}, behind_by=0)" + + # ----- path-scope check ----- + # PRs from the admin lane may ONLY modify admin/control-plane + # paths (Medium scope). The + # GET /repos/{owner}/{repo}/pulls/{n}/files endpoint returns + # every changed path; we paginate to handle large PRs and + # match each path against an allow-list of glob patterns. + allowed_globs=( + ".github/workflows/00*.yml" + ".github-stars/control-plane/*" + ".github-stars/control-plane/**" + "AGENTS.md" + "docs/automation/*" + "docs/automation/**" + "docs/security.md" + ".github/PULL_REQUEST_TEMPLATE.md" + ) + + # bash glob match — enable extglob + globstar for `**`. + shopt -s extglob globstar nullglob + + path_in_scope() { + local path="$1" + local glob + for glob in "${allowed_globs[@]}"; do + # shellcheck disable=SC2053 # intentional glob match, not literal + if [[ "${path}" == ${glob} ]]; then + return 0 + fi + done + return 1 + } + + changed=$(gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename') + + violations=() + while IFS= read -r path; do + [ -z "${path}" ] && continue + if ! path_in_scope "${path}"; then + violations+=("${path}") + fi + done <<<"${changed}" + + if [ "${#violations[@]}" -gt 0 ]; then + echo "::error::ghapp/repo-admin PR touches paths outside the admin/control-plane scope. Move non-admin changes to a separate PR via 'next'. Out-of-scope paths:" + for p in "${violations[@]}"; do + echo "::error:: - ${p}" + done + echo "" + echo "Allowed paths (Medium scope):" + for g in "${allowed_globs[@]}"; do + echo " - ${g}" + done exit 1 fi - echo "admin/* PR head is up to date with ${BASE_REF} (ahead_by=${ahead_by}, behind_by=0)." + echo "path-scope: OK (all changed paths are within the admin/control-plane scope)" - name: Sync guard summary if: always() @@ -96,8 +165,10 @@ jobs: { 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)" + echo "- Trigger: \`pull_request\` to \`main\` from canonical \`${ADMIN_LANE_BRANCH}\` head" + echo "- Behind-main: GitHub REST \`GET /repos/{owner}/{repo}/compare/{base}...{head}\`, fail if \`behind_by > 0\`" + echo "- Path-scope: \`GET /repos/{owner}/{repo}/pulls/{n}/files\` with paginate, allow-list match" + echo "- Allowed paths (Medium scope): \`.github/workflows/00*.yml\`, \`.github-stars/control-plane/**\`, \`AGENTS.md\`, \`docs/automation/**\`, \`docs/security.md\`, \`.github/PULL_REQUEST_TEMPLATE.md\`" + echo "- Unblock-stale: rebase against \`${{ github.event.pull_request.base.ref }}\` or click \"Update branch\" in the PR UI (calls update-branch API with expected_head_sha)" + echo "- Unblock-paths: split out-of-scope changes to a separate PR via \`next\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00f-sync-next-with-main.yml b/.github/workflows/00f-sync-next-with-main.yml deleted file mode 100644 index 1c06e5689..000000000 --- a/.github/workflows/00f-sync-next-with-main.yml +++ /dev/null @@ -1,121 +0,0 @@ -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: - 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: - # 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: - - 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 }} - # On push: default `sync`. On dispatch: use the input. - OPERATION: ${{ inputs.operation || 'sync' }} - run: | - set -euo pipefail - - owner="${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 "- 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\` 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" diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml new file mode 100644 index 000000000..217b90ebc --- /dev/null +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -0,0 +1,262 @@ +name: sync-protected-branches-with-main + +# Per the protection-stage brief: when `main` receives a real commit, +# both long-lived working lanes must be brought forward from `main` +# or marked stale. Otherwise we built a drawbridge and forgot the +# road. +# +# Lanes reconciled: +# 1. `next` — integration lane (release flow lives here) +# 2. `ghapp/repo-admin` — GitHub App / control-plane admin lane +# +# For each lane, prefer GitHub's documented PR update-branch API +# when there is exactly one open repo-owned ` -> main` PR. +# Fall back to a fast-forward push on the branch itself for +# `ghapp/repo-admin` only — the admin lane is allowed to advance +# without an open PR (release flow is gated by the PR existence; +# admin sync is gated by FF-ability to avoid blind force). +# +# Doctrine: +# - GitHub App installation token only. No PAT fallback. +# - `expected_head_sha` for every update-branch call (stale-head +# guard). +# - Fail loudly per lane if the lane cannot be advanced safely. +# - Do not silently ignore either branch. +# - Do not blindly push. + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +env: + ADMIN_LANE_BRANCH: ghapp/repo-admin + RELEASE_LANE_BRANCH: next + +jobs: + sync-protected-branches-with-main: + name: sync-protected-branches-with-main + 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: Reconcile next + ghapp/repo-admin against main + id: reconcile + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + owner="${REPO%%/*}" + + gh_api() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + # State accumulator for the summary step. + : >state.txt + record() { + printf '%s\n' "$*" >>state.txt + } + + record "main_sha=${MAIN_SHA}" + + # ------------------------------------------------------------ + # Helper: find exactly one open repo-owned PR with the given + # head into main. Echoes a JSON object {number, head_sha, + # html_url} on stdout, or empty if none/multiple. + # ------------------------------------------------------------ + find_release_pr() { + local head_branch="$1" + local head_filter="${owner}:${head_branch}" + local pr_json + pr_json=$(gh_api "/repos/${REPO}/pulls?state=open&base=main&head=${head_filter}" \ + | jq --arg head "${head_branch}" --arg base "main" \ + 'map(select(.head.ref == $head and .base.ref == $base and .head.repo.full_name == .base.repo.full_name))') + + local count + count=$(jq 'length' <<<"${pr_json}") + if [ "${count}" -eq 1 ]; then + jq '.[0] | {number, head_sha: .head.sha, html_url}' <<<"${pr_json}" + return 0 + fi + if [ "${count}" -eq 0 ]; then + return 1 + fi + echo "::error::Multiple open repo-owned ${head_branch} -> main PRs found; refusing to choose." + jq -r '.[] | " - #\(.number) \(.html_url)"' <<<"${pr_json}" >&2 + return 2 + } + + # ------------------------------------------------------------ + # Helper: GitHub's PUT /pulls/{n}/update-branch with + # expected_head_sha (stale-head guard). Updates the PR's + # head branch to incorporate the latest base. + # ------------------------------------------------------------ + update_pr_branch() { + local pr_number="$1" + local expected_head_sha="$2" + gh_api \ + --method PUT \ + "/repos/${REPO}/pulls/${pr_number}/update-branch" \ + -f "expected_head_sha=${expected_head_sha}" + } + + # ------------------------------------------------------------ + # Helper: compare a branch against main; returns 0 if FF-able + # (behind_by > 0 AND ahead_by == 0), 1 if up-to-date already, + # 2 if divergent (both ahead and behind). + # ------------------------------------------------------------ + ff_state() { + local branch="$1" + local cmp + cmp=$(gh_api "/repos/${REPO}/compare/${branch}...main") + local behind_by ahead_by + behind_by=$(jq -r '.behind_by' <<<"${cmp}") + ahead_by=$(jq -r '.ahead_by' <<<"${cmp}") + echo "${ahead_by} ${behind_by}" + } + + # ============================================================ + # Lane 1: next (release lane) + # ============================================================ + echo "::group::Reconcile next lane" + next_before=$(gh_api "/repos/${REPO}/branches/${RELEASE_LANE_BRANCH}" --jq '.commit.sha') + record "next_sha_before=${next_before}" + echo "next before: ${next_before}" + + if next_pr_json=$(find_release_pr "${RELEASE_LANE_BRANCH}" 2>/dev/null); then + next_pr_number=$(jq -r '.number' <<<"${next_pr_json}") + next_pr_head=$(jq -r '.head_sha' <<<"${next_pr_json}") + echo "Found release PR #${next_pr_number} (head ${next_pr_head})" + record "next_pr=#${next_pr_number}" + update_pr_branch "${next_pr_number}" "${next_pr_head}" + sleep 2 + next_after=$(gh_api "/repos/${REPO}/branches/${RELEASE_LANE_BRANCH}" --jq '.commit.sha') + record "next_sha_after=${next_after}" + echo "next after: ${next_after}" + else + rc=$? + if [ "${rc}" -eq 1 ]; then + echo "::error::No open repo-owned ${RELEASE_LANE_BRANCH} -> main PR found. Per release-lane policy, the release PR is the documented surface for next->main update-branch calls. Open the release PR or accept that next will not be auto-synced." + record "next_status=blocked_no_release_pr" + echo "next_sync_failed=true" >>"$GITHUB_OUTPUT" + else + record "next_status=blocked_multiple_release_prs" + echo "next_sync_failed=true" >>"$GITHUB_OUTPUT" + # already errored to stderr inside find_release_pr + fi + fi + echo "::endgroup::" + + # ============================================================ + # Lane 2: ghapp/repo-admin (control-plane admin lane) + # ============================================================ + echo "::group::Reconcile ghapp/repo-admin lane" + admin_before=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') + record "admin_sha_before=${admin_before}" + echo "admin before: ${admin_before}" + + if admin_pr_json=$(find_release_pr "${ADMIN_LANE_BRANCH}" 2>/dev/null); then + admin_pr_number=$(jq -r '.number' <<<"${admin_pr_json}") + admin_pr_head=$(jq -r '.head_sha' <<<"${admin_pr_json}") + echo "Found admin PR #${admin_pr_number} (head ${admin_pr_head})" + record "admin_pr=#${admin_pr_number}" + update_pr_branch "${admin_pr_number}" "${admin_pr_head}" + sleep 2 + admin_after=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') + record "admin_sha_after=${admin_after}" + echo "admin after: ${admin_after}" + else + # No open admin PR — try fast-forward of the branch itself. + echo "No open ${ADMIN_LANE_BRANCH} -> main PR. Checking fast-forward state." + read -r ahead_by behind_by < <(ff_state "${ADMIN_LANE_BRANCH}") + echo "ahead_by=${ahead_by} behind_by=${behind_by}" + if [ "${ahead_by}" -eq 0 ] && [ "${behind_by}" -eq 0 ]; then + echo "${ADMIN_LANE_BRANCH} is already up-to-date with main." + record "admin_status=up_to_date" + record "admin_sha_after=${admin_before}" + elif [ "${ahead_by}" -eq 0 ] && [ "${behind_by}" -gt 0 ]; then + echo "${ADMIN_LANE_BRANCH} is ${behind_by} commit(s) behind main and 0 ahead — fast-forward via App." + # PATCH /repos/{owner}/{repo}/git/refs/heads/{branch} with + # main's SHA. Force=false; FF-only (we already verified + # ahead_by=0). + gh_api \ + --method PATCH \ + "/repos/${REPO}/git/refs/heads/${ADMIN_LANE_BRANCH}" \ + -f "sha=${MAIN_SHA}" \ + -F "force=false" + admin_after=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') + record "admin_sha_after=${admin_after}" + record "admin_status=fast_forwarded" + echo "admin after: ${admin_after}" + else + echo "::error::${ADMIN_LANE_BRANCH} has ${ahead_by} commit(s) ahead and ${behind_by} commit(s) behind main — divergent history. Manual rebase/update required; refusing to blind-push." + record "admin_status=blocked_divergent_${ahead_by}_ahead_${behind_by}_behind" + echo "admin_sync_failed=true" >>"$GITHUB_OUTPUT" + fi + fi + echo "::endgroup::" + + # ============================================================ + # Final disposition: if either lane failed to sync, fail the + # workflow so the operator sees the red signal. PR #79 + # remains blocked while either lane is stale. + # ============================================================ + cat state.txt + + if grep -qE '^(next|admin)_sync_failed=true' "${GITHUB_OUTPUT:-/dev/null}" 2>/dev/null; then + echo "::error::One or more protected branches could not be synced; PR #79 remains blocked until both lanes are caught up." + exit 1 + fi + + - name: Sync summary + if: always() + env: + MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + { + echo "# sync-protected-branches-with-main" + echo "" + echo "- Trigger: \`${{ github.event_name }}\`" + echo "- main SHA: \`${MAIN_SHA}\`" + echo "- Lanes reconciled: \`next\`, \`ghapp/repo-admin\`" + echo "" + if [ -f state.txt ]; then + echo "## Reconciliation state" + echo "" + while IFS= read -r line; do + echo "- \`${line}\`" + done >"$GITHUB_STEP_SUMMARY" From f19b3abebb998077d1cf08a020239607c4d1bacc Mon Sep 17 00:00:00 2001 From: primeinc Date: Sun, 10 May 2026 23:41:42 -0400 Subject: [PATCH 20/36] =?UTF-8?q?admin:=20address=20PR=20#81=20review=20co?= =?UTF-8?q?mments=20=E2=80=94=20FF=20semantics,=20FD3=20stderr,=20generic?= =?UTF-8?q?=20msgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real fixes from the PR #81 review pass (codex P1 + 2 Copilot mediums): 1) [chatgpt-codex P1, 00f:200] Fix FF detection inverted compare semantics. The compare API returns ahead_by/behind_by relative to `compare/{base}...{head}` order: ahead_by = commits in head not in base behind_by = commits in base not in head Previous code called `compare/${branch}...main` then read behind_by as "branch behind main" — exactly inverted. With compare/branch...main: ahead_by = commits-in-main-not-in-branch (i.e. branch BEHIND), behind_by = commits-in-branch-not-in-main (i.e. branch AHEAD). The caller's variable names assumed the correct semantic, so a cleanly fast-forwardable branch was being reported as divergent and skipped. Fix: ff_state() now uses `compare/main...${branch}` so the API's ahead_by/behind_by directly match the caller's "branch ahead/behind main" semantic. Comment block expanded to make the API direction explicit so future-me doesn't re-invert it. 2) [Copilot 00f:106] find_release_pr's "Multiple open PRs" error message went to stdout while callers do `... 2>/dev/null`. The stderr redirect didn't help because the annotation was on stdout, and stdout is captured by command substitution. Net effect: silent failure on the multi-PR error path. Fix: open FD 3 to the script's real stderr at the top of the step (`exec 3>&2`). Helper functions now emit their `::error::` annotations + diagnostic lists to FD 3, which survives both the caller's command substitution and the `2>/dev/null` swallow on the happy path. 3) [Copilot 00f:229] Two summary strings hardcoded "PR #79 remains blocked..." This will rot the moment another downstream PR exists. Replace with generic "downstream PRs targeting either lane remain blocked..." in both the error path and the summary section heading. Not addressed in this commit (with reasons): - [Copilot 00e:36] `environment: ${{ inputs.operation == 'upsert' && 'github-admin' || '' }}` empty-string env on the check path. Per ../refs/github/docs/data/reusables/actions/jobs/section-using- environments-for-jobs.md, the docs describe valid forms (single name string, or object with name+url) but do not document empty-string behavior. actionlint accepts the YAML. The 00e workflow is dispatch-only and the brief explicitly defers live upsert ("Do not activate live rulesets"), so the empty-string path is currently unreachable. If/when 00e fires in upsert mode and the empty-string env breaks at runtime, split into two jobs (render + upsert, only upsert declares environment). - [gemini approval-count = 1, both rulesets] Brief explicitly says "No `require_code_owner_review` unless a real separate reviewer/team exists. It does not." With one actor, required_approving_review_count: 1 is unsatisfiable. Brief-aligned value is 0. - [gemini hardcoded App ID 3663316 in tracked JSON, both rulesets] Brief explicitly says "Tracked JSON must not hardcode numeric App IDs." Bypass actor is rendered at runtime in 00e from vars.GH_APP_ID. Tracked specs MUST stay with empty bypass_actors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../00f-sync-protected-branches-with-main.yml | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml index 217b90ebc..5532d5d7d 100644 --- a/.github/workflows/00f-sync-protected-branches-with-main.yml +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -64,6 +64,12 @@ jobs: run: | set -euo pipefail + # Open FD 3 to the workflow's real stderr so helper + # functions can emit ::error:: annotations that survive + # callers using `... 2>/dev/null` to suppress noise on the + # happy path. See Copilot review comment on 00f:106. + exec 3>&2 + owner="${REPO%%/*}" gh_api() { @@ -103,8 +109,24 @@ jobs: if [ "${count}" -eq 0 ]; then return 1 fi - echo "::error::Multiple open repo-owned ${head_branch} -> main PRs found; refusing to choose." - jq -r '.[] | " - #\(.number) \(.html_url)"' <<<"${pr_json}" >&2 + # Multi-PR error path. Caller captures stdout via command + # substitution + `2>/dev/null`. Write the diagnostic to + # the workflow log directly via gh's GITHUB_STEP_SUMMARY + # would be wrong here (helper, not a step); instead emit + # to file descriptor 3 which we open to a known log path. + # Simpler: write to >&2 NOW (before the redirect by the + # caller would matter), and ALSO set a global state + # variable the caller can read. Since the caller pattern + # is `if find_release_pr ... 2>/dev/null; then ... else + # rc=$?; fi`, the stderr WILL be swallowed. Use FD 3 + # (opened in the main script body) to bypass. + # + # Per Copilot review on PR #81: route both the + # `::error::` annotation and the per-PR list to FD 3 so + # they survive the caller's `2>/dev/null`. FD 3 is + # opened to /dev/stderr at the top of this run script. + echo "::error::Multiple open repo-owned ${head_branch} -> main PRs found; refusing to choose." >&3 + jq -r '.[] | " - #\(.number) \(.html_url)"' <<<"${pr_json}" >&3 return 2 } @@ -123,17 +145,26 @@ jobs: } # ------------------------------------------------------------ - # Helper: compare a branch against main; returns 0 if FF-able - # (behind_by > 0 AND ahead_by == 0), 1 if up-to-date already, - # 2 if divergent (both ahead and behind). + # Helper: compare a branch against main. Returns + # ` ` on stdout. + # + # Uses `compare/main...{branch}` (NOT `{branch}...main`) + # because the GitHub compare API's `ahead_by`/`behind_by` + # are computed relative to the basehead order: + # compare/A...B → ahead_by = commits in B not in A + # behind_by = commits in A not in B + # So compare/main...{branch} gives: + # ahead_by = commits in branch not in main = branch AHEAD + # behind_by = commits in main not in branch = branch BEHIND + # which matches the caller's variable semantic. # ------------------------------------------------------------ ff_state() { local branch="$1" local cmp - cmp=$(gh_api "/repos/${REPO}/compare/${branch}...main") + cmp=$(gh_api "/repos/${REPO}/compare/main...${branch}") local behind_by ahead_by - behind_by=$(jq -r '.behind_by' <<<"${cmp}") ahead_by=$(jq -r '.ahead_by' <<<"${cmp}") + behind_by=$(jq -r '.behind_by' <<<"${cmp}") echo "${ahead_by} ${behind_by}" } @@ -219,14 +250,15 @@ jobs: echo "::endgroup::" # ============================================================ - # Final disposition: if either lane failed to sync, fail the - # workflow so the operator sees the red signal. PR #79 - # remains blocked while either lane is stale. + # Final disposition: if either lane failed to sync, fail + # the workflow so the operator sees the red signal. + # Downstream PRs targeting either lane remain blocked + # while that lane is stale. # ============================================================ cat state.txt if grep -qE '^(next|admin)_sync_failed=true' "${GITHUB_OUTPUT:-/dev/null}" 2>/dev/null; then - echo "::error::One or more protected branches could not be synced; PR #79 remains blocked until both lanes are caught up." + echo "::error::One or more protected branches could not be synced; downstream PRs remain blocked until both lanes are caught up to main." exit 1 fi @@ -257,6 +289,6 @@ jobs: echo "- Fast-forward fallback for \`ghapp/repo-admin\` when no open PR: \`PATCH /repos/{owner}/{repo}/git/refs/heads/{branch}\` with \`force=false\` (FF-only)" echo "- Compare API: \`GET /repos/{owner}/{repo}/compare/{branch}...main\` for FF-state inspection" echo "" - echo "## PR #79 unblock condition" - echo "PR #79 remains blocked until BOTH lanes are caught up to main. If either lane is stale or divergent, the workflow fails red and the operator must reconcile manually before the next push to main can clear the gate." + echo "## Downstream PR unblock condition" + echo "Downstream PRs targeting either lane remain blocked until BOTH lanes are caught up to main. If either lane is stale or divergent, this workflow fails red and the operator must reconcile manually before the next push to main can clear the gate." } >>"$GITHUB_STEP_SUMMARY" From 86461c9e774d66282670809b976defce101ce0c9 Mon Sep 17 00:00:00 2001 From: primeinc Date: Sun, 10 May 2026 23:46:02 -0400 Subject: [PATCH 21/36] admin: split 00e into check + upsert jobs (Copilot review on 00e:36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ../refs/github/docs/data/reusables/actions/jobs/section-using- environments-for-jobs.md, the `environment:` job key takes a single name string or an object with name+url — empty-string expressions are not documented. The previous shape: environment: ${{ inputs.operation == 'upsert' && 'github-admin' || '' }} worked under actionlint but is undocumented behavior at runtime. Per Copilot review on PR #81 (00e:36), split into two jobs so only the upsert job declares `environment: github-admin`: - check job: always runs on workflow_dispatch (any operation), no environment gate. Read-only — render specs, diff against live rulesets, fail on drift. Drives the green-or-red signal for the operator's `check` invocation. - upsert job: needs check, runs only when `inputs.operation == 'upsert'`, declares `environment: github-admin` (canonical single name string form). Validates the APPLY_RULESETS confirmation string, then renders + creates-or-updates each ruleset, then re-fetches and diffs to verify the upsert landed cleanly. Bash logic is duplicated inline rather than sourced from a shared `.github/workflows/scripts/branch-rulesets-lib.sh` — sourcing isn't the canonical Actions pattern, and would require expanding the admin-lane path-scope allow-list to a non-rulesets directory. Two ~100-line bash blocks is the right shape for this surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/00e-branch-rulesets.yml | 266 +++++++++++++++++----- 1 file changed, 212 insertions(+), 54 deletions(-) diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 278f19a5f..ac16cd217 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -1,5 +1,25 @@ name: branch-rulesets +# Per the protection-stage brief, this workflow operates the +# tracked ruleset specs in `.github-stars/control-plane/rulesets/` +# in two modes: +# +# - check (default): render specs at runtime, diff against the +# live rulesets, fail on drift. Read-only. +# - upsert (gated): render + create-or-update the live rulesets +# via PUT /repos/{owner}/{repo}/rulesets/{id}. +# +# Per Copilot review on PR #81 (00e:36) and per +# ../refs/github/docs/data/reusables/actions/jobs/section-using- +# environments-for-jobs.md: the `environment:` job key takes a +# single name string (or an object with name+url). It does not +# accept an empty-string expression. Split into two jobs so only +# the upsert job declares `environment: github-admin`. Bash logic +# is duplicated inline in each job rather than sourced from a +# shared file — that's the simpler, more idiomatic shape and +# avoids spreading the path-scope allow-list to a non-rulesets +# directory. + on: workflow_dispatch: inputs: @@ -29,17 +49,186 @@ permissions: contents: read jobs: - branch-rulesets: - name: branch-rulesets-${{ inputs.operation }} + # ---------------------------------------------------------------- + # check: always runs. Renders specs, diffs against live rulesets, + # fails on drift. No environment gate; this path is read-only. + # ---------------------------------------------------------------- + check: + name: branch-rulesets-check 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: 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 ruleset drift (read-only) + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + ENFORCEMENT: ${{ inputs.enforcement }} + GH_APP_ID: ${{ vars.GH_APP_ID }} + OPERATION: check + 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 + + gh_rules_api() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + ruleset_id_by_name() { + local name="$1" + gh_rules_api --paginate "/repos/${REPO}/rulesets?includes_parents=false" \ + | jq -r --arg name "${name}" ' + if type == "array" then .[] else . end + | select(.name == $name) + | .id + ' \ + | head -n 1 + } + + 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} + ] | sort_by(.context) + } + } + else {type} + end + ] | sort_by(.type), + bypass_actors: [ + .bypass_actors[]? + | { + actor_id, + actor_type, + bypass_mode: (.bypass_mode // "pull_request") + } + ] | sort_by(.actor_type, .actor_id, .bypass_mode) + }' + } + + render_spec() { + # `bypass_mode: "pull_request"` 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." Strictly tighter than `always`. + 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: "pull_request" + } + ]' \ + "${spec}" > "${rendered}" + } + + check_ruleset() { + local spec="$1" + local name rendered desired actual id + name=$(jq -r '.name' "${spec}") + rendered=$(mktemp); desired=$(mktemp); actual=$(mktemp) + render_spec "${spec}" "${rendered}" + normalize_ruleset < "${rendered}" > "${desired}" + id=$(ruleset_id_by_name "${name}" || true) + if [ -z "${id}" ]; then + echo "::error::ruleset missing: ${name}" + return 1 + fi + gh_rules_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: ${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: Check summary + if: always() + run: | + { + echo "# Branch rulesets — check" + echo "" + echo "- Operation: \`check\` (drift report only; no mutation)" + 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\` (rendered at runtime)" + echo "- App permission required: \`Administration: write\` on this repository" + } >>"$GITHUB_STEP_SUMMARY" + + # ---------------------------------------------------------------- + # upsert: gated. Runs only when the operator dispatches + # operation=upsert AND confirm_upsert=APPLY_RULESETS. Declares + # `environment: github-admin` so deployment protection rules + # (when configured) gate the actual mutation. + # ---------------------------------------------------------------- + upsert: + name: branch-rulesets-upsert + runs-on: ubuntu-latest + needs: check + if: github.ref == 'refs/heads/main' && inputs.operation == 'upsert' + environment: 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: | @@ -59,13 +248,13 @@ jobs: repositories: ${{ github.event.repository.name }} permission-administration: write - - name: Check or upsert branch rulesets + - name: Upsert + verify ruleset state env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - OPERATION: ${{ inputs.operation }} ENFORCEMENT: ${{ inputs.enforcement }} GH_APP_ID: ${{ vars.GH_APP_ID }} + OPERATION: upsert run: | set -euo pipefail @@ -81,13 +270,9 @@ jobs: "$@" } - list_rulesets() { - gh_rules_api --paginate "/repos/${REPO}/rulesets?includes_parents=false" - } - ruleset_id_by_name() { local name="$1" - list_rulesets \ + gh_rules_api --paginate "/repos/${REPO}/rulesets?includes_parents=false" \ | jq -r --arg name "${name}" ' if type == "array" then .[] else . end | select(.name == $name) @@ -147,14 +332,6 @@ 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}" \ @@ -174,7 +351,6 @@ jobs: local payload="$2" local id id=$(ruleset_id_by_name "${name}" || true) - if [ -n "${id}" ]; then echo "Updating ruleset ${name} (${id})" gh_rules_api \ @@ -192,62 +368,44 @@ jobs: fi } - check_ruleset() { + upsert_and_verify() { local spec="$1" - local name - local rendered - local desired - local actual - local id - + local name rendered desired actual id name=$(jq -r '.name' "${spec}") - rendered=$(mktemp) - desired=$(mktemp) - actual=$(mktemp) - + rendered=$(mktemp); desired=$(mktemp); actual=$(mktemp) render_spec "${spec}" "${rendered}" normalize_ruleset < "${rendered}" > "${desired}" - + upsert_ruleset "${name}" "${rendered}" id=$(ruleset_id_by_name "${name}" || true) - if [ -z "${id}" ]; then - if [ "${OPERATION}" = "upsert" ]; then - upsert_ruleset "${name}" "${rendered}" - id=$(ruleset_id_by_name "${name}" || true) - else - echo "::error::ruleset missing: ${name}" - return 1 - fi - elif [ "${OPERATION}" = "upsert" ]; then - upsert_ruleset "${name}" "${rendered}" + echo "::error::upsert succeeded but ruleset id not found by name: ${name}" + return 1 fi - gh_rules_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}" + echo "::error::ruleset drift after upsert: ${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" + upsert_and_verify ".github-stars/control-plane/rulesets/protect-next.json" + upsert_and_verify ".github-stars/control-plane/rulesets/protect-main-release-only.json" - - name: Ruleset summary + - name: Upsert summary if: always() run: | { - echo "# Branch rulesets" + echo "# Branch rulesets — upsert" echo "" - echo "- Operation: \`${{ inputs.operation }}\`" + echo "- Operation: \`upsert\` (create or update)" 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 "- Confirmation: \`confirm_upsert=APPLY_RULESETS\` required" + echo "- Environment: \`github-admin\` (deployment protection rules apply if configured)" echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" - echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\`" + echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\` (rendered at runtime)" echo "- App permission required: \`Administration: write\` on this repository" - } >> "$GITHUB_STEP_SUMMARY" + } >>"$GITHUB_STEP_SUMMARY" From 52e73f36d4ec7b79f4c72cd03c520e4b1bf2bac8 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:01:21 -0400 Subject: [PATCH 22/36] =?UTF-8?q?admin:=2000g-verify-app-credentials.yml?= =?UTF-8?q?=20=E2=80=94=20one-shot=20diagnostic=20to=20prove=20vars.GH=5FA?= =?UTF-8?q?PP=5FID=20+=20secrets.GH=5FAPP=5FPRIVATE=5FKEY=20match=20the=20?= =?UTF-8?q?installed=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's question 'where does the App ID come from': the canonical answer per ../refs/github/docs/.../*.md is the App settings page in browser (https://github.com/settings/apps/). The programmatic route requires JWT auth (GET /app), which requires knowing the App ID — chicken/egg without a verified-good ID. This workflow inverts: mint an installation token via actions/create-github-app-token@v3. The mint step fails loud with a JWT signature error if vars.GH_APP_ID is wrong (or if it doesn't match the private key). Successful mint + GET /installation/repositories confirming this repo is in the App's installation scope = both vars are correct AND the App is installed where expected. Run from the Actions tab via workflow_dispatch on ghapp/repo-admin or after this PR merges to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/00g-verify-app-credentials.yml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/00g-verify-app-credentials.yml diff --git a/.github/workflows/00g-verify-app-credentials.yml b/.github/workflows/00g-verify-app-credentials.yml new file mode 100644 index 000000000..529d18ff7 --- /dev/null +++ b/.github/workflows/00g-verify-app-credentials.yml @@ -0,0 +1,95 @@ +name: verify-app-credentials + +# One-shot diagnostic: mint a GitHub App installation token using +# `vars.GH_APP_ID` + `secrets.GH_APP_PRIVATE_KEY`. If the App ID or +# the private key don't match the actual App, the token-mint step +# fails loud with a JWT signature error. Successful mint proves +# both vars are correct. +# +# This workflow exists because reading the App's own ID via +# `GET /app` requires JWT auth (chicken/egg without a known-good +# App ID). The canonical "where is my App ID" answer per +# ../refs/github/docs/content/apps/creating-github-apps/.../*.md +# is the App settings page in browser: +# https://github.com/settings/apps/ +# This workflow is the programmatic round-trip equivalent. +# +# Run from the Actions tab via workflow_dispatch. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: verify-app-credentials + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Validate vars.GH_APP_ID format + env: + 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. Got: '${GH_APP_ID}'" + exit 1 + fi + echo "vars.GH_APP_ID format ok: ${GH_APP_ID}" + + - name: Mint App installation token (proves App ID + private key match) + 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 }} + + - name: Read App identity via installation token + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} + run: | + set -euo pipefail + echo "installation-id=${INSTALLATION_ID}" + + # `GET /installation/repositories` is callable with an + # installation token and confirms the App is installed on + # this repo. Per + # ../refs/github/docs/content/rest/apps/installations.md + # — endpoints requiring app installation auth. + repos=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /installation/repositories \ + --jq '.repositories | map({full_name, private})') + + echo "Installed on:" + echo "${repos}" | jq -r '.[] | " - \(.full_name)\(if .private then " (private)" else " (public)" end)"' + + # Sanity check: this repo should be in the list. + this_repo="${{ github.repository }}" + if ! echo "${repos}" | jq -e --arg r "${this_repo}" '.[] | select(.full_name == $r)' >/dev/null; then + echo "::error::App is NOT installed on ${this_repo} (App ID ${{ vars.GH_APP_ID }}). Check the App's installation scope." + exit 1 + fi + echo "App is installed on ${this_repo}: OK" + + - name: Summary + if: always() + run: | + { + echo "# verify-app-credentials" + echo "" + echo "- vars.GH_APP_ID: \`${{ vars.GH_APP_ID }}\`" + echo "- secrets.GH_APP_PRIVATE_KEY: present (mint succeeded)" + echo "- Installation ID: \`${{ steps.app-token.outputs.installation-id }}\`" + echo "- Verified: this repo is in the App's installation scope" + echo "" + echo "## What this proves" + echo "- vars.GH_APP_ID is the correct numeric App ID for the App backed by secrets.GH_APP_PRIVATE_KEY (token mint requires both to match — JWT signature would fail otherwise)." + echo "- The App is installed on this repository." + } >>"$GITHUB_STEP_SUMMARY" From 49489a497198d59006907b918ae36f0e82cacba4 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:01:53 -0400 Subject: [PATCH 23/36] =?UTF-8?q?admin:=20drop=20refs/heads/main=20guard?= =?UTF-8?q?=20from=2000g=20=E2=80=94=20diagnostic=20is=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/00g-verify-app-credentials.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/00g-verify-app-credentials.yml b/.github/workflows/00g-verify-app-credentials.yml index 529d18ff7..6ba69f5de 100644 --- a/.github/workflows/00g-verify-app-credentials.yml +++ b/.github/workflows/00g-verify-app-credentials.yml @@ -26,7 +26,9 @@ jobs: verify: name: verify-app-credentials runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + # Diagnostic-only; read-only API calls (no mutation). Allowed + # to run from any branch via workflow_dispatch so the operator + # can verify the App credentials before this PR merges to main. steps: - name: Validate vars.GH_APP_ID format env: From 200176579484bad6f6e012c3a0b636db3418b2ec Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:07:23 -0400 Subject: [PATCH 24/36] admin: fold App-creds verification into 00d, delete 00g MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 00g-verify-app-credentials.yml workflow was workflow_dispatch-only, which per ../refs/github/docs/content/actions/how-tos/manage-workflow- runs/manually-run-a-workflow.md requires the workflow to exist on the default branch to register. That meant the verifier couldn't run on PRs to main from ghapp/repo-admin — exactly the moment when verification matters most. Fix: fold the verification steps directly into 00d-admin-branch-sync- guard.yml. It already runs on every PR to main from the admin lane and now also: - Validates vars.GH_APP_ID matches ^[0-9]+$ - Mints an App installation token via actions/create-github-app- token@v3 (JWT-signature failure is fail-loud if App ID + private key don't match) - Calls GET /installation/repositories with the installation token to confirm the App is installed on this repo (catches the case where vars.GH_APP_ID is numeric-valid but points at the wrong App) All three steps are gated on if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH so non-admin PRs to main pass through (preserving the required-status-check pass-through pattern for the rest of 00d). Delete 00g — the workflow_dispatch-only verifier was the wrong shape for this gate. --- .../workflows/00d-admin-branch-sync-guard.yml | 61 ++++++++++++ .../workflows/00g-verify-app-credentials.yml | 97 ------------------- 2 files changed, 61 insertions(+), 97 deletions(-) delete mode 100644 .github/workflows/00g-verify-app-credentials.yml diff --git a/.github/workflows/00d-admin-branch-sync-guard.yml b/.github/workflows/00d-admin-branch-sync-guard.yml index cadee1b7d..2a2e432f3 100644 --- a/.github/workflows/00d-admin-branch-sync-guard.yml +++ b/.github/workflows/00d-admin-branch-sync-guard.yml @@ -159,6 +159,65 @@ jobs: echo "path-scope: OK (all changed paths are within the admin/control-plane scope)" + # App-credentials verification: every admin-lane PR proves + # that vars.GH_APP_ID + secrets.GH_APP_PRIVATE_KEY mint a + # valid installation token AND the App is installed on this + # repo. If the App ID is wrong or the key doesn't match, the + # token-mint step fails loud with a JWT signature error + # before the PR can merge. Skipped for non-admin heads (those + # PRs aren't allowed to mutate the App-shaped workflows). + - name: Validate vars.GH_APP_ID format + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + env: + 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. Got: '${GH_APP_ID}'" + exit 1 + fi + echo "vars.GH_APP_ID format ok: ${GH_APP_ID}" + + - name: Mint App installation token (proves App ID + private key match) + id: app-token + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + 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 }} + + - name: Verify App is installed on this repo + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} + THIS_REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "installation-id=${INSTALLATION_ID}" + + # `GET /installation/repositories` is callable with an + # installation token and confirms the App is installed + # where expected. Per + # ../refs/github/docs/content/rest/apps/installations.md + # — endpoints requiring app installation auth. + repos=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /installation/repositories \ + --jq '.repositories | map(.full_name)') + + echo "Installed on:" + echo "${repos}" | jq -r '.[] | " - \(.)"' + + if ! echo "${repos}" | jq -e --arg r "${THIS_REPO}" '. | index($r)' >/dev/null; then + echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}. Check the App's installation scope at https://github.com/settings/apps//installations." + exit 1 + fi + echo "app-creds: OK (App installed on ${THIS_REPO})" + - name: Sync guard summary if: always() run: | @@ -169,6 +228,8 @@ jobs: echo "- Behind-main: GitHub REST \`GET /repos/{owner}/{repo}/compare/{base}...{head}\`, fail if \`behind_by > 0\`" echo "- Path-scope: \`GET /repos/{owner}/{repo}/pulls/{n}/files\` with paginate, allow-list match" echo "- Allowed paths (Medium scope): \`.github/workflows/00*.yml\`, \`.github-stars/control-plane/**\`, \`AGENTS.md\`, \`docs/automation/**\`, \`docs/security.md\`, \`.github/PULL_REQUEST_TEMPLATE.md\`" + echo "- App-creds: \`actions/create-github-app-token@v3\` mints installation token from \`vars.GH_APP_ID\` + \`secrets.GH_APP_PRIVATE_KEY\` (JWT-signature failure is fail-loud); \`GET /installation/repositories\` confirms App is installed on this repo" echo "- Unblock-stale: rebase against \`${{ github.event.pull_request.base.ref }}\` or click \"Update branch\" in the PR UI (calls update-branch API with expected_head_sha)" echo "- Unblock-paths: split out-of-scope changes to a separate PR via \`next\`" + echo "- Unblock-app-creds: set \`vars.GH_APP_ID\` to the numeric App ID (find at https://github.com/settings/apps/) and verify \`secrets.GH_APP_PRIVATE_KEY\` is the matching PEM" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00g-verify-app-credentials.yml b/.github/workflows/00g-verify-app-credentials.yml deleted file mode 100644 index 6ba69f5de..000000000 --- a/.github/workflows/00g-verify-app-credentials.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: verify-app-credentials - -# One-shot diagnostic: mint a GitHub App installation token using -# `vars.GH_APP_ID` + `secrets.GH_APP_PRIVATE_KEY`. If the App ID or -# the private key don't match the actual App, the token-mint step -# fails loud with a JWT signature error. Successful mint proves -# both vars are correct. -# -# This workflow exists because reading the App's own ID via -# `GET /app` requires JWT auth (chicken/egg without a known-good -# App ID). The canonical "where is my App ID" answer per -# ../refs/github/docs/content/apps/creating-github-apps/.../*.md -# is the App settings page in browser: -# https://github.com/settings/apps/ -# This workflow is the programmatic round-trip equivalent. -# -# Run from the Actions tab via workflow_dispatch. - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - verify: - name: verify-app-credentials - runs-on: ubuntu-latest - # Diagnostic-only; read-only API calls (no mutation). Allowed - # to run from any branch via workflow_dispatch so the operator - # can verify the App credentials before this PR merges to main. - steps: - - name: Validate vars.GH_APP_ID format - env: - 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. Got: '${GH_APP_ID}'" - exit 1 - fi - echo "vars.GH_APP_ID format ok: ${GH_APP_ID}" - - - name: Mint App installation token (proves App ID + private key match) - 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 }} - - - name: Read App identity via installation token - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} - run: | - set -euo pipefail - echo "installation-id=${INSTALLATION_ID}" - - # `GET /installation/repositories` is callable with an - # installation token and confirms the App is installed on - # this repo. Per - # ../refs/github/docs/content/rest/apps/installations.md - # — endpoints requiring app installation auth. - repos=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /installation/repositories \ - --jq '.repositories | map({full_name, private})') - - echo "Installed on:" - echo "${repos}" | jq -r '.[] | " - \(.full_name)\(if .private then " (private)" else " (public)" end)"' - - # Sanity check: this repo should be in the list. - this_repo="${{ github.repository }}" - if ! echo "${repos}" | jq -e --arg r "${this_repo}" '.[] | select(.full_name == $r)' >/dev/null; then - echo "::error::App is NOT installed on ${this_repo} (App ID ${{ vars.GH_APP_ID }}). Check the App's installation scope." - exit 1 - fi - echo "App is installed on ${this_repo}: OK" - - - name: Summary - if: always() - run: | - { - echo "# verify-app-credentials" - echo "" - echo "- vars.GH_APP_ID: \`${{ vars.GH_APP_ID }}\`" - echo "- secrets.GH_APP_PRIVATE_KEY: present (mint succeeded)" - echo "- Installation ID: \`${{ steps.app-token.outputs.installation-id }}\`" - echo "- Verified: this repo is in the App's installation scope" - echo "" - echo "## What this proves" - echo "- vars.GH_APP_ID is the correct numeric App ID for the App backed by secrets.GH_APP_PRIVATE_KEY (token mint requires both to match — JWT signature would fail otherwise)." - echo "- The App is installed on this repository." - } >>"$GITHUB_STEP_SUMMARY" From 720efb786cb684a8f3e78255bee1a943968d2735 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:14:01 -0400 Subject: [PATCH 25/36] admin: rename workflow + job names for text-message-style UI clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR Checks UI renders each status as ' / '. Previous names duplicated (main-release-guard / main-release-guard) and used technical jargon (gate, build, workflow-lint) that required critical thinking to decode. Renamed for instant-readability per the constraint 'green check + name = full meaning, no thinking required': Workflow | Job | Renders as -------------|------------------|------------------------------ Merge Block | Label cleared | Merge Block / Label cleared Lane Check | Source allowed | Lane Check / Source allowed Admin Lane | Ready to merge | Admin Lane / Ready to merge Code Check | All gates pass | Code Check / All gates pass Code Check | Workflows valid | Code Check / Workflows valid Site Build | Build succeeds | Site Build / Build succeeds Each renders as a status sentence — operator sees the name and immediately knows what passed/failed without reading docs or inspecting the run. Updated both ruleset specs (protect-main-release-only.json, protect-next.json) to reference the new context strings — required_status_checks[].context matches the job's display name, not the job ID, so rename without ruleset update would leave the rulesets pointing at dead names (perpetually-pending once enforcement turns on). Job IDs (workflow YAML keys) kept unchanged so file paths and log selectors don't break for tooling that uses gh api / gh run view by job id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rulesets/protect-main-release-only.json | 12 ++++++------ .../control-plane/rulesets/protect-next.json | 8 ++++---- .github/workflows/00-ci.yml | 4 +++- .github/workflows/00a-do-not-merge-yet.yml | 4 ++-- .github/workflows/00b-web-ci.yml | 3 ++- .github/workflows/00c-main-release-guard.yml | 4 ++-- .github/workflows/00d-admin-branch-sync-guard.yml | 4 ++-- 7 files changed, 21 insertions(+), 18 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 2463e580f..3d8a88606 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -31,22 +31,22 @@ "strict_required_status_checks_policy": true, "required_status_checks": [ { - "context": "admin-branch-sync-guard" + "context": "Ready to merge" }, { - "context": "main-release-guard" + "context": "Source allowed" }, { - "context": "do-not-merge-yet" + "context": "Label cleared" }, { - "context": "gate" + "context": "All gates pass" }, { - "context": "workflow-lint" + "context": "Workflows valid" }, { - "context": "build" + "context": "Build succeeds" } ] } diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 2100f0cbd..8d5225ef4 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -31,16 +31,16 @@ "strict_required_status_checks_policy": true, "required_status_checks": [ { - "context": "do-not-merge-yet" + "context": "Label cleared" }, { - "context": "gate" + "context": "All gates pass" }, { - "context": "workflow-lint" + "context": "Workflows valid" }, { - "context": "build" + "context": "Build succeeds" } ] } diff --git a/.github/workflows/00-ci.yml b/.github/workflows/00-ci.yml index 924d7e3d8..313b8dfc4 100644 --- a/.github/workflows/00-ci.yml +++ b/.github/workflows/00-ci.yml @@ -1,4 +1,4 @@ -name: 'CI' +name: 'Code Check' on: pull_request: @@ -15,6 +15,7 @@ permissions: jobs: gate: + name: All gates pass # `pnpm gate` is the single readiness command per issue #69 lesson 1. # Sub-stages: typecheck, test, validate (taxonomy + schema), generated # artifact registry presence, actionlint workflow lint. @@ -87,6 +88,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" workflow-lint: + name: Workflows valid # Cheap structural guards that pnpm gate's actionlint stage cannot # express. See `.sisyphus/proofs/02M-mode-correction.md`. runs-on: ubuntu-latest diff --git a/.github/workflows/00a-do-not-merge-yet.yml b/.github/workflows/00a-do-not-merge-yet.yml index 4b40f81c2..2819e1fa3 100644 --- a/.github/workflows/00a-do-not-merge-yet.yml +++ b/.github/workflows/00a-do-not-merge-yet.yml @@ -1,4 +1,4 @@ -name: DoNotMergeYet label gate +name: Merge Block on: pull_request: @@ -16,7 +16,7 @@ permissions: jobs: do-not-merge-yet: - name: do-not-merge-yet + name: Label cleared runs-on: ubuntu-latest steps: - name: Fail when PR has DoNotMergeYet label diff --git a/.github/workflows/00b-web-ci.yml b/.github/workflows/00b-web-ci.yml index c41a4bdc5..fc093e9ef 100644 --- a/.github/workflows/00b-web-ci.yml +++ b/.github/workflows/00b-web-ci.yml @@ -1,5 +1,5 @@ --- -name: 'Web CI' +name: 'Site Build' on: # Run on EVERY PR to main/next (no paths filter) so this can be a @@ -27,6 +27,7 @@ concurrency: jobs: build: + name: Build succeeds runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index 9f1a0230a..5a8b332ce 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -1,4 +1,4 @@ -name: main-release-guard +name: Lane Check # Per the protection-stage brief, `main` accepts exactly two # repo-owned source branches: @@ -27,7 +27,7 @@ permissions: jobs: main-release-guard: - name: main-release-guard + name: Source allowed runs-on: ubuntu-latest steps: - name: Require repo-owned next or ghapp/repo-admin as source branch for main diff --git a/.github/workflows/00d-admin-branch-sync-guard.yml b/.github/workflows/00d-admin-branch-sync-guard.yml index 2a2e432f3..72a01ba02 100644 --- a/.github/workflows/00d-admin-branch-sync-guard.yml +++ b/.github/workflows/00d-admin-branch-sync-guard.yml @@ -1,4 +1,4 @@ -name: admin-branch-sync-guard +name: Admin Lane # Per the protection-stage brief: the canonical admin/control-plane # lane is exactly one branch — `ghapp/repo-admin`. This guard runs @@ -48,7 +48,7 @@ env: jobs: admin-branch-sync-guard: - name: admin-branch-sync-guard + name: Ready to merge runs-on: ubuntu-latest steps: - name: Compare head to base + path-scope (ghapp/repo-admin only) From 941717a93b2bf3761e72c8df42ab9e11ad7eb5d2 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:18:55 -0400 Subject: [PATCH 26/36] admin: rename Lane Check -> Branch Source / Allowed into main Reads more directly: the check answers 'is this PR's source branch allowed to merge into the protected main branch?' Green check + name is now self-evident even without context. Updated protect-main-release-only.json's required_status_checks context to match the new job name. --- .../control-plane/rulesets/protect-main-release-only.json | 2 +- .github/workflows/00c-main-release-guard.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 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 3d8a88606..8026da3de 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -34,7 +34,7 @@ "context": "Ready to merge" }, { - "context": "Source allowed" + "context": "Allowed into main" }, { "context": "Label cleared" diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index 5a8b332ce..a484c05dc 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -1,4 +1,4 @@ -name: Lane Check +name: Branch Source # Per the protection-stage brief, `main` accepts exactly two # repo-owned source branches: @@ -27,7 +27,7 @@ permissions: jobs: main-release-guard: - name: Source allowed + name: Allowed into main runs-on: ubuntu-latest steps: - name: Require repo-owned next or ghapp/repo-admin as source branch for main From 6ee5cacabb08ecb51f9f7caa733b7d49392dc3c6 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:21:39 -0400 Subject: [PATCH 27/36] admin: rename to Protected Branch / Src branch allowed (UI dispo) --- .../control-plane/rulesets/protect-main-release-only.json | 2 +- .github/workflows/00c-main-release-guard.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 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 8026da3de..4b768135a 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -34,7 +34,7 @@ "context": "Ready to merge" }, { - "context": "Allowed into main" + "context": "Src branch allowed" }, { "context": "Label cleared" diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index a484c05dc..304dd7a5a 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -1,4 +1,4 @@ -name: Branch Source +name: Protected Branch # Per the protection-stage brief, `main` accepts exactly two # repo-owned source branches: @@ -27,7 +27,7 @@ permissions: jobs: main-release-guard: - name: Allowed into main + name: Src branch allowed runs-on: ubuntu-latest steps: - name: Require repo-owned next or ghapp/repo-admin as source branch for main From 49e160d4eacea422b5822dc5a24aebf0a4dea66b Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 00:52:50 -0400 Subject: [PATCH 28/36] admin: split admin-lane guard into 3 checks with dependency-surface slugs The single Admin Lane / Ready to merge job did three different things at once. Extracted into 3 workflows with slugs that name the dependency surface (gh-action for the github-builtin-only checks; gh-app for the one that needs the App token): gh-action branch staleness / head matches main gh-action file allowlist / only allowed files gh-app credentials / token + install Files: - 00d-gh-action-branch-staleness.yml (was 00d-admin-branch-sync-guard.yml) - 00h-gh-action-file-allowlist.yml (new) - 00i-gh-app-credentials.yml (new) Updated protect-main-release-only.json: replaced the old 'Ready to merge' required context with the three new contexts 'head matches main', 'only allowed files', 'token + install'. Each check has unique left-slug + sentence right-side. UI renders as a status sentence per check, no jargon, no name/name duplication. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rulesets/protect-main-release-only.json | 8 +- .../workflows/00d-admin-branch-sync-guard.yml | 235 ------------------ .../00d-gh-action-branch-staleness.yml | 70 ++++++ .../00h-gh-action-file-allowlist.yml | 108 ++++++++ .github/workflows/00i-gh-app-credentials.yml | 89 +++++++ 5 files changed, 274 insertions(+), 236 deletions(-) delete mode 100644 .github/workflows/00d-admin-branch-sync-guard.yml create mode 100644 .github/workflows/00d-gh-action-branch-staleness.yml create mode 100644 .github/workflows/00h-gh-action-file-allowlist.yml create mode 100644 .github/workflows/00i-gh-app-credentials.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 4b768135a..a2d5c8e97 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -31,7 +31,13 @@ "strict_required_status_checks_policy": true, "required_status_checks": [ { - "context": "Ready to merge" + "context": "head matches main" + }, + { + "context": "only allowed files" + }, + { + "context": "token + install" }, { "context": "Src branch allowed" diff --git a/.github/workflows/00d-admin-branch-sync-guard.yml b/.github/workflows/00d-admin-branch-sync-guard.yml deleted file mode 100644 index 72a01ba02..000000000 --- a/.github/workflows/00d-admin-branch-sync-guard.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: Admin Lane - -# Per the protection-stage brief: the canonical admin/control-plane -# lane is exactly one branch — `ghapp/repo-admin`. This guard runs -# on every PR to `main` and enforces two invariants on that branch: -# -# 1. Behind-main check: `ghapp/repo-admin` must not silently drift -# behind `main`. Implemented via -# GET /repos/{owner}/{repo}/compare/{base}...{head} — `behind_by` -# counts commits in `main` that are not in the PR head. Anything -# > 0 fails the gate. Operator unblocks by rebase or by clicking -# "Update branch" in the PR UI (which calls -# PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch -# with `expected_head_sha` for the stale-head guard). -# -# 2. Path-scope check: `ghapp/repo-admin -> main` may ONLY modify -# admin/control-plane files. Anything outside the allowed set -# fails the gate so unrelated changes can't ride in on an admin -# PR. Allowed paths (Medium scope per brief): -# - .github/workflows/00*.yml -# - .github-stars/control-plane/** -# - AGENTS.md -# - docs/automation/** -# - docs/security.md -# - .github/PULL_REQUEST_TEMPLATE.md -# -# Pass-through for non-`ghapp/repo-admin` heads so this check name -# remains a viable required-status-check on every PR to main — -# required checks that never run leave the PR perpetually pending. - -on: - pull_request: - branches: - - main - types: - - opened - - reopened - - synchronize - - ready_for_review - - edited - -permissions: - contents: read - pull-requests: read - -env: - ADMIN_LANE_BRANCH: ghapp/repo-admin - -jobs: - admin-branch-sync-guard: - name: Ready to merge - runs-on: ubuntu-latest - steps: - - name: Compare head to base + path-scope (ghapp/repo-admin only) - 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 }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - echo "base=${BASE_REF}" - echo "head=${HEAD_REF}" - echo "head_sha=${HEAD_SHA}" - echo "admin_lane=${ADMIN_LANE_BRANCH}" - - if [ "${HEAD_REF}" != "${ADMIN_LANE_BRANCH}" ]; then - echo "Non-admin head (${HEAD_REF}); admin sync guard does not apply. Pass." - exit 0 - fi - - echo "Admin lane head detected. Running behind-base check + path-scope check." - - # ----- behind-main check ----- - # `compare/{base}...{head}` per - # https://docs.github.com/en/rest/commits/commits#compare-two-commits - # — `behind_by` counts commits in base not in head. - compare=$(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' <<<"${compare}") - ahead_by=$(jq -r '.ahead_by' <<<"${compare}") - status=$(jq -r '.status' <<<"${compare}") - - echo "ahead_by=${ahead_by}" - echo "behind_by=${behind_by}" - echo "status=${status}" - - if [ "${behind_by}" -gt 0 ]; then - echo "::error::ghapp/repo-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 "behind-main: OK (ahead_by=${ahead_by}, behind_by=0)" - - # ----- path-scope check ----- - # PRs from the admin lane may ONLY modify admin/control-plane - # paths (Medium scope). The - # GET /repos/{owner}/{repo}/pulls/{n}/files endpoint returns - # every changed path; we paginate to handle large PRs and - # match each path against an allow-list of glob patterns. - allowed_globs=( - ".github/workflows/00*.yml" - ".github-stars/control-plane/*" - ".github-stars/control-plane/**" - "AGENTS.md" - "docs/automation/*" - "docs/automation/**" - "docs/security.md" - ".github/PULL_REQUEST_TEMPLATE.md" - ) - - # bash glob match — enable extglob + globstar for `**`. - shopt -s extglob globstar nullglob - - path_in_scope() { - local path="$1" - local glob - for glob in "${allowed_globs[@]}"; do - # shellcheck disable=SC2053 # intentional glob match, not literal - if [[ "${path}" == ${glob} ]]; then - return 0 - fi - done - return 1 - } - - changed=$(gh api --paginate \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${REPO}/pulls/${PR_NUMBER}/files" \ - --jq '.[].filename') - - violations=() - while IFS= read -r path; do - [ -z "${path}" ] && continue - if ! path_in_scope "${path}"; then - violations+=("${path}") - fi - done <<<"${changed}" - - if [ "${#violations[@]}" -gt 0 ]; then - echo "::error::ghapp/repo-admin PR touches paths outside the admin/control-plane scope. Move non-admin changes to a separate PR via 'next'. Out-of-scope paths:" - for p in "${violations[@]}"; do - echo "::error:: - ${p}" - done - echo "" - echo "Allowed paths (Medium scope):" - for g in "${allowed_globs[@]}"; do - echo " - ${g}" - done - exit 1 - fi - - echo "path-scope: OK (all changed paths are within the admin/control-plane scope)" - - # App-credentials verification: every admin-lane PR proves - # that vars.GH_APP_ID + secrets.GH_APP_PRIVATE_KEY mint a - # valid installation token AND the App is installed on this - # repo. If the App ID is wrong or the key doesn't match, the - # token-mint step fails loud with a JWT signature error - # before the PR can merge. Skipped for non-admin heads (those - # PRs aren't allowed to mutate the App-shaped workflows). - - name: Validate vars.GH_APP_ID format - if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH - env: - 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. Got: '${GH_APP_ID}'" - exit 1 - fi - echo "vars.GH_APP_ID format ok: ${GH_APP_ID}" - - - name: Mint App installation token (proves App ID + private key match) - id: app-token - if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH - 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 }} - - - name: Verify App is installed on this repo - if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} - THIS_REPO: ${{ github.repository }} - run: | - set -euo pipefail - echo "installation-id=${INSTALLATION_ID}" - - # `GET /installation/repositories` is callable with an - # installation token and confirms the App is installed - # where expected. Per - # ../refs/github/docs/content/rest/apps/installations.md - # — endpoints requiring app installation auth. - repos=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /installation/repositories \ - --jq '.repositories | map(.full_name)') - - echo "Installed on:" - echo "${repos}" | jq -r '.[] | " - \(.)"' - - if ! echo "${repos}" | jq -e --arg r "${THIS_REPO}" '. | index($r)' >/dev/null; then - echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}. Check the App's installation scope at https://github.com/settings/apps//installations." - exit 1 - fi - echo "app-creds: OK (App installed on ${THIS_REPO})" - - - name: Sync guard summary - if: always() - run: | - { - echo "# admin-branch-sync-guard" - echo "" - echo "- Trigger: \`pull_request\` to \`main\` from canonical \`${ADMIN_LANE_BRANCH}\` head" - echo "- Behind-main: GitHub REST \`GET /repos/{owner}/{repo}/compare/{base}...{head}\`, fail if \`behind_by > 0\`" - echo "- Path-scope: \`GET /repos/{owner}/{repo}/pulls/{n}/files\` with paginate, allow-list match" - echo "- Allowed paths (Medium scope): \`.github/workflows/00*.yml\`, \`.github-stars/control-plane/**\`, \`AGENTS.md\`, \`docs/automation/**\`, \`docs/security.md\`, \`.github/PULL_REQUEST_TEMPLATE.md\`" - echo "- App-creds: \`actions/create-github-app-token@v3\` mints installation token from \`vars.GH_APP_ID\` + \`secrets.GH_APP_PRIVATE_KEY\` (JWT-signature failure is fail-loud); \`GET /installation/repositories\` confirms App is installed on this repo" - echo "- Unblock-stale: rebase against \`${{ github.event.pull_request.base.ref }}\` or click \"Update branch\" in the PR UI (calls update-branch API with expected_head_sha)" - echo "- Unblock-paths: split out-of-scope changes to a separate PR via \`next\`" - echo "- Unblock-app-creds: set \`vars.GH_APP_ID\` to the numeric App ID (find at https://github.com/settings/apps/) and verify \`secrets.GH_APP_PRIVATE_KEY\` is the matching PEM" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00d-gh-action-branch-staleness.yml b/.github/workflows/00d-gh-action-branch-staleness.yml new file mode 100644 index 000000000..53cc7f68a --- /dev/null +++ b/.github/workflows/00d-gh-action-branch-staleness.yml @@ -0,0 +1,70 @@ +name: gh-action branch staleness + +# Per the protection-stage brief: the canonical admin/control-plane +# lane is exactly one branch — `ghapp/repo-admin`. This guard runs +# on every PR to `main` and enforces that the admin lane head is +# not behind `main`. +# +# Mechanism: GET /repos/{owner}/{repo}/compare/{base}...{head_sha} +# returns `behind_by` (commits in main not in head). Anything > 0 +# fails. Operator unblocks via rebase or "Update branch" in PR UI +# (which calls PUT /pulls/{n}/update-branch with expected_head_sha). +# +# Pass-through for non-`ghapp/repo-admin` heads so this check name +# remains a viable required-status-check on every PR to main. + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - edited + +permissions: + contents: read + pull-requests: read + +env: + ADMIN_LANE_BRANCH: ghapp/repo-admin + +jobs: + branch-staleness: + name: head matches main + runs-on: ubuntu-latest + steps: + - name: Compare head to base (ghapp/repo-admin only) + 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 + + if [ "${HEAD_REF}" != "${ADMIN_LANE_BRANCH}" ]; then + echo "Non-admin head (${HEAD_REF}); branch staleness check does not apply. Pass." + exit 0 + fi + + compare=$(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' <<<"${compare}") + ahead_by=$(jq -r '.ahead_by' <<<"${compare}") + + echo "ahead_by=${ahead_by}" + echo "behind_by=${behind_by}" + + if [ "${behind_by}" -gt 0 ]; then + echo "::error::ghapp/repo-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 "head matches main (ahead_by=${ahead_by}, behind_by=0)" diff --git a/.github/workflows/00h-gh-action-file-allowlist.yml b/.github/workflows/00h-gh-action-file-allowlist.yml new file mode 100644 index 000000000..d4a5c676c --- /dev/null +++ b/.github/workflows/00h-gh-action-file-allowlist.yml @@ -0,0 +1,108 @@ +name: gh-action file allowlist + +# Per the protection-stage brief: PRs from `ghapp/repo-admin` to +# `main` may ONLY modify admin/control-plane files. Anything outside +# the allowed set fails so unrelated changes can't ride in on an +# admin PR. +# +# Allowed paths (Medium scope per brief): +# - .github/workflows/00*.yml +# - .github-stars/control-plane/** +# - AGENTS.md +# - docs/automation/** +# - docs/security.md +# - .github/PULL_REQUEST_TEMPLATE.md +# +# Pass-through for non-`ghapp/repo-admin` heads so this check name +# remains a viable required-status-check on every PR to main. + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - edited + +permissions: + contents: read + pull-requests: read + +env: + ADMIN_LANE_BRANCH: ghapp/repo-admin + +jobs: + file-allowlist: + name: only allowed files + runs-on: ubuntu-latest + steps: + - name: Match changed files against allowlist (ghapp/repo-admin only) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + if [ "${HEAD_REF}" != "${ADMIN_LANE_BRANCH}" ]; then + echo "Non-admin head (${HEAD_REF}); file allowlist check does not apply. Pass." + exit 0 + fi + + allowed_globs=( + ".github/workflows/00*.yml" + ".github-stars/control-plane/*" + ".github-stars/control-plane/**" + "AGENTS.md" + "docs/automation/*" + "docs/automation/**" + "docs/security.md" + ".github/PULL_REQUEST_TEMPLATE.md" + ) + + shopt -s extglob globstar nullglob + + path_in_scope() { + local path="$1" + local glob + for glob in "${allowed_globs[@]}"; do + # shellcheck disable=SC2053 # intentional glob match, not literal + if [[ "${path}" == ${glob} ]]; then + return 0 + fi + done + return 1 + } + + changed=$(gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename') + + violations=() + while IFS= read -r path; do + [ -z "${path}" ] && continue + if ! path_in_scope "${path}"; then + violations+=("${path}") + fi + done <<<"${changed}" + + if [ "${#violations[@]}" -gt 0 ]; then + echo "::error::ghapp/repo-admin PR touches paths outside the admin/control-plane scope. Move non-admin changes to a separate PR via 'next'. Out-of-scope paths:" + for p in "${violations[@]}"; do + echo "::error:: - ${p}" + done + echo "" + echo "Allowed paths:" + for g in "${allowed_globs[@]}"; do + echo " - ${g}" + done + exit 1 + fi + + echo "only allowed files (every changed path is within the admin/control-plane scope)" diff --git a/.github/workflows/00i-gh-app-credentials.yml b/.github/workflows/00i-gh-app-credentials.yml new file mode 100644 index 000000000..bb2e86b03 --- /dev/null +++ b/.github/workflows/00i-gh-app-credentials.yml @@ -0,0 +1,89 @@ +name: gh-app credentials + +# Per the protection-stage brief: every PR from `ghapp/repo-admin` +# proves that vars.GH_APP_ID + secrets.GH_APP_PRIVATE_KEY mint a +# valid installation token AND the App is installed on this repo. +# +# If App ID is wrong or the key doesn't match, the token-mint step +# fails loud with a JWT signature error. If the App isn't installed +# here, the install-verification step fails loud. Both cases must +# be resolved before the admin lane can mutate anything. +# +# Pass-through for non-`ghapp/repo-admin` heads so this check name +# remains a viable required-status-check on every PR to main. + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - edited + +permissions: + contents: read + +env: + ADMIN_LANE_BRANCH: ghapp/repo-admin + +jobs: + credentials: + name: token + install + runs-on: ubuntu-latest + steps: + - name: Validate vars.GH_APP_ID format (ghapp/repo-admin only) + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + env: + 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. Got: '${GH_APP_ID}'" + exit 1 + fi + echo "vars.GH_APP_ID format ok: ${GH_APP_ID}" + + - name: Mint App installation token (proves App ID + private key match) + id: app-token + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + 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 }} + + - name: Verify App is installed on this repo + if: github.event.pull_request.head.ref == env.ADMIN_LANE_BRANCH + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} + THIS_REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "installation-id=${INSTALLATION_ID}" + + repos=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /installation/repositories \ + --jq '.repositories | map(.full_name)') + + echo "Installed on:" + echo "${repos}" | jq -r '.[] | " - \(.)"' + + if ! echo "${repos}" | jq -e --arg r "${THIS_REPO}" '. | index($r)' >/dev/null; then + echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}. Check the App's installation scope at https://github.com/settings/apps//installations." + exit 1 + fi + echo "token + install (App installed on ${THIS_REPO})" + + - name: Pass-through for non-admin heads + if: github.event.pull_request.head.ref != env.ADMIN_LANE_BRANCH + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + echo "Non-admin head (${HEAD_REF}); credentials check does not apply. Pass." From b9d55e49ac0be1678fc3527c95e3adb6cff18c06 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 01:17:00 -0400 Subject: [PATCH 29/36] admin: workflow-triggers doctrine + concurrency on all 10 protection workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical answer to "when pull_request vs push vs pull_request_target vs merge_group, and where does concurrency belong" — grounded in ../refs/github/docs and ../refs/github/github-well-architected. Doctrine now lives at .github-stars/control-plane/workflow-triggers.md and binds every gate workflow's trigger + concurrency choice to a cited section of the canonical docs. Concurrency added to all 10 workflows under .github/workflows/00*.yml: - gates (00, 00a, 00b, 00c, 00d, 00h, 00i, 00j): cancel-in-progress: true (newer run wins; older is wasted compute) - mutations (00e ruleset upsert, 00f branch sync): cancel-in-progress: false (never cancel mid-PATCH; non-transactional) Group key is the canonical `${{ github.workflow }}-${{ github.ref }}` per data/reusables/actions/actions-group-concurrency.md L122-126 — unique per workflow, well-defined for both pull_request and push refs. Trigger doctrine confirmed (NOT changed): - pull_request stays on every required-status-check workflow. Removing it would silently break the ruleset's required_status_checks gate. - pull_request + push: [main, next] is canonical, NOT an anti-pattern. The two events fire on disjoint refs (refs/pull/N/merge vs refs/heads/) — no duplicate run during PR review. The github docs themselves publish push: [main] standalone as the canonical concurrency example. - pull_request_target stays banned per github-well-architected application-security/recommendations/actions-security/index.md L88 (pwn-request risk). Zero workflows in this repo use it. - merge_group deferred until a merge queue is enabled on main. Also folds in pre-existing slug renames on 00b + the two ruleset specs (Build succeeds -> build succeeds, Label cleared -> DoNotMergeYet absent, etc.) so the required_status_checks contexts match the lowercase job names committed in the prior split. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rulesets/protect-main-release-only.json | 10 +- .../control-plane/rulesets/protect-next.json | 8 +- .../control-plane/workflow-triggers.md | 257 ++++++++++++++++++ .github/workflows/00-ci.yml | 154 +---------- .github/workflows/00a-do-not-merge-yet.yml | 8 +- .github/workflows/00b-web-ci.yml | 4 +- .github/workflows/00c-main-release-guard.yml | 8 +- .../00d-gh-action-branch-staleness.yml | 4 + .github/workflows/00e-branch-rulesets.yml | 8 + .../00f-sync-protected-branches-with-main.yml | 9 + .../00h-gh-action-file-allowlist.yml | 4 + .github/workflows/00i-gh-app-credentials.yml | 4 + .../workflows/00j-gh-action-workflow-lint.yml | 160 +++++++++++ 13 files changed, 480 insertions(+), 158 deletions(-) create mode 100644 .github-stars/control-plane/workflow-triggers.md create mode 100644 .github/workflows/00j-gh-action-workflow-lint.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 a2d5c8e97..f24fc4477 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -40,19 +40,19 @@ "context": "token + install" }, { - "context": "Src branch allowed" + "context": "src branch allowed" }, { - "context": "Label cleared" + "context": "DoNotMergeYet absent" }, { - "context": "All gates pass" + "context": "all gates pass" }, { - "context": "Workflows valid" + "context": "workflows valid" }, { - "context": "Build succeeds" + "context": "build succeeds" } ] } diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 8d5225ef4..49fe386da 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -31,16 +31,16 @@ "strict_required_status_checks_policy": true, "required_status_checks": [ { - "context": "Label cleared" + "context": "DoNotMergeYet absent" }, { - "context": "All gates pass" + "context": "all gates pass" }, { - "context": "Workflows valid" + "context": "workflows valid" }, { - "context": "Build succeeds" + "context": "build succeeds" } ] } diff --git a/.github-stars/control-plane/workflow-triggers.md b/.github-stars/control-plane/workflow-triggers.md new file mode 100644 index 000000000..1f71d7c8c --- /dev/null +++ b/.github-stars/control-plane/workflow-triggers.md @@ -0,0 +1,257 @@ +# Workflow trigger + concurrency doctrine + +Canonical answer for: when does this repo use `on: pull_request` vs +`on: push` vs `on: pull_request_target` vs `on: merge_group`, and +what does `concurrency:` look like on each workflow. + +Cited inline. Every claim is grounded in `../refs/github/docs` or +`../refs/github/github-well-architected` — no "should work." + +--- + +## 1. Trigger taxonomy + +### `pull_request` + +**When**: Gates that must FIRE on every PR — required status checks, +label checks, branch-source checks, file-allowlist checks, +credential-mint checks, branch-staleness checks. + +**Why** (citations): +- `../refs/github/docs/content/actions/reference/workflows-and-actions/events-that-trigger-workflows.md` L504-641: `pull_request` runs in the merge-ref context (`refs/pull/N/merge`) with `GITHUB_TOKEN` scoped read-only by default; PRs from forks do NOT receive secrets. +- Same file, L512: workflows do NOT run on PRs with merge conflicts. So `pull_request` cannot be the sole gate for the post-merge state of `main` — that's what `push: [main]` covers. +- Default activity types are `opened, synchronize, reopened`. Add `ready_for_review`, `edited`, `labeled`, `unlabeled` only when the gate logic actually reads them. + +**Default activity types** to add explicitly across all gates that +depend on PR metadata: `opened, reopened, synchronize, ready_for_review, edited`. +The label-gate (00a) additionally needs `labeled, unlabeled`. + +### `push` + +**When**: To establish the green baseline on a long-lived branch +itself (so the branch's tip carries an authoritative status), AND to +trigger reactive automation that runs after the PR has merged. + +**Why** (citations): +- `events-that-trigger-workflows.md` L825-897: `push` triggers on + commit/tag push, `GITHUB_REF` is the updated ref. This is the + canonical post-merge signal. +- `../refs/github/docs/data/reusables/actions/actions-group-concurrency.md` L13-24: + GitHub's own canonical concurrency example uses `on: push: branches: [main]` + standalone. The combination of `pull_request` + `push: [main]` is **not** + an anti-pattern — they fire on disjoint refs: + - `pull_request` → `refs/pull/N/merge` (PR-time) + - `push: [main]` → `refs/heads/main` (post-merge) + They never both run on the same ref in the same context. There is no + "double run" during PR review. + +### `pull_request_target` + +**When**: NEVER, except for the narrow upstream-sanctioned uses: +posting comments / labels on PRs, or other base-context interactions +that do NOT check out PR head code. + +**Why** (citations): +- `../refs/github/github-well-architected/content/library/application-security/recommendations/actions-security/index.md` L88, L205-229: + "`pull_request_target` ... runs in the base repository context with + full access to repository secrets and write permissions" — combined + with checking out PR-head code, this is a "pwn request." +- `events-that-trigger-workflows.md` L706-823: same warning, plus the + detail that `pull_request_target` runs in the BASE-branch context, so + the workflow file used is the one already merged into base — useful + for anti-tampering of the workflow itself, but only safe when the + workflow does not execute PR-head code. + +**This repo has zero `pull_request_target` workflows. It will stay that way.** + +### `merge_group` + +**When**: Only after a merge queue is enabled on the repo. + +**Why**: +- `events-that-trigger-workflows.md` L356-377: the canonical pattern + for merge-queue-aware workflows is `pull_request: branches: [main]` + PLUS `merge_group: types: [checks_requested]`. Without a merge queue + enabled, `merge_group` events never fire — adding them is dead code. + +**Status in this repo**: NOT enabled. Adding `merge_group` triggers is +deferred until a merge queue is configured on the protected branch. +This is tracked as a follow-up; do not add `merge_group` triggers +until the queue is on. + +### `workflow_dispatch` + +**When**: Operator-driven mutations. The two ruleset operations +(`check`, `upsert`) are operator-initiated — they must NOT run on +arbitrary push or PR. + +**Constraint** (from prior session): `workflow_dispatch` requires the +workflow file to be on the default branch (`main`) for the dispatch +button to be visible. This is why the ruleset workflow lives at +`.github/workflows/00e-branch-rulesets.yml` and merges through the +admin lane all the way to `main`. + +--- + +## 2. Combination semantics + +The user asked: "if both `pull_request: branches: [main]` and +`push: branches: [main]` are present, what happens?" Answer, grounded: + +- The two events fire on **disjoint refs**. There is no duplicate run + during PR review. +- `pull_request` fires when the PR opens / synchronizes / etc. It runs + the workflow file from the **merge ref** (`refs/pull/N/merge`). +- `push: [main]` fires when the PR is merged to `main` (and on direct + pushes, which are blocked by the ruleset). It runs the workflow file + from `main` itself. +- Net effect: a PR-time check (gates the PR can't merge without it + green) PLUS a post-merge baseline run (records the green status on + `main` itself, useful for badges, for `actions/cache` keying off the + branch tip, and for the next PR's `strict_required_status_checks_policy` + comparison). + +This is **canonical**, not an anti-pattern. The github docs themselves +publish `on: push: branches: [main]` as the standalone concurrency +example. Removing `push: [main]` from the gate workflows would leave +`main` without an authoritative tip status. + +--- + +## 3. Concurrency doctrine + +### Canonical shape + +From `../refs/github/docs/data/reusables/actions/actions-group-concurrency.md` +L122-126 (heading: "Only cancel in-progress jobs or runs for the +current workflow"): + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +``` + +Why this shape (vs `${{ github.ref }}` alone or `${{ github.head_ref }}`): + +- L116-118: "concurrency group names must be unique across workflows + to avoid canceling in-progress jobs or runs from other workflows." + → must include `${{ github.workflow }}`. +- `${{ github.ref }}` is well-defined for both PR (`refs/pull/N/merge`) + and push (`refs/heads/`) events, so no fallback is needed. + The L102-110 `github.head_ref || github.run_id` fallback is only + necessary when the workflow ALSO runs on events where `head_ref` is + undefined (e.g. cron, workflow_dispatch on a non-PR ref). For our + PR+push gate workflows, plain `${{ github.ref }}` is correct. + +### When `cancel-in-progress: true` + +**Read-only gates that just observe state**: cancel the older run +when a new push to the same PR / branch arrives. The newer run is +the authoritative result; the older one is wasted compute. + +Applies to: `00`, `00a`, `00b`, `00c`, `00d`, `00h`, `00i`, `00j`. + +### When `cancel-in-progress: false` + +**Mutating workflows that touch live API state**: never cancel a run +mid-PATCH. The half-applied state would be visible to the next +operator (and to the next gate run), and ruleset / branch updates are +not transactional. + +Applies to: `00e` (ruleset upsert/check), `00f` (sync-protected-branches-with-main). + +For these, concurrency still SERIALIZES against itself — only one +ruleset upsert and one branch-sync may be in flight at a time — +but in-progress runs complete instead of being canceled. + +--- + +## 4. Workflow-by-workflow mapping + +Eight workflow files in `.github/workflows/00*.yml`, mapped against +the doctrine above. "Trigger" reflects what the file MUST be after +this commit. "Conc." is the concurrency stanza added. + +| File | Workflow `name` | Job `name` (status check) | Trigger | Conc. cancel | +|---|---|---|---|---| +| 00-ci.yml | `bun gate` | `all gates pass` | `pull_request: [main, next]` + `push: [main, next]` | true | +| 00a-do-not-merge-yet.yml | `gh-action label gate` | `DoNotMergeYet absent` | `pull_request` (any base) | true | +| 00b-web-ci.yml | `bun web build` | `build succeeds` | `pull_request: [main, next]` + `push: [main, next]` (paths-filtered on push) | true | +| 00c-main-release-guard.yml | `gh-action protected branch` | `src branch allowed` | `pull_request: [main]` | true | +| 00d-gh-action-branch-staleness.yml | `gh-action branch staleness` | `head matches main` | `pull_request: [main]` | true | +| 00e-branch-rulesets.yml | `branch-rulesets` | `branch-rulesets-check` / `branch-rulesets-upsert` | `workflow_dispatch` | **false** (mutation) | +| 00f-sync-protected-branches-with-main.yml | `sync-protected-branches-with-main` | `sync-protected-branches-with-main` | `push: [main]` + `workflow_dispatch` | **false** (mutation) | +| 00h-gh-action-file-allowlist.yml | `gh-action file allowlist` | `only allowed files` | `pull_request: [main]` | true | +| 00i-gh-app-credentials.yml | `gh-app credentials` | `token + install` | `pull_request: [main]` | true | +| 00j-gh-action-workflow-lint.yml | `gh-action workflow lint` | `workflows valid` | `pull_request: [main, next]` + `push: [main, next]` | true | + +Notes: + +- 00a runs on every PR regardless of base branch — the + `DoNotMergeYet` label is a global "don't merge me" signal, not + specific to the protected lanes. +- 00c, 00d, 00h, 00i are admin-lane-aware: they pass-through for + non-`ghapp/repo-admin` heads so the check name remains a viable + required-status-check on every PR to `main`. +- 00b's `push` trigger keeps the existing `paths:` filter (web build + doesn't need to re-run on non-web pushes), but the `pull_request` + trigger has NO `paths:` filter — non-web PRs must still get a green + status for the required check to clear. +- 00e is operator-dispatched only. It is NOT a required status check + on any ruleset — it's the workflow that OPERATES the rulesets. +- 00f post-merge sync runs on `push: [main]`, not on `pull_request` + — the work it does (calling `update-branch` and PATCHing + `git/refs/heads/`) is only meaningful AFTER the merge to + `main` has actually landed. + +--- + +## 5. Required-status-check binding + +The two ruleset specs in +`.github-stars/control-plane/rulesets/` must list status-check +contexts that match the JOB names in the table above (GitHub Checks +UI compares on job name, not workflow name). + +`protect-main-release-only.json` required contexts: +- `head matches main` (00d) +- `only allowed files` (00h) +- `token + install` (00i) +- `src branch allowed` (00c) +- `DoNotMergeYet absent` (00a) +- `all gates pass` (00) +- `workflows valid` (00j) +- `build succeeds` (00b) + +`protect-next.json` required contexts: +- `DoNotMergeYet absent` (00a) +- `all gates pass` (00) +- `workflows valid` (00j) +- `build succeeds` (00b) + +This binding holds because every workflow above keeps its +`pull_request` trigger — removing `pull_request` from any of those +eight gates would silently break the required-status-check +expectation and the ruleset would block forever waiting for a check +that no longer fires on PRs. + +--- + +## 6. Banned patterns + +| Pattern | Banned because | +|---|---| +| `pull_request_target` anywhere | well-architected L88: pwn-request risk; the trigger only exists for narrow base-context use cases this repo does not have. | +| `paths:` filter on a `pull_request` gate that is a required status check | the gate would skip on irrelevant PRs and the required-status-check would stay pending forever, blocking merge. | +| `STARS_TOKEN || GITHUB_TOKEN` (or any cross-class secret OR) | mixed-credential laundering — caught by 00j. | +| `app-token.outputs.token || secrets.X` | same as above. | +| `blocked_orgs` workflow output (CSV of names) | leaks blocked source names to public Actions logs — caught by 00j. | +| `merge_group` triggers without a merge queue enabled | dead trigger; will fire never. | + +--- + +## 7. Open follow-ups (deferred — NOT applied here) + +- **Merge queue**: when enabled on `main`, add `merge_group: types: [checks_requested]` to the gate workflows that have `pull_request: branches: [main]`. Tracked separately. +- **`pull_request_target` for label/comment automation**: not applicable today; revisit only if a PR-comment-driven workflow is introduced. diff --git a/.github/workflows/00-ci.yml b/.github/workflows/00-ci.yml index 313b8dfc4..ec3bf7166 100644 --- a/.github/workflows/00-ci.yml +++ b/.github/workflows/00-ci.yml @@ -1,4 +1,4 @@ -name: 'Code Check' +name: 'bun gate' on: pull_request: @@ -13,10 +13,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: gate: - name: All gates pass - # `pnpm gate` is the single readiness command per issue #69 lesson 1. + name: all gates pass + # `bun run gate` is the single readiness command per issue #69 lesson 1. # Sub-stages: typecheck, test, validate (taxonomy + schema), generated # artifact registry presence, actionlint workflow lint. runs-on: ubuntu-latest @@ -65,11 +69,11 @@ jobs: - name: pnpm gate run: pnpm gate - - name: CI summary + - name: bun gate summary if: always() run: | { - echo "# CI — pnpm gate" + echo "# bun gate" echo "" echo "- Workflow: \`${{ github.workflow }}\`" echo "- Run ID: \`${{ github.run_id }}\`" @@ -83,142 +87,6 @@ jobs: echo "- generated-artifacts registry presence" echo "- \`actionlint\` workflow lint" echo "" - echo "Web build/lint runs in the separate \`Web CI\` workflow (\`.github/workflows/00b-web-ci.yml\`)." - echo "Workflow-lint extra footgun guards run in the \`workflow-lint\` job below." - } >> "$GITHUB_STEP_SUMMARY" - - workflow-lint: - name: Workflows valid - # Cheap structural guards that pnpm gate's actionlint stage cannot - # express. See `.sisyphus/proofs/02M-mode-correction.md`. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - # Specific guard against the cardinalby/schema-validator-action mode - # footgun. The action accepts default | lax | strong | spec only. - # `strict` is invalid and would only fail at runtime in the - # production chain (see 02M-mode-correction.md Bug A). actionlint - # cannot catch this because cardinalby's action.yml does not enum - # the mode field. - # - # The regex is anchored to the start of a YAML key so it cannot - # match `mode: 'strict'` substrings inside echo lines or comments - # (which is how the v1 of this guard self-tripped on its own - # diagnostic strings). - - name: Reject invalid cardinalby/schema-validator-action mode values - run: | - set -euo pipefail - shopt -s nullglob - files=( .github/workflows/*.yml .github/workflows/*.yaml ) - bad="" - for f in "${files[@]}"; do - # Only inspect files that actually use the action. - if ! grep -q 'cardinalby/schema-validator-action' "$f"; then - continue - fi - # Anchored to ^\s*mode: so we only match real YAML keys. - hit=$(grep -nE "^[[:space:]]+mode:[[:space:]]*['\"]?strict['\"]?[[:space:]]*$" "$f" || true) - if [ -n "$hit" ]; then - bad="${bad}${f}:\n${hit}\n" - fi - done - if [ -n "$bad" ]; then - { - echo "::error::cardinalby/schema-validator-action does not accept the value reserved for an invalid mode." - echo "::error::Allowed: default | lax | strong | spec. See .sisyphus/proofs/02M-mode-correction.md." - printf '%b' "$bad" - } >&2 - exit 1 - fi - echo "No invalid mode values found in cardinalby/schema-validator-action steps." - - # Per session-oracle verdict rule 1-7: a workflow MUST NOT silently - # mix credential classes. The historical laundering shape was - # `secrets.STARS_TOKEN || secrets.GITHUB_TOKEN` - # or - # `steps.app-token.outputs.token || secrets.STARS_TOKEN || ...` - # Reject any expression that ORs across credential classes inside - # a single role (token slot). Allowed: branch on - # steps.doctor.outputs.selected_mode and assign one credential per - # branch (the new pattern in 01-fetch / 02-sync). - - name: Reject mixed-credential laundering in workflow expressions - run: | - set -euo pipefail - shopt -s nullglob - files=( .github/workflows/*.yml .github/workflows/*.yaml ) - bad="" - # Strip comment lines (lines whose first non-whitespace char is #) - # before grepping. Without this the guard would self-trip on its - # own diagnostic comments — same class of bug as the - # cardinalby-mode guard hit in v1 (see 02M-mode-correction.md). - for f in "${files[@]}"; do - non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) - # Pattern 1: STARS_TOKEN || GITHUB_TOKEN (any order, any whitespace). - hit1=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.STARS_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.GITHUB_TOKEN' || true) - hit2=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.GITHUB_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.STARS_TOKEN' || true) - # Pattern 2: app-token.outputs.token OR'd with any other secret. - hit3=$(printf '%s\n' "$non_comments" | grep -nE 'app-token\.outputs\.token[[:space:]]*\|\|[[:space:]]*secrets\.' || true) - for h in "$hit1" "$hit2" "$hit3"; do - if [ -n "$h" ]; then bad="${bad}${f}:\n${h}\n"; fi - done - done - if [ -n "$bad" ]; then - { - echo "::error::Mixed-credential laundering detected in workflow YAML." - echo "::error::A token slot must use exactly one credential class per run, selected by steps.doctor.outputs.selected_mode." - echo "::error::See session-oracle verdict rules 1-7 (PR adding strict 3-mode resolver)." - printf '%b' "$bad" - } >&2 - exit 1 - fi - echo "No mixed-credential expressions detected in workflow YAML." - - # Per session-oracle verdict rule 8: blocked/private source NAMES - # must not surface in public outputs/summaries. The legacy - # `blocked_orgs` step output (a CSV of org names) is forbidden; - # use `blocked_orgs_count` instead. - - name: Reject leaked blocked-org names in workflow outputs - run: | - set -euo pipefail - shopt -s nullglob - files=( .github/workflows/*.yml .github/workflows/*.yaml ) - bad="" - # Strip comment lines first so the guard cannot self-trip on - # its own inline doc text. Same defensive pattern as the - # mixed-credential guard above. - for f in "${files[@]}"; do - non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) - # ^\s+blocked_orgs: ... (job/step output key, not _count). - hit=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+blocked_orgs:[[:space:]]" || true) - if [ -n "$hit" ]; then bad="${bad}${f}:\n${hit}\n"; fi - # BLOCKED_ORGS env var (used to substitute names into summaries). - hit2=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+BLOCKED_ORGS:[[:space:]]" || true) - if [ -n "$hit2" ]; then bad="${bad}${f}:\n${hit2}\n"; fi - done - if [ -n "$bad" ]; then - { - echo "::error::Workflow exposes blocked org NAMES in outputs/env. Use blocked_orgs_count (count only) per session-oracle verdict rule 8." - printf '%b' "$bad" - } >&2 - exit 1 - fi - echo "No blocked-org name leakage detected in workflow YAML." - - - name: workflow-lint summary - if: always() - run: | - { - echo "# CI (workflow-lint footgun guards)" - echo "" - echo "- Workflow: \`${{ github.workflow }}\`" - echo "- Run ID: \`${{ github.run_id }}\`" - echo "" - echo "## Gates" - echo "- cardinalby/schema-validator-action mode-value guard (rejects unsupported values)" - echo "- mixed-credential laundering guard (rejects \`STARS_TOKEN || GITHUB_TOKEN\` and \`app-token || secrets.\` patterns)" - echo "- blocked-org name leakage guard (rejects \`blocked_orgs\` output / \`BLOCKED_ORGS\` env in workflow YAML)" - echo "" - echo "actionlint runs inside \`pnpm gate\` (CI / gate job)." - echo "Tracking issue for richer dry-run gates: #62" + echo "Web build/lint runs in the separate \`bun web build\` workflow (\`.github/workflows/00b-web-ci.yml\`)." + echo "Workflow-lint extra footgun guards run in the separate \`gh-action workflow lint\` workflow (\`.github/workflows/00j-gh-action-workflow-lint.yml\`)." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00a-do-not-merge-yet.yml b/.github/workflows/00a-do-not-merge-yet.yml index 2819e1fa3..a6b567294 100644 --- a/.github/workflows/00a-do-not-merge-yet.yml +++ b/.github/workflows/00a-do-not-merge-yet.yml @@ -1,4 +1,4 @@ -name: Merge Block +name: gh-action label gate on: pull_request: @@ -14,9 +14,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: do-not-merge-yet: - name: Label cleared + name: DoNotMergeYet absent runs-on: ubuntu-latest steps: - name: Fail when PR has DoNotMergeYet label diff --git a/.github/workflows/00b-web-ci.yml b/.github/workflows/00b-web-ci.yml index fc093e9ef..15f4d1f96 100644 --- a/.github/workflows/00b-web-ci.yml +++ b/.github/workflows/00b-web-ci.yml @@ -1,5 +1,5 @@ --- -name: 'Site Build' +name: 'bun web build' on: # Run on EVERY PR to main/next (no paths filter) so this can be a @@ -27,7 +27,7 @@ concurrency: jobs: build: - name: Build succeeds + name: build succeeds runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index 304dd7a5a..1d762811f 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -1,4 +1,4 @@ -name: Protected Branch +name: gh-action protected branch # Per the protection-stage brief, `main` accepts exactly two # repo-owned source branches: @@ -25,9 +25,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: main-release-guard: - name: Src branch allowed + name: src branch allowed runs-on: ubuntu-latest steps: - name: Require repo-owned next or ghapp/repo-admin as source branch for main diff --git a/.github/workflows/00d-gh-action-branch-staleness.yml b/.github/workflows/00d-gh-action-branch-staleness.yml index 53cc7f68a..c3b27d967 100644 --- a/.github/workflows/00d-gh-action-branch-staleness.yml +++ b/.github/workflows/00d-gh-action-branch-staleness.yml @@ -28,6 +28,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: ADMIN_LANE_BRANCH: ghapp/repo-admin diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index ac16cd217..079224f5b 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -48,6 +48,14 @@ on: permissions: contents: read +# Mutating workflow: serialize to one in-flight run, but do NOT +# cancel a run mid-PATCH. Ruleset updates are not transactional — +# canceling mid-flight could leave half-applied state visible to the +# next operator. See `.github-stars/control-plane/workflow-triggers.md` §3. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: # ---------------------------------------------------------------- # check: always runs. Renders specs, diffs against live rulesets, diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml index 5532d5d7d..7cb32ea00 100644 --- a/.github/workflows/00f-sync-protected-branches-with-main.yml +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -34,6 +34,15 @@ permissions: contents: read pull-requests: read +# Mutating workflow: serialize to one in-flight run, but do NOT +# cancel a run mid-call. Calling `update-branch` and PATCHing +# `git/refs/heads/` is not transactional — canceling +# mid-flight could leave one lane updated and the other not. +# See `.github-stars/control-plane/workflow-triggers.md` §3. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: ADMIN_LANE_BRANCH: ghapp/repo-admin RELEASE_LANE_BRANCH: next diff --git a/.github/workflows/00h-gh-action-file-allowlist.yml b/.github/workflows/00h-gh-action-file-allowlist.yml index d4a5c676c..2b00ae481 100644 --- a/.github/workflows/00h-gh-action-file-allowlist.yml +++ b/.github/workflows/00h-gh-action-file-allowlist.yml @@ -31,6 +31,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: ADMIN_LANE_BRANCH: ghapp/repo-admin diff --git a/.github/workflows/00i-gh-app-credentials.yml b/.github/workflows/00i-gh-app-credentials.yml index bb2e86b03..06b677638 100644 --- a/.github/workflows/00i-gh-app-credentials.yml +++ b/.github/workflows/00i-gh-app-credentials.yml @@ -26,6 +26,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: ADMIN_LANE_BRANCH: ghapp/repo-admin diff --git a/.github/workflows/00j-gh-action-workflow-lint.yml b/.github/workflows/00j-gh-action-workflow-lint.yml new file mode 100644 index 000000000..8969da228 --- /dev/null +++ b/.github/workflows/00j-gh-action-workflow-lint.yml @@ -0,0 +1,160 @@ +name: gh-action workflow lint + +# Cheap structural guards on workflow YAML that pnpm gate's actionlint +# stage cannot express. See `.sisyphus/proofs/02M-mode-correction.md`. +# +# Carved out of 00-ci.yml because it has a different dependency +# surface than the bun-runtime gate — only github builtins (bash + +# grep), no project source code. + +on: + pull_request: + branches: + - main + - next + push: + branches: + - main + - next + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + workflow-lint: + name: workflows valid + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # Specific guard against the cardinalby/schema-validator-action mode + # footgun. The action accepts default | lax | strong | spec only. + # `strict` is invalid and would only fail at runtime in the + # production chain (see 02M-mode-correction.md Bug A). actionlint + # cannot catch this because cardinalby's action.yml does not enum + # the mode field. + # + # The regex is anchored to the start of a YAML key so it cannot + # match `mode: 'strict'` substrings inside echo lines or comments + # (which is how the v1 of this guard self-tripped on its own + # diagnostic strings). + - name: Reject invalid cardinalby/schema-validator-action mode values + run: | + set -euo pipefail + shopt -s nullglob + files=( .github/workflows/*.yml .github/workflows/*.yaml ) + bad="" + for f in "${files[@]}"; do + # Only inspect files that actually use the action. + if ! grep -q 'cardinalby/schema-validator-action' "$f"; then + continue + fi + # Anchored to ^\s*mode: so we only match real YAML keys. + hit=$(grep -nE "^[[:space:]]+mode:[[:space:]]*['\"]?strict['\"]?[[:space:]]*$" "$f" || true) + if [ -n "$hit" ]; then + bad="${bad}${f}:\n${hit}\n" + fi + done + if [ -n "$bad" ]; then + { + echo "::error::cardinalby/schema-validator-action does not accept the value reserved for an invalid mode." + echo "::error::Allowed: default | lax | strong | spec. See .sisyphus/proofs/02M-mode-correction.md." + printf '%b' "$bad" + } >&2 + exit 1 + fi + echo "No invalid mode values found in cardinalby/schema-validator-action steps." + + # Per session-oracle verdict rule 1-7: a workflow MUST NOT silently + # mix credential classes. The historical laundering shape was + # `secrets.STARS_TOKEN || secrets.GITHUB_TOKEN` + # or + # `steps.app-token.outputs.token || secrets.STARS_TOKEN || ...` + # Reject any expression that ORs across credential classes inside + # a single role (token slot). Allowed: branch on + # steps.doctor.outputs.selected_mode and assign one credential per + # branch (the new pattern in 01-fetch / 02-sync). + - name: Reject mixed-credential laundering in workflow expressions + run: | + set -euo pipefail + shopt -s nullglob + files=( .github/workflows/*.yml .github/workflows/*.yaml ) + bad="" + # Strip comment lines (lines whose first non-whitespace char is #) + # before grepping. Without this the guard would self-trip on its + # own diagnostic comments — same class of bug as the + # cardinalby-mode guard hit in v1 (see 02M-mode-correction.md). + for f in "${files[@]}"; do + non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) + # Pattern 1: STARS_TOKEN || GITHUB_TOKEN (any order, any whitespace). + hit1=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.STARS_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.GITHUB_TOKEN' || true) + hit2=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.GITHUB_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.STARS_TOKEN' || true) + # Pattern 2: app-token.outputs.token OR'd with any other secret. + hit3=$(printf '%s\n' "$non_comments" | grep -nE 'app-token\.outputs\.token[[:space:]]*\|\|[[:space:]]*secrets\.' || true) + for h in "$hit1" "$hit2" "$hit3"; do + if [ -n "$h" ]; then bad="${bad}${f}:\n${h}\n"; fi + done + done + if [ -n "$bad" ]; then + { + echo "::error::Mixed-credential laundering detected in workflow YAML." + echo "::error::A token slot must use exactly one credential class per run, selected by steps.doctor.outputs.selected_mode." + echo "::error::See session-oracle verdict rules 1-7 (PR adding strict 3-mode resolver)." + printf '%b' "$bad" + } >&2 + exit 1 + fi + echo "No mixed-credential expressions detected in workflow YAML." + + # Per session-oracle verdict rule 8: blocked/private source NAMES + # must not surface in public outputs/summaries. The legacy + # `blocked_orgs` step output (a CSV of org names) is forbidden; + # use `blocked_orgs_count` instead. + - name: Reject leaked blocked-org names in workflow outputs + run: | + set -euo pipefail + shopt -s nullglob + files=( .github/workflows/*.yml .github/workflows/*.yaml ) + bad="" + # Strip comment lines first so the guard cannot self-trip on + # its own inline doc text. Same defensive pattern as the + # mixed-credential guard above. + for f in "${files[@]}"; do + non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) + # ^\s+blocked_orgs: ... (job/step output key, not _count). + hit=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+blocked_orgs:[[:space:]]" || true) + if [ -n "$hit" ]; then bad="${bad}${f}:\n${hit}\n"; fi + # BLOCKED_ORGS env var (used to substitute names into summaries). + hit2=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+BLOCKED_ORGS:[[:space:]]" || true) + if [ -n "$hit2" ]; then bad="${bad}${f}:\n${hit2}\n"; fi + done + if [ -n "$bad" ]; then + { + echo "::error::Workflow exposes blocked org NAMES in outputs/env. Use blocked_orgs_count (count only) per session-oracle verdict rule 8." + printf '%b' "$bad" + } >&2 + exit 1 + fi + echo "No blocked-org name leakage detected in workflow YAML." + + - name: workflow-lint summary + if: always() + run: | + { + echo "# gh-action workflow lint" + echo "" + echo "- Workflow: \`${{ github.workflow }}\`" + echo "- Run ID: \`${{ github.run_id }}\`" + echo "" + echo "## Gates" + echo "- cardinalby/schema-validator-action mode-value guard (rejects unsupported values)" + echo "- mixed-credential laundering guard (rejects \`STARS_TOKEN || GITHUB_TOKEN\` and \`app-token || secrets.\` patterns)" + echo "- blocked-org name leakage guard (rejects \`blocked_orgs\` output / \`BLOCKED_ORGS\` env in workflow YAML)" + echo "" + echo "actionlint runs inside \`pnpm gate\` (\`bun gate\` workflow)." + echo "Tracking issue for richer dry-run gates: #62" + } >> "$GITHUB_STEP_SUMMARY" From 79d687ec97b0cbbb5e687025f36935680ff5f724 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 01:33:30 -0400 Subject: [PATCH 30/36] admin: fix 00e bootstrap deadlock + 00f next FF parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 00e (P1 — bootstrap deadlock): upsert had `needs: check`, but `check_ruleset` returns 1 when the ruleset does not yet exist (`echo "::error::ruleset missing: ..."`). First-ever creation could never run — check fails red, upsert never starts. Drop `needs: check`. Upsert is independent: it does its own pre-state lookup (ruleset_id_by_name -> empty => POST, non-empty => PUT) and post-upsert verification (upsert_and_verify diffs rendered spec against live ruleset). Authorization remains the `environment: github-admin` deployment-protection gate, not the check job. 00f (P2 — next FF policy parity with admin): Old behavior: no open `next -> main` PR -> red. Only `ghapp/repo-admin` got the FF-only PATCH fallback. New behavior (per user direction "successful pull to main should FF both branches"): both lanes use the same reconcile policy: 1. open PR present -> PUT /pulls/{n}/update-branch 2. no PR, FF-able -> PATCH /git/refs/heads/ force=false 3. no PR, divergent -> red, refuse blind push 4. multiple open PRs -> red, refuse to choose Refactored the two near-identical lane blocks into one `reconcile_lane ` helper so the policy parity is visible in the code, not just in the comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/00e-branch-rulesets.yml | 12 +- .../00f-sync-protected-branches-with-main.yml | 174 ++++++++++-------- 2 files changed, 111 insertions(+), 75 deletions(-) diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 079224f5b..2e1daa000 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -226,10 +226,20 @@ jobs: # `environment: github-admin` so deployment protection rules # (when configured) gate the actual mutation. # ---------------------------------------------------------------- + # ---------------------------------------------------------------- + # upsert: gated. Runs INDEPENDENT of the `check` job — `check` + # fails red when a ruleset does not yet exist (return 1 from + # `check_ruleset` on missing id), which would deadlock the very + # first creation if upsert had `needs: check`. Upsert performs its + # own pre-state inspection (`ruleset_id_by_name` -> empty => POST, + # non-empty => PUT) and post-upsert verification (`upsert_and_verify` + # diffs the rendered spec against the live ruleset after writing). + # The `environment: github-admin` deployment-protection gate is + # what authorizes the mutation, not the check job. + # ---------------------------------------------------------------- upsert: name: branch-rulesets-upsert runs-on: ubuntu-latest - needs: check if: github.ref == 'refs/heads/main' && inputs.operation == 'upsert' environment: github-admin steps: diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml index 7cb32ea00..39b030741 100644 --- a/.github/workflows/00f-sync-protected-branches-with-main.yml +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -9,17 +9,24 @@ name: sync-protected-branches-with-main # 1. `next` — integration lane (release flow lives here) # 2. `ghapp/repo-admin` — GitHub App / control-plane admin lane # -# For each lane, prefer GitHub's documented PR update-branch API -# when there is exactly one open repo-owned ` -> main` PR. -# Fall back to a fast-forward push on the branch itself for -# `ghapp/repo-admin` only — the admin lane is allowed to advance -# without an open PR (release flow is gated by the PR existence; -# admin sync is gated by FF-ability to avoid blind force). +# Policy (both lanes, identical — "successful pull to main FFs both +# branches"): +# 1. If exactly one open repo-owned ` -> main` PR exists, +# call PUT /pulls/{n}/update-branch with expected_head_sha. +# The PR-driven path is preferred (carries reviewer + checks). +# 2. Else fall back to a fast-forward PATCH on the branch itself +# (`PATCH /git/refs/heads/` with `force=false`). This +# guarantees that a successful push to `main` advances both +# lanes whether or not a release PR happens to be open. +# 3. Divergent history (ahead_by > 0) fails red — refuses +# blind-force push. +# 4. Multiple open PRs fail red — refuses to choose. # # Doctrine: # - GitHub App installation token only. No PAT fallback. # - `expected_head_sha` for every update-branch call (stale-head # guard). +# - FF-only on the branch-PATCH fallback (`force=false`). # - Fail loudly per lane if the lane cannot be advanced safely. # - Do not silently ignore either branch. # - Do not blindly push. @@ -177,85 +184,104 @@ jobs: echo "${ahead_by} ${behind_by}" } - # ============================================================ - # Lane 1: next (release lane) - # ============================================================ - echo "::group::Reconcile next lane" - next_before=$(gh_api "/repos/${REPO}/branches/${RELEASE_LANE_BRANCH}" --jq '.commit.sha') - record "next_sha_before=${next_before}" - echo "next before: ${next_before}" + # ------------------------------------------------------------ + # Helper: reconcile one lane against main. Policy (both + # lanes, identical): + # 1. If there is exactly one open repo-owned ` -> + # main` PR, call PUT /pulls/{n}/update-branch with + # expected_head_sha (stale-head guard). + # 2. If no open PR: + # - already up-to-date -> record up_to_date + # - 0 ahead, N behind -> FF-only PATCH + # /git/refs/heads/ with force=false + # - divergent (ahead>0) -> red, refuse blind push + # 3. If multiple open PRs -> red (already errored to FD3 + # from find_release_pr). + # + # This is the canonical "successful pull to main FFs both + # branches" policy. PR-driven update-branch is preferred + # when a PR exists (carries reviewer + checks + history); + # FF-only PATCH is the fallback so absence of a release PR + # does not block sync. + # ------------------------------------------------------------ + reconcile_lane() { + local lane="$1" + local prefix="$2" + + local before + before=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + record "${prefix}_sha_before=${before}" + echo "${lane} before: ${before}" - if next_pr_json=$(find_release_pr "${RELEASE_LANE_BRANCH}" 2>/dev/null); then - next_pr_number=$(jq -r '.number' <<<"${next_pr_json}") - next_pr_head=$(jq -r '.head_sha' <<<"${next_pr_json}") - echo "Found release PR #${next_pr_number} (head ${next_pr_head})" - record "next_pr=#${next_pr_number}" - update_pr_branch "${next_pr_number}" "${next_pr_head}" - sleep 2 - next_after=$(gh_api "/repos/${REPO}/branches/${RELEASE_LANE_BRANCH}" --jq '.commit.sha') - record "next_sha_after=${next_after}" - echo "next after: ${next_after}" - else - rc=$? - if [ "${rc}" -eq 1 ]; then - echo "::error::No open repo-owned ${RELEASE_LANE_BRANCH} -> main PR found. Per release-lane policy, the release PR is the documented surface for next->main update-branch calls. Open the release PR or accept that next will not be auto-synced." - record "next_status=blocked_no_release_pr" - echo "next_sync_failed=true" >>"$GITHUB_OUTPUT" - else - record "next_status=blocked_multiple_release_prs" - echo "next_sync_failed=true" >>"$GITHUB_OUTPUT" - # already errored to stderr inside find_release_pr + local pr_json + if pr_json=$(find_release_pr "${lane}" 2>/dev/null); then + local pr_number pr_head + pr_number=$(jq -r '.number' <<<"${pr_json}") + pr_head=$(jq -r '.head_sha' <<<"${pr_json}") + echo "Found ${lane} -> main PR #${pr_number} (head ${pr_head})" + record "${prefix}_pr=#${pr_number}" + update_pr_branch "${pr_number}" "${pr_head}" + sleep 2 + local after + after=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + record "${prefix}_sha_after=${after}" + record "${prefix}_status=updated_via_pr" + echo "${lane} after: ${after}" + return 0 fi - fi - echo "::endgroup::" - # ============================================================ - # Lane 2: ghapp/repo-admin (control-plane admin lane) - # ============================================================ - echo "::group::Reconcile ghapp/repo-admin lane" - admin_before=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') - record "admin_sha_before=${admin_before}" - echo "admin before: ${admin_before}" + local rc=$? + if [ "${rc}" -eq 2 ]; then + record "${prefix}_status=blocked_multiple_open_prs" + echo "${prefix}_sync_failed=true" >>"$GITHUB_OUTPUT" + return 0 + fi - if admin_pr_json=$(find_release_pr "${ADMIN_LANE_BRANCH}" 2>/dev/null); then - admin_pr_number=$(jq -r '.number' <<<"${admin_pr_json}") - admin_pr_head=$(jq -r '.head_sha' <<<"${admin_pr_json}") - echo "Found admin PR #${admin_pr_number} (head ${admin_pr_head})" - record "admin_pr=#${admin_pr_number}" - update_pr_branch "${admin_pr_number}" "${admin_pr_head}" - sleep 2 - admin_after=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') - record "admin_sha_after=${admin_after}" - echo "admin after: ${admin_after}" - else - # No open admin PR — try fast-forward of the branch itself. - echo "No open ${ADMIN_LANE_BRANCH} -> main PR. Checking fast-forward state." - read -r ahead_by behind_by < <(ff_state "${ADMIN_LANE_BRANCH}") + # rc=1: no open PR. Fall back to FF-only PATCH. + echo "No open ${lane} -> main PR. Checking fast-forward state." + local ahead_by behind_by + read -r ahead_by behind_by < <(ff_state "${lane}") echo "ahead_by=${ahead_by} behind_by=${behind_by}" + if [ "${ahead_by}" -eq 0 ] && [ "${behind_by}" -eq 0 ]; then - echo "${ADMIN_LANE_BRANCH} is already up-to-date with main." - record "admin_status=up_to_date" - record "admin_sha_after=${admin_before}" - elif [ "${ahead_by}" -eq 0 ] && [ "${behind_by}" -gt 0 ]; then - echo "${ADMIN_LANE_BRANCH} is ${behind_by} commit(s) behind main and 0 ahead — fast-forward via App." - # PATCH /repos/{owner}/{repo}/git/refs/heads/{branch} with - # main's SHA. Force=false; FF-only (we already verified - # ahead_by=0). + echo "${lane} is already up-to-date with main." + record "${prefix}_status=up_to_date" + record "${prefix}_sha_after=${before}" + return 0 + fi + + if [ "${ahead_by}" -eq 0 ] && [ "${behind_by}" -gt 0 ]; then + echo "${lane} is ${behind_by} commit(s) behind main and 0 ahead — fast-forward via App." gh_api \ --method PATCH \ - "/repos/${REPO}/git/refs/heads/${ADMIN_LANE_BRANCH}" \ + "/repos/${REPO}/git/refs/heads/${lane}" \ -f "sha=${MAIN_SHA}" \ -F "force=false" - admin_after=$(gh_api "/repos/${REPO}/branches/${ADMIN_LANE_BRANCH}" --jq '.commit.sha') - record "admin_sha_after=${admin_after}" - record "admin_status=fast_forwarded" - echo "admin after: ${admin_after}" - else - echo "::error::${ADMIN_LANE_BRANCH} has ${ahead_by} commit(s) ahead and ${behind_by} commit(s) behind main — divergent history. Manual rebase/update required; refusing to blind-push." - record "admin_status=blocked_divergent_${ahead_by}_ahead_${behind_by}_behind" - echo "admin_sync_failed=true" >>"$GITHUB_OUTPUT" + local after + after=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + record "${prefix}_sha_after=${after}" + record "${prefix}_status=fast_forwarded" + echo "${lane} after: ${after}" + return 0 fi - fi + + echo "::error::${lane} has ${ahead_by} commit(s) ahead and ${behind_by} commit(s) behind main — divergent history. Manual rebase/update required; refusing to blind-push." + record "${prefix}_status=blocked_divergent_${ahead_by}_ahead_${behind_by}_behind" + echo "${prefix}_sync_failed=true" >>"$GITHUB_OUTPUT" + } + + # ============================================================ + # Lane 1: next (release lane) + # ============================================================ + echo "::group::Reconcile next lane" + reconcile_lane "${RELEASE_LANE_BRANCH}" "next" + echo "::endgroup::" + + # ============================================================ + # Lane 2: ghapp/repo-admin (control-plane admin lane) + # ============================================================ + echo "::group::Reconcile ghapp/repo-admin lane" + reconcile_lane "${ADMIN_LANE_BRANCH}" "admin" echo "::endgroup::" # ============================================================ From 1104121d843990ffc5fcc370a39daea8aa7a8649 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 02:02:18 -0400 Subject: [PATCH 31/36] admin: 00e self-bootstraps on push to main; drop empty environment ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled fixes that close the bootstrap gap in PR #81: 1) Self-bootstrap trigger. Add `push: [main]` (paths-filtered to the ruleset specs and 00e itself) to 00e. When PR #81 merges, the same merge commit fires 00e on main and upserts both rulesets live with enforcement=active — no human dispatch click required. Two trigger paths now coexist: - push: bootstrap. enforcement=active. APPLY_RULESETS guard skipped (the paths-filter + ref-guard + App-token mint are the authorization). - workflow_dispatch: human ops. enforcement=inputs.enforcement. APPLY_RULESETS typed-string guard still required. The check job's enforcement env, the upsert job's enforcement env, and both summary blocks all use the same fallback expression `github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active'` so the rendered spec on the push path matches what gets PUT. 2) Drop `environment: github-admin`. Per ../refs/github/docs/content/actions/how-tos/deploy/configure-and- manage-deployments/manage-environments.md L95: "Running a workflow that references an environment that does not exist will create an environment with the referenced name. ... the newly created environment will not have any protection rules or secrets configured." Nothing in this repo configures `github-admin`. The line was theater — on first run it would silently create an empty environment with no gates. Removed from the upsert job, removed from both summary blocks, removed from the file header comment. Authorization for the mutation is the combination of the ref-guard, the App-token mint (only works if the App is installed with Administration: write), the paths-filter on push, and the APPLY_RULESETS typed-string guard on dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../control-plane/workflow-triggers.md | 13 ++- .github/workflows/00e-branch-rulesets.yml | 82 +++++++++++++------ 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/.github-stars/control-plane/workflow-triggers.md b/.github-stars/control-plane/workflow-triggers.md index 1f71d7c8c..8f88124ac 100644 --- a/.github-stars/control-plane/workflow-triggers.md +++ b/.github-stars/control-plane/workflow-triggers.md @@ -180,7 +180,7 @@ this commit. "Conc." is the concurrency stanza added. | 00b-web-ci.yml | `bun web build` | `build succeeds` | `pull_request: [main, next]` + `push: [main, next]` (paths-filtered on push) | true | | 00c-main-release-guard.yml | `gh-action protected branch` | `src branch allowed` | `pull_request: [main]` | true | | 00d-gh-action-branch-staleness.yml | `gh-action branch staleness` | `head matches main` | `pull_request: [main]` | true | -| 00e-branch-rulesets.yml | `branch-rulesets` | `branch-rulesets-check` / `branch-rulesets-upsert` | `workflow_dispatch` | **false** (mutation) | +| 00e-branch-rulesets.yml | `branch-rulesets` | `branch-rulesets-check` / `branch-rulesets-upsert` | `push: [main]` (paths-filtered) + `workflow_dispatch` | **false** (mutation) | | 00f-sync-protected-branches-with-main.yml | `sync-protected-branches-with-main` | `sync-protected-branches-with-main` | `push: [main]` + `workflow_dispatch` | **false** (mutation) | | 00h-gh-action-file-allowlist.yml | `gh-action file allowlist` | `only allowed files` | `pull_request: [main]` | true | | 00i-gh-app-credentials.yml | `gh-app credentials` | `token + install` | `pull_request: [main]` | true | @@ -198,8 +198,15 @@ Notes: doesn't need to re-run on non-web pushes), but the `pull_request` trigger has NO `paths:` filter — non-web PRs must still get a green status for the required check to clear. -- 00e is operator-dispatched only. It is NOT a required status check - on any ruleset — it's the workflow that OPERATES the rulesets. +- 00e has a self-bootstrap path: a push to `main` that touches the + ruleset specs (`.github-stars/control-plane/rulesets/**`) or 00e + itself (`.github/workflows/00e-branch-rulesets.yml`) automatically + upserts the live rulesets with `enforcement=active`. The + `workflow_dispatch` form is kept for human-driven ops (drift checks, + enforcement toggle, manual re-upsert) and still requires the + `confirm_upsert=APPLY_RULESETS` typed-string guard on that path. + 00e is NOT a required status check on any ruleset — it OPERATES + the rulesets, it doesn't gate PRs. - 00f post-merge sync runs on `push: [main]`, not on `pull_request` — the work it does (calling `update-branch` and PATCHing `git/refs/heads/`) is only meaningful AFTER the merge to diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index 2e1daa000..da7260a3d 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -9,18 +9,26 @@ name: branch-rulesets # - upsert (gated): render + create-or-update the live rulesets # via PUT /repos/{owner}/{repo}/rulesets/{id}. # -# Per Copilot review on PR #81 (00e:36) and per -# ../refs/github/docs/data/reusables/actions/jobs/section-using- -# environments-for-jobs.md: the `environment:` job key takes a -# single name string (or an object with name+url). It does not -# accept an empty-string expression. Split into two jobs so only -# the upsert job declares `environment: github-admin`. Bash logic -# is duplicated inline in each job rather than sourced from a -# shared file — that's the simpler, more idiomatic shape and -# avoids spreading the path-scope allow-list to a non-rulesets -# directory. +# Split into two jobs (check vs upsert) per Copilot review on PR #81 +# (00e:36). Bash logic is duplicated inline in each job rather than +# sourced from a shared file — that's the simpler, more idiomatic +# shape and avoids spreading the admin-lane path-scope allow-list to +# a non-rulesets directory. on: + # Self-bootstrap path: when a push to main touches either the + # tracked ruleset specs or this workflow file, automatically upsert + # the live rulesets with enforcement=active. No human dispatch + # click required. The same gates as workflow_dispatch (ref-guard + + # App-token mint) keep this safe; the typed-string confirmation is + # only meaningful for human operators on workflow_dispatch and is + # skipped on the push path. + push: + branches: + - main + paths: + - '.github-stars/control-plane/rulesets/**' + - '.github/workflows/00e-branch-rulesets.yml' workflow_dispatch: inputs: operation: @@ -59,7 +67,7 @@ concurrency: jobs: # ---------------------------------------------------------------- # check: always runs. Renders specs, diffs against live rulesets, - # fails on drift. No environment gate; this path is read-only. + # fails on drift. Read-only. # ---------------------------------------------------------------- check: name: branch-rulesets-check @@ -83,7 +91,9 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - ENFORCEMENT: ${{ inputs.enforcement }} + # On push: the self-bootstrap path renders enforcement=active, + # so the drift check must compare against the same shape. + ENFORCEMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }} GH_APP_ID: ${{ vars.GH_APP_ID }} OPERATION: check run: | @@ -213,19 +223,14 @@ jobs: echo "# Branch rulesets — check" echo "" echo "- Operation: \`check\` (drift report only; no mutation)" - echo "- Enforcement: \`${{ inputs.enforcement }}\`" + echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }}\`" + echo "- Trigger: \`${{ github.event_name }}\`" 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\` (rendered at runtime)" echo "- App permission required: \`Administration: write\` on this repository" } >>"$GITHUB_STEP_SUMMARY" - # ---------------------------------------------------------------- - # upsert: gated. Runs only when the operator dispatches - # operation=upsert AND confirm_upsert=APPLY_RULESETS. Declares - # `environment: github-admin` so deployment protection rules - # (when configured) gate the actual mutation. - # ---------------------------------------------------------------- # ---------------------------------------------------------------- # upsert: gated. Runs INDEPENDENT of the `check` job — `check` # fails red when a ruleset does not yet exist (return 1 from @@ -234,19 +239,39 @@ jobs: # own pre-state inspection (`ruleset_id_by_name` -> empty => POST, # non-empty => PUT) and post-upsert verification (`upsert_and_verify` # diffs the rendered spec against the live ruleset after writing). - # The `environment: github-admin` deployment-protection gate is - # what authorizes the mutation, not the check job. + # + # Two trigger paths: + # - push to main on the ruleset specs / this workflow file: + # self-bootstrap. enforcement=active. APPLY_RULESETS guard is + # skipped (the push event itself — already gated by the admin + # ruleset's required-status-checks once enforcement is on — IS + # the authorization). + # - workflow_dispatch with operation=upsert: human-driven. + # Requires inputs.confirm_upsert == 'APPLY_RULESETS' as a + # fat-finger guard. enforcement comes from inputs.enforcement. + # + # Authorization for the mutation is the combination of: + # - `if: github.ref == 'refs/heads/main'` (only runs from main) + # - On dispatch: APPLY_RULESETS typed-string guard. + # - On push: paths-filter limited to the spec files + this + # workflow itself, so unrelated commits do not trigger. + # - GitHub App token with `Administration: write` (only mints when + # vars.GH_APP_ID + secrets.GH_APP_PRIVATE_KEY match an installed App) # ---------------------------------------------------------------- upsert: name: branch-rulesets-upsert runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && inputs.operation == 'upsert' - environment: github-admin + if: | + github.ref == 'refs/heads/main' && ( + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'upsert') + ) steps: - name: Checkout tracked ruleset specs uses: actions/checkout@v6 - - name: Validate upsert authorization + - name: Validate upsert authorization (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' env: CONFIRM_UPSERT: ${{ inputs.confirm_upsert }} run: | @@ -270,7 +295,10 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - ENFORCEMENT: ${{ inputs.enforcement }} + # enforcement source: workflow_dispatch -> inputs.enforcement + # (operator's choice); push -> 'active' (self-bootstrap path + # always lands the live rulesets enabled). + ENFORCEMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }} GH_APP_ID: ${{ vars.GH_APP_ID }} OPERATION: upsert run: | @@ -419,10 +447,10 @@ jobs: echo "# Branch rulesets — upsert" echo "" echo "- Operation: \`upsert\` (create or update)" - echo "- Enforcement: \`${{ inputs.enforcement }}\`" + echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }}\`" + echo "- Trigger: \`${{ github.event_name }}\`" echo "- Ref guard: \`refs/heads/main\` only" echo "- Confirmation: \`confirm_upsert=APPLY_RULESETS\` required" - echo "- Environment: \`github-admin\` (deployment protection rules apply if configured)" echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\` (rendered at runtime)" echo "- App permission required: \`Administration: write\` on this repository" From e4f5478bb755926c50efeccf60832e70eaa6312a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 06:24:37 +0000 Subject: [PATCH 32/36] =?UTF-8?q?admin:=20PR=20#81=20review=20=E2=80=94=20?= =?UTF-8?q?env=20gate,=20read-only=20check,=20install=20probe,=20doc=20fix?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/primeinc/github-stars/sessions/0540959d-fabb-4050-ae01-83273f39a5ec Co-authored-by: primeinc <4395149+primeinc@users.noreply.github.com> --- .../control-plane/workflow-triggers.md | 6 +- .github/workflows/00-ci.yml | 23 ++- .github/workflows/00b-web-ci.yml | 2 +- .github/workflows/00e-branch-rulesets.yml | 137 ++++++++++++++---- .../00f-sync-protected-branches-with-main.yml | 2 +- .github/workflows/00i-gh-app-credentials.yml | 37 +++-- .../workflows/00j-gh-action-workflow-lint.yml | 2 +- 7 files changed, 160 insertions(+), 49 deletions(-) diff --git a/.github-stars/control-plane/workflow-triggers.md b/.github-stars/control-plane/workflow-triggers.md index 8f88124ac..8ec084682 100644 --- a/.github-stars/control-plane/workflow-triggers.md +++ b/.github-stars/control-plane/workflow-triggers.md @@ -169,15 +169,15 @@ but in-progress runs complete instead of being canceled. ## 4. Workflow-by-workflow mapping -Eight workflow files in `.github/workflows/00*.yml`, mapped against +Ten workflow files in `.github/workflows/00*.yml`, mapped against the doctrine above. "Trigger" reflects what the file MUST be after this commit. "Conc." is the concurrency stanza added. | File | Workflow `name` | Job `name` (status check) | Trigger | Conc. cancel | |---|---|---|---|---| -| 00-ci.yml | `bun gate` | `all gates pass` | `pull_request: [main, next]` + `push: [main, next]` | true | +| 00-ci.yml | `pnpm gate` | `all gates pass` | `pull_request: [main, next]` + `push: [main, next]` | true | | 00a-do-not-merge-yet.yml | `gh-action label gate` | `DoNotMergeYet absent` | `pull_request` (any base) | true | -| 00b-web-ci.yml | `bun web build` | `build succeeds` | `pull_request: [main, next]` + `push: [main, next]` (paths-filtered on push) | true | +| 00b-web-ci.yml | `npm web build` | `build succeeds` | `pull_request: [main, next]` + `push: [main, next]` (paths-filtered on push) | true | | 00c-main-release-guard.yml | `gh-action protected branch` | `src branch allowed` | `pull_request: [main]` | true | | 00d-gh-action-branch-staleness.yml | `gh-action branch staleness` | `head matches main` | `pull_request: [main]` | true | | 00e-branch-rulesets.yml | `branch-rulesets` | `branch-rulesets-check` / `branch-rulesets-upsert` | `push: [main]` (paths-filtered) + `workflow_dispatch` | **false** (mutation) | diff --git a/.github/workflows/00-ci.yml b/.github/workflows/00-ci.yml index ec3bf7166..8cfb2f5b7 100644 --- a/.github/workflows/00-ci.yml +++ b/.github/workflows/00-ci.yml @@ -1,5 +1,13 @@ name: 'bun gate' +# Workflow name is forward-looking and tracks the bun migration in +# PR #79 (chore: modernization sprint — bun 1.3.13 + ts 6 + zod 4). +# Until #79 merges, the implementation below still bootstraps pnpm +# and invokes `pnpm gate`; renaming the status check on every step +# of the migration would invalidate the ruleset binding. The job +# `name:` (`all gates pass`) is the required-status-check context +# in the rulesets and is toolchain-agnostic. + on: pull_request: branches: @@ -20,9 +28,12 @@ concurrency: jobs: gate: name: all gates pass - # `bun run gate` is the single readiness command per issue #69 lesson 1. - # Sub-stages: typecheck, test, validate (taxonomy + schema), generated - # artifact registry presence, actionlint workflow lint. + # `bun run gate` is the single readiness command per issue #69 + # lesson 1, and the destination toolchain per PR #79. Until #79 + # merges, this job bootstraps pnpm and invokes `pnpm gate` (the + # same sub-stages, just a different package manager). Sub-stages: + # typecheck, test, validate (taxonomy + schema), generated artifact + # registry presence, actionlint workflow lint. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -69,11 +80,11 @@ jobs: - name: pnpm gate run: pnpm gate - - name: bun gate summary + - name: pnpm gate summary if: always() run: | { - echo "# bun gate" + echo "# pnpm gate" echo "" echo "- Workflow: \`${{ github.workflow }}\`" echo "- Run ID: \`${{ github.run_id }}\`" @@ -87,6 +98,6 @@ jobs: echo "- generated-artifacts registry presence" echo "- \`actionlint\` workflow lint" echo "" - echo "Web build/lint runs in the separate \`bun web build\` workflow (\`.github/workflows/00b-web-ci.yml\`)." + echo "Web build/lint runs in the separate \`npm web build\` workflow (\`.github/workflows/00b-web-ci.yml\`)." echo "Workflow-lint extra footgun guards run in the separate \`gh-action workflow lint\` workflow (\`.github/workflows/00j-gh-action-workflow-lint.yml\`)." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/00b-web-ci.yml b/.github/workflows/00b-web-ci.yml index 15f4d1f96..b485bbf37 100644 --- a/.github/workflows/00b-web-ci.yml +++ b/.github/workflows/00b-web-ci.yml @@ -1,5 +1,5 @@ --- -name: 'bun web build' +name: 'npm web build' on: # Run on EVERY PR to main/next (no paths filter) so this can be a diff --git a/.github/workflows/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml index da7260a3d..d8fb121c3 100644 --- a/.github/workflows/00e-branch-rulesets.yml +++ b/.github/workflows/00e-branch-rulesets.yml @@ -5,24 +5,52 @@ name: branch-rulesets # in two modes: # # - check (default): render specs at runtime, diff against the -# live rulesets, fail on drift. Read-only. +# live rulesets, fail on drift. Read-only. Mints an App +# token with `permission-administration: read`. # - upsert (gated): render + create-or-update the live rulesets -# via PUT /repos/{owner}/{repo}/rulesets/{id}. +# via PUT /repos/{owner}/{repo}/rulesets/{id}. Mints an +# App token with `permission-administration: write` and +# is gated by the `github-admin` GitHub Actions environment +# (required reviewers). See issue #82 for the doctrine. # # Split into two jobs (check vs upsert) per Copilot review on PR #81 # (00e:36). Bash logic is duplicated inline in each job rather than # sourced from a shared file — that's the simpler, more idiomatic # shape and avoids spreading the admin-lane path-scope allow-list to # a non-rulesets directory. +# +# Authorization model (per issue #82): +# +# - The GitHub App is the actor that performs the mutation, but +# the `environment: github-admin` on the upsert job is the +# boundary that controls WHEN the bot is allowed to receive +# mutation authority. Environment protection rules (required +# reviewers, branch restriction to main) must be configured in +# repository Settings → Environments → github-admin. The YAML +# alone is not the gate; the repo-side configuration is. +# - `deployment: false` is used so this gate does not emit +# deployment records (we are not deploying anything; we are +# reusing the environment approval mechanism as a credential +# release gate). Per GitHub docs, `deployment: false` is +# incompatible with custom deployment protection-rule Apps; +# this repo does not use those, so the trade-off is correct. +# +# Push-triggered self-bootstrap intentionally inherits the +# enforcement value from the tracked spec JSON (currently +# `disabled` for both specs). To activate enforcement, edit the +# spec JSON and push — that change is itself reviewable, and the +# upsert job will still wait for `github-admin` approval before +# minting the write-capable App token. This is Option 2 from +# issue #82 (no automatic activation without explicit operator +# action). on: # Self-bootstrap path: when a push to main touches either the - # tracked ruleset specs or this workflow file, automatically upsert - # the live rulesets with enforcement=active. No human dispatch - # click required. The same gates as workflow_dispatch (ref-guard + - # App-token mint) keep this safe; the typed-string confirmation is - # only meaningful for human operators on workflow_dispatch and is - # skipped on the push path. + # tracked ruleset specs or this workflow file, the upsert job + # renders the live rulesets with the enforcement value read from + # the tracked JSON spec itself. The `github-admin` environment + # approval (configured in repo Settings) authorizes the bot + # credential release before the App token is minted. push: branches: - main @@ -40,7 +68,7 @@ on: - check - upsert enforcement: - description: 'Ruleset enforcement mode' + description: 'Ruleset enforcement mode (workflow_dispatch only; push reads enforcement from tracked spec JSON)' required: true default: disabled type: choice @@ -85,15 +113,21 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} - permission-administration: write + # Read-only drift check. `GET /repos/{owner}/{repo}/rulesets` + # and `GET /repos/{owner}/{repo}/rulesets/{id}` only need + # `Administration: read`. Granting `write` here would be a + # credential-exposure surface for a job that does not mutate + # (issue #82, section C). + permission-administration: read - name: Check ruleset drift (read-only) env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - # On push: the self-bootstrap path renders enforcement=active, - # so the drift check must compare against the same shape. - ENFORCEMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }} + # On push: the upsert job renders enforcement from the + # tracked spec JSON, so the drift check must compare + # against the same shape. On dispatch: operator's choice. + ENFORCEMENT_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || '' }} GH_APP_ID: ${{ vars.GH_APP_ID }} OPERATION: check run: | @@ -175,10 +209,22 @@ jobs: # "pull_request means that an actor can only bypass rules # on pull requests" and "pull_request is only applicable # to branch rulesets." Strictly tighter than `always`. + # + # Enforcement resolution: + # - workflow_dispatch: ENFORCEMENT_OVERRIDE = inputs.enforcement + # - push: ENFORCEMENT_OVERRIDE empty → inherit the tracked + # spec's own `.enforcement` value (currently `disabled` + # for both specs; activation requires editing the JSON). local spec="$1" local rendered="$2" + local enforcement + if [ -n "${ENFORCEMENT_OVERRIDE}" ]; then + enforcement="${ENFORCEMENT_OVERRIDE}" + else + enforcement=$(jq -r '.enforcement' "${spec}") + fi jq \ - --arg enforcement "${ENFORCEMENT}" \ + --arg enforcement "${enforcement}" \ --argjson app_id "${GH_APP_ID}" \ '.enforcement = $enforcement | .bypass_actors = [ @@ -223,12 +269,12 @@ jobs: echo "# Branch rulesets — check" echo "" echo "- Operation: \`check\` (drift report only; no mutation)" - echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }}\`" + echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'from tracked spec JSON' }}\`" echo "- Trigger: \`${{ github.event_name }}\`" 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\` (rendered at runtime)" - echo "- App permission required: \`Administration: write\` on this repository" + echo "- App permission required: \`Administration: read\` on this repository" } >>"$GITHUB_STEP_SUMMARY" # ---------------------------------------------------------------- @@ -242,16 +288,28 @@ jobs: # # Two trigger paths: # - push to main on the ruleset specs / this workflow file: - # self-bootstrap. enforcement=active. APPLY_RULESETS guard is - # skipped (the push event itself — already gated by the admin - # ruleset's required-status-checks once enforcement is on — IS - # the authorization). + # self-bootstrap. enforcement inherited from the tracked spec + # JSON (currently `disabled` for both specs; activation + # requires editing the JSON and pushing, which is itself a + # reviewable change). APPLY_RULESETS guard is skipped on push + # — the `github-admin` environment approval is the authorization. # - workflow_dispatch with operation=upsert: human-driven. # Requires inputs.confirm_upsert == 'APPLY_RULESETS' as a # fat-finger guard. enforcement comes from inputs.enforcement. + # The `github-admin` environment approval is still required + # before the App token is minted. # # Authorization for the mutation is the combination of: # - `if: github.ref == 'refs/heads/main'` (only runs from main) + # - `environment: github-admin` (required reviewer approval + + # environment-scoped credential release; see issue #82). The + # environment must be pre-configured in repository Settings; + # a missing environment is auto-created with NO protection + # rules, so this guard is hollow until the operator configures + # required reviewers + branch restriction to main. + # - `deployment: false` so the gate runs without emitting + # deployment records (we are not deploying; we are reusing + # environment approval as a credential-release gate). # - On dispatch: APPLY_RULESETS typed-string guard. # - On push: paths-filter limited to the spec files + this # workflow itself, so unrelated commits do not trigger. @@ -261,6 +319,16 @@ jobs: upsert: name: branch-rulesets-upsert runs-on: ubuntu-latest + # `github-admin` is the canonical environment name from issue + # #82's acceptance criteria. `deployment: false` opts out of + # deployment-record emission while keeping required-reviewer + # protection. NOTE: if this environment does not exist in repo + # Settings, GitHub auto-creates it with NO protection rules; the + # gate is hollow until the operator configures required reviewers + # and branch restriction to main. + environment: + name: github-admin + deployment: false if: | github.ref == 'refs/heads/main' && ( github.event_name == 'push' || @@ -295,10 +363,13 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - # enforcement source: workflow_dispatch -> inputs.enforcement - # (operator's choice); push -> 'active' (self-bootstrap path - # always lands the live rulesets enabled). - ENFORCEMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }} + # Enforcement resolution: + # workflow_dispatch -> ENFORCEMENT_OVERRIDE = inputs.enforcement + # push -> empty, inherit from tracked spec JSON + # render_spec() falls back to `.enforcement` from the spec + # file when ENFORCEMENT_OVERRIDE is empty (see check job + # for the matching shape). + ENFORCEMENT_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || '' }} GH_APP_ID: ${{ vars.GH_APP_ID }} OPERATION: upsert run: | @@ -376,10 +447,21 @@ jobs: } render_spec() { + # Enforcement resolution (mirrors check job): + # - workflow_dispatch: ENFORCEMENT_OVERRIDE = inputs.enforcement + # - push: ENFORCEMENT_OVERRIDE empty → inherit `.enforcement` + # from the tracked spec JSON. Activation therefore + # requires editing the JSON (a reviewable change). local spec="$1" local rendered="$2" + local enforcement + if [ -n "${ENFORCEMENT_OVERRIDE}" ]; then + enforcement="${ENFORCEMENT_OVERRIDE}" + else + enforcement=$(jq -r '.enforcement' "${spec}") + fi jq \ - --arg enforcement "${ENFORCEMENT}" \ + --arg enforcement "${enforcement}" \ --argjson app_id "${GH_APP_ID}" \ '.enforcement = $enforcement | .bypass_actors = [ @@ -447,10 +529,11 @@ jobs: echo "# Branch rulesets — upsert" echo "" echo "- Operation: \`upsert\` (create or update)" - echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'active' }}\`" + echo "- Enforcement: \`${{ github.event_name == 'workflow_dispatch' && inputs.enforcement || 'from tracked spec JSON' }}\`" echo "- Trigger: \`${{ github.event_name }}\`" echo "- Ref guard: \`refs/heads/main\` only" - echo "- Confirmation: \`confirm_upsert=APPLY_RULESETS\` required" + echo "- Environment: \`github-admin\` (\`deployment: false\`); environment approval authorizes bot credential release, the App is the actor that performs the mutation" + echo "- Confirmation (dispatch only): \`confirm_upsert=APPLY_RULESETS\` required" echo "- Specs: \`.github-stars/control-plane/rulesets/*.json\`" echo "- Bypass actor: GitHub App Integration from \`vars.GH_APP_ID\` (rendered at runtime)" echo "- App permission required: \`Administration: write\` on this repository" diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml index 39b030741..511d428ec 100644 --- a/.github/workflows/00f-sync-protected-branches-with-main.yml +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -322,7 +322,7 @@ jobs: echo "- GitHub App installation token (no PAT fallback)" echo "- Update-branch API: \`PUT /repos/{owner}/{repo}/pulls/{n}/update-branch\` with \`expected_head_sha\`" echo "- Fast-forward fallback for \`ghapp/repo-admin\` when no open PR: \`PATCH /repos/{owner}/{repo}/git/refs/heads/{branch}\` with \`force=false\` (FF-only)" - echo "- Compare API: \`GET /repos/{owner}/{repo}/compare/{branch}...main\` for FF-state inspection" + echo "- Compare API: \`GET /repos/{owner}/{repo}/compare/main...{branch}\` for FF-state inspection (basehead order yields \`ahead_by\` = commits in branch not in main, \`behind_by\` = commits in main not in branch)" echo "" echo "## Downstream PR unblock condition" echo "Downstream PRs targeting either lane remain blocked until BOTH lanes are caught up to main. If either lane is stale or divergent, this workflow fails red and the operator must reconcile manually before the next push to main can clear the gate." diff --git a/.github/workflows/00i-gh-app-credentials.yml b/.github/workflows/00i-gh-app-credentials.yml index 06b677638..e85629f0a 100644 --- a/.github/workflows/00i-gh-app-credentials.yml +++ b/.github/workflows/00i-gh-app-credentials.yml @@ -70,20 +70,37 @@ jobs: set -euo pipefail echo "installation-id=${INSTALLATION_ID}" - repos=$(gh api \ + # Probe ONLY this repo's installation. Two reasons we do NOT + # call `/installation/repositories` here: + # + # 1. `/installation/repositories` is paginated (default 30 + # per page). Without `--paginate` the response would be + # truncated and a repo on a later page would falsely + # register as "not installed" (Copilot review on PR #81, + # 00i:74). + # 2. `/installation/repositories` enumerates EVERY repo + # (including private ones) the App has access to. On a + # public repo, dumping that list into Actions logs would + # leak private repo names (Copilot review on PR #81, + # 00i:73). + # + # `GET /repos/{owner}/{repo}/installation` returns 200 when + # the App is installed on this specific repo and 404 when it + # isn't — exactly the question we're asking, with no + # enumeration side-effect. `gh api` returns non-zero on 404 + # so we can branch on exit status directly. `2>/dev/null` + # suppresses gh's 404 body (no useful info; would just spam + # the log on the not-installed path). + if gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - /installation/repositories \ - --jq '.repositories | map(.full_name)') - - echo "Installed on:" - echo "${repos}" | jq -r '.[] | " - \(.)"' - - if ! echo "${repos}" | jq -e --arg r "${THIS_REPO}" '. | index($r)' >/dev/null; then - echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}. Check the App's installation scope at https://github.com/settings/apps//installations." + --silent \ + "/repos/${THIS_REPO}/installation" 2>/dev/null; then + echo "token + install (App installed on ${THIS_REPO})" + else + echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}, or the installation lookup failed. Check the App's installation scope at https://github.com/settings/apps//installations." exit 1 fi - echo "token + install (App installed on ${THIS_REPO})" - name: Pass-through for non-admin heads if: github.event.pull_request.head.ref != env.ADMIN_LANE_BRANCH diff --git a/.github/workflows/00j-gh-action-workflow-lint.yml b/.github/workflows/00j-gh-action-workflow-lint.yml index 8969da228..619ec5426 100644 --- a/.github/workflows/00j-gh-action-workflow-lint.yml +++ b/.github/workflows/00j-gh-action-workflow-lint.yml @@ -155,6 +155,6 @@ jobs: echo "- mixed-credential laundering guard (rejects \`STARS_TOKEN || GITHUB_TOKEN\` and \`app-token || secrets.\` patterns)" echo "- blocked-org name leakage guard (rejects \`blocked_orgs\` output / \`BLOCKED_ORGS\` env in workflow YAML)" echo "" - echo "actionlint runs inside \`pnpm gate\` (\`bun gate\` workflow)." + echo "actionlint runs inside \`pnpm gate\` (\`pnpm gate\` workflow)." echo "Tracking issue for richer dry-run gates: #62" } >> "$GITHUB_STEP_SUMMARY" From b1450cad36c883b634732a6f7b462b1c569ca0c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 06:29:51 +0000 Subject: [PATCH 33/36] =?UTF-8?q?admin:=20fix=2000i=20install=20probe=20?= =?UTF-8?q?=E2=80=94=20installation=20token=20can't=20call=20/repos/{}/ins?= =?UTF-8?q?tallation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/primeinc/github-stars/sessions/105b45c3-159a-4556-bd04-2fecfbfd3942 Co-authored-by: primeinc <4395149+primeinc@users.noreply.github.com> --- .github/workflows/00i-gh-app-credentials.yml | 52 ++++++++++---------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/00i-gh-app-credentials.yml b/.github/workflows/00i-gh-app-credentials.yml index e85629f0a..685b6bf6b 100644 --- a/.github/workflows/00i-gh-app-credentials.yml +++ b/.github/workflows/00i-gh-app-credentials.yml @@ -70,35 +70,37 @@ jobs: set -euo pipefail echo "installation-id=${INSTALLATION_ID}" - # Probe ONLY this repo's installation. Two reasons we do NOT - # call `/installation/repositories` here: + # `GET /repos/{owner}/{repo}/installation` requires JWT + # (App-level) auth and rejects installation access tokens — + # which is exactly what `actions/create-github-app-token@v3` + # produces. So we use `/installation/repositories`, which IS + # accessible with the installation token, but we tighten the + # two Copilot-flagged failure modes (PR #81 review): # - # 1. `/installation/repositories` is paginated (default 30 - # per page). Without `--paginate` the response would be - # truncated and a repo on a later page would falsely - # register as "not installed" (Copilot review on PR #81, - # 00i:74). - # 2. `/installation/repositories` enumerates EVERY repo - # (including private ones) the App has access to. On a - # public repo, dumping that list into Actions logs would - # leak private repo names (Copilot review on PR #81, - # 00i:73). + # 1. Use `--paginate` so installations with >30 repos + # don't false-negative on a repo that lives on a later + # page (00i:74). + # 2. Use `--jq` to extract just the full_name field, then + # `grep -Fxq` for membership. Neither `gh api` nor the + # grep prints the repository list — we only emit a + # single ok/error line — so the full installation + # scope never reaches the log (00i:73). # - # `GET /repos/{owner}/{repo}/installation` returns 200 when - # the App is installed on this specific repo and 404 when it - # isn't — exactly the question we're asking, with no - # enumeration side-effect. `gh api` returns non-zero on 404 - # so we can branch on exit status directly. `2>/dev/null` - # suppresses gh's 404 body (no useful info; would just spam - # the log on the not-installed path). - if gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - --silent \ - "/repos/${THIS_REPO}/installation" 2>/dev/null; then + # As a defense-in-depth signal, `create-github-app-token` + # with `repositories: ${{ github.event.repository.name }}` + # already fails to mint a token if the App isn't installed + # on this repo. Reaching this step at all means we have a + # valid installation token for at least this repo; the grep + # below is the explicit, observable proof. + if gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /installation/repositories \ + --jq '.repositories[].full_name' \ + | grep -Fxq "${THIS_REPO}"; then echo "token + install (App installed on ${THIS_REPO})" else - echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}, or the installation lookup failed. Check the App's installation scope at https://github.com/settings/apps//installations." + echo "::error::App (vars.GH_APP_ID=${{ vars.GH_APP_ID }}) is NOT installed on ${THIS_REPO}. Check the App's installation scope at https://github.com/settings/apps//installations." exit 1 fi From bf83ed7783e746ee05ca1f298ebd5eca538e3d2f Mon Sep 17 00:00:00 2001 From: Willie-P Date: Mon, 11 May 2026 02:36:02 -0400 Subject: [PATCH 34/36] Update protect-main-release-only.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../control-plane/rulesets/protect-main-release-only.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f24fc4477..337bdd35d 100644 --- a/.github-stars/control-plane/rulesets/protect-main-release-only.json +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -1,7 +1,7 @@ { "name": "protect-main-release-only", "target": "branch", - "enforcement": "disabled", + "enforcement": "active", "conditions": { "ref_name": { "include": [ From 3c1dbe63916f547580042a9b75adb7dfda479821 Mon Sep 17 00:00:00 2001 From: Willie-P Date: Mon, 11 May 2026 02:36:14 -0400 Subject: [PATCH 35/36] Update protect-next.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github-stars/control-plane/rulesets/protect-next.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github-stars/control-plane/rulesets/protect-next.json b/.github-stars/control-plane/rulesets/protect-next.json index 49fe386da..547ad3bf0 100644 --- a/.github-stars/control-plane/rulesets/protect-next.json +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -1,7 +1,7 @@ { "name": "protect-next", "target": "branch", - "enforcement": "disabled", + "enforcement": "active", "conditions": { "ref_name": { "include": [ From a6e5b1e3a20593d50dcc6af63d0195e9a4cdbb74 Mon Sep 17 00:00:00 2001 From: primeinc Date: Mon, 11 May 2026 02:58:00 -0400 Subject: [PATCH 36/36] =?UTF-8?q?admin:=20address=20PR=20#81=20bot=20revie?= =?UTF-8?q?ws=20=E2=80=94=20URL-encode=20lanes,=20line=20numbers,=20doc=20?= =?UTF-8?q?drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four real issues from Copilot/Codex/Gemini reviews; ignoring code-owner and review-count complaints per the protection-stage brief. 00f — URL-encode `${lane}`/`${branch}` in REST path segments. Per Copilot 00f:185 + Codex 00f:212 (P1). The admin lane name `ghapp/repo-admin` contains `/`, so raw interpolation produced `/branches/ghapp/repo-admin` which the GitHub API parses as a multi-segment path → 404. Per ../refs/github/docs/content/rest/using-the-rest-api/troubleshooting- the-rest-api.md L74: "any path parameters must be URL encoded. For example, any slashes in the parameter value must be replaced with `%2F`." Added a `urlencode()` helper using `jq -rn @uri` and applied it to all 5 path-segment interpolations: /branches/{lane} (3 sites), /git/refs/heads/{lane} (1 site), /compare/main...{branch} (1 site). PR-update-branch path was unaffected — uses PR number, no slashes. 00c — fix wrong filename reference in comment. Per Copilot 00c:12. Comment referenced `00d-admin-branch-sync-guard.yml` which doesn't exist in the repo (post-split, that responsibility lives in 00h-gh-action-file-allowlist.yml + 00d-gh-action-branch- staleness.yml). 00j — preserve source line numbers in lint guards. Per Copilot 00j:97 + 00j:132. Both the mixed-credential and blocked-org guards stripped comment lines via `grep -v` before `grep -nE`, which produced shifted line numbers in error output — reviewers chasing a violation would land on the wrong line. Refactored to grep the original file with `-n`, then awk-filter out hits whose matched content is a comment line, preserving the source line number throughout. workflow-triggers.md — fix doc/code drift. Per Copilot review-comments-md:178 + 206 + Gemini 204. Table row for 00-ci.yml said `pnpm gate`; file says `bun gate`. The 00e self-bootstrap note said push activates with enforcement=active; per current code (issue #82 doctrine) push reads enforcement from the tracked spec JSON and the github-admin environment approval is what releases credentials. Updated both. actionlint.yaml — ignore stale-schema false positive. actionlint 1.7.12 (latest) does not yet ship the `environment.deployment` key in its schema, even though `deployment: false` is canonical per 4 separate ../refs github docs (section-using-environments-for-jobs.md L57-67, deploy-to-environment.md L60, control-deployments.md L69, create-custom-protection-rules.md L45). 00e uses it per issue #82 to gate credential release without emitting a deployment record. Added a path-scoped `ignore` entry alongside the existing `models` permission and SC2034/SC2015 ignores. Dismissed (not changed): - vars.GH_APP_ID vs vars.GH_APP_CLIENT_ID (00e:116, 00f:71, 00i:60): both vars exist in repo (verified `gh variable list`); 00i token mint is GREEN on PR #81. The numeric ID is required for the ruleset bypass_actors[].actor_id field. Intentional. - Hardcode App ID into spec JSON bypass_actors (rulesets:64,70): brief says tracked JSON must not hardcode App IDs; bypass_actors is rendered at runtime from vars.GH_APP_ID by 00e's render_spec. - Required review count = 0 (rulesets:24,58): solo-owner repo, per brief. Merge-time governance comes from required_status_checks. - Push-default to disabled / environment missing (00e:59, 00e:336): resolved by issue #82 doctrine commit (3c1dbe63) — push inherits spec JSON, upsert job has `environment: github-admin` with `deployment: false`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../control-plane/workflow-triggers.md | 16 ++++--- .github/actionlint.yaml | 9 ++++ .github/workflows/00c-main-release-guard.yml | 3 +- .../00f-sync-protected-branches-with-main.yml | 28 ++++++++++--- .../workflows/00j-gh-action-workflow-lint.yml | 42 ++++++++++++------- 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/.github-stars/control-plane/workflow-triggers.md b/.github-stars/control-plane/workflow-triggers.md index 8ec084682..c923f3db7 100644 --- a/.github-stars/control-plane/workflow-triggers.md +++ b/.github-stars/control-plane/workflow-triggers.md @@ -175,7 +175,7 @@ this commit. "Conc." is the concurrency stanza added. | File | Workflow `name` | Job `name` (status check) | Trigger | Conc. cancel | |---|---|---|---|---| -| 00-ci.yml | `pnpm gate` | `all gates pass` | `pull_request: [main, next]` + `push: [main, next]` | true | +| 00-ci.yml | `bun gate` | `all gates pass` | `pull_request: [main, next]` + `push: [main, next]` | true | | 00a-do-not-merge-yet.yml | `gh-action label gate` | `DoNotMergeYet absent` | `pull_request` (any base) | true | | 00b-web-ci.yml | `npm web build` | `build succeeds` | `pull_request: [main, next]` + `push: [main, next]` (paths-filtered on push) | true | | 00c-main-release-guard.yml | `gh-action protected branch` | `src branch allowed` | `pull_request: [main]` | true | @@ -200,10 +200,16 @@ Notes: status for the required check to clear. - 00e has a self-bootstrap path: a push to `main` that touches the ruleset specs (`.github-stars/control-plane/rulesets/**`) or 00e - itself (`.github/workflows/00e-branch-rulesets.yml`) automatically - upserts the live rulesets with `enforcement=active`. The - `workflow_dispatch` form is kept for human-driven ops (drift checks, - enforcement toggle, manual re-upsert) and still requires the + itself (`.github/workflows/00e-branch-rulesets.yml`) fires the + upsert job. Per issue #82, the upsert job inherits the enforcement + value from the tracked spec JSON on the push path (NOT hardcoded to + `active`) — to flip enforcement, edit the JSON and push. The + upsert job declares `environment: github-admin` with + `deployment: false`; the environment's required-reviewer rule + (configured in repo Settings) is what authorizes credential release + before the App token is minted. The `workflow_dispatch` form is + kept for human-driven ops (drift checks, manual enforcement + override, manual re-upsert) and still requires the `confirm_upsert=APPLY_RULESETS` typed-string guard on that path. 00e is NOT a required status check on any ruleset — it OPERATES the rulesets, it doesn't gate PRs. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 338fd69f1..ff2f58a0f 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -20,3 +20,12 @@ paths: # either pull or push fails. SC2015 warns about the general footgun; # the body has no side-effect that would silently swallow `&&`. - 'shellcheck reported issue in this script: SC2015:.+' + # `environment.deployment: false` is canonical per + # ../refs/github/docs/data/reusables/actions/jobs/section-using- + # environments-for-jobs.md L57-67 and three other canonical docs + # (deploy-to-environment.md L60, control-deployments.md L69, + # create-custom-protection-rules.md L45). actionlint 1.7.12 does + # not yet ship the updated environment-section schema. Used by + # 00e-branch-rulesets.yml's upsert job per issue #82 to gate + # credential release without emitting a deployment record. + - 'unexpected key "deployment" for "environment" section' diff --git a/.github/workflows/00c-main-release-guard.yml b/.github/workflows/00c-main-release-guard.yml index 1d762811f..5a8875b8f 100644 --- a/.github/workflows/00c-main-release-guard.yml +++ b/.github/workflows/00c-main-release-guard.yml @@ -9,7 +9,8 @@ name: gh-action protected branch # Anything else fails this gate. PRs from forks fail (BASE_REPO != # HEAD_REPO). Feature/chore work flows through `next` first; admin / # workflow / ruleset work flows through `ghapp/repo-admin` and is -# additionally path-scoped by `00d-admin-branch-sync-guard.yml`. +# additionally path-scoped by `00h-gh-action-file-allowlist.yml` and +# staleness-checked by `00d-gh-action-branch-staleness.yml`. on: pull_request: diff --git a/.github/workflows/00f-sync-protected-branches-with-main.yml b/.github/workflows/00f-sync-protected-branches-with-main.yml index 511d428ec..99c967d2e 100644 --- a/.github/workflows/00f-sync-protected-branches-with-main.yml +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -95,6 +95,20 @@ jobs: "$@" } + # URL-encode a value for use as a single REST path segment. + # Per ../refs/github/docs/content/rest/using-the-rest-api/ + # troubleshooting-the-rest-api.md L74: "any path parameters + # must be URL encoded. For example, any slashes in the + # parameter value must be replaced with `%2F`." Required for + # branch names containing `/` like `ghapp/repo-admin` — raw + # interpolation produces `/branches/ghapp/repo-admin` which + # the API parses as a multi-segment path and returns 404. + # Per Copilot review on PR #81 (00f:185) and Codex review + # (00f:212). + urlencode() { + jq -rn --arg v "$1" '$v | @uri' + } + # State accumulator for the summary step. : >state.txt record() { @@ -176,8 +190,10 @@ jobs: # ------------------------------------------------------------ ff_state() { local branch="$1" + local branch_enc + branch_enc=$(urlencode "${branch}") local cmp - cmp=$(gh_api "/repos/${REPO}/compare/main...${branch}") + cmp=$(gh_api "/repos/${REPO}/compare/main...${branch_enc}") local behind_by ahead_by ahead_by=$(jq -r '.ahead_by' <<<"${cmp}") behind_by=$(jq -r '.behind_by' <<<"${cmp}") @@ -207,9 +223,11 @@ jobs: reconcile_lane() { local lane="$1" local prefix="$2" + local lane_enc + lane_enc=$(urlencode "${lane}") local before - before=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + before=$(gh_api "/repos/${REPO}/branches/${lane_enc}" --jq '.commit.sha') record "${prefix}_sha_before=${before}" echo "${lane} before: ${before}" @@ -223,7 +241,7 @@ jobs: update_pr_branch "${pr_number}" "${pr_head}" sleep 2 local after - after=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + after=$(gh_api "/repos/${REPO}/branches/${lane_enc}" --jq '.commit.sha') record "${prefix}_sha_after=${after}" record "${prefix}_status=updated_via_pr" echo "${lane} after: ${after}" @@ -254,11 +272,11 @@ jobs: echo "${lane} is ${behind_by} commit(s) behind main and 0 ahead — fast-forward via App." gh_api \ --method PATCH \ - "/repos/${REPO}/git/refs/heads/${lane}" \ + "/repos/${REPO}/git/refs/heads/${lane_enc}" \ -f "sha=${MAIN_SHA}" \ -F "force=false" local after - after=$(gh_api "/repos/${REPO}/branches/${lane}" --jq '.commit.sha') + after=$(gh_api "/repos/${REPO}/branches/${lane_enc}" --jq '.commit.sha') record "${prefix}_sha_after=${after}" record "${prefix}_status=fast_forwarded" echo "${lane} after: ${after}" diff --git a/.github/workflows/00j-gh-action-workflow-lint.yml b/.github/workflows/00j-gh-action-workflow-lint.yml index 619ec5426..0bfdfee19 100644 --- a/.github/workflows/00j-gh-action-workflow-lint.yml +++ b/.github/workflows/00j-gh-action-workflow-lint.yml @@ -84,17 +84,24 @@ jobs: shopt -s nullglob files=( .github/workflows/*.yml .github/workflows/*.yaml ) bad="" - # Strip comment lines (lines whose first non-whitespace char is #) - # before grepping. Without this the guard would self-trip on its - # own diagnostic comments — same class of bug as the - # cardinalby-mode guard hit in v1 (see 02M-mode-correction.md). + # Grep the ORIGINAL file with `-n` so reported line numbers + # match the source. Drop hits whose matched line is a YAML + # comment (first non-space char `#`) via awk post-filter, + # preserving the source line number. Prior shape stripped + # comments first then grepped -n, which produced shifted + # line numbers (Copilot review on PR #81: 00j:97). + filter_non_comment_hits() { + awk -F: '{ + n=index($0,":"); content=substr($0, n+1) + if (content !~ /^[[:space:]]*#/) print + }' + } for f in "${files[@]}"; do - non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) # Pattern 1: STARS_TOKEN || GITHUB_TOKEN (any order, any whitespace). - hit1=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.STARS_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.GITHUB_TOKEN' || true) - hit2=$(printf '%s\n' "$non_comments" | grep -nE 'secrets\.GITHUB_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.STARS_TOKEN' || true) + hit1=$(grep -nE 'secrets\.STARS_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.GITHUB_TOKEN' "$f" | filter_non_comment_hits || true) + hit2=$(grep -nE 'secrets\.GITHUB_TOKEN[[:space:]]*\|\|[[:space:]]*secrets\.STARS_TOKEN' "$f" | filter_non_comment_hits || true) # Pattern 2: app-token.outputs.token OR'd with any other secret. - hit3=$(printf '%s\n' "$non_comments" | grep -nE 'app-token\.outputs\.token[[:space:]]*\|\|[[:space:]]*secrets\.' || true) + hit3=$(grep -nE 'app-token\.outputs\.token[[:space:]]*\|\|[[:space:]]*secrets\.' "$f" | filter_non_comment_hits || true) for h in "$hit1" "$hit2" "$hit3"; do if [ -n "$h" ]; then bad="${bad}${f}:\n${h}\n"; fi done @@ -120,16 +127,23 @@ jobs: shopt -s nullglob files=( .github/workflows/*.yml .github/workflows/*.yaml ) bad="" - # Strip comment lines first so the guard cannot self-trip on - # its own inline doc text. Same defensive pattern as the - # mixed-credential guard above. + # Same shape as the mixed-credential guard above: grep the + # ORIGINAL file with `-n` so reported line numbers match the + # source, then awk-filter out comment-line hits without + # losing the source line number (Copilot review on PR #81: + # 00j:132). + filter_non_comment_hits() { + awk -F: '{ + n=index($0,":"); content=substr($0, n+1) + if (content !~ /^[[:space:]]*#/) print + }' + } for f in "${files[@]}"; do - non_comments=$(grep -vE '^[[:space:]]*#' "$f" || true) # ^\s+blocked_orgs: ... (job/step output key, not _count). - hit=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+blocked_orgs:[[:space:]]" || true) + hit=$(grep -nE "^[[:space:]]+blocked_orgs:[[:space:]]" "$f" | filter_non_comment_hits || true) if [ -n "$hit" ]; then bad="${bad}${f}:\n${hit}\n"; fi # BLOCKED_ORGS env var (used to substitute names into summaries). - hit2=$(printf '%s\n' "$non_comments" | grep -nE "^[[:space:]]+BLOCKED_ORGS:[[:space:]]" || true) + hit2=$(grep -nE "^[[:space:]]+BLOCKED_ORGS:[[:space:]]" "$f" | filter_non_comment_hits || true) if [ -n "$hit2" ]; then bad="${bad}${f}:\n${hit2}\n"; fi done if [ -n "$bad" ]; then