diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ff9c26..78a27ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,31 +131,22 @@ jobs: if: github.event_name == 'push' needs: [build-cli] runs-on: ubuntu-latest - permissions: {} # all repo access comes from the minted App token below + concurrency: + group: docs-regen + cancel-in-progress: true + environment: soothfast-bot + permissions: + contents: read + id-token: write # soothfast-bot token via the broker, see bot/ env: SOOTHFAST: ./bin/cargo-soothfast 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: - ref: ${{ github.ref_name }} - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable @@ -187,28 +178,18 @@ jobs: sed '/^\*\*Measured:\*\*/d' llms.txt > target/llms-churn/new.txt git show HEAD:llms.txt | sed '/^\*\*Measured:\*\*/d' > target/llms-churn/old.txt cmp -s target/llms-churn/old.txt target/llms-churn/new.txt && git checkout -- llms.txt || true - - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - id: cpr - with: - token: ${{ steps.app-token.outputs.token }} - add-paths: | - llms.txt - README.md - docs/** - commit-message: "docs: regenerate derived docs" - title: "docs: regenerate derived docs" - body: Automated regeneration of captured output and reference pages. - branch: bot/docs-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 + - name: Mint a soothfast-bot token + id: bot + run: action/bot-token.sh + - name: Land the regenerated docs 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/docs-update + TITLE: "docs: regenerate derived docs" + BODY: Automated regeneration of captured output and reference pages. + PATHS: llms.txt README.md docs + run: action/land.sh # Dogfood: gate each self-bench crate's perf against the PR's merge-base. # Keep this package list in sync with Makefile's BENCH_CRATES. @@ -229,11 +210,11 @@ jobs: ] permissions: contents: read + pull-requests: write # gate comment uses: ./.github/workflows/soothfast-gate.yml with: package: ${{ matrix.package }} - secrets: - CHANGELOG_APP_PRIVATE_KEY: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} + cli-artifact: cargo-soothfast-bin # One stable context for branch protection; the per-crate gate legs # change names whenever the crate list does. @@ -259,20 +240,13 @@ jobs: needs: [check, docs, build-cli] runs-on: ubuntu-latest permissions: - contents: read + contents: write # push the built site to gh-pages env: SOOTHFAST: ./bin/cargo-soothfast 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 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -295,5 +269,5 @@ jobs: - run: bin/cargo-soothfast docs build --baseline self - uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: - github_token: ${{ steps.app-token.outputs.token }} + github_token: ${{ github.token }} publish_dir: ./site diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56a8b78..52e1a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,13 +142,6 @@ jobs: run: | gh api -X POST "repos/${{ github.repository }}/git/refs" \ -f ref="refs/tags/$TAG" -f sha="${{ github.sha }}" - - 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 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -226,7 +219,7 @@ jobs: - name: Create GitHub Release env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} TAG: ${{ needs.resolve.outputs.tag }} USE_AUTO: ${{ steps.changelog.outputs.use_auto }} run: | diff --git a/.github/workflows/soothfast-gate.yml b/.github/workflows/soothfast-gate.yml index 5f88e82..fe1ad53 100644 --- a/.github/workflows/soothfast-gate.yml +++ b/.github/workflows/soothfast-gate.yml @@ -12,9 +12,17 @@ on: required: false type: string default: "" + cli-artifact: + description: >- + Artifact holding a prebuilt cargo-soothfast at bin/cargo-soothfast. + Empty installs the release matching the caller's Cargo.lock. + required: false + type: string + default: "" secrets: + # Unused. Declared so callers that still pass it keep validating. CHANGELOG_APP_PRIVATE_KEY: - required: true + required: false permissions: {} @@ -23,18 +31,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: write # gate comment steps: - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - - name: Mint a soothfast-bot token - id: app-token - if: github.event_name == 'pull_request' - 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-pull-requests: write - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -45,19 +46,22 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # callgrind fallback + triage artifacts for PMU-less runners - run: sudo apt-get update -qq && sudo apt-get install -y -qq valgrind - # Built once by ci.yml's build-cli job - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - if: inputs.cli-artifact != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cargo-soothfast-bin + name: ${{ inputs.cli-artifact }} path: bin - - run: chmod +x bin/cargo-soothfast + - if: inputs.cli-artifact != '' + run: chmod +x bin/cargo-soothfast + - if: inputs.cli-artifact == '' + uses: Verdenroz/soothfast@ead9d50c79606edcf80a438f8fac73229c01120c # v0.2.0 - name: Run gate id: gate run: | REF="${INPUTS_BASE_REF}" if [ -z "$REF" ]; then REF="origin/${GITHUB_BASE_REF:-master}"; fi - bin/cargo-soothfast \ - gate -p "${INPUTS_PACKAGE}" --against-ref "$REF" | tee gate-output.txt + if [ -x bin/cargo-soothfast ]; then set -- bin/cargo-soothfast; else set -- cargo soothfast; fi + "$@" gate -p "${INPUTS_PACKAGE}" --against-ref "$REF" | tee gate-output.txt env: INPUTS_BASE_REF: ${{ inputs.base-ref }} INPUTS_PACKAGE: ${{ inputs.package }} @@ -68,17 +72,27 @@ jobs: name: soothfast-triage path: .soothfast/triage/ if-no-files-found: ignore + # Fork PRs get a read-only token here; the comment is best effort. The + # marker finds our own comment: github.token's author is shared with + # every other action in the repo, so --edit-last would hit theirs. - name: PR comment with gate results if: always() && github.event_name == 'pull_request' continue-on-error: true env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | { + echo '' echo '## soothfast gate' echo '```' tail -n 60 gate-output.txt echo '```' } > comment.md - gh pr comment "${{ github.event.pull_request.number }}" \ - --body-file comment.md --edit-last --create-if-none + id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + --jq '[.[] | select(.body | startswith("")) | .id][0] // empty') + if [ -n "$id" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${id}" -F body=@comment.md >/dev/null + else + gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F body=@comment.md >/dev/null + fi diff --git a/.github/workflows/spec.yml b/.github/workflows/spec.yml index 66792da..5889ab8 100644 --- a/.github/workflows/spec.yml +++ b/.github/workflows/spec.yml @@ -21,29 +21,20 @@ jobs: generate: if: github.event_name == 'push' runs-on: ubuntu-latest - permissions: {} # all repo access comes from the minted App token below + concurrency: + group: spec + 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: - ref: ${{ github.ref_name }} - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable @@ -57,28 +48,18 @@ jobs: run: cargo run --release -p cargo-soothfast -- spec gen -p "$SPEC_PKG" # Direct pushes to master are blocked by the branch ruleset, so the bot # opens a PR and squash-merges it itself instead. - - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - id: cpr - with: - token: ${{ steps.app-token.outputs.token }} - add-paths: | - *.yaml - *.yml - *.json - commit-message: "chore: regenerate API specs" - title: "chore: regenerate API specs" - body: Automated spec regeneration. - branch: bot/spec-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 + - name: Mint a soothfast-bot token + id: bot + run: action/bot-token.sh + - name: Land the regenerated specs 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/spec-update + TITLE: "chore: regenerate API specs" + BODY: Automated spec regeneration. + PATHS: "*.yaml *.yml *.json" + run: action/land.sh # On a PR, prove the committed specs are current and that the API change is # backwards compatible with the merge-base. diff --git a/CLAUDE.md b/CLAUDE.md index 236247e..9f0bc29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,21 +296,24 @@ claim being enforced, not a bug in the check. a push. - `scorecard.yml` — OSSF Scorecard supply-chain analysis, published to the public Scorecard API and uploaded to code scanning as SARIF. -- `soothfast-gate.yml` — reusable workflow; runs `cargo soothfast gate` for a - given package against the PR's merge-base, uploads `.soothfast/triage/` on - failure, and posts/updates a PR comment with the gate output. +- `soothfast-gate.yml` — reusable workflow, callable from any repo; runs + `cargo soothfast gate` for a given package against the PR's merge-base, + uploads `.soothfast/triage/` on failure, and posts/updates a PR comment + with the gate output using `github.token`. `cli-artifact` names a + prebuilt CLI uploaded earlier in the run (what `ci.yml` does); without it + the root `action.yml` installs the release matching `Cargo.lock`. +- `bot.yml` / `bot-smoke.yml` — type-check, test, and deploy the + soothfast-bot token broker under `bot/` (a Cloudflare Worker holding the + App private key), and prove a deployment by minting, listing, and revoking + a token. See the "soothfast-bot" section below. - `changelog.yml` — on push to `master`, regenerates the living `CHANGELOG.md` and lands it through a bot-opened, auto-squash-merged PR on `bot/changelog-update`. It skips runs whose actor already ends in `[bot]`, which is what stops its own merge from retriggering it. Per-merge is affordable because the derived sections drop out when the API surface and the gate are quiet, so a run has nothing to say unless something merged. - Commits authenticate with a short-lived token from a repo-installed App - (`actions/create-github-app-token`, `CHANGELOG_APP_CLIENT_ID` / - `CHANGELOG_APP_PRIVATE_KEY`) rather than the default `GITHUB_TOKEN` — - GitHub gates every subsequent workflow run on a PR behind manual approval - once a `github-actions[bot]`-authored commit lands on it, and an - explicitly installed App doesn't trip that gate. + A `concurrency` group cancels older regenerations so only the newest tree + lands. - `spec.yml` — on push to `master`, regenerates `mode = "generate"` spec files and lands them via a bot-opened, self-merged PR (the branch ruleset blocks direct pushes to `master`), so nobody has to remember to; on PRs, @@ -320,6 +323,21 @@ claim being enforced, not a bug in the check. - `release.yml` — on `v*` tag push, runs checks + gate, then publishes all 10 workspace crates to crates.io in dependency order. +### soothfast-bot + +Every write a workflow makes on the bot's behalf (`changelog.yml`, `spec.yml`, +`ci.yml` docs-regen) is authored by the soothfast-bot GitHub App, but no +workflow holds the App's private key. The job runs `action/bot-token.sh`, +which trades the job's GitHub Actions OIDC token for a one-hour installation +token minted by the broker under `bot/` (a Cloudflare Worker, deployed by +`bot.yml`). The broker mints only for a job in the `soothfast-bot` +environment, on a `push`/`workflow_dispatch`/`schedule` event, on the +repository's default branch, for a repository the App is installed on, and +scopes the token to that repository. `action/land.sh` then commits, pushes, +opens or refreshes the bot PR, enables auto-merge, and revokes the token. +Minting happens after the build step on purpose: no step that compiles the +tree holds a write token. + All third-party actions are pinned to a full commit SHA (never a mutable tag), every job declares explicit least-privilege `permissions:`, every step starts with `step-security/harden-runner`, and checkouts that don't diff --git a/README.md b/README.md index 11e6a75..7209d92 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,21 @@ unpinned CLI silently outruns it. The `version` input overrides the pin; `lockfile` points at a `Cargo.lock` outside the working directory. Outputs are `version` and `cache-hit`. +The gate itself, with its PR comment and triage upload, is a reusable +workflow. Call it once per package from a `pull_request` job; it installs the +CLI the same way and comments on the PR with `github.token`: + +```yaml ignore +jobs: + gate: + permissions: + contents: read + pull-requests: write + uses: Verdenroz/soothfast/.github/workflows/soothfast-gate.yml@ + with: + package: mylib +``` + ## Dogfood CI runs soothfast on soothfast. Seven crates each carry a bench target