Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/actions/bot-token/action.yml
Original file line number Diff line number Diff line change
@@ -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: "<slug>[bot]", value: "${{ steps.who.outputs.name }}" }
user-email: { description: "<user-id>+<slug>[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"
106 changes: 106 additions & 0 deletions .github/workflows/bot-token-test.yml
Original file line number Diff line number Diff line change
@@ -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 "<code> <message>"; 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]" ]
13 changes: 8 additions & 5 deletions .github/workflows/gemini-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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

Expand Down
35 changes: 29 additions & 6 deletions .github/workflows/notify-umbrella.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
# notify:
# uses: marola-dev/marola-devkit/.github/workflows/[email protected]
# 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:
Expand All @@ -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: {}
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>`, 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 <[email protected]>`.

## Phase discipline (hard rule)
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N> --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
Expand All @@ -233,14 +233,16 @@ jobs:
gemini:
if: github.event.requested_team.slug == 'gemini'
uses: marola-dev/marola-devkit/.github/workflows/[email protected]
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).
Expand Down
7 changes: 4 additions & 3 deletions docs/3-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
3 changes: 2 additions & 1 deletion docs/4-reference_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading