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..2463e580f --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -0,0 +1,65 @@ +{ + "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": "admin-branch-sync-guard" + }, + { + "context": "main-release-guard" + }, + { + "context": "do-not-merge-yet" + }, + { + "context": "gate" + }, + { + "context": "workflow-lint" + }, + { + "context": "build" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "bypass_actors": [] +} 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..2100f0cbd --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -0,0 +1,59 @@ +{ + "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": "do-not-merge-yet" + }, + { + "context": "gate" + }, + { + "context": "workflow-lint" + }, + { + "context": "build" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "bypass_actors": [] +} 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 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." 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' 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." 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 new file mode 100644 index 000000000..278f19a5f --- /dev/null +++ b/.github/workflows/00e-branch-rulesets.yml @@ -0,0 +1,253 @@ +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 + confirm_upsert: + description: 'Required for operation=upsert: type APPLY_RULESETS' + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + branch-rulesets: + 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 + 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 + + 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, + 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() { + 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}" \ + '.enforcement = $enforcement + | .bypass_actors = [ + { + actor_id: $app_id, + actor_type: "Integration", + bypass_mode: "pull_request" + } + ]' \ + "${spec}" > "${rendered}" + } + + upsert_ruleset() { + local name="$1" + local payload="$2" + local id + id=$(ruleset_id_by_name "${name}" || true) + + if [ -n "${id}" ]; then + echo "Updating ruleset ${name} (${id})" + gh_rules_api \ + --method PUT \ + "/repos/${REPO}/rulesets/${id}" \ + --input "${payload}" \ + --jq '{id, name, target, enforcement}' + else + echo "Creating ruleset ${name}" + gh_rules_api \ + --method POST \ + "/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=$(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}" + 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}" + 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 "- 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" + } >> "$GITHUB_STEP_SUMMARY" 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..1c06e5689 --- /dev/null +++ b/.github/workflows/00f-sync-next-with-main.yml @@ -0,0 +1,121 @@ +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"