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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,13 @@ token minted by the broker under `bot/` (a Cloudflare Worker, deployed by
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.
opens or refreshes the bot PR, merges it (queued behind required checks when
the default branch has any, immediately otherwise), and revokes the token.
Minting happens after the build step on purpose: no step that compiles the
tree holds a write token.
tree holds a write token. The root `action.yml` is the adopter surface: it
runs `action/bot-token.sh` and `land.sh`, the same two scripts these
workflows run, plus `prepare.sh`, `gate.sh`, `comment.sh`, and `regen.sh`
around them; `docs/ci.md` is the adopter-facing description.

All third-party actions are pinned to a full commit SHA (never a mutable
tag), every job declares explicit least-privilege `permissions:`, every
Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ CARGO := cargo
SOOTHFAST ?= $(CARGO) run -p cargo-soothfast --
BASE ?= origin/master

# rustdoc JSON is nightly-only and its format changes frequently
export SOOTHFAST_RUSTDOC_TOOLCHAIN ?= nightly-2026-08-07
# rustdoc JSON is nightly-only and its format changes frequently. The pin
# lives in action.yml (the rustdoc-toolchain input default) so adopters and
# this repo bump it in one place.
export SOOTHFAST_RUSTDOC_TOOLCHAIN ?= $(shell sed -n 's/^ *default: \(nightly-[0-9-]*\)$$/\1/p' action.yml)

# Self-measuring bench crates; runs merge into the shared "self" baseline.
BENCH_CRATES := soothfast-registry soothfast-measure soothfast-docs \
Expand Down
47 changes: 27 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,34 +139,41 @@ Numbers in prose become gated facts.

## In CI

A composite action installs the CLI on GitHub Actions, pinned to the
`soothfast` version in your `Cargo.lock` and cached across runs:

```yaml ignore
- uses: Verdenroz/soothfast@<tag-or-sha>
- run: cargo soothfast gate -p mylib --against-ref origin/master
```

`soothfast-measure` builds into your bench binary from the lock, so an
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`:
One step. On pull requests it gates every package with a soothfast bench
target against the base branch and comments the result. On pushes to the
default branch it regenerates `CHANGELOG.md` and lands it as a pull request
authored by soothfast-bot that merges itself once your checks pass.

```yaml ignore
jobs:
gate:
soothfast:
runs-on: ubuntu-latest
environment: soothfast-bot
permissions:
contents: read
pull-requests: write
uses: Verdenroz/soothfast/.github/workflows/soothfast-gate.yml@<tag-or-sha>
with:
package: mylib
id-token: write
concurrency:
group: soothfast-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Verdenroz/soothfast@<tag-or-sha>
```

Two things to set up once: install the
[Soothfast Bot](https://github.com/apps/soothfast-bot) on the repository, and
create an environment named `soothfast-bot`. No secrets. The step trades the
job's OIDC identity for a one-hour bot token scoped to your repository, and
only a job in that environment on your default branch can do so. Everything
the action does, every input, and the reusable gate workflow are in
[docs/ci.md](docs/ci.md).

The CLI it installs is pinned to the `soothfast` version in your `Cargo.lock`
and cached across runs. `soothfast-measure` builds into your bench binary
from the lock, so an unpinned CLI silently outruns it.

## Dogfood

CI runs soothfast on soothfast. Seven crates each carry a bench target
Expand Down
148 changes: 140 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
name: Setup cargo-soothfast
name: soothfast
description: >-
Install the cargo-soothfast CLI, pinned to the soothfast version locked in
the caller's Cargo.lock, and cache the binary across workflow runs.
soothfast-measure builds into the caller's bench binary from that lock, so
an unpinned CLI silently outruns it.
Gate performance on pull requests and land regenerated docs as
soothfast-bot on the default branch. Installs cargo-soothfast pinned to the
soothfast version locked in the caller's Cargo.lock and caches it across
runs. soothfast-measure builds into the caller's bench binary from that
lock, so an unpinned CLI silently outruns it.
branding:
icon: activity
color: purple

inputs:
version:
Expand All @@ -16,6 +20,59 @@ inputs:
description: Path to the Cargo.lock holding the soothfast version.
required: false
default: Cargo.lock
binary:
description: Prebuilt cargo-soothfast to run instead of installing one.
required: false
default: ""
packages:
description: >-
Space-separated packages to gate and measure. Empty means every package
with a bench target named soothfast.
required: false
default: ""
gate:
description: Run the gate on pull requests.
required: false
default: "true"
changelog:
description: Regenerate CHANGELOG.md on pushes to the default branch.
required: false
default: "true"
spec:
description: >-
Space-separated packages whose generate-mode specs are regenerated on
pushes to the default branch.
required: false
default: ""
baseline:
description: Baseline name the regeneration measures into.
required: false
default: base
rustdoc-toolchain:
description: >-
Toolchain that produces rustdoc JSON for the changelog. The default is
the nightly the released CLI was tested against; the Makefile reads it
from here.
required: false
default: nightly-2026-08-07
token:
description: Token for the gate comment and repository lookups.
required: false
default: ${{ github.token }}
bot-token:
description: >-
A soothfast-bot installation token you minted yourself. Skips the
broker.
required: false
default: ""
bot-slug:
description: App slug that `bot-token` belongs to.
required: false
default: soothfast-bot
broker:
description: Token broker URL. Empty uses the default in action/bot-token.sh.
required: false
default: ""

outputs:
version:
Expand All @@ -28,13 +85,19 @@ outputs:
runs:
using: composite
steps:
- name: Resolve cargo-soothfast version
- name: Resolve cargo-soothfast
id: resolve
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_LOCKFILE: ${{ inputs.lockfile }}
INPUT_BINARY: ${{ inputs.binary }}
run: |
if [ -n "$INPUT_BINARY" ]; then
echo "bin=$INPUT_BINARY" >> "$GITHUB_OUTPUT"
echo "install=false" >> "$GITHUB_OUTPUT"
exit 0
fi
version="$INPUT_VERSION"
if [ -z "$version" ]; then
version=$(grep -A1 '^name = "soothfast"$' "$INPUT_LOCKFILE" | grep '^version' | cut -d'"' -f2)
Expand All @@ -45,19 +108,21 @@ runs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "bin=${CARGO_HOME:-$HOME/.cargo}/bin/cargo-soothfast" >> "$GITHUB_OUTPUT"
echo "install=true" >> "$GITHUB_OUTPUT"
- name: Restore cargo-soothfast
if: steps.resolve.outputs.install == 'true'
id: cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.resolve.outputs.bin }}
key: cargo-soothfast-${{ runner.os }}-${{ runner.arch }}-${{ steps.resolve.outputs.version }}
- name: Install cargo-binstall
if: steps.cache.outputs.cache-hit != 'true'
if: steps.resolve.outputs.install == 'true' && steps.cache.outputs.cache-hit != 'true'
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
with:
tool: cargo-binstall
- name: Install cargo-soothfast
if: steps.cache.outputs.cache-hit != 'true'
if: steps.resolve.outputs.install == 'true' && steps.cache.outputs.cache-hit != 'true'
shell: bash
env:
VERSION: ${{ steps.resolve.outputs.version }}
Expand All @@ -73,3 +138,70 @@ runs:
echo "::error::$BIN is missing or not executable after setup"
exit 1
fi

- name: Prepare
id: prepare
shell: bash
env:
GATE: ${{ inputs.gate }}
REGEN: ${{ inputs.changelog == 'true' || inputs.spec != '' }}
PACKAGES: ${{ inputs.packages }}
RUSTDOC_TOOLCHAIN: ${{ inputs.rustdoc-toolchain }}
GH_TOKEN: ${{ inputs.token }}
run: "$GITHUB_ACTION_PATH/action/prepare.sh"

- name: Gate against the base branch
if: github.event_name == 'pull_request' && inputs.gate == 'true'
id: gate
shell: bash
env:
CLI: ${{ steps.resolve.outputs.bin }}
PACKAGES: ${{ steps.prepare.outputs.packages }}
BASE_REF: ${{ github.base_ref }}
GH_TOKEN: ${{ inputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: "$GITHUB_ACTION_PATH/action/gate.sh"
- name: Upload triage artifacts
if: steps.gate.outputs.failed == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: soothfast-triage
path: .soothfast/triage/
if-no-files-found: ignore
- name: Fail on a regression
if: steps.gate.outputs.failed == 'true'
shell: bash
run: |
echo "::error::soothfast gate failed; see the pull request comment and the soothfast-triage artifact"
exit 1

- name: Regenerate derived files
if: github.event_name != 'pull_request' && steps.prepare.outputs.on_default_branch == 'true'
id: regen
shell: bash
env:
CLI: ${{ steps.resolve.outputs.bin }}
PACKAGES: ${{ steps.prepare.outputs.packages }}
BASELINE: ${{ inputs.baseline }}
CHANGELOG: ${{ inputs.changelog }}
SPEC: ${{ inputs.spec }}
run: "$GITHUB_ACTION_PATH/action/regen.sh"
# Minted after everything that compiles the tree has run.
- name: Mint a soothfast-bot token
if: steps.regen.outputs.changed == 'true' && inputs.bot-token == ''
id: bot
shell: bash
env:
BROKER: ${{ inputs.broker }}
run: "$GITHUB_ACTION_PATH/action/bot-token.sh"
- name: Land the regenerated files
if: steps.regen.outputs.changed == 'true'
shell: bash
env:
TOKEN: ${{ inputs.bot-token || steps.bot.outputs.token }}
APP_SLUG: ${{ steps.bot.outputs.app_slug || inputs.bot-slug }}
BRANCH: bot/soothfast-update
TITLE: "chore: regenerate soothfast outputs"
BODY: Automated regeneration by soothfast.
PATHS: ${{ steps.regen.outputs.paths }}
run: "$GITHUB_ACTION_PATH/action/land.sh"
16 changes: 16 additions & 0 deletions action/comment.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Create or update this action's one comment on a pull request. A marker
# line identifies it: github.token's author is shared with every other
# action in the repository, so "edit the last comment by me" would hit theirs.
# Inputs: GH_TOKEN PR_NUMBER MARKER BODY_FILE.
set -euo pipefail

body="${RUNNER_TEMP:-/tmp}/soothfast-comment.md"
{ echo "$MARKER"; cat "$BODY_FILE"; } >"$body"
id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
--jq "[.[] | select(.body | startswith(\"$MARKER\")) | .id][0] // empty")
if [ -n "$id" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${id}" -F "body=@${body}" >/dev/null
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F "body=@${body}" >/dev/null
fi
28 changes: 28 additions & 0 deletions action/gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Gate every package against the pull request's base branch and post the
# tail of each package's output as one PR comment. Never exits non-zero on a
# regression: the caller reads the `failed` output so the comment and triage
# upload still happen first.
# Inputs: CLI PACKAGES BASE_REF GH_TOKEN PR_NUMBER. Output: failed (true|false).
set -euo pipefail

read -ra pkgs <<<"$PACKAGES"
failed=false
out_dir="${RUNNER_TEMP:-/tmp}/soothfast-gate"
mkdir -p "$out_dir"
{
echo '## soothfast gate'
for pkg in "${pkgs[@]}"; do
out="${out_dir}/${pkg}.txt"
"$CLI" gate -p "$pkg" --against-ref "origin/${BASE_REF}" 2>&1 | tee "$out" >&2 || failed=true
echo "### ${pkg}"
echo '```'
tail -n 40 "$out"
echo '```'
done
} >"${out_dir}/comment.md"

MARKER='<!-- soothfast-gate -->' BODY_FILE="${out_dir}/comment.md" "$(dirname "$0")/comment.sh" ||
echo "::warning::could not comment on the pull request (read-only token on a fork?)"

echo "failed=$failed" >>"$GITHUB_OUTPUT"
47 changes: 47 additions & 0 deletions action/prepare.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Work out what this run will do and fetch what it needs: the default-branch
# check, the package list, full history for merge-base and tag lookups, the
# callgrind fallback, and the rustdoc toolchain when anything regenerates.
# Inputs: GATE REGEN (true|false) PACKAGES RUSTDOC_TOOLCHAIN GH_TOKEN.
# Outputs: on_default_branch, packages.
set -euo pipefail

default=$(gh api "repos/${GITHUB_REPOSITORY}" --jq .default_branch)
on_default=false
[ "${GITHUB_REF:-}" = "refs/heads/${default}" ] && on_default=true
echo "on_default_branch=$on_default" >>"$GITHUB_OUTPUT"

gating=false
[ "${GITHUB_EVENT_NAME:-}" = pull_request ] && [ "$GATE" = true ] && gating=true
regenerating=false
[ "${GITHUB_EVENT_NAME:-}" != pull_request ] && [ "$on_default" = true ] && [ "$REGEN" = true ] && regenerating=true
if [ "$gating" = false ] && [ "$regenerating" = false ]; then
echo "packages=" >>"$GITHUB_OUTPUT"
exit 0
fi

if [ -z "$PACKAGES" ]; then
PACKAGES=$(cargo metadata --format-version 1 --no-deps |
jq -r '[.packages[] | select(any(.targets[]; .kind == ["bench"] and .name == "soothfast")) | .name] | join(" ")')
fi
if [ -z "$PACKAGES" ]; then
echo "::error::no package has a bench target named soothfast; pass the packages input"
exit 1
fi
echo "packages=$PACKAGES" >>"$GITHUB_OUTPUT"

if [ "$(git rev-parse --is-shallow-repository)" = true ]; then
git fetch --quiet --unshallow --tags
fi
if [ "$gating" = true ] && [ -n "${GITHUB_BASE_REF:-}" ]; then
git fetch --quiet origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}"
fi

if [ "$(uname -s)" = Linux ] && ! command -v valgrind >/dev/null && command -v apt-get >/dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq valgrind
fi

if [ "$regenerating" = true ]; then
rustup toolchain install "$RUSTDOC_TOOLCHAIN" --profile minimal
echo "SOOTHFAST_RUSTDOC_TOOLCHAIN=$RUSTDOC_TOOLCHAIN" >>"$GITHUB_ENV"
fi
Loading
Loading