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..337bdd35d --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-main-release-only.json @@ -0,0 +1,71 @@ +{ + "name": "protect-main-release-only", + "target": "branch", + "enforcement": "active", + "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": "head matches main" + }, + { + "context": "only allowed files" + }, + { + "context": "token + install" + }, + { + "context": "src branch allowed" + }, + { + "context": "DoNotMergeYet absent" + }, + { + "context": "all gates pass" + }, + { + "context": "workflows valid" + }, + { + "context": "build succeeds" + } + ] + } + }, + { + "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..547ad3bf0 --- /dev/null +++ b/.github-stars/control-plane/rulesets/protect-next.json @@ -0,0 +1,59 @@ +{ + "name": "protect-next", + "target": "branch", + "enforcement": "active", + "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": "DoNotMergeYet absent" + }, + { + "context": "all gates pass" + }, + { + "context": "workflows valid" + }, + { + "context": "build succeeds" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "bypass_actors": [] +} diff --git a/.github-stars/control-plane/workflow-triggers.md b/.github-stars/control-plane/workflow-triggers.md new file mode 100644 index 000000000..c923f3db7 --- /dev/null +++ b/.github-stars/control-plane/workflow-triggers.md @@ -0,0 +1,270 @@ +# 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 + +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 | +| 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 | +| 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) | +| 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 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`) 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. +- 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/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/00-ci.yml b/.github/workflows/00-ci.yml index 18dc9bc4c..8cfb2f5b7 100644 --- a/.github/workflows/00-ci.yml +++ b/.github/workflows/00-ci.yml @@ -1,21 +1,39 @@ -name: 'CI' +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: - main + - next push: branches: - main + - next permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: gate: - # `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. + name: all gates pass + # `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 @@ -62,11 +80,11 @@ jobs: - name: pnpm gate run: pnpm gate - - name: CI summary + - name: pnpm gate summary if: always() run: | { - echo "# CI — pnpm gate" + echo "# pnpm gate" echo "" echo "- Workflow: \`${{ github.workflow }}\`" echo "- Run ID: \`${{ github.run_id }}\`" @@ -80,141 +98,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: - # 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 \`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/00a-do-not-merge-yet.yml b/.github/workflows/00a-do-not-merge-yet.yml new file mode 100644 index 000000000..a6b567294 --- /dev/null +++ b/.github/workflows/00a-do-not-merge-yet.yml @@ -0,0 +1,35 @@ +name: gh-action label gate + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - edited + - labeled + - unlabeled + - ready_for_review + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + do-not-merge-yet: + name: DoNotMergeYet absent + 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..b485bbf37 100644 --- a/.github/workflows/00b-web-ci.yml +++ b/.github/workflows/00b-web-ci.yml @@ -1,16 +1,18 @@ --- -name: 'Web CI' +name: 'npm web build' 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' @@ -25,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 new file mode 100644 index 000000000..5a8875b8f --- /dev/null +++ b/.github/workflows/00c-main-release-guard.yml @@ -0,0 +1,69 @@ +name: gh-action protected branch + +# 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 `00h-gh-action-file-allowlist.yml` and +# staleness-checked by `00d-gh-action-branch-staleness.yml`. + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + main-release-guard: + name: src branch allowed + runs-on: ubuntu-latest + steps: + - 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 }} + 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" ]; 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 + + 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-gh-action-branch-staleness.yml b/.github/workflows/00d-gh-action-branch-staleness.yml new file mode 100644 index 000000000..c3b27d967 --- /dev/null +++ b/.github/workflows/00d-gh-action-branch-staleness.yml @@ -0,0 +1,74 @@ +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 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +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/00e-branch-rulesets.yml b/.github/workflows/00e-branch-rulesets.yml new file mode 100644 index 000000000..d8fb121c3 --- /dev/null +++ b/.github/workflows/00e-branch-rulesets.yml @@ -0,0 +1,540 @@ +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. Mints an App +# token with `permission-administration: read`. +# - upsert (gated): render + create-or-update the live rulesets +# 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, 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 + paths: + - '.github-stars/control-plane/rulesets/**' + - '.github/workflows/00e-branch-rulesets.yml' + 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 (workflow_dispatch only; push reads enforcement from tracked spec JSON)' + 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 + +# 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, + # fails on drift. Read-only. + # ---------------------------------------------------------------- + check: + name: branch-rulesets-check + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + 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 }} + # 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 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: | + 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`. + # + # 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}" \ + --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: \`${{ 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: read\` on this repository" + } >>"$GITHUB_STEP_SUMMARY" + + # ---------------------------------------------------------------- + # 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). + # + # Two trigger paths: + # - push to main on the ruleset specs / this workflow file: + # 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. + # - 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 + # `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' || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'upsert') + ) + steps: + - name: Checkout tracked ruleset specs + uses: actions/checkout@v6 + + - name: Validate upsert authorization (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' + 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: Upsert + verify ruleset state + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + # 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: | + 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() { + # 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}" \ + --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 + } + + upsert_and_verify() { + 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}" + upsert_ruleset "${name}" "${rendered}" + id=$(ruleset_id_by_name "${name}" || true) + if [ -z "${id}" ]; then + 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 upsert: ${name}" + return 1 + fi + } + + 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: Upsert summary + if: always() + run: | + { + echo "# Branch rulesets — upsert" + echo "" + echo "- Operation: \`upsert\` (create or update)" + 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 "- 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" + } >>"$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..99c967d2e --- /dev/null +++ b/.github/workflows/00f-sync-protected-branches-with-main.yml @@ -0,0 +1,347 @@ +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 +# +# 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. + +on: + push: + branches: + - main + workflow_dispatch: + +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 + +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 + + # 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() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + # 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() { + 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 + # 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 + } + + # ------------------------------------------------------------ + # 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 + # ` ` 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 branch_enc + branch_enc=$(urlencode "${branch}") + local cmp + 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}") + echo "${ahead_by} ${behind_by}" + } + + # ------------------------------------------------------------ + # 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 lane_enc + lane_enc=$(urlencode "${lane}") + + local before + before=$(gh_api "/repos/${REPO}/branches/${lane_enc}" --jq '.commit.sha') + record "${prefix}_sha_before=${before}" + echo "${lane} before: ${before}" + + 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_enc}" --jq '.commit.sha') + record "${prefix}_sha_after=${after}" + record "${prefix}_status=updated_via_pr" + echo "${lane} after: ${after}" + return 0 + fi + + local rc=$? + if [ "${rc}" -eq 2 ]; then + record "${prefix}_status=blocked_multiple_open_prs" + echo "${prefix}_sync_failed=true" >>"$GITHUB_OUTPUT" + return 0 + fi + + # 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 "${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/${lane_enc}" \ + -f "sha=${MAIN_SHA}" \ + -F "force=false" + local after + 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}" + return 0 + 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::" + + # ============================================================ + # 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; downstream PRs remain blocked until both lanes are caught up to main." + 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" 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..2b00ae481 --- /dev/null +++ b/.github/workflows/00h-gh-action-file-allowlist.yml @@ -0,0 +1,112 @@ +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 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +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..685b6bf6b --- /dev/null +++ b/.github/workflows/00i-gh-app-credentials.yml @@ -0,0 +1,112 @@ +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 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +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}" + + # `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. 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). + # + # 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}. Check the App's installation scope at https://github.com/settings/apps//installations." + exit 1 + fi + + - 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." 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..0bfdfee19 --- /dev/null +++ b/.github/workflows/00j-gh-action-workflow-lint.yml @@ -0,0 +1,174 @@ +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="" + # 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 + # Pattern 1: STARS_TOKEN || GITHUB_TOKEN (any order, any whitespace). + 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=$(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 + 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="" + # 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 + # ^\s+blocked_orgs: ... (job/step output key, not _count). + 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=$(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 + { + 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\` (\`pnpm gate\` workflow)." + echo "Tracking issue for richer dry-run gates: #62" + } >> "$GITHUB_STEP_SUMMARY"