diff --git a/.github/consumers.txt b/.github/consumers.txt index 6f9a0e8..9f1eafa 100644 --- a/.github/consumers.txt +++ b/.github/consumers.txt @@ -1,6 +1,6 @@ # Repos that pin the devkit; bump-consumers.yml opens one PR in each after a release. Only -# marola-dev repos: the org's fine-grained PAT cannot reach h0ffmann/ww3-gpu, whose two pins in -# .github/workflows/skills.yml are bumped by hand. +# marola-dev repos: marola-merge-bot, an org App, cannot be installed on h0ffmann/ww3-gpu, whose two +# pins in .github/workflows/skills.yml are bumped by hand. marola-dev/marola marola-dev/marola-app marola-dev/marola-site diff --git a/.github/merge-key-allowlist b/.github/merge-key-allowlist new file mode 100644 index 0000000..74e9e5b --- /dev/null +++ b/.github/merge-key-allowlist @@ -0,0 +1,3 @@ +# Workflows whose jobs may name MERGE_BOT_KEY (MIP-0086 §5.6; scripts/merge_key_check.py). +.github/workflows/bump-consumers.yml +.github/workflows/release.yml diff --git a/.github/workflows/bump-consumers.yml b/.github/workflows/bump-consumers.yml index 57f1661..74b5ca6 100644 --- a/.github/workflows/bump-consumers.yml +++ b/.github/workflows/bump-consumers.yml @@ -1,10 +1,8 @@ # One pin-bump PR per consumer after a release (scripts/bump-consumers.sh). release.yml calls it -# once the release is published; dispatch it by hand to retry or to bump to an older tag. -# It reads MAROLA_BUMP_PAT (Contents, Pull requests and Workflows write on every repo in -# .github/consumers.txt), an org secret only marola-devkit may read: a PR opened with GITHUB_TOKEN -# would start no CI in the consumer, and could not reach it anyway. Not MAROLA_CROSS_REPO_PAT: ten -# workflows across the org receive that one, and with Workflows write any of them could push a -# branch whose new workflow runs with that repo's secrets (marola-dev/marola-devkit#63). +# once the release is published; dispatch it by hand to retry or to bump to an older tag. prepare +# runs nix with no key; publish holds marola-merge-bot's key and runs only checked-in scripts +# (MIP-0086 §5.6, merge-key-check). The bot's token starts CI in each consumer, which a +# GITHUB_TOKEN-opened PR would not. name: bump consumers on: @@ -12,7 +10,7 @@ on: inputs: version: { type: string, required: true } secrets: - MAROLA_BUMP_PAT: { required: true } + MERGE_BOT_KEY: { required: true } workflow_dispatch: inputs: version: { description: "X.Y.Z, an existing tag without the v", required: true } @@ -24,7 +22,7 @@ concurrency: cancel-in-progress: false jobs: - bump: + prepare: runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -33,6 +31,41 @@ jobs: - uses: actions/checkout@v7 - uses: DeterminateSystems/nix-installer-action@v23 - env: - GH_TOKEN: ${{ secrets.MAROLA_BUMP_PAT }} VERSION: ${{ inputs.version }} - run: scripts/bump-consumers.sh "$VERSION" + run: scripts/bump-consumers.sh prepare "$VERSION" "$RUNNER_TEMP/bump" + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: devkit-bump + path: ${{ runner.temp }}/bump/ + retention-days: 1 + + publish: + needs: prepare + # One consumer failing in prepare must not stop the others' PRs; the run stays red. + if: ${{ !cancelled() && needs.prepare.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: devkit-bump + path: ${{ runner.temp }}/bump + - id: repos + run: echo "list=$(scripts/bump-consumers.sh names)" >>"$GITHUB_OUTPUT" + - id: bot + uses: ./.github/actions/bot-token + with: + app-id: ${{ vars.MERGE_BOT_APP_ID }} + private-key: ${{ secrets.MERGE_BOT_KEY }} + repositories: ${{ steps.repos.outputs.list }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + - env: + GH_TOKEN: ${{ steps.bot.outputs.token }} + VERSION: ${{ inputs.version }} + run: scripts/bump-consumers.sh publish "$VERSION" "$RUNNER_TEMP/bump" diff --git a/.github/workflows/gemini.yml b/.github/workflows/gemini.yml index fbb6bfc..08bd431 100644 --- a/.github/workflows/gemini.yml +++ b/.github/workflows/gemini.yml @@ -11,7 +11,9 @@ jobs: gemini: if: github.event.requested_team.slug == 'gemini' uses: ./.github/workflows/gemini-review.yml - secrets: inherit + secrets: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + PR_BOT_KEY: ${{ secrets.PR_BOT_KEY }} with: # The base commit under pull_request_target: a fork's head is never what runs. devkit-ref: ${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b07ca53..61e0fa9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,4 +31,4 @@ jobs: with: version: ${{ github.ref_name }} secrets: - MAROLA_BUMP_PAT: ${{ secrets.MAROLA_BUMP_PAT }} + MERGE_BOT_KEY: ${{ secrets.MERGE_BOT_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 280bbdf..10ef48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Each release moves `plugin.json`'s `version`, the flake package version and ever together. A consumer adopts one by bumping its flake input, its workflow `@v…`/`devkit-ref` and its marketplace `ref` at once. +## v0.11.0 — 2026-10-14 + +- `bump-consumers` runs as `prepare` (nix, no key) and `publish` (marola-merge-bot, checked-in scripts + only), so the bump PRs and their commits come from `marola-merge-bot[bot]`. `release.yml` passes + `MERGE_BOT_KEY` and `MAROLA_BUMP_PAT` is no longer read: marola-devkit must hold `MERGE_BOT_APP_ID` + and `MERGE_BOT_KEY`. `gemini.yml` passes its secrets explicitly (#80). + ## v0.10.1 — 2026-10-14 - `skills-update` takes an optional secret `pr_bot_key` and opens its PR as `marola-pr-bot`; callers diff --git a/docs/3-development.md b/docs/3-development.md index c2fc26b..46f0b9a 100644 --- a/docs/3-development.md +++ b/docs/3-development.md @@ -67,12 +67,10 @@ Once the release is published, `release.yml` calls `bump-consumers.yml`, which o each repo of `.github/consumers.txt` on `chore/devkit-vX.Y.Z`: the flake input and its `flake.lock` node, every devkit workflow `@v…`, `devkit-ref` and docs-lint clone, and the marketplace `ref`; in a repo whose `REPOS.md` has the wiring markers (the umbrella), also that -block, regenerated by the new tag's `wiring` over shallow submodules. A person merges each. It uses the org secret `MAROLA_BUMP_PAT`, so the PRs are -authored by that token's owner: a fine-grained PAT owned by marola-dev with access to all its -repositories and Contents, Pull requests and Workflows write, whose secret only marola-devkit may -read. It is not `MAROLA_CROSS_REPO_PAT`, which ten workflows across the org receive: with Workflows -write, any of them could push a branch whose new workflow runs with that repo's secrets. h0ffmann/ww3-gpu is outside the org token's reach and not listed: bump -the `@v…` and `devkit-ref` in its `skills.yml` by hand. Dispatch +block, regenerated by the new tag's `wiring` over shallow submodules. A person merges each. A `prepare` job runs nix with no key and uploads one +patch per repo; a `publish` job holds the repo secret `MERGE_BOT_KEY` and runs only checked-in +scripts (`merge-key-check` enforces it), so the PRs are authored by `marola-merge-bot[bot]` and +start CI. h0ffmann/ww3-gpu is outside the App's reach and not listed: bump the `@v…` and `devkit-ref` in its `skills.yml` by hand. Dispatch `bump-consumers.yml` by hand to retry a version: it updates the open PRs instead of adding more. Adding a consumer is one line in `.github/consumers.txt`. @@ -120,8 +118,9 @@ Never install graphify any other way, pass `--backend`, or run its installers or ## Secrets and cost 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 +`bump-consumers.yml` reads the repo's `MERGE_BOT_KEY` and `MERGE_BOT_APP_ID`; +`MAROLA_CROSS_REPO_PAT` (the callers) remains until +MIP-0086 row 11 retires it. 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 75549c2..73ba3a6 100644 --- a/docs/4-reference_config.md +++ b/docs/4-reference_config.md @@ -20,7 +20,7 @@ has a default, so a repo that sets none of them gets the org's behaviour. | `MAROLA_RUNNER_PREFIX` | `setup-runners` | `marola` | The runner name prefix | | `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)) | +| `MERGE_BOT_KEY`, `MERGE_BOT_APP_ID` | `bump-consumers.yml` | — | Not environment variables: the repo secret and variable of the `marola-merge-bot` App, which opens the pin-bump PRs ([releases](3-development.md#releases)) | | `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` | diff --git a/scripts/bump-consumers.sh b/scripts/bump-consumers.sh index 583303e..41ce083 100755 --- a/scripts/bump-consumers.sh +++ b/scripts/bump-consumers.sh @@ -1,10 +1,15 @@ #!/usr/bin/env bash # bump-consumers: after a devkit release, one PR per repo in .github/consumers.txt on -# chore/devkit-vX.Y.Z, moving its pins (release.py --consumer), where it commits one the -# flake.lock's devkit node, and where it has one REPOS.md's wiring block (BUMP_WIRING overrides -# the new tag's `nix run …#wiring`). A re-run force-updates the branch and edits the open PR. GH_TOKEN must -# reach every repo; the PRs are authored by its owner. One repo failing doesn't stop the others. -# scripts/bump-consumers.sh X.Y.Z +# chore/devkit-vX.Y.Z, in two steps so the merge bot's key never shares a job with nix (MIP-0086 +# §5.6). prepare needs no token: it clones each repo, moves its pins (release.py --consumer), its +# flake.lock's devkit node and REPOS.md's wiring block (BUMP_WIRING overrides the new tag's +# `nix run …#wiring`), commits, and writes DIR/version and, per bumped repo, +# DIR/_.patch, .base and (with a flake.lock) .lock. publish needs +# GH_TOKEN on every repo: it applies each patch to a fresh clone as the git identity it runs under, +# force-pushes the branch and opens or edits the PR. One repo failing doesn't stop the others. +# scripts/bump-consumers.sh prepare X.Y.Z DIR +# scripts/bump-consumers.sh publish X.Y.Z DIR +# scripts/bump-consumers.sh names # the consumers as repo names, comma-separated (bot-token's input) # scripts/bump-consumers.sh --self-test set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -38,14 +43,15 @@ echo "\$*" >>"$t/wiring.log" sed "s/^sub at .*/sub at \$(git -C sub rev-parse HEAD)/" "\$1" >"\$1.new" mv "\$1.new" "\$1" EOF - chmod +x "$t/bin/gh" "$t/bin/wiring" + printf '#!%s\necho "nix $*" >>"%s/nix.log"\n' "$BASH" "$t" >"$t/bin/nix" + chmod +x "$t/bin/gh" "$t/bin/wiring" "$t/bin/nix" git init -q -b main "$t/sub" git -C "$t/sub" commit -q --allow-empty -m sub - fixture() { # fixture NAME MARKERS(0|1) + fixture() { # fixture NAME MARKERS(0|1) [PINNED_VERSION] local d="$t/src/$1" git init -q -b main "$d" mkdir -p "$d/docs/2-Building-marola" - echo 'inputs.marola-devkit.url = "github:marola-dev/marola-devkit/v0.0.1";' >"$d/flake.nix" + echo "inputs.marola-devkit.url = \"github:marola-dev/marola-devkit/v${3:-0.0.1}\";" >"$d/flake.nix" git -C "$d" submodule add -q "file://$t/sub" sub if [ "$2" = 1 ]; then printf '# Repos\n\nsub at stale\n\n' >"$d/docs/2-Building-marola/REPOS.md" @@ -56,12 +62,20 @@ EOF git -C "$d" commit -q -m fixture git clone -q --bare "$d" "$t/remote/a/$1" } - run() { # run REPO [ENV=VALUE...]: one bump against the fixtures, its exit status echoed + prep() { # prep REPO [ENV=VALUE...]: exit status echoed local repo="$1" rc=0; shift env PATH="$t/bin:$PATH" BUMP_CONSUMERS_URL="file://$t/remote" BUMP_WIRING=wiring "$@" \ - bash "$root/scripts/bump-consumers.sh" --one 9.9.9 "$repo" >/dev/null 2>"$t/err" || rc=$? + bash "$root/scripts/bump-consumers.sh" --one prepare 9.9.9 "$repo" "$t/out" >/dev/null 2>"$t/err" || rc=$? echo "$rc" } + pub() { + local repo="$1" rc=0; shift + env PATH="$t/bin:$PATH" BUMP_CONSUMERS_URL="file://$t/remote" GIT_AUTHOR_NAME=bot GIT_COMMITTER_NAME=bot "$@" \ + bash "$root/scripts/bump-consumers.sh" --one publish 9.9.9 "$repo" "$t/out" >/dev/null 2>>"$t/err" || rc=$? + echo "$rc" + } + run() { local rc; rc="$(prep "$@")"; [ "$rc" != 0 ] || rc="$(pub "$1")"; echo "$rc"; } + mkdir -p "$t/out" files() { git -C "$t/remote/$1" diff-tree --no-commit-id --name-only -r chore/devkit-v9.9.9 2>/dev/null | paste -sd' ' -; } echo "-- umbrella_bump_regenerates_wiring_block --" @@ -85,15 +99,41 @@ EOF ok "$(files a/broken)" "" "nothing pushed" ok "$(grep -c wiring "$t/err")" 1 "the failure names wiring" + echo "-- prepare_needs_no_token --" + fixture quiet 0 + rm -f "$t/gh.log" + ok "$(prep a/quiet)" 0 "prepare succeeds" + ok "$(cat "$t/gh.log" 2>/dev/null || echo never)" never "prepare never calls gh" + + echo "-- publish_runs_no_nix_or_wiring --" + rm -f "$t/wiring.log" "$t/nix.log" + ok "$(pub a/quiet)" 0 "publish succeeds" + ok "$(cat "$t/wiring.log" "$t/nix.log" 2>/dev/null || echo never)" never "publish runs neither" + ok "$(grep -c 'pr create' "$t/gh.log")" 1 "publish opens the PR" + ok "$(git -C "$t/remote/a/quiet" log -1 --format=%an chore/devkit-v9.9.9)" bot "the commit is the publisher's" + + echo "-- already_pinned_writes_no_patch --" + fixture pinned 0 9.9.9 + ok "$(prep a/pinned)" 0 "prepare succeeds" + ok "$(ls "$t/out" | grep -c '^a_pinned\.')" 0 "no patch for a repo already on the tag" + ok "$(pub a/pinned)" 0 "publish skips it" + [ "$fails" -eq 0 ] || { echo "bump-consumers: self-test FAILED ($fails)" >&2; exit 1; } echo "bump-consumers: self-test ok" >&2 exit 0 fi +usage() { echo "bump-consumers: usage: bump-consumers.sh prepare|publish X.Y.Z DIR | names" >&2; exit 1; } + +if [ "${1:-}" = names ]; then repos | sed 's#^marola-dev/##' | paste -sd, -; exit; fi + one="" -if [ "${1:-}" = --one ]; then one="$3"; shift; fi -version="${1:-}"; version="${version#v}" -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bump-consumers: usage: bump-consumers.sh X.Y.Z" >&2; exit 1; } +if [ "${1:-}" = --one ]; then mode="${2:-}" version="${3:-}" one="${4:-}" dir="${5:-}" +else mode="${1:-}" version="${2:-}" dir="${3:-}"; fi +version="${version#v}" +case "$mode" in prepare|publish) ;; *) usage ;; esac +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ -n "$dir" ] || usage +mkdir -p "$dir"; dir="$(cd "$dir" && pwd)" tag="v$version" branch="chore/devkit-v$version" work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT @@ -107,10 +147,10 @@ Opened by marola-devkit's \`bump-consumers.yml\`; this repo's own CI gates it. W MD } -bump() { - local repo="$1" dir="$work/${1//\//_}" base lock="" regen="" - git clone -q --depth 1 "${BUMP_CONSUMERS_URL:-https://github.com}/$repo" "$dir" - cd "$dir" +prepare_one() { + local repo="$1" slug="${1//\//_}" d="$work/${1//\//_}" base lock="" regen="" + git clone -q --depth 1 "${BUMP_CONSUMERS_URL:-https://github.com}/$repo" "$d" + cd "$d" base="$(git branch --show-current)" git switch -q -C "$branch" python3 "$root/scripts/release.py" --consumer . "$version" >/dev/null @@ -137,8 +177,27 @@ bump() { commit -qam "chore: marola-devkit $tag" \ -m "Tested: release.py --consumer moved the pins${lock:+, nix flake update marola-devkit the lock}${regen:+, wiring at $tag regenerated $repos_md}; this repo's CI gates the rest Co-Authored-By: Claude " + git format-patch -1 --binary --stdout >"$dir/$slug.patch" + printf '%s\n' "$base" >"$dir/$slug.base" + [ -z "$lock" ] || : >"$dir/$slug.lock" + echo "bump-consumers: $repo prepared" >&2 +} + +publish_one() { + local repo="$1" slug="${1//\//_}" d="$work/${1//\//_}" base lock="" pr + if [ ! -f "$dir/$slug.patch" ]; then + echo "bump-consumers: $repo has nothing to publish" >&2 + return 0 + fi + base="$(cat "$dir/$slug.base")" + [ ! -f "$dir/$slug.lock" ] || lock=1 + git clone -q --depth 1 --branch "$base" "${BUMP_CONSUMERS_URL:-https://github.com}/$repo" "$d" + cd "$d" + git switch -q -C "$branch" + git am -q --keep-cr "$dir/$slug.patch" + # The patch was committed in the keyless job; the PR's commit should name the bot that pushes it. + git commit -q --amend --no-edit --reset-author git push -q -f origin "$branch" - local pr pr="$(gh pr list -R "$repo" --head "$branch" --state open --json number -q '.[0].number // empty')" if [ -n "$pr" ]; then gh pr edit "$pr" -R "$repo" --body "$(body "$lock")" >/dev/null @@ -149,15 +208,19 @@ Co-Authored-By: Claude " } if [ -n "$one" ]; then - bump "$one" + "${mode}_one" "$one" exit fi -[ -n "${GH_TOKEN:-}" ] || { echo "bump-consumers: GH_TOKEN is empty; give marola-devkit access to the org secret MAROLA_BUMP_PAT (docs/3-development.md, Releases)" >&2; exit 1; } +if [ "$mode" = prepare ]; then + printf '%s\n' "$version" >"$dir/version" +else + [ -n "${GH_TOKEN:-}" ] || { echo "bump-consumers publish: GH_TOKEN is empty; it must be marola-merge-bot's token on every consumer (MIP-0086)" >&2; exit 1; } + gh auth setup-git +fi # Each repo in its own process: set -e does not apply inside a function called from `||`. -gh auth setup-git failed=() while read -r repo; do - "$0" --one "$version" "$repo" || { echo "::error::bump-consumers: $repo failed" >&2; failed+=("$repo"); } + "$0" --one "$mode" "$version" "$repo" "$dir" || { echo "::error::bump-consumers $mode: $repo failed" >&2; failed+=("$repo"); } done < <(repos) -[ "${#failed[@]}" -eq 0 ] || { echo "bump-consumers: failed: ${failed[*]}" >&2; exit 1; } +[ "${#failed[@]}" -eq 0 ] || { echo "bump-consumers $mode: failed: ${failed[*]}" >&2; exit 1; }