diff --git a/.github/actions/bot-token/action.yml b/.github/actions/bot-token/action.yml new file mode 100644 index 0000000..bd2d115 --- /dev/null +++ b/.github/actions/bot-token/action.yml @@ -0,0 +1,86 @@ +# bot-token — a marola-pr-bot or marola-merge-bot token for named repos, with git committing as that +# bot for the rest of the job (MIP-0086 §5.4). A composite action cannot read secrets: the caller passes the App's id and key. +name: bot-token +description: Mint a GitHub App installation token for named marola-dev repos and make git commit as the App's bot user. + +inputs: + app-id: + description: vars.PR_BOT_APP_ID or vars.MERGE_BOT_APP_ID + required: true + private-key: + description: secrets.PR_BOT_KEY or secrets.MERGE_BOT_KEY + required: true + repositories: + description: >- + Comma- or newline-separated repo names in marola-dev. Never empty: with owner set and no list, + the token would cover every repo the App is installed on. + required: true + permission-contents: { description: "read | write", required: false, default: "" } + permission-pull-requests: { description: "read | write", required: false, default: "" } + permission-workflows: { description: "write", required: false, default: "" } + permission-checks: { description: "read", required: false, default: "" } + permission-statuses: { description: "read", required: false, default: "" } + permission-issues: { description: "read | write", required: false, default: "" } + +outputs: + token: { description: "The installation token, valid for one hour.", value: "${{ steps.mint.outputs.token }}" } + app-slug: { description: "The App's slug.", value: "${{ steps.mint.outputs.app-slug }}" } + installation-id: { description: "The installation's id.", value: "${{ steps.mint.outputs.installation-id }}" } + user-name: { description: "[bot]", value: "${{ steps.who.outputs.name }}" } + user-email: { description: "+[bot]@users.noreply.github.com", value: "${{ steps.who.outputs.email }}" } + +runs: + using: composite + steps: + # The action's own parser would turn "\r\n" into [] and mint for every installed repo, so the list + # is cleaned here and the cleaned list is what the mint step gets. v3 skips empty permission-* + # inputs, and with none set the token holds every permission the App has. + - id: guard + shell: bash + env: + REPOS: ${{ inputs.repositories }} + PERMS: ${{ inputs.permission-contents }}${{ inputs.permission-pull-requests }}${{ inputs.permission-workflows }}${{ inputs.permission-checks }}${{ inputs.permission-statuses }}${{ inputs.permission-issues }} + run: | + set -euo pipefail + [ -n "${PERMS//[[:space:]]/}" ] || { echo "::error::bot-token: no permission-* input set; the token would hold every permission the App has"; exit 1; } + cleaned="" + while IFS= read -r repo; do + repo="${repo//[[:space:]]/}" + [ -n "$repo" ] || continue + [[ "$repo" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "::error::bot-token: '$repo' is not a bare repo name in marola-dev"; exit 1; } + cleaned+="${cleaned:+,}$repo" + done < <(tr ',' '\n' <<<"$REPOS") + [ -n "$cleaned" ] || { echo "::error::bot-token: repositories is empty; name every repo the token needs"; exit 1; } + echo "repositories=$cleaned" >>"$GITHUB_OUTPUT" + + - id: mint + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + owner: marola-dev + repositories: ${{ steps.guard.outputs.repositories }} + permission-contents: ${{ inputs.permission-contents }} + permission-pull-requests: ${{ inputs.permission-pull-requests }} + permission-workflows: ${{ inputs.permission-workflows }} + permission-checks: ${{ inputs.permission-checks }} + permission-statuses: ${{ inputs.permission-statuses }} + permission-issues: ${{ inputs.permission-issues }} + + # GIT_CONFIG_GLOBAL, not `git config --global`, which would outlive the job on a self-hosted runner. + # The commit email needs the bot user's id, which is not the App id (create-github-app-token's README). + - id: who + shell: bash + env: + GH_TOKEN: ${{ steps.mint.outputs.token }} + SLUG: ${{ steps.mint.outputs.app-slug }} + run: | + set -euo pipefail + uid="$(gh api "users/${SLUG}%5Bbot%5D" --jq .id)" + name="${SLUG}[bot]" email="${uid}+${SLUG}[bot]@users.noreply.github.com" + cfg="$RUNNER_TEMP/bot-token.gitconfig" + if [ -f ~/.gitconfig ]; then cp ~/.gitconfig "$cfg"; else : >"$cfg"; fi + git config --file "$cfg" user.name "$name" + git config --file "$cfg" user.email "$email" + echo "GIT_CONFIG_GLOBAL=$cfg" >>"$GITHUB_ENV" + { echo "name=$name"; echo "email=$email"; } >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/bot-token-test.yml b/.github/workflows/bot-token-test.yml new file mode 100644 index 0000000..c6784e4 --- /dev/null +++ b/.github/workflows/bot-token-test.yml @@ -0,0 +1,106 @@ +# bot-token-test — the live test of .github/actions/bot-token (MIP-0086 §7). It needs PR_BOT_KEY, +# so it skips fork PRs, which receive no secrets. +name: bot-token test + +on: + pull_request: + paths: [".github/actions/bot-token/**", ".github/workflows/bot-token-test.yml"] + workflow_dispatch: + +permissions: {} + +jobs: + refuses-unsafe-input: + if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - id: empty + continue-on-error: true + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY }} + repositories: "" + permission-contents: read + - id: blank + continue-on-error: true + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY }} + repositories: "\r\n" + permission-contents: read + - id: qualified + continue-on-error: true + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY }} + repositories: marola-dev/marola-devkit + permission-contents: read + - id: noperm + continue-on-error: true + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY }} + repositories: marola-devkit + - name: every one was refused + env: + OUTCOMES: ${{ steps.empty.outcome }} ${{ steps.blank.outcome }} ${{ steps.qualified.outcome }} ${{ steps.noperm.outcome }} + run: '[ "$OUTCOMES" = "failure failure failure failure" ] || { echo "::error::bot-token accepted unsafe input: $OUTCOMES"; exit 1; }' + + mints-scoped: + if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - id: bot + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY }} + repositories: marola-devkit + permission-contents: write + - name: one repo, contents only, git commits as the bot + env: + GH_TOKEN: ${{ steps.bot.outputs.token }} + SLUG: ${{ steps.bot.outputs.app-slug }} + EMAIL: ${{ steps.bot.outputs.user-email }} + run: | + set -euo pipefail + repos="$(gh api installation/repositories --jq '[.repositories[].full_name] | join(",")')" + [ "$repos" = marola-dev/marola-devkit ] || { echo "::error::the token reaches $repos"; exit 1; } + # Prints " "; callers compare the code, errors show the whole line. + probe() { + local body code + body="$(mktemp)" + code="$(curl -s -o "$body" -w '%{http_code}' -X POST -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$1" -d '{}')" + echo "$code $(jq -r '.message // empty' "$body" 2>/dev/null)" + } + # pulls checks permission before the body: 403 means pull_requests was not granted. git/refs + # does not (run 38306845732 got 422 for `{}` on marola-site too), so pushes prove contents. + pulls="$(probe marola-dev/marola-devkit/pulls)"; echo "devkit pulls: $pulls" + [ "${pulls%% *}" = 403 ] || { echo "::error::pull_requests was granted: $pulls"; exit 1; } + ref="refs/heads/bot-token-probe-${{ github.run_id }}" + git push --dry-run "https://x-access-token:${GH_TOKEN}@github.com/marola-dev/marola-devkit.git" "HEAD:$ref" >push-devkit.log 2>&1 \ + || { echo "::error::dry-run push to marola-devkit failed"; tail -n 5 push-devkit.log; exit 1; } + echo "dry-run push to marola-devkit: ok" + if git push --dry-run "https://x-access-token:${GH_TOKEN}@github.com/marola-dev/marola-site.git" "HEAD:$ref" >push-site.log 2>&1; then + echo "::error::dry-run push to marola-site succeeded"; tail -n 5 push-site.log; exit 1 + fi + grep -Eq '403|denied' push-site.log || { echo "::error::push to marola-site failed, but not with 403/denied"; tail -n 5 push-site.log; exit 1; } + echo "dry-run push to marola-site: refused ($(tail -n 1 push-site.log))" + [[ "$(git config --global user.email)" =~ ^[0-9]+\+${SLUG}\[bot\]@users\.noreply\.github\.com$ ]] || { echo "::error::git email is $(git config --global user.email)"; exit 1; } + [ "$(git config --global user.email)" = "$EMAIL" ] + [ "$(git config --global user.name)" = "${SLUG}[bot]" ] diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index 02e98bf..d018582 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -6,8 +6,9 @@ # model, which an agent loop spent on a single PR. The fix is applied from that same answer, only # where its `original` text still matches, and never under .github/. # -# Identity is the org App behind vars.GEMINI_APP_ID / secrets.GEMINI_APP_PRIVATE_KEY, not -# GITHUB_TOKEN: a push made with GITHUB_TOKEN starts no CI on the PR. +# Identity is `marola-pr-bot` (vars.PR_BOT_APP_ID / secrets.PR_BOT_KEY, MIP-0086), falling back to +# the GEMINI_* names until they are deleted, not GITHUB_TOKEN: a push made with GITHUB_TOKEN starts +# no CI on the PR. # # Callers trigger on pull_request_target, so a fork PR gets the secrets too (only a maintainer can # request the team). A fork's head is checked out as text into .pr-head and never run; it gets a @@ -51,7 +52,9 @@ on: GEMINI_API_KEY: required: true GEMINI_APP_PRIVATE_KEY: - required: true + required: false + PR_BOT_KEY: + required: false concurrency: group: gemini-review-${{ github.event.pull_request.number }} @@ -72,8 +75,8 @@ jobs: id: app uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.GEMINI_APP_ID }} - private-key: ${{ secrets.GEMINI_APP_PRIVATE_KEY }} + app-id: ${{ vars.PR_BOT_APP_ID || vars.GEMINI_APP_ID }} + private-key: ${{ secrets.PR_BOT_KEY || secrets.GEMINI_APP_PRIVATE_KEY }} permission-contents: write permission-pull-requests: write diff --git a/.github/workflows/notify-umbrella.yml b/.github/workflows/notify-umbrella.yml index f4407b0..e20b52f 100644 --- a/.github/workflows/notify-umbrella.yml +++ b/.github/workflows/notify-umbrella.yml @@ -15,11 +15,12 @@ # notify: # uses: marola-dev/marola-devkit/.github/workflows/notify-umbrella.yml@v0.9.0 # secrets: -# token: ${{ secrets.MAROLA_CROSS_REPO_PAT }} +# pr_bot_key: ${{ secrets.PR_BOT_KEY }} # -# MAROLA_CROSS_REPO_PAT: the org's fine-grained PAT with Contents: read & write on the umbrella — -# repository_dispatch needs it; GITHUB_TOKEN cannot reach another repository. Unset is a notice, -# never a failure: the umbrella's own daily cron still picks the change up. +# pr_bot_key: marola-pr-bot's key (MIP-0086), minted here into a token for the umbrella only, with +# Contents write, which repository_dispatch needs. `token` is the old PAT, read only when +# pr_bot_key is absent, until every caller moves (MIP-0086 row 16). Neither set is a notice, never a +# failure: the umbrella's own daily cron still picks the change up. name: notify-umbrella on: @@ -34,8 +35,11 @@ on: type: string default: ubuntu-latest secrets: + pr_bot_key: + description: "secrets.PR_BOT_KEY" + required: false token: - description: "Fine-grained PAT with Contents: read & write on the umbrella." + description: "Deprecated: the old PAT, read only when pr_bot_key is absent." required: false permissions: {} @@ -44,6 +48,8 @@ jobs: dispatch: runs-on: ${{ inputs.runner }} timeout-minutes: 2 + env: + HAS_PR_BOT_KEY: ${{ secrets.pr_bot_key != '' }} steps: # A reusable workflow can only keep or lower the caller's permissions, and every caller sets # permissions: {} — so this reads the compare API instead of checking the repo out. Even a @@ -75,9 +81,26 @@ jobs: fi echo "changed=$changed" >>"$GITHUB_OUTPUT" + - name: umbrella owner and name + id: um + env: + UMBRELLA: ${{ inputs.umbrella }} + run: echo "owner=${UMBRELLA%%/*}" >>"$GITHUB_OUTPUT"; echo "name=${UMBRELLA#*/}" >>"$GITHUB_OUTPUT" + + - name: marola-pr-bot token for the umbrella + id: app + if: env.HAS_PR_BOT_KEY == 'true' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.PR_BOT_APP_ID }} + private-key: ${{ secrets.pr_bot_key }} + owner: ${{ steps.um.outputs.owner }} + repositories: ${{ steps.um.outputs.name }} + permission-contents: write + - name: repository_dispatch -> ${{ inputs.umbrella }} env: - TOKEN: ${{ secrets.token }} + TOKEN: ${{ steps.app.outputs.token || secrets.token }} UMBRELLA: ${{ inputs.umbrella }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} diff --git a/AGENTS.md b/AGENTS.md index d0e260c..af06fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ An agent starts work only on an issue carrying `agent-ready`, in this repo (MIP- Commits carry `Tested:` and `Co-Authored-By: Claude `, nothing else. `.claude/settings.json` sets the attribution; `pr-flow` (`just pr`) fills a missing `Tested:`. -The one exception is `gemini-review`'s fix commit, written by Gemini as `marola-gemini-bot`: its +The one exception is `gemini-review`'s fix commit, written by Gemini as `marola-pr-bot`: its second trailer is `Co-Authored-By: Gemini `. ## Phase discipline (hard rule) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f0032..35ef415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ marketplace `ref` at once. `marola-merge-bot` (5257042) becomes a pull-request-only bypass actor on every consumer (MIP-0086 §5.7, #100). `just rulesets-apply` over the consumers after this release also moves org and repo admins from `always` to `pull_request` where v0.8.2's rule was never applied. +- `.github/actions/bot-token` mints a `marola-pr-bot` or `marola-merge-bot` token for named repos + (MIP-0086 §5.4); `release.py --consumer` moves its pin. `merge-key-check` runs in `just quality` + (§5.6). `notify-umbrella` takes `pr_bot_key` (the `token` PAT is still read as fallback); + `gemini-review` reads `PR_BOT_*` with `GEMINI_*` as fallback, and `GEMINI_APP_PRIVATE_KEY` is no + longer required (#79). ## v0.8.3 — 2026-10-10 diff --git a/README.md b/README.md index d5626b5..1e67c9a 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ version, not its `main` (MIP-0074 §5.3). Their first real run is in a consuming **Gemini review on request** — on a PR, request the reviewer `marola-dev/gemini` (sidebar → Reviewers, or `gh pr edit --add-reviewer marola-dev/gemini`). A few minutes later -`marola-gemini-bot` posts one review: a short summary and at most 10 inline comments tagged +`marola-pr-bot` posts one review: a short summary and at most 10 inline comments tagged `[high]`/`[medium]`/`[low]`, with a suggestion block where the fix is exact. It then pushes one `fix: apply Gemini review findings` commit with the fixes whose original text still matches, after the repo's `check-command` passes, and comments what it fixed and what it left. Request the @@ -233,14 +233,16 @@ jobs: gemini: if: github.event.requested_team.slug == 'gemini' uses: marola-dev/marola-devkit/.github/workflows/gemini-review.yml@v0.9.0 - secrets: inherit + secrets: # not `inherit`: merge-key-check rejects it in a repo that holds the merge key + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + PR_BOT_KEY: ${{ secrets.PR_BOT_KEY }} with: devkit-ref: v0.9.0 check-command: "" # a fast gate that needs no Nix, e.g. node scripts/site_check.js ``` -It needs the org's `marola-gemini-bot` App installed on the repo, the `gemini` team with Read on -it, and the org's `GEMINI_API_KEY`, `GEMINI_APP_PRIVATE_KEY` and `GEMINI_APP_ID`. One Gemini API +It needs the org's `marola-pr-bot` App installed on the repo, the `gemini` team with Read on +it, and the org's `GEMINI_API_KEY`, `PR_BOT_KEY` and `PR_BOT_APP_ID`. One Gemini API call per review keeps it on the free tier, about 20 reviews a day per model; `vars.GEMINI_MODEL` picks another model. This repo calls it from `gemini.yml`, with `bash tests/self-tests.sh` as the check. Details: [docs/4-reference_workflows.md](docs/4-reference_workflows.md#gemini-review). diff --git a/docs/3-development.md b/docs/3-development.md index a38ab43..ffde7a0 100644 --- a/docs/3-development.md +++ b/docs/3-development.md @@ -119,9 +119,10 @@ Never install graphify any other way, pass `--backend`, or run its installers or ## Secrets and cost -The devkit deploys nothing. Its one secret is `MAROLA_BUMP_PAT`, read by `bump-consumers.yml` -alone; its reusable workflows take what the caller passes (the `notify-umbrella` token, -`MAROLA_CROSS_REPO_PAT` in the callers) or the default `GITHUB_TOKEN`. +The devkit deploys nothing. `bot-token-test` reads the org's `PR_BOT_KEY` and `PR_BOT_APP_ID`; +`MAROLA_BUMP_PAT` (`bump-consumers.yml`) and `MAROLA_CROSS_REPO_PAT` (the callers) remain until +MIP-0086 row 11 retires them. Its reusable workflows take what the caller passes or the default +`GITHUB_TOKEN`. `setup-runners` and `gha-runner` touch a self-hosted runner only when a human runs them ([runners](4-reference_runners.md)). diff --git a/docs/4-reference_config.md b/docs/4-reference_config.md index 49d045e..75549c2 100644 --- a/docs/4-reference_config.md +++ b/docs/4-reference_config.md @@ -21,7 +21,8 @@ has a default, so a repo that sets none of them gets the org's behaviour. | `MAROLA_RUNNER_SOURCE` | `setup-runners` | `~/code/actions-runner`, then `~/actions-runner` | An unpacked actions-runner release to copy from | | `MAROLA_OBSIDIAN_VAULT` | the `obsidian-vault` skill | `~/Documents/2nd-brain` | The maintainer's Obsidian vault | | `MAROLA_BUMP_PAT` | `bump-consumers.yml` | — | Not an environment variable: the org secret, readable by marola-devkit alone, whose PAT opens the pin-bump PRs ([releases](3-development.md#releases)) | -| `MAROLA_CROSS_REPO_PAT` | a caller of `notify-umbrella` | — | Not an environment variable: the repository secret a caller passes as the workflow's `token` ([workflows](4-reference_workflows.md#notify-umbrella)) | +| `PR_BOT_KEY`, `PR_BOT_APP_ID` | a caller of `notify-umbrella` and `gemini-review` | — | Not environment variables: the org secret and variable of the `marola-pr-bot` App, passed as `notify-umbrella`'s `pr_bot_key` ([workflows](4-reference_workflows.md#notify-umbrella)) | +| `MAROLA_CROSS_REPO_PAT` | a caller of `notify-umbrella` | — | Deprecated fallback, read only without `pr_bot_key`: the repository secret a caller passes as the workflow's `token` | `MAROLA_TRACES` also appears in `scripts/`, but only as sample text in `tasks_issues.py`'s self-test (a marola-app task row); the devkit does not read it. diff --git a/docs/4-reference_tools.md b/docs/4-reference_tools.md index fd17bf5..4adcc34 100644 --- a/docs/4-reference_tools.md +++ b/docs/4-reference_tools.md @@ -54,6 +54,7 @@ extension rather than a devkit tool. | `backfill-pr-labels` | `backfill-pr-labels.sh` | `just pr-labels-backfill` | The same for every merged or closed PR with no label; `--limit`, `--nlp` (compare with `pr-label-nlp`), `--nlp-apply-unscoped` (fill only an `area/unscoped` gap) | | `pr-label-nlp` | `pr_label_nlp.py` | (none) | A local TF-IDF classifier for `area/*`, kept to compare against the deterministic one, never applied on its own. `--pr N` or `--title`/`--body` | | `ruleset-sync` | `ruleset-sync.sh` | `just rulesets-check`, `just rulesets-apply` | `check [owner/repo…]` diffs a repo's live branch ruleset against `.github/rulesets/main-rule.json`, plus that repo's bypass actors in `.github/rulesets/bypass-extras.json` (each with a reason; the `@consumers` key applies to every repo in `.github/consumers.txt`, `--consumers FILE` to override); `apply ` creates or updates it; `--all-org ORG` covers an org | +| `merge-key-check` | `merge_key_check.py` | `just quality` | Fails when `MERGE_BOT_KEY` appears in a workflow outside the allowlist `.github/merge-key-allowlist` (workflow files, one repo-relative path per line, `#` comments), or in a job that runs third-party actions or fetch/build commands, calls a reusable workflow (except a listed local one), sets `container`/`services` or a shell other than bash or sh, or sits beside workflow-level env carrying the key (MIP-0086 §5.6). Once the allowlist exists, `secrets: inherit` is flagged repo-wide | | `agents-check` | `agents-check.sh` | `just agents-check` | Compare AGENTS.md's invariants block byte for byte with the pinned devkit's `agents/invariants.md` (`--block` or `MAROLA_INVARIANTS_BLOCK` to use another) | | `docs-lint` | `docs_lint.py` | (none) | MIP-0074 §7's stale-content check over `README.md` and `docs/**/*.md`: undefined or unmarked foreign recipes, another repo's paths, split-era wording, `docs/index.md`, links leaving the repo | | `wiring` | `wiring.py` | `just wiring` | MIP-0076 §5.1's four tables (artifact, dispatch, pin bump, deploy), parsed from the umbrella's and every submodule's workflows, pin files, scripts, justfiles, Dockerfiles, compose files and `build.sbt`, with each devkit workflow call's ref (each call is read at that ref: from a local devkit clone's tags, else fetched from GitHub, a tag cached under `~/.cache/marola-wiring`). `wiring [FILE]` prints the block or rewrites it between FILE's `wiring:start`/`wiring:end` markers; `--check [FILE]` fails on a stale block, a dispatch sent or listened for on one side only, or an artifact nobody reads that the umbrella's `wiring.allow` (`: ` per line) does not list; run it in an umbrella checkout with submodules | diff --git a/docs/4-reference_workflows.md b/docs/4-reference_workflows.md index 68d043c..a8d5899 100644 --- a/docs/4-reference_workflows.md +++ b/docs/4-reference_workflows.md @@ -132,7 +132,7 @@ jobs: notify: uses: marola-dev/marola-devkit/.github/workflows/notify-umbrella.yml@v0.9.0 secrets: - token: ${{ secrets.MAROLA_CROSS_REPO_PAT }} + pr_bot_key: ${{ secrets.PR_BOT_KEY }} ``` | Input | Default | Notes | @@ -140,10 +140,12 @@ jobs: | `umbrella` | `marola-dev/marola` | MAROLA_UMBRELLA, MIP-0070 §5.6 | | `runner` | `ubuntu-latest` | resolved in the *caller's* repo | -**Secret** `token` (optional): every repo passes the org secret `MAROLA_CROSS_REPO_PAT`, a -fine-grained PAT with Contents: read & write on the umbrella (`repository_dispatch` needs it; -`GITHUB_TOKEN` cannot reach another repo). Unset is a notice, not a failure — the umbrella's daily -cron still catches the change. +**Secret** `pr_bot_key` (optional): every repo passes the org secret `PR_BOT_KEY`, and the workflow +mints a `marola-pr-bot` token (`vars.PR_BOT_APP_ID`) for the umbrella alone, with Contents write +(`repository_dispatch` needs it; `GITHUB_TOKEN` cannot reach another repo). **Secret** `token` is +the deprecated fallback, the old `MAROLA_CROSS_REPO_PAT`, read only when `pr_bot_key` is absent +(MIP-0086). With neither set it is a notice, not a failure — the umbrella's daily cron still +catches the change. A zero `github.event.before` (a branch's first push), a compare API call that doesn't answer 200 (e.g. `before` unknown to it after a force-push), or a truncated compare (the API caps its file @@ -355,7 +357,9 @@ jobs: gemini: if: github.event.requested_team.slug == 'gemini' uses: marola-dev/marola-devkit/.github/workflows/gemini-review.yml@v0.9.0 - secrets: inherit + secrets: # not `inherit`: merge-key-check rejects it in a repo that holds the merge key + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + PR_BOT_KEY: ${{ secrets.PR_BOT_KEY }} with: devkit-ref: v0.9.0 check-command: "" # e.g. node scripts/site_check.js @@ -370,8 +374,10 @@ jobs: | `extra-trailers` | `""` | e.g. marola-site's `MIP: none — Gemini review fix` | | `gemini-model` | `""` | falls back to `vars.GEMINI_MODEL`, then `gemini-3.6-flash` | -Secrets: `GEMINI_API_KEY` and `GEMINI_APP_PRIVATE_KEY`; variable `GEMINI_APP_ID`. All three are -set at the org, scoped to the repos the `marola-gemini-bot` App is installed on. The App needs +Secrets: `GEMINI_API_KEY` and `PR_BOT_KEY`; variable `PR_BOT_APP_ID`. All three are set at the +org, scoped to the repos the `marola-pr-bot` App is installed on. `GEMINI_APP_PRIVATE_KEY` and +`GEMINI_APP_ID` are read when the `PR_BOT_*` names are unset, and no caller needs to pass +`GEMINI_APP_PRIVATE_KEY` any more (MIP-0086). The App needs Contents and Pull requests read/write and no Workflows permission, so GitHub itself refuses a push from it that edits a workflow; a push with its token, unlike `GITHUB_TOKEN`'s, starts the PR's CI. @@ -408,6 +414,22 @@ jobs: Secret `token`, optional: a PR opened with the default `GITHUB_TOKEN` starts no CI; one opened with a PAT or App token does. +## bot-token (composite action) + +`uses: marola-dev/marola-devkit/.github/actions/bot-token@v0.9.0` mints an installation token for +`marola-pr-bot` or `marola-merge-bot` on named `marola-dev` repos, and points git at that bot's +identity for the rest of the job (MIP-0086 §5.4). A composite action cannot read secrets, so the +caller passes `app-id` (`vars.PR_BOT_APP_ID` or `vars.MERGE_BOT_APP_ID`) and `private-key` +(`secrets.PR_BOT_KEY` or `secrets.MERGE_BOT_KEY`). `repositories` is required, a comma- or +newline-separated list of repo names; an empty list fails the step, because the token would +otherwise cover every repo the App is installed on, and so does a call with no `permission-*` input +set (`permission-contents`, `-pull-requests`, `-workflows`, `-checks`, `-statuses`, `-issues`). +Outputs: `token`, `app-slug`, `installation-id`, `user-name` and `user-email`. The git identity is +job-scoped, through `GIT_CONFIG_GLOBAL` rather than `git config --global`. A job that pushes to +the repo it checked out needs `actions/checkout` with `persist-credentials: false`; otherwise +checkout's `GITHUB_TOKEN` extraheader is used for the push and no CI starts. `release.py --consumer` +moves its `@v…` pin with the other devkit pins. + ## devkit-ci (not reusable) This repo's own CI: installs Nix, creates or verifies `flake.lock`, runs `nix flake check`, then diff --git a/flake.nix b/flake.nix index 671c8ef..a6c6ce3 100644 --- a/flake.nix +++ b/flake.nix @@ -47,6 +47,7 @@ deps-stack = "scripts/deps-stack.sh"; deps-merge = "scripts/deps-merge.sh"; ruleset-sync = "scripts/ruleset-sync.sh"; + merge-key-check = "scripts/merge_key_check.py"; api-docs-push = "scripts/api-docs-push.sh"; gha-runner = "scripts/gha-runner.sh"; setup-runners = "scripts/setup-runners.sh"; diff --git a/justfile b/justfile index d8f87d6..bca1ffd 100644 --- a/justfile +++ b/justfile @@ -21,6 +21,7 @@ quality: actionlint bash tests/self-tests.sh scripts/agents-check.sh + python3 scripts/merge_key_check.py . python3 scripts/docs_lint.py python3 scripts/skills_vendor.py check --lock plugins/marola-devkit/skills/skills.lock python3 scripts/release.py --check diff --git a/scripts/merge_key_check.py b/scripts/merge_key_check.py new file mode 100755 index 0000000..16037e8 --- /dev/null +++ b/scripts/merge_key_check.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""merge-key-check: MERGE_BOT_KEY only where MIP-0086 §5.6 allows it. + +merge-key-check [ROOT] # ROOT/.github/workflows against ROOT/.github/merge-key-allowlist +merge-key-check --self-test +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tempfile +from pathlib import Path + +import yaml + +KEY = "MERGE_BOT_KEY" +ALLOWLIST = ".github/merge-key-allowlist" +USES_OK = re.compile( + r"^(actions/checkout|actions/create-github-app-token|actions/download-artifact" + r"|marola-dev/marola-devkit/\.github/actions/bot-token|\./\.github/actions/bot-token)(@|$)" +) +LOCAL_WF = re.compile(r"^\./\.github/workflows/[^/@]+$") +# Secret names are case-insensitive when referenced, so every presence test is too. +HAS_KEY = re.compile(KEY, re.I) +SHELL_OK = re.compile(r"^(bash|sh)(\s|$)") +# Whatever fetches or builds third-party code must not share a job with the key. +RUN_BAD = re.compile(r"(? set[str]: + f = root / ALLOWLIST + if not f.is_file(): + return set() + lines = (ln.strip() for ln in f.read_text().splitlines()) + return {ln for ln in lines if ln and not ln.startswith("#")} + + +def default_shell(rel: str, where: str, node: dict) -> list[str]: + sh = ((node.get("defaults") or {}).get("run") or {}).get("shell") + if sh and not SHELL_OK.match(sh): + return [f"{rel}: {where}: defaults.run.shell `{sh}` runs beside {KEY}"] + return [] + + +def problems(root: Path) -> list[str]: + allow = allowed(root) + out = [ + f"{ALLOWLIST}: {rel} does not exist" for rel in sorted(allow) if not (root / rel).is_file() + ] + for wf in sorted((root / ".github" / "workflows").glob("*.y*ml")): + rel = wf.relative_to(root).as_posix() + text = wf.read_text() + doc = yaml.safe_load(text) or {} + keyed = bool(HAS_KEY.search(text)) and rel in allow + if HAS_KEY.search(text) and not keyed: + out.append(f"{rel}: names {KEY} but is not in {ALLOWLIST}") + if keyed: + if HAS_KEY.search(yaml.safe_dump(doc.get("env") or {})): + out.append(f"{rel}: workflow-level env hands {KEY} to every job") + out += default_shell(rel, "workflow", doc) + for name, job in (doc.get("jobs") or {}).items(): + if (root / ALLOWLIST).is_file() and job.get("secrets") == "inherit": + out.append(f"{rel}: job {name}: `secrets: inherit` hands {KEY} to the callee") + if not keyed or not HAS_KEY.search(yaml.safe_dump(job)): + continue + out += default_shell(rel, f"job {name}", job) + uses = job.get("uses") + # A listed local callee is scanned itself, so the key does not leave the audited set. + if uses and not (LOCAL_WF.match(uses) and uses[2:] in allow): + out.append(f"{rel}: job {name}: reusable workflow call runs beside {KEY}") + elif uses: + # Under another name the callee never names the key, so it would go unscanned. + passed = job.get("secrets") + for k, v in (passed if isinstance(passed, dict) else {}).items(): + if HAS_KEY.search(str(v)) and k.lower() != KEY.lower(): + out.append( + f"{rel}: job {name}: passes {KEY} to {uses} under another name ({k})" + ) + for field in ("container", "services"): + if job.get(field): + out.append(f"{rel}: job {name}: `{field}` runs images beside {KEY}") + for i, step in enumerate(job.get("steps") or [], 1): + uses = step.get("uses") + if uses and not USES_OK.match(uses): + out.append(f"{rel}: job {name}, step {i}: `{uses}` runs beside {KEY}") + sh = step.get("shell") + if sh and not SHELL_OK.match(sh): + out.append(f"{rel}: job {name}, step {i}: shell `{sh}` runs beside {KEY}") + m = RUN_BAD.search(step.get("run") or "") + if m: + out.append(f"{rel}: job {name}, step {i}: `{m.group(1)}` runs beside {KEY}") + return out + + +def self_test() -> None: + def case(files: dict[str, str]) -> list[str]: + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / ".github" / "workflows").mkdir(parents=True) + for rel, text in files.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + return problems(root) + + wf = ".github/workflows/merge.yml" + clean = ( + "jobs:\n merge:\n runs-on: ubuntu-latest\n steps:\n" + " - uses: actions/checkout@v7\n" + " - id: bot\n uses: ./.github/actions/bot-token\n" + " with:\n private-key: ${{ secrets.MERGE_BOT_KEY }}\n" + " - run: scripts/merge.sh\n" + ) + keyless = "\n build:\n runs-on: ubuntu-latest\n steps:\n - run: nix develop --command just build\n" + assert case({".github/workflows/ci.yml": "jobs: {}\n"}) == [], "no_reference_is_clean" + got = case({wf: clean}) + assert len(got) == 1 and "not in" in got[0], f"unlisted_file_fails: {got}" + assert case({wf: clean, ALLOWLIST: f"# merge jobs\n{wf}\n"}) == [], "listed_clean_job_passes" + got = case({wf: clean + " - run: nix develop --command true\n", ALLOWLIST: wf}) + assert len(got) == 1 and "`nix`" in got[0], f"nix_beside_key_fails: {got}" + got = case({wf: clean + " - uses: some/action@v1\n", ALLOWLIST: wf}) + assert len(got) == 1 and "some/action" in got[0], f"third_party_action_fails: {got}" + got = case( + {wf: clean.replace("actions/checkout@v7", "actions/checkout-evil@v1"), ALLOWLIST: wf} + ) + assert len(got) == 1, f"lookalike_action_fails: {got}" + assert case({wf: clean + keyless, ALLOWLIST: wf}) == [], "nix_in_a_keyless_job_passes" + got = case({wf: "env:\n K: ${{ secrets.MERGE_BOT_KEY }}\n" + clean, ALLOWLIST: wf}) + assert any("workflow-level" in p for p in got), f"workflow_env_fails: {got}" + got = case({ALLOWLIST: ".github/workflows/gone.yml\n"}) + assert len(got) == 1 and "does not exist" in got[0], f"stale_allowlist_entry_fails: {got}" + lower = clean.replace(KEY, KEY.lower()) + got = case({wf: lower + " - run: npm ci\n"}) + assert len(got) == 1 and "not in" in got[0], f"lowercase_unlisted_fails: {got}" + got = case({wf: lower + " - run: nix develop\n", ALLOWLIST: wf}) + assert len(got) == 1 and "`nix`" in got[0], f"lowercase_job_checked: {got}" + call = "\n call:\n uses: o/r/.github/workflows/x.yml@v1\n secrets:\n k: ${{ secrets.MERGE_BOT_KEY }}\n" + got = case({wf: clean + call, ALLOWLIST: wf}) + assert len(got) == 1 and "reusable workflow" in got[0], f"reusable_call_fails: {got}" + local = ".github/workflows/bump.yml" + callee = "jobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.MERGE_BOT_KEY }}\n" + lcall = "\n call:\n uses: ./.github/workflows/bump.yml\n secrets:\n MERGE_BOT_KEY: ${{ secrets.MERGE_BOT_KEY }}\n" + got = case({wf: clean + lcall, local: callee, ALLOWLIST: f"{wf}\n{local}\n"}) + assert got == [], f"listed_local_reusable_passes: {got}" + got = case({wf: clean + lcall, local: callee, ALLOWLIST: wf}) + assert any("reusable workflow" in p for p in got), f"unlisted_local_reusable_fails: {got}" + got = case( + { + wf: clean + lcall, + local: callee + " - run: npm ci\n", + ALLOWLIST: f"{wf}\n{local}\n", + } + ) + assert len(got) == 1 and local in got[0] and "`npm`" in got[0], f"listed_callee_scanned: {got}" + renamed = lcall.replace(" MERGE_BOT_KEY:", " k:") + hidden = "jobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci && echo ${{ secrets.k }}\n" + got = case({wf: clean + renamed, local: hidden, ALLOWLIST: f"{wf}\n{local}\n"}) + assert any("under another name (k)" in p for p in got), f"renamed_key_fails: {got}" + inherit = "\n call:\n uses: o/r/.github/workflows/x.yml@v1\n secrets: inherit\n" + got = case({wf: clean + inherit, ALLOWLIST: wf}) + assert any("inherit" in p for p in got), f"secrets_inherit_fails: {got}" + got = case( + {wf: clean.replace(" steps:", " container: node:20\n steps:"), ALLOWLIST: wf} + ) + assert len(got) == 1 and "container" in got[0], f"container_fails: {got}" + got = case( + { + wf: clean.replace( + " steps:", " services:\n db:\n image: x\n steps:" + ), + ALLOWLIST: wf, + } + ) + assert len(got) == 1 and "services" in got[0], f"services_fails: {got}" + got = case({wf: clean + " shell: nix develop --command bash {0}\n", ALLOWLIST: wf}) + assert len(got) == 1 and "shell" in got[0], f"custom_shell_fails: {got}" + assert case({wf: clean + " shell: bash -e {0}\n", ALLOWLIST: wf}) == [], ( + "bash_shell_passes" + ) + got = case({wf: "env:\n K: ${{ secrets.merge_bot_key }}\n" + clean, ALLOWLIST: wf}) + assert any("workflow-level" in p for p in got), f"lowercase_workflow_env_fails: {got}" + nixsh = "nix develop --command bash {0}" + got = case({wf: f"defaults:\n run:\n shell: {nixsh}\n" + clean, ALLOWLIST: wf}) + assert any("defaults" in p for p in got), f"workflow_default_shell_fails: {got}" + got = case( + { + wf: clean.replace( + " steps:", f" defaults:\n run:\n shell: {nixsh}\n steps:" + ), + ALLOWLIST: wf, + } + ) + assert any("defaults" in p for p in got), f"job_default_shell_fails: {got}" + other = ".github/workflows/other.yml" + plain = "jobs:\n c:\n uses: o/r/.github/workflows/x.yml@v1\n secrets: inherit\n" + got = case({wf: clean, other: plain, ALLOWLIST: wf}) + assert len(got) == 1 and other in got[0] and "inherit" in got[0], ( + f"unlisted_inherit_fails: {got}" + ) + assert case({other: plain}) == [], "inherit_without_allowlist_passes" + assert not RUN_BAD.search("set -euo pipefail\nnixpkgs=1 scripts/x.sh /nix/store/y"), ( + "no_false_positive" + ) + print("merge-key-check self-test: ok") + + +def main() -> int: + ap = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + ap.add_argument("root", nargs="?", default=".") + ap.add_argument("--self-test", action="store_true") + a = ap.parse_args() + if a.self_test: + self_test() + return 0 + found = problems(Path(a.root)) + for p in found: + print(f"merge-key-check: {p}", file=sys.stderr) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release.py b/scripts/release.py index 48d7746..d7ebd2e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -27,7 +27,7 @@ # A pin is a v-tag in one of these contexts; actions/checkout@v7 and history in CHANGELOG are not. # \s* because some repos spread settings.json's marketplace entry over lines (#65). PIN = re.compile( - rf"(marola-devkit/v|marola-devkit/\.github/workflows/[\w.-]+@v|devkit-ref: v" + rf"(marola-devkit/v|marola-devkit/\.github/(?:workflows|actions)/[\w.-]+@v|devkit-ref: v" rf'|marola-devkit",\s*"ref": "v|\*\*Status:\*\* this is v)({SEMVER})()' ) PIN_GLOBS = ["README.md", "docs/**/*.md", ".github/workflows/*.yml"] @@ -180,14 +180,18 @@ def self_test(): ), ".github/workflows/notify-umbrella.yml": "# uses: marola-dev/marola-devkit/.github/workflows/n.yml@v0.5.1\n", "CHANGELOG.md": "# Changelog\n\nIntro.\n\n## v0.5.1 — 2026-10-07\n\n- old\n", + ".github/workflows/ci.yml": " - uses: marola-dev/marola-devkit/.github/actions/bot-token@v0.5.1\n", } for rel, text in files.items(): (root / rel).parent.mkdir(parents=True, exist_ok=True) (root / rel).write_text(text) - assert len(found(root)) == 8, found(root) + assert len(found(root)) == 9, found(root) assert any("0.4.0" in m for m in mismatches(root, "0.5.1")), "a stale pin went unnoticed" bump(root, "0.6.0") assert not mismatches(root, "0.6.0"), mismatches(root, "0.6.0") + assert "bot-token@v0.6.0" in (root / ".github/workflows/ci.yml").read_text(), ( + "an action pin did not move" + ) assert "actions/checkout@v7.0.0" in (root / "README.md").read_text(), ( "a third-party pin moved" ) diff --git a/tests/self-tests.sh b/tests/self-tests.sh index 1cc3e20..7725cc1 100755 --- a/tests/self-tests.sh +++ b/tests/self-tests.sh @@ -15,7 +15,7 @@ sh_tests=( ) py_tests=( scripts/gemini_review.py scripts/pr_label_nlp.py scripts/workflow_runners.py scripts/docs_lint.py - scripts/skills_vendor.py scripts/release.py scripts/wiring.py + scripts/skills_vendor.py scripts/release.py scripts/wiring.py scripts/merge_key_check.py scripts/lib/req_merge.py scripts/lib/uses_merge.py scripts/lib/mip_index_merge.py scripts/lib/tasks_issues.py plugins/marola-devkit/skills/voice-note-ingest/scripts/transcribe.py )