From bbd5dba4c4bbf9bcfd9a6eb628ae33aaf0db6e4f Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Sat, 5 Sep 2026 20:29:42 -0400 Subject: [PATCH 1/2] ci: land the changelog as soothfast-bot via the broker - changelog.yml is the first workflow off the App private key; the token comes from action/bot-token.sh after the build finishes, so no step that compiles the tree ever holds it. - land.sh replaces create-pull-request with plain git and gh so the same script serves this repo and the adopter action, and revokes the token on every exit path. - The concurrency group makes the newest regeneration the only one that lands, which is the fix #145 asks for. Closes #145 --- .github/workflows/changelog.yml | 61 ++++++++++++++------------------- action/land.sh | 41 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 36 deletions(-) create mode 100755 action/land.sh diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 7df2171..7621c7d 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -12,30 +12,23 @@ jobs: changelog: if: ${{ !endsWith(github.actor, '[bot]') }} runs-on: ubuntu-latest - permissions: {} # all repo access comes from the minted App token below + # Only the newest regeneration may land: an older one sees a stale tree. + # A cancel between mint and land leaves its one-hour token unrevoked. + concurrency: + group: changelog + cancel-in-progress: true + environment: soothfast-bot + permissions: + contents: read + id-token: write # soothfast-bot token via the broker, see bot/ steps: - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - - name: Mint a soothfast-bot token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.CHANGELOG_APP_CLIENT_ID }} - private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - - name: Get soothfast-bot user id - id: app-user - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - run: echo "id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # zizmor: ignore[artipacked] + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # surface diff + previous-tag lookup need history - ref: ${{ github.ref_name }} - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable @@ -59,22 +52,18 @@ jobs: # shellcheck disable=SC2086 # both are deliberate flag lists cargo run --release -p cargo-soothfast -- \ report changelog $PKGS --baseline self $AGAINST - - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - id: cpr - with: - token: ${{ steps.app-token.outputs.token }} - add-paths: CHANGELOG.md - commit-message: "docs: regenerate CHANGELOG.md" - title: "docs: regenerate CHANGELOG.md" - body: Automated CHANGELOG regeneration. - branch: bot/changelog-update - delete-branch: true - author: >- - ${{ steps.app-token.outputs.app-slug }}[bot] - <${{ steps.app-user.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com> - - name: Merge pull request - if: steps.cpr.outputs.pull-request-number + # Minted after the build so no step that compiles the tree holds it. + - name: Mint a soothfast-bot token + id: bot + env: + BROKER: https://soothfast-bot.verdenroz.workers.dev + run: action/bot-token.sh + - name: Land the regenerated changelog env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} - run: gh pr merge --auto --squash --delete-branch "$PR_NUMBER" + TOKEN: ${{ steps.bot.outputs.token }} + APP_SLUG: ${{ steps.bot.outputs.app_slug }} + BRANCH: bot/changelog-update + TITLE: "docs: regenerate CHANGELOG.md" + BODY: Automated CHANGELOG regeneration. + PATHS: CHANGELOG.md + run: action/land.sh diff --git a/action/land.sh b/action/land.sh new file mode 100755 index 0000000..e49f629 --- /dev/null +++ b/action/land.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Commit PATHS as APP_SLUG[bot], push them to BRANCH, open or refresh its +# pull request against the default branch, and merge it: auto-merge when the +# default branch carries rules, immediately otherwise. TOKEN is revoked on +# every exit path. +# Inputs: TOKEN APP_SLUG BRANCH TITLE BODY PATHS (space separated pathspecs). +set -euo pipefail + +: "${TOKEN:?}" "${APP_SLUG:?}" "${BRANCH:?}" "${TITLE:?}" "${PATHS:?}" +export GH_TOKEN="$TOKEN" +trap 'gh api -X DELETE /installation/token >/dev/null || true' EXIT + +read -ra paths <<<"$PATHS" +git add -- "${paths[@]}" +if git diff --cached --quiet; then + echo "land: nothing to commit" + exit 0 +fi + +bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) +git -c "user.name=${APP_SLUG}[bot]" \ + -c "user.email=${bot_id}+${APP_SLUG}[bot]@users.noreply.github.com" \ + commit -q -m "$TITLE" +git push --force "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}" \ + "HEAD:refs/heads/${BRANCH}" + +default=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name) +pr=$(gh pr list --head "$BRANCH" --base "$default" --state open --json number --jq '.[0].number // empty') +if [ -z "$pr" ]; then + gh pr create --head "$BRANCH" --base "$default" --title "$TITLE" --body "${BODY:-}" >/dev/null + pr=$(gh pr view "$BRANCH" --json number --jq .number) +else + gh pr edit "$pr" --title "$TITLE" --body "${BODY:-}" >/dev/null +fi + +rules=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${default}" --jq length) +if [ "$rules" -gt 0 ]; then + gh pr merge --auto --squash --delete-branch "$pr" +else + gh pr merge --squash --delete-branch "$pr" +fi From d6a7682680bd84f184d3488fca7e70289bac911f Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Sat, 5 Sep 2026 20:34:08 -0400 Subject: [PATCH 2/2] fix: choose land.sh merge mode by blocking rules - History-only rules leave the PR clean and --auto refuses it; only status-check, pull-request and merge-queue rules mean there is something to wait for. - The push token now travels through a credential helper, so a git error cannot print it, and HEAD is restored after the push for callers that run further steps. --- .github/workflows/changelog.yml | 2 -- action/land.sh | 24 ++++++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 7621c7d..046e0f8 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -55,8 +55,6 @@ jobs: # Minted after the build so no step that compiles the tree holds it. - name: Mint a soothfast-bot token id: bot - env: - BROKER: https://soothfast-bot.verdenroz.workers.dev run: action/bot-token.sh - name: Land the regenerated changelog env: diff --git a/action/land.sh b/action/land.sh index e49f629..68f2f7d 100755 --- a/action/land.sh +++ b/action/land.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Commit PATHS as APP_SLUG[bot], push them to BRANCH, open or refresh its # pull request against the default branch, and merge it: auto-merge when the -# default branch carries rules, immediately otherwise. TOKEN is revoked on -# every exit path. +# default branch has a rule that blocks merging, immediately otherwise. The +# working tree keeps the regenerated files but HEAD is left where it was. +# TOKEN is revoked on every exit path. # Inputs: TOKEN APP_SLUG BRANCH TITLE BODY PATHS (space separated pathspecs). set -euo pipefail @@ -18,23 +19,30 @@ if git diff --cached --quiet; then fi bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) +head=$(git rev-parse HEAD) git -c "user.name=${APP_SLUG}[bot]" \ -c "user.email=${bot_id}+${APP_SLUG}[bot]@users.noreply.github.com" \ commit -q -m "$TITLE" -git push --force "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}" \ - "HEAD:refs/heads/${BRANCH}" +# shellcheck disable=SC2016 # git expands $TOKEN when it runs the helper +git -c credential.helper= \ + -c credential.helper='!f() { echo "username=x-access-token"; echo "password=$TOKEN"; }; f' \ + push --force "https://github.com/${GITHUB_REPOSITORY}" "HEAD:refs/heads/${BRANCH}" +git reset -q --soft "$head" default=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name) pr=$(gh pr list --head "$BRANCH" --base "$default" --state open --json number --jq '.[0].number // empty') if [ -z "$pr" ]; then - gh pr create --head "$BRANCH" --base "$default" --title "$TITLE" --body "${BODY:-}" >/dev/null - pr=$(gh pr view "$BRANCH" --json number --jq .number) + url=$(gh pr create --head "$BRANCH" --base "$default" --title "$TITLE" --body "${BODY:-}") + pr=${url##*/} else gh pr edit "$pr" --title "$TITLE" --body "${BODY:-}" >/dev/null fi -rules=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${default}" --jq length) -if [ "$rules" -gt 0 ]; then +# History-only rules (deletion, linear history) leave the PR clean, and +# --auto refuses a PR with nothing to wait for. +blocking=$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${default}" \ + --jq '[.[] | select(.type == "required_status_checks" or .type == "pull_request" or .type == "merge_queue")] | length') +if [ "$blocking" -gt 0 ]; then gh pr merge --auto --squash --delete-branch "$pr" else gh pr merge --squash --delete-branch "$pr"