diff --git a/CLAUDE.md b/CLAUDE.md index 9f0bc29..87f4b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index 7ce8b1b..5580334 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/README.md b/README.md index 7209d92..64ddcdb 100644 --- a/README.md +++ b/README.md @@ -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@ -- 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@ - 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@ ``` +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 diff --git a/action.yml b/action.yml index cac15f4..f4c5def 100644 --- a/action.yml +++ b/action.yml @@ -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: @@ -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: @@ -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) @@ -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 }} @@ -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" diff --git a/action/comment.sh b/action/comment.sh new file mode 100755 index 0000000..1729ba4 --- /dev/null +++ b/action/comment.sh @@ -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 diff --git a/action/gate.sh b/action/gate.sh new file mode 100755 index 0000000..3237e2a --- /dev/null +++ b/action/gate.sh @@ -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='' 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" diff --git a/action/prepare.sh b/action/prepare.sh new file mode 100755 index 0000000..4a8afdd --- /dev/null +++ b/action/prepare.sh @@ -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 diff --git a/action/regen.sh b/action/regen.sh new file mode 100755 index 0000000..2dcc308 --- /dev/null +++ b/action/regen.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Regenerate derived files on the default branch: a fresh baseline for every +# package, then CHANGELOG.md against the latest tag and any generate-mode +# specs. +# Inputs: CLI PACKAGES BASELINE CHANGELOG (true|false) SPEC (package list). +# Outputs: paths (pathspecs for land.sh), changed (true when any of them +# differs from HEAD, so a no-op run never mints a token). +set -euo pipefail + +read -ra pkgs <<<"$PACKAGES" +paths=() + +if [ "$CHANGELOG" = true ]; then + for pkg in "${pkgs[@]}"; do + "$CLI" measure -p "$pkg" --save-baseline "$BASELINE" + done + args=() + for pkg in "${pkgs[@]}"; do args+=(-p "$pkg"); done + prev=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -n "$prev" ]; then args+=(--against-ref "$prev"); fi + "$CLI" report changelog "${args[@]}" --baseline "$BASELINE" + paths+=(CHANGELOG.md) +fi + +if [ -n "$SPEC" ]; then + read -ra specs <<<"$SPEC" + for pkg in "${specs[@]}"; do + "$CLI" spec gen -p "$pkg" + done + paths+=('*.yaml' '*.yml' '*.json') +fi + +changed=false +if [ "${#paths[@]}" -gt 0 ] && [ -n "$(git status --porcelain -- "${paths[@]}")" ]; then + changed=true +fi +{ + echo "paths=${paths[*]:-}" + echo "changed=$changed" +} >>"$GITHUB_OUTPUT" diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..03d0fa7 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,159 @@ +# CI + +soothfast in another repository is one step in the workflow you already +have. On a pull request it gates performance against the base branch and +comments the result. On a push to the default branch it regenerates derived +files and lands them as a pull request authored by soothfast-bot, which +merges itself once your checks pass. + +```yaml ignore +jobs: + soothfast: + runs-on: ubuntu-latest + environment: soothfast-bot + permissions: + contents: read + pull-requests: write + 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@ +``` + +## Setup + +Two one-time settings, no secrets: + +1. Install the [Soothfast Bot](https://github.com/apps/soothfast-bot) GitHub + App on the repository. It asks for Contents and Pull requests write and + Metadata read. +2. Create an environment named `soothfast-bot` (Settings, Environments) with + no deployment branch policy and no required reviewers. The job above runs + on pull requests too, and a branch policy would fail every one of them, + the bot's own included. The broker enforces the default-branch rule + itself. + +Because the job names an environment, every run of it, pull request or push, +appears under Environments and Deployments and on the pull request as +"deployed to soothfast-bot". If you would rather keep pull requests out of +the environment, split the step into two jobs with the same `uses:` line: +the gate job on `pull_request` without an environment and with `changelog: +false`, and a regeneration job on `push` with the environment and `gate: +false`. That split also lets you put a default-branch policy on the +environment. + +Two repository settings decide what happens to the bot's pull request: + +- If your default branch has a ruleset or branch protection with required + checks, turn on "Allow auto-merge" so the bot can queue its PR behind them. + Without rules the bot merges immediately. +- "Automatically delete head branches" removes `bot/soothfast-update` after + the merge. Without it the branch lingers until the next regeneration + force-pushes it. + +A ruleset that requires approving reviews applies to the bot too: its PR +waits for a human. + +## What the step does + +Every run installs `cargo-soothfast` pinned to the `soothfast` version in +your `Cargo.lock`, cached across runs. Pin the action to a release no newer +than that version: the scripts pass flags the CLI at the action's ref +understands. Runs that have nothing to do (a push to another branch, a pull +request with `gate: false`) stop there. Otherwise the checkout is unshallowed +so merge-base and tag lookups work. + +**On `pull_request`.** For each package (see `packages` below) it runs `cargo +soothfast gate -p PKG --against-ref origin/` and appends the output to +one comment on the pull request, updated in place on later pushes. The +comment shows the last forty lines per package. On a regression it uploads +`.soothfast/triage/` as the `soothfast-triage` artifact and fails the step. +The comment is posted with `github.token`, so on a pull request from a fork, +where that token is read-only, the comment is skipped with a warning and the +gate result still decides the step. A fork pull request never receives a bot +token. + +**On a push to the default branch.** It measures each package into the +`baseline` baseline, regenerates `CHANGELOG.md` against the latest tag (or +lists the initial surface when there is no tag), regenerates any packages +named in `spec`, and lands whatever changed as one pull request on +`bot/soothfast-update`. Pushes to other branches do nothing beyond the +install. + +The bot PR triggers your workflows like any other pull request, this step +included, which is how its required checks get satisfied. Jobs that skip on a +`[bot]` actor count as passed for required checks. If the bot PR is left +open, your ruleset gave auto-merge nothing to wait on (a pull request rule +with zero required approvals and no required checks); merge it by hand or +drop the rule. + +## Why the environment + +The step needs `id-token: write` to prove its identity to the broker. That +permission is also common on jobs that publish to crates.io, PyPI, or a cloud +provider over OIDC. Requiring the `soothfast-bot` environment means only a +job that opts in can obtain a bot token; a compromised action in one of those +other jobs cannot. The broker also refuses any event other than `push`, +`workflow_dispatch`, or `schedule`, any ref other than your default branch, +and any repository the App is not installed on, and it scopes the token it +mints to your repository for one hour. The token is revoked when the step +finishes. + +## Inputs + +| Input | Default | Meaning | +|---|---|---| +| `packages` | every package with a bench target named `soothfast` | Space-separated packages to gate and measure. The same list feeds `report changelog`; pass it explicitly when the crates whose API ships differ from the ones with benches. | +| `gate` | `true` | Run the gate on pull requests. | +| `changelog` | `true` | Regenerate `CHANGELOG.md` on default-branch pushes. | +| `spec` | none | Space-separated packages whose `mode = "generate"` specs to regenerate. | +| `baseline` | `base` | Baseline name the regeneration measures into. | +| `rustdoc-toolchain` | the nightly the release was tested with | Toolchain for rustdoc JSON. A floating `nightly` can change the JSON format under the API diff. | +| `version` | from `Cargo.lock` | `cargo-soothfast` version to install. | +| `lockfile` | `Cargo.lock` | Where to read the pinned version. | +| `binary` | none | A prebuilt `cargo-soothfast`; skips the install. | +| `token` | `github.token` | Token for the gate comment and repository lookups. | +| `bot-token` | none | Bring your own soothfast-bot installation token; skips the broker. | +| `bot-slug` | `soothfast-bot` | App slug `bot-token` belongs to, for the commit author. | +| `broker` | built in | Token broker URL. | + +Outputs are `version` and `cache-hit`. + +## Bringing your own token + +If you would rather not depend on the broker, install your own GitHub App +with Contents write, Pull requests write, and Metadata read, mint an +installation token in an earlier step (for example with +`actions/create-github-app-token`), and pass it as `bot-token` with the App's +slug as `bot-slug`. The `id-token: write` permission and the environment are +then unnecessary. + +## Self-hosted runners + +The scripts use `gh`, `jq`, `curl`, `git`, and `cargo`. On Linux the step +installs `valgrind` with `apt-get` when it is missing, for the callgrind +fallback on machines without performance counters. + +## The gate on its own + +The gate, with its comment and triage upload, is also a reusable workflow for +callers that want it as a separate job or a matrix over packages: + +```yaml ignore +jobs: + gate: + permissions: + contents: read + pull-requests: write + uses: Verdenroz/soothfast/.github/workflows/soothfast-gate.yml@ + with: + package: mylib +``` + +`cli-artifact` names an artifact holding a prebuilt CLI at +`bin/cargo-soothfast` from earlier in the same run; without it the workflow +installs the release matching your `Cargo.lock`. diff --git a/soothfast.toml b/soothfast.toml index 14f4abe..dbf7937 100644 --- a/soothfast.toml +++ b/soothfast.toml @@ -9,7 +9,7 @@ repo = "https://github.com/Verdenroz/soothfast" [[site.nav]] title = "Guide" -pages = ["index.md", "measuring.md", "gating.md", "living-docs.md", "spec.md", "sdk.md", "reports.md"] +pages = ["index.md", "measuring.md", "gating.md", "ci.md", "living-docs.md", "spec.md", "sdk.md", "reports.md"] [[site.nav]] title = "API reference"