diff --git a/.anvil.lock b/.anvil.lock
index 0c987e7b..c282120d 100644
--- a/.anvil.lock
+++ b/.anvil.lock
@@ -1,7 +1,7 @@
version = 1
tool = "anvil"
tool_version = "0.4.0"
-catalog_checksum = "sha256:df5327676750f40b3d34bea369cd683e40628dcfcab79d6c8fa3a3e209c33828"
+catalog_checksum = "sha256:742b87bdd81a2398ffdd2369af1eb56cab92d249b407717be5191606956065e5"
[[file]]
path = ".anvil/container/Containerfile"
@@ -77,19 +77,19 @@ checksum = "sha256:a153fa2ce4307ef9da91ff215e4f37e5a329d0bbb2c04c2b1f26096887f64
[[file]]
path = ".github/workflows/anvil-pr-impl.yml"
-checksum = "sha256:bf8b39c2ccb0cb6a682b68f5055df08d944913f863414202454ea75dafe4a839"
+checksum = "sha256:8bf6f1c1b62901c53bf7a9fa5c0cf8fd50625731496e88f5ffa3a790bc3a3c56"
[[file]]
path = ".github/workflows/anvil-pr.yml"
-checksum = "sha256:cb7996cc978eb3f6db6572a78eec1341bfbebb592c90f78f69d622eaacf6d44a"
+checksum = "sha256:18350505aedb0d3e4bc0941016205acbd17b5619b83caa24689fd9d2ddcc14b9"
[[file]]
path = ".github/workflows/anvil-scheduled-impl.yml"
-checksum = "sha256:c0404d734d90a3d4cb184fcb40536f61417c735c497212e39c156c40fcd374c8"
+checksum = "sha256:6c1cd79cff3660b8086cd57eef9f8e02b8145f7efb7236f0e97577e4588d6a94"
[[file]]
path = ".github/workflows/anvil-scheduled.yml"
-checksum = "sha256:91408602dc3ee274b593e234841934c749ff03bba0ee7846ab88247c06f20cae"
+checksum = "sha256:8be4848b851fa5b74702c87560fc628d0b90447ed27dc4dcdb655f92c55305c7"
[[file]]
path = "justfiles/anvil/checks/aprz.just"
@@ -269,11 +269,11 @@ checksum = "sha256:713c5a2ae28b6b5aa20dd38226278b3b7f71bbc5e6b84a16eaf5244713f27
[[file]]
path = "justfiles/anvil/tools.just"
-checksum = "sha256:662ad55792b05981ada347a4ff4a8026ad0969a61ae9bd046b8e5a03cc7e820c"
+checksum = "sha256:a1e44ca16f172b487afa3997f102512733d3b65a4418cf894cbd749a3abc17dc"
[[file]]
path = "justfiles/anvil/versions.just"
-checksum = "sha256:7f56852a5c4882fd535eeda7853e5cb45e32b79933a0cc46c62aeef289c9e69a"
+checksum = "sha256:acbea93d5117db747537f4f7b9a5eb90b7d3e0dd3e8684cc0e4dc1dcb15ac93e"
[[region]]
host = ".delta.toml"
diff --git a/.github/workflows/anvil-pr-impl.yml b/.github/workflows/anvil-pr-impl.yml
index 7b9d5cfe..5a914a9c 100644
--- a/.github/workflows/anvil-pr-impl.yml
+++ b/.github/workflows/anvil-pr-impl.yml
@@ -31,6 +31,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only pr-fast's pull
+# request scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: every multi-OS job below hardcodes its OS axis as
# an inline YAML array. Per-leg runner *labels* are inputs (so adopters
# can swap in self-hosted runners), but the OS axis itself is part of
@@ -79,6 +85,9 @@ jobs:
uses: ./.github/actions/anvil-impact
pr-fast:
+ permissions:
+ contents: read
+ pull-requests: write
# Cross-OS / cross-arch because pr-fast contains compile-sensitive
# checks (clippy, doc-build, udeps, semver-check, external-types)
# whose results can differ across host for crates that use
diff --git a/.github/workflows/anvil-pr.yml b/.github/workflows/anvil-pr.yml
index eb822683..e545a6ca 100644
--- a/.github/workflows/anvil-pr.yml
+++ b/.github/workflows/anvil-pr.yml
@@ -19,6 +19,9 @@ concurrency:
jobs:
anvil-pr:
uses: ./.github/workflows/anvil-pr-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores pull-requests:write
+ # only on pr-fast. See docs/design/github.md §9.
permissions:
contents: read
# Write needed so the pr-fast job can upsert/clear the sticky PR
diff --git a/.github/workflows/anvil-scheduled-impl.yml b/.github/workflows/anvil-scheduled-impl.yml
index 8a622be7..0e23c571 100644
--- a/.github/workflows/anvil-scheduled-impl.yml
+++ b/.github/workflows/anvil-scheduled-impl.yml
@@ -31,6 +31,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only the publisher's
+# issues scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: see pr-impl-workflow.yml for the rationale. OS
# matrices are hardcoded; per-leg runner labels are inputs.
@@ -122,3 +128,71 @@ jobs:
with:
lfs: true
- uses: ./.github/actions/anvil-scheduled-exhaustive
+
+ publish-failure:
+ name: Publish scheduled failure
+ needs:
+ - scheduled-test
+ - scheduled-advisories
+ - scheduled-runtime-analysis
+ - scheduled-exhaustive
+ if: ${{ always() && vars.ANVIL_PUBLISH_FAILURE_ISSUE != 'false'
+ && contains(needs.*.result, 'failure') }}
+ runs-on: ${{ inputs.linux_runner }}
+ permissions:
+ issues: write
+ steps:
+ - name: Create or update failure issue
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ ANVIL_JOB_RESULTS: ${{ toJSON(needs) }}
+ with:
+ script: |
+ const title = "[Anvil] Scheduled checks failed";
+ const marker = "";
+ const runUrl =
+ `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` +
+ `/actions/runs/${context.runId}`;
+ const results = JSON.parse(process.env.ANVIL_JOB_RESULTS);
+ const failedJobs = Object.entries(results)
+ .filter(([, job]) => job.result === "failure")
+ .map(([job]) => `- \`${job}\``)
+ .join("\n");
+ const body = [
+ marker,
+ "",
+ "The Anvil scheduled workflow failed.",
+ "",
+ "Failed jobs:",
+ failedJobs,
+ "",
+ `[View workflow run](${runUrl})`,
+ ].join("\n");
+
+ const query =
+ `repo:${context.repo.owner}/${context.repo.repo} ` +
+ `is:issue is:open in:body "anvil scheduled failure"`;
+ const { data: search } =
+ await github.rest.search.issuesAndPullRequests({
+ q: query,
+ per_page: 100,
+ });
+ const existing = search.items.find(
+ issue => issue.body?.includes(marker),
+ );
+
+ if (existing) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: existing.number,
+ body,
+ });
+ } else {
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title,
+ body,
+ });
+ }
diff --git a/.github/workflows/anvil-scheduled.yml b/.github/workflows/anvil-scheduled.yml
index 7421a886..4ecb2a60 100644
--- a/.github/workflows/anvil-scheduled.yml
+++ b/.github/workflows/anvil-scheduled.yml
@@ -16,6 +16,10 @@ permissions:
jobs:
anvil-scheduled:
uses: ./.github/workflows/anvil-scheduled-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores issues:write only on
+ # publish-failure. See docs/design/github.md §9.
permissions:
contents: read
+ issues: write
secrets: inherit
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 99456e96..a796afac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -35,6 +35,7 @@ jobs:
if: ${{ needs.nightly-gatekeeper.outputs.should_skip != 'true' }}
runs-on: ubuntu-latest
permissions:
+ contents: read
issues: write
steps:
# prep
@@ -57,19 +58,26 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- ISSUE_TITLE="🚨 Nightly Build Failed"
- ISSUE_DATE="$(date +'%Y-%m-%d')"
- ISSUE_FULL_TITLE="$ISSUE_TITLE: $ISSUE_DATE"
- ISSUE_BODY="The nightly scheduled build failed. Please check the logs here: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
- # Search for open issues with the same base title and label
- EXISTING_ISSUE=$(gh issue list --label "bug" --state open --search "$ISSUE_TITLE" --json number,title | jq -r '.[] | select(.title | startswith("'"$ISSUE_TITLE"'")) | .number' | head -n 1)
+ ISSUE_TITLE="[Anvil] Scheduled checks failed"
+ MARKER=""
+ ISSUE_BODY="$MARKER
+
+ The legacy nightly mutation-testing workflow failed.
+
+ [View workflow run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)"
+ # Share Anvil's marker-owned incident so overlapping mutation
+ # failures produce one durable issue and one notification stream.
+ EXISTING_ISSUE=$(gh issue list \
+ --state open \
+ --search '"anvil scheduled failure" in:body' \
+ --limit 100 \
+ --json number,body \
+ --jq '.[] | select(.body | contains("")) | .number' \
+ | head -n 1)
if [[ -n "$EXISTING_ISSUE" ]]; then
- # Add a comment to the existing issue
gh issue comment "$EXISTING_ISSUE" --body "$ISSUE_BODY"
else
- # Create a new issue
gh issue create \
- --title "$ISSUE_FULL_TITLE" \
- --body "$ISSUE_BODY" \
- --label "bug"
+ --title "$ISSUE_TITLE" \
+ --body "$ISSUE_BODY"
fi
diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md
index 3c4887c9..232373e9 100644
--- a/crates/cargo-anvil/README.md
+++ b/crates/cargo-anvil/README.md
@@ -95,6 +95,13 @@ runs perform impact analysis (via [`cargo-delta`][__link0])
and run each check only over the affected packages, whereas a local
`just anvil-pr` runs every check over the whole workspace.
+The generated GitHub scheduled workflow publishes failures as GitHub
+issues. On failure, it best-effort reuses an open marker-owned issue and
+comments when later scheduled runs also fail. A maintainer closes the issue
+after resolving the incident; successful runs do not close it automatically.
+Repositories can disable this behavior by setting the
+`ANVIL_PUBLISH_FAILURE_ISSUE` Actions repository variable to `false`.
+
### Containerized local checks
Anvil can run any generated recipe in a content-addressed Linux container.
@@ -292,6 +299,16 @@ Four escape valves, in increasing severity:
region. The next `update` detects the dirt and writes a
`.anvil-proposed` sibling instead of overwriting.
+#### Scheduled failure issue publication (GitHub)
+
+The generated GitHub scheduled workflow creates or updates
+`[Anvil] Scheduled checks failed` when a scheduled group fails.
+To disable this behavior without editing an Anvil-owned workflow,
+set the Actions repository variable `ANVIL_PUBLISH_FAILURE_ISSUE`
+to `false` under **Settings → Secrets and variables → Actions →
+Variables**. Removing the variable or setting any other value
+restores the default publication behavior.
+
### In-tree tool customization
anvil follows a few source-level and `Cargo.toml` conventions so you
@@ -427,7 +444,7 @@ And `docs/verification.md` for the continuous-validation strategy.
This crate was developed as part of The Oxidizer Project. Browse this crate's source code.
- [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbRQpVpEjw3x0b7FHf_9HBExgbfia0zvhKdz8bZ7R_zqIR8z1hZIGDa2NhcmdvLWFudmlsZTAuNC4wa2NhcmdvX2Fudmls
+ [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbwZ4st3e_65sbkyK8ewAQvKkbC226ePbvbHsbZyKr94ICgj9hZIGDa2NhcmdvLWFudmlsZTAuNC4wa2NhcmdvX2Fudmls
[__link0]: https://crates.io/crates/cargo-delta
[__link1]: https://crates.io/crates/cargo-spellcheck
[__link2]: https://crates.io/crates/cargo-coverage-gate
diff --git a/crates/cargo-anvil/docs/design/github.md b/crates/cargo-anvil/docs/design/github.md
index 0edffe2c..92abdddc 100644
--- a/crates/cargo-anvil/docs/design/github.md
+++ b/crates/cargo-anvil/docs/design/github.md
@@ -128,6 +128,7 @@ flowchart LR
sadv_job["scheduled-advisories
matrix: linux, windows,
linux-arm, windows-arm"]:::job
srun_job["scheduled-runtime-analysis
matrix: linux, windows,
linux-arm, windows-arm"]:::job
sexh_job["scheduled-exhaustive
matrix: linux, windows"]:::job
+ publish_job["publish-failure
upsert incident issue"]:::job
stest_setup[".github/actions/
anvil-setup"]:::action
sadv_setup[".github/actions/
anvil-setup"]:::action
srun_setup[".github/actions/
anvil-setup"]:::action
@@ -137,6 +138,7 @@ flowchart LR
srun_act[".github/actions/
anvil-scheduled-runtime-analysis"]:::action
sexh_act[".github/actions/
anvil-scheduled-exhaustive"]:::action
codecov_act["codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
v7.0.0"]:::external
+ github_issues["GitHub Issues"]:::external
stest_just["just anvil-scheduled-test"]:::recipe
stest_setup_just["just anvil-setup"]:::recipe
sadv_just["just anvil-scheduled-advisories"]:::recipe
@@ -150,22 +152,32 @@ flowchart LR
sched_root -. uses .-> sched_impl
sched_impl --> stest_job
sched_impl --> sadv_job
+ sched_impl --> srun_job
sched_impl --> sexh_job
+ stest_job --> publish_job
+ sadv_job --> publish_job
+ srun_job --> publish_job
+ sexh_job --> publish_job
stest_job ==> stest_act
stest_job ==> codecov_act
sadv_job ==> sadv_act
+ srun_job ==> srun_act
sexh_job ==> sexh_act
+ publish_job ==> github_issues
stest_act ==> stest_setup
stest_act ==> stest_just
sadv_act ==> sadv_setup
sadv_act ==> sadv_just
+ srun_act ==> srun_setup
+ srun_act ==> srun_just
sexh_act ==> sexh_setup
sexh_act ==> sexh_just
stest_setup ==> stest_setup_just
sadv_setup ==> sadv_setup_just
+ srun_setup ==> srun_setup_just
sexh_setup ==> sexh_setup_just
classDef trigger fill:#fff4d6,stroke:#b08800,stroke-width:1px;
@@ -381,46 +393,24 @@ contract.
The scheduled reusable workflow is simpler — it omits the `impact` job and runs each group
full-workspace. The include inputs default to empty strings, so recipes fall through to
-their local-default behavior (`--workspace`):
+their local-default behavior (`--workspace`). The following is deliberately a
+non-executable schematic; the generated
+[`scheduled-impl-workflow.yml`](../../templates/github/scheduled-impl-workflow.yml)
+is the canonical YAML:
-```yaml
-# .github/workflows/anvil-scheduled-impl.yml (owned)
-on:
- workflow_call:
- inputs:
- linux_runner: { type: string, default: ubuntu-latest }
- windows_runner: { type: string, default: windows-latest }
- linux_arm_runner: { type: string, default: ubuntu-24.04-arm }
- windows_arm_runner: { type: string, default: windows-11-arm }
-jobs:
- scheduled-test:
- strategy:
- fail-fast: false
- matrix:
- os: [linux, windows, linux-arm, windows-arm]
- runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner
- || matrix.os == 'windows' && inputs.windows_runner
- || matrix.os == 'linux-arm' && inputs.linux_arm_runner
- || inputs.windows_arm_runner }}
- steps: [ { uses: actions/checkout }, { uses: ./.github/actions/anvil-scheduled-test } ]
- scheduled-advisories:
- strategy:
- fail-fast: false
- matrix:
- os: [linux, windows, linux-arm, windows-arm]
- runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner
- || matrix.os == 'windows' && inputs.windows_runner
- || matrix.os == 'linux-arm' && inputs.linux_arm_runner
- || inputs.windows_arm_runner }}
- steps: [ { uses: actions/checkout }, { uses: ./.github/actions/anvil-scheduled-advisories } ]
- scheduled-exhaustive:
- # x86_64 only -- cargo-mutants constraint.
- strategy:
- fail-fast: false
- matrix:
- os: [linux, windows]
- runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner || inputs.windows_runner }}
- steps: [ { uses: actions/checkout }, { uses: ./.github/actions/anvil-scheduled-exhaustive } ]
+```text
+caller anvil-scheduled.yml
+ permissions upper bound: contents:read + issues:write
+ └─ called anvil-scheduled-impl.yml
+ default reset: contents:read
+ ├─ scheduled-test (Linux/Windows × x64/ARM64)
+ ├─ scheduled-advisories (Linux/Windows × x64/ARM64)
+ ├─ scheduled-runtime-analysis (Linux/Windows × x64/ARM64)
+ ├─ scheduled-exhaustive (Linux/Windows x64)
+ └─ publish-failure
+ needs: all four scheduled groups
+ condition: at least one failure and publication not disabled
+ job override: issues:write only
```
Scheduled composite actions don't receive any `include_*` inputs at all — their inputs
@@ -645,8 +635,17 @@ Recommended root workflow shape:
- `permissions: contents: read` at the workflow level. anvil's default ships with
this.
-- No `pull-requests: write` (the PR-title check only needs the title from the event
- payload, which is already in `${{ github.event.pull_request.title }}`).
+- The scheduled reusable-workflow call grants `issues: write` at job scope so its
+ publisher can create or comment on the failure issue. The called workflow resets its
+ default permissions to `contents: read`, then restores `issues: write` only on the
+ publishing job. That job-level map omits `contents`, so the publisher cannot read
+ repository contents; scheduled check jobs retain read-only access. The PR workflow
+ never receives this permission.
+- The PR reusable-workflow call grants `pull-requests: write` for advisory comments.
+ The called workflow resets its default permissions to `contents: read`, then restores
+ `pull-requests: write` only on `pr-fast`, where the sticky-comment steps run. Other PR
+ jobs do not inherit write access. The PR-title check itself reads the title from the
+ event payload and does not use the write permission.
- Scheduled-tier secrets, if any, live on `anvil-scheduled.yml` only — never on `anvil-pr.yml`.
- All cargo-tool installs done by the catalog setup recipes use `--locked` (with
`cargo install` or `cargo binstall` depending on `installer`).
@@ -692,7 +691,67 @@ anvil does not gate the PR on coverage. The lcov upload is informational; Codeco
own status check is the gating layer when the adopter wants one (configured in Codecov,
visible as a separate required check in branch protection).
-## 11. Advisory PR comments
+## 11. Scheduled failure issues
+
+The GitHub scheduled reusable workflow publishes a failure as a repository issue by
+default. The publisher depends on every scheduled group and uses `always()` so it can
+inspect their terminal results even when one or more groups fail. It runs only when at
+least one result is `failure`; successful, skipped, and cancelled runs do not create
+issues.
+
+The issue title is `[Anvil] Scheduled checks failed`, while the stable hidden marker
+`` identifies the repository's shared scheduled-failure
+incident. Anvil and any legacy scheduled publisher that adopts this identity converge on
+the same open issue. Each publisher makes one repository-scoped Search API request for
+open issues whose bodies match the marker terms, then verifies the exact marker
+client-side:
+
+- If none exists, it creates one containing the failed group names and a link to the
+ workflow run.
+- If one exists, it adds the new failure details as a comment instead of creating a
+ duplicate.
+
+This is a bounded best-effort upsert, not a singleton guarantee. One Search request is
+the selected boundary because a scheduled failure should spend a fixed, minimal amount
+of API quota instead of paginating through repository issues; the marker is expected to
+match at most one open incident. GitHub's search index is eventually consistent and the
+request considers at most 100 results, so closely overlapping failures can occasionally
+create duplicate incident issues. Marker-based identity prevents a human-authored issue
+with the same title from being reused and survives a maintainer renaming an incident.
+
+No label is required because repositories can remove or rename their default labels.
+Here, "open incident" means an open marker-owned issue, not an automatically tracked
+failure state. Successful runs do not close or update it. The issue remains open until a
+maintainer resolves the underlying failure and closes it; a later failure after closure
+creates a new incident issue.
+
+This repository's legacy nightly mutation workflow intentionally uses the same title and
+marker. Its mutation configuration remains distinct from Anvil's exhaustive group, but
+an overlapping failure updates the same durable incident instead of creating a second
+notification owner and a duplicate Teams post.
+
+The publisher uses the workflow's short-lived `GITHUB_TOKEN`. The scheduled root call
+allows `issues: write`, while the reusable workflow defaults to `contents: read` and
+grants `issues: write` only to the publishing job. Scheduled check jobs therefore retain
+read-only access. The publisher's job-level permission map omits `contents`, so it cannot
+read repository contents, and it does not forward logs or environment data into the
+issue. This narrow GitHub-native path also lets GitHub's Teams app relay issue
+notifications without an external webhook or additional secret.
+
+The generated root and implementation workflows must be updated together. A repository
+that has taken ownership of the root workflow must retain `issues: write` on the reusable
+workflow call (or apply the generated `.anvil-proposed` update) when adopting this job.
+Repositories with Issues disabled cannot publish failure incidents. Missing permission or
+disabled Issues deliberately fails the publishing job rather than silently losing the
+notification; the original failing scheduled jobs remain visible alongside that error.
+
+Repositories that do not want issue publication set the
+`ANVIL_PUBLISH_FAILURE_ISSUE` Actions repository variable to `false`. This configuration
+lives in repository settings instead of an Anvil-owned workflow, so the root workflow
+stays on the automatic update path. The scheduled call retains `issues: write`; the
+publisher's condition prevents use of that permission when publication is disabled.
+
+## 12. Advisory PR comments
Recipes that surface non-blocking findings exit 0 and write a markdown body to
`target/anvil/comments/.md` (see [checks.md §6](./checks.md#6-advisory-pr-comments)
@@ -735,9 +794,9 @@ Conditions explained:
`pull-requests: write` to fork-PR workflow runs by default, so the action would 403.
Permissions: the reusable workflow's caller (`anvil-pr.yml`) declares
-`pull-requests: write` on the `anvil-pr` job that calls `anvil-pr-impl.yml`. The
-top-level `permissions:` block stays at `contents: read` so unrelated reads in the same
-workflow are still least-privilege.
+`pull-requests: write` on the `anvil-pr` job that calls `anvil-pr-impl.yml`. The called
+workflow resets its default to `contents: read` and restores `pull-requests: write` only
+on `pr-fast`, where the sticky-comment steps run. Other called jobs remain read-only.
Adding a new advisory check is a two-step change: the recipe writes
`target/anvil/comments/.md` (and removes it on a clean run); the workflow gains
diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md
index 46650639..69db4dbd 100644
--- a/crates/cargo-anvil/docs/design/local.md
+++ b/crates/cargo-anvil/docs/design/local.md
@@ -196,10 +196,10 @@ The catalog records, for each cargo subcommand, a **catalog version** (e.g.
installs *exactly* that version (`--version '={{ pin }}'`), never `>=`. Pulling
latest-matching at install time is a cloud-workflow reproducibility risk -- an upstream release
between yesterday's green build and today's PR can break things, even though the
- catalog hasn't moved. `cargo-spellcheck 0.15.7`'s em-dash word-boundary regression is
- the canonical example: with `>=0.15.1` the catalog would have silently picked it up,
- breaking every PR until the catalog was edited. With `=0.15.1` the catalog locks in
- the version it was validated against.
+ catalog hasn't moved. Exact pins let the catalog accommodate behavior changes before
+ upgrading; for example, cargo-spellcheck 0.15.7 is paired with the explicit
+ `tokenization_splitchars` boundary list in `spellcheck.toml` rather than being selected
+ implicitly by a range.
- **On runtime check** (`anvil-tool--validate-prereqs`): the recipe enforces
`installed >= pin`. A local developer who has manually upgraded a tool for their own
reasons (e.g. needing a bugfix the catalog hasn't pinned yet) is not downgraded by
@@ -279,9 +279,14 @@ The `installer` argument:
Slow on a cold runner (~30 min for the full catalog) because every tool
re-compiles common deps (`clap`, `syn`, `quote`, ...) from scratch independently.
- `binstall` -- `cargo binstall --no-confirm --locked --version '='`.
- Downloads a prebuilt binary from each tool's GitHub Releases when available.
- Cuts the cold-runner install phase from ~30 min to ~1 min. `cargo-binstall`
- itself needs to be on PATH; the GH setup composite arranges this.
+ This selects an ordered strategy, not a binary-only backend. Anvil first asks
+ cargo-binstall to install the exact pin. Tools without a source prerequisite retain
+ cargo-binstall's compile strategy. For tools that declare a source prerequisite,
+ Anvil disables that compile strategy so compilation cannot bypass the check. Any
+ nonzero binstall result then falls back to Anvil's exact-pin `cargo install`; the
+ declared prerequisite, when present, runs immediately before that fallback.
+ A successful binary path cuts the cold-runner install phase from ~30 min to ~1 min.
+ `cargo-binstall` itself needs to be on PATH; the GH setup composite arranges this.
The GitHub composite setup action calls `just anvil--setup binstall`
(or just `anvil-setup binstall` when no group is scoped). The ADO setup step
@@ -323,8 +328,9 @@ channel, and `versions.just`. See
A small set of catalog tools have non-Rust build dependencies that `cargo install`
can't satisfy on its own. Today the only entry is `libclang`, needed by
-`cargo-spellcheck` (via `clang-sys` / `hunspell-rs`) at build time. The `binstall`
-install path sidesteps these entirely by downloading prebuilt binaries.
+`cargo-spellcheck` (via `clang-sys` / `hunspell-rs`) at build time. A successful
+prebuilt binstall sidesteps these; a failed binstall can reach the controlled source
+fallback and therefore still requires them.
Scope policy: only check for system libs that an anvil catalog tool **directly**
requires. anvil is not a general-purpose dev-env doctor. Repository-specific
@@ -341,8 +347,9 @@ scoop / winget) and exits non-zero. **No auto-install** -- admin/sudo decisions
package-manager choice stay with the user. The cargo-spellcheck install recipe passes
`anvil-tool-cargo-spellcheck-source-deps-check` to `_install-tool` as its source prerequisite.
The prerequisite runs for an explicit source-build `install` backend and when `binstall`
-cannot provide a binary and falls back to a source build, so missing libclang surfaces as a
-clear hint instead of a cryptic clang-sys build error 10 minutes into the install.
+cannot provide a binary. Anvil disables binstall's own compile strategy and performs the
+source fallback itself, so missing libclang surfaces as a clear hint before compilation
+instead of a cryptic clang-sys build error 10 minutes into the install.
Each tool with a source-build system dependency owns a tool-specific prerequisite recipe and
wires it into `_install-tool`. Catalog changes propagate to adopters via `cargo anvil` like
diff --git a/crates/cargo-anvil/src/anvil/artifacts/github.rs b/crates/cargo-anvil/src/anvil/artifacts/github.rs
index 9b030f85..e08503d0 100644
--- a/crates/cargo-anvil/src/anvil/artifacts/github.rs
+++ b/crates/cargo-anvil/src/anvil/artifacts/github.rs
@@ -142,6 +142,9 @@ pub(crate) fn all() -> Vec {
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
+ use std::fs;
+ use std::process::Command;
+
use super::*;
#[test]
@@ -166,6 +169,7 @@ mod tests {
assert!(SETUP_ACTION.contains("/usr/local/lib/android"));
assert!(SETUP_ACTION.contains(r"C:\Program Files (x86)\Android"));
assert!(!SETUP_ACTION.contains("Install libclang"));
+ assert!(!SETUP_ACTION.contains("apt-get install -y libclang-dev"));
}
#[test]
@@ -212,6 +216,10 @@ mod tests {
#[test]
fn pr_impl_workflow_has_expected_jobs() {
assert!(PR_IMPL_WORKFLOW.contains("workflow_call:"));
+ assert!(
+ !PR_IMPL_WORKFLOW.contains("ANVIL_SPELLCHECK_SKIP_UNSUPPORTED_ARM64"),
+ "the PR workflow must run spellcheck on ARM64"
+ );
for needle in [
"impact-linux:",
"impact-windows:",
@@ -240,6 +248,8 @@ mod tests {
);
assert!(PR_IMPL_WORKFLOW.contains("matrix.os != 'windows-arm'"));
assert!(PR_IMPL_WORKFLOW.contains("flags: ${{ matrix.os }}"));
+ assert!(PR_IMPL_WORKFLOW.contains("\npermissions:\n contents: read\n"));
+ assert_eq!(PR_IMPL_WORKFLOW.matches("pull-requests: write").count(), 1);
assert_eq!(
PR_IMPL_WORKFLOW.matches("free-disk-space: true").count(),
1,
@@ -254,6 +264,7 @@ mod tests {
"scheduled-advisories:",
"scheduled-runtime-analysis:",
"scheduled-exhaustive:",
+ "publish-failure:",
] {
assert!(
SCHEDULED_IMPL_WORKFLOW.contains(needle),
@@ -261,6 +272,23 @@ mod tests {
);
}
assert!(SCHEDULED_IMPL_WORKFLOW.contains("codecov/codecov-action"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("vars.ANVIL_PUBLISH_FAILURE_ISSUE != 'false'"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("contains(needs.*.result, 'failure')"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("github.rest.search.issuesAndPullRequests"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("github.rest.issues.createComment"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("github.rest.issues.create"));
+ assert!(SCHEDULED_IMPL_WORKFLOW.contains("\npermissions:\n contents: read\n"));
+ assert_eq!(SCHEDULED_IMPL_WORKFLOW.matches("issues: write").count(), 1);
+ let publisher_permissions = SCHEDULED_IMPL_WORKFLOW
+ .split_once("\n publish-failure:")
+ .expect("scheduled workflow should contain publish-failure")
+ .1
+ .split_once("\n steps:")
+ .expect("publish-failure should contain steps")
+ .0;
+ assert!(publisher_permissions.contains("\n permissions:\n issues: write"));
+ assert!(!publisher_permissions.contains("contents: read"));
assert_eq!(
SCHEDULED_IMPL_WORKFLOW.matches("free-disk-space: true").count(),
1,
@@ -268,6 +296,119 @@ mod tests {
);
}
+ #[test]
+ #[cfg_attr(miri, ignore = "uses filesystem and subprocesses; miri isolation forbids them")]
+ fn scheduled_failure_script_upserts_marker_owned_issues() {
+ let script = SCHEDULED_IMPL_WORKFLOW
+ .split_once(" script: |\n")
+ .expect("scheduled workflow should contain an inline script")
+ .1
+ .lines()
+ .map(|line| line.strip_prefix(" ").unwrap_or(line))
+ .collect::>()
+ .join("\n");
+ let harness = format!("const workflowScript = {script:?};\n")
+ + r#"
+const assert = require("node:assert/strict");
+// github-script executes an asynchronous body with injected runtime values.
+// Model only the github/context/process values this script uses; the API client
+// remains mocked rather than recreating the complete action runtime or Node image.
+const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
+const run = new AsyncFunction("github", "context", "process", workflowScript);
+const marker = "";
+const searchableMarker = marker.replace(/^$/g, "");
+const title = "[Anvil] Scheduled checks failed";
+const context = {
+ serverUrl: "https://github.com",
+ repo: { owner: "microsoft", repo: "ox-tools" },
+ runId: 42,
+};
+const expectedQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:body "${searchableMarker}"`;
+
+async function scenario(items) {
+ const calls = { search: [], create: [], comment: [] };
+ const github = {
+ rest: {
+ search: {
+ issuesAndPullRequests: async args => {
+ calls.search.push(args);
+ return { data: { items: args.q === expectedQuery ? items : [] } };
+ },
+ },
+ issues: {
+ create: async args => calls.create.push(args),
+ createComment: async args => calls.comment.push(args),
+ },
+ },
+ };
+ const process = {
+ env: {
+ ANVIL_JOB_RESULTS: JSON.stringify({
+ "scheduled-test": { result: "failure" },
+ "scheduled-advisories": { result: "success" },
+ "scheduled-runtime-analysis": { result: "cancelled" },
+ "scheduled-exhaustive": { result: "failure" },
+ }),
+ },
+ };
+ await run(github, context, process);
+ return calls;
+}
+
+(async () => {
+ const created = await scenario([]);
+ assert.equal(created.search.length, 1);
+ assert.equal(created.search[0].q, expectedQuery);
+ assert.equal(created.create.length, 1);
+ assert.equal(created.comment.length, 0);
+ assert.equal(created.create[0].title, title);
+ assert.match(created.create[0].body, new RegExp(marker));
+ assert.match(created.create[0].body, /- `scheduled-test`/);
+ assert.match(created.create[0].body, /- `scheduled-exhaustive`/);
+ assert.doesNotMatch(created.create[0].body, /scheduled-advisories/);
+ assert.doesNotMatch(created.create[0].body, /scheduled-runtime-analysis/);
+ assert.match(
+ created.create[0].body,
+ /https:\/\/github\.com\/microsoft\/ox-tools\/actions\/runs\/42/,
+ );
+
+ const existing = await scenario([
+ { number: 17, title: "Maintainer-renamed incident", body: marker },
+ ]);
+ assert.equal(existing.create.length, 0);
+ assert.equal(existing.comment.length, 1);
+ assert.equal(existing.comment[0].issue_number, 17);
+
+ const collision = await scenario([
+ { number: 23, title, body: "A human-authored issue without the marker." },
+ ]);
+ assert.equal(collision.create.length, 1);
+ assert.equal(collision.comment.length, 0);
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
+"#;
+
+ if Command::new("node").arg("--version").output().is_err() {
+ return;
+ }
+
+ let dir = tempfile::tempdir().expect("create temporary test directory");
+ let path = dir.path().join("scheduled-failure.test.cjs");
+ fs::write(&path, harness).expect("write JavaScript behavior test");
+ let output = Command::new("node")
+ .arg(&path)
+ .output()
+ .expect("execute generated github-script behavior test");
+ assert!(
+ output.status.success(),
+ "generated github-script behavior test failed:\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
#[test]
fn root_workflows_call_reusable_workflows() {
assert!(PR_ROOT_WORKFLOW.contains("uses: ./.github/workflows/anvil-pr-impl.yml"));
@@ -275,6 +416,7 @@ mod tests {
assert!(PR_ROOT_WORKFLOW.contains("merge_group:"));
assert!(SCHEDULED_ROOT_WORKFLOW.contains("uses: ./.github/workflows/anvil-scheduled-impl.yml"));
assert!(SCHEDULED_ROOT_WORKFLOW.contains("schedule:"));
+ assert!(SCHEDULED_ROOT_WORKFLOW.contains("issues: write"));
}
#[test]
diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs
index f493aabe..4a880cb8 100644
--- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs
+++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs
@@ -296,9 +296,15 @@ mod tests {
#[test]
fn spellcheck_checks_source_prerequisites_before_source_builds() {
assert!(
- TOOLS_JUST.contains(
- "_install-tool \"cargo-spellcheck\" cargo_spellcheck_version installer \"anvil-tool-cargo-spellcheck-source-deps-check\""
- ),
+ TOOLS_JUST.contains("if ($sourcePrereq)"),
+ "binstall compile strategy must only be disabled for tools with source prerequisites"
+ );
+ assert!(
+ TOOLS_JUST.contains("$binstallArgs += @('--disable-strategies', 'compile')"),
+ "binstall must not compile before Anvil checks source prerequisites"
+ );
+ assert!(
+ TOOLS_JUST.contains("anvil-tool-cargo-spellcheck-source-deps-check"),
"spellcheck installer must run libclang validation before source builds"
);
let checks = all_check_bodies();
@@ -306,6 +312,10 @@ mod tests {
!checks.contains("anvil-spellcheck-setup installer=\"install\": anvil-tool-cargo-spellcheck-source-deps-check"),
"spellcheck setup must not require libclang before binstall"
);
+ assert!(
+ !TOOLS_JUST.contains("ANVIL_SPELLCHECK_SKIP_UNSUPPORTED_ARM64") && !checks.contains("ANVIL_SPELLCHECK_SKIP_UNSUPPORTED_ARM64"),
+ "spellcheck must run normally on ARM64"
+ );
}
#[test]
diff --git a/crates/cargo-anvil/src/lib.rs b/crates/cargo-anvil/src/lib.rs
index e9275f5d..22b81115 100644
--- a/crates/cargo-anvil/src/lib.rs
+++ b/crates/cargo-anvil/src/lib.rs
@@ -96,6 +96,13 @@
//! and run each check only over the affected packages, whereas a local
//! `just anvil-pr` runs every check over the whole workspace.
//!
+//! The generated GitHub scheduled workflow publishes failures as GitHub
+//! issues. On failure, it best-effort reuses an open marker-owned issue and
+//! comments when later scheduled runs also fail. A maintainer closes the issue
+//! after resolving the incident; successful runs do not close it automatically.
+//! Repositories can disable this behavior by setting the
+//! `ANVIL_PUBLISH_FAILURE_ISSUE` Actions repository variable to `false`.
+//!
//! ## Containerized local checks
//!
//! Anvil can run any generated recipe in a content-addressed Linux container.
@@ -293,6 +300,16 @@
//! region. The next `update` detects the dirt and writes a
//! `.anvil-proposed` sibling instead of overwriting.
//!
+//! ### Scheduled failure issue publication (GitHub)
+//!
+//! The generated GitHub scheduled workflow creates or updates
+//! `[Anvil] Scheduled checks failed` when a scheduled group fails.
+//! To disable this behavior without editing an Anvil-owned workflow,
+//! set the Actions repository variable `ANVIL_PUBLISH_FAILURE_ISSUE`
+//! to `false` under **Settings → Secrets and variables → Actions →
+//! Variables**. Removing the variable or setting any other value
+//! restores the default publication behavior.
+//!
//! ## In-tree tool customization
//!
//! anvil follows a few source-level and `Cargo.toml` conventions so you
diff --git a/crates/cargo-anvil/templates/github/pr-impl-workflow.yml b/crates/cargo-anvil/templates/github/pr-impl-workflow.yml
index 7b9d5cfe..5a914a9c 100644
--- a/crates/cargo-anvil/templates/github/pr-impl-workflow.yml
+++ b/crates/cargo-anvil/templates/github/pr-impl-workflow.yml
@@ -31,6 +31,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only pr-fast's pull
+# request scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: every multi-OS job below hardcodes its OS axis as
# an inline YAML array. Per-leg runner *labels* are inputs (so adopters
# can swap in self-hosted runners), but the OS axis itself is part of
@@ -79,6 +85,9 @@ jobs:
uses: ./.github/actions/anvil-impact
pr-fast:
+ permissions:
+ contents: read
+ pull-requests: write
# Cross-OS / cross-arch because pr-fast contains compile-sensitive
# checks (clippy, doc-build, udeps, semver-check, external-types)
# whose results can differ across host for crates that use
diff --git a/crates/cargo-anvil/templates/github/pr-root-workflow.yml b/crates/cargo-anvil/templates/github/pr-root-workflow.yml
index eb822683..e545a6ca 100644
--- a/crates/cargo-anvil/templates/github/pr-root-workflow.yml
+++ b/crates/cargo-anvil/templates/github/pr-root-workflow.yml
@@ -19,6 +19,9 @@ concurrency:
jobs:
anvil-pr:
uses: ./.github/workflows/anvil-pr-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores pull-requests:write
+ # only on pr-fast. See docs/design/github.md §9.
permissions:
contents: read
# Write needed so the pr-fast job can upsert/clear the sticky PR
diff --git a/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml b/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml
index 8a622be7..0e23c571 100644
--- a/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml
+++ b/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml
@@ -31,6 +31,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only the publisher's
+# issues scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: see pr-impl-workflow.yml for the rationale. OS
# matrices are hardcoded; per-leg runner labels are inputs.
@@ -122,3 +128,71 @@ jobs:
with:
lfs: true
- uses: ./.github/actions/anvil-scheduled-exhaustive
+
+ publish-failure:
+ name: Publish scheduled failure
+ needs:
+ - scheduled-test
+ - scheduled-advisories
+ - scheduled-runtime-analysis
+ - scheduled-exhaustive
+ if: ${{ always() && vars.ANVIL_PUBLISH_FAILURE_ISSUE != 'false'
+ && contains(needs.*.result, 'failure') }}
+ runs-on: ${{ inputs.linux_runner }}
+ permissions:
+ issues: write
+ steps:
+ - name: Create or update failure issue
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ ANVIL_JOB_RESULTS: ${{ toJSON(needs) }}
+ with:
+ script: |
+ const title = "[Anvil] Scheduled checks failed";
+ const marker = "";
+ const runUrl =
+ `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` +
+ `/actions/runs/${context.runId}`;
+ const results = JSON.parse(process.env.ANVIL_JOB_RESULTS);
+ const failedJobs = Object.entries(results)
+ .filter(([, job]) => job.result === "failure")
+ .map(([job]) => `- \`${job}\``)
+ .join("\n");
+ const body = [
+ marker,
+ "",
+ "The Anvil scheduled workflow failed.",
+ "",
+ "Failed jobs:",
+ failedJobs,
+ "",
+ `[View workflow run](${runUrl})`,
+ ].join("\n");
+
+ const query =
+ `repo:${context.repo.owner}/${context.repo.repo} ` +
+ `is:issue is:open in:body "anvil scheduled failure"`;
+ const { data: search } =
+ await github.rest.search.issuesAndPullRequests({
+ q: query,
+ per_page: 100,
+ });
+ const existing = search.items.find(
+ issue => issue.body?.includes(marker),
+ );
+
+ if (existing) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: existing.number,
+ body,
+ });
+ } else {
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title,
+ body,
+ });
+ }
diff --git a/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml b/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml
index 7421a886..4ecb2a60 100644
--- a/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml
+++ b/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml
@@ -16,6 +16,10 @@ permissions:
jobs:
anvil-scheduled:
uses: ./.github/workflows/anvil-scheduled-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores issues:write only on
+ # publish-failure. See docs/design/github.md §9.
permissions:
contents: read
+ issues: write
secrets: inherit
diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just
index 7e7df78f..cb6aab14 100644
--- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just
+++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just
@@ -173,9 +173,9 @@ anvil-tool-pwsh-validate-prereqs:
# or no-op if it is already installed at or above that version. The
# `installer` parameter selects between:
# - "install" (cargo install --locked, pure-source). Default.
-# - "binstall" (cargo binstall --no-confirm --locked, with cargo install
-# fallback if binstall fails). Bootstraps cargo-binstall
-# itself if not on PATH.
+# - "binstall" (cargo binstall --no-confirm --locked, with a controlled
+# cargo install fallback for tools that declare source
+# prerequisites). Bootstraps cargo-binstall itself if not on PATH.
# `source_prereq`, when set, runs immediately before a source installation,
# including a binstall fallback.
[script("pwsh", "-NoProfile")]
@@ -223,7 +223,15 @@ _install-tool name version installer source_prereq="":
exit $LASTEXITCODE
}
}
- cargo binstall --no-confirm --locked $name --version "=$version"
+ # Keep source builds behind Anvil's prerequisite check instead of
+ # allowing binstall to compile before that check can run. Tools with
+ # no source prerequisite may still use binstall's compile strategy.
+ $binstallArgs = @('binstall', '--no-confirm', '--locked')
+ if ($sourcePrereq) {
+ $binstallArgs += @('--disable-strategies', 'compile')
+ }
+ $binstallArgs += @($name, '--version', "=$version")
+ cargo @binstallArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow
}
diff --git a/crates/cargo-anvil/templates/justfiles/anvil/versions.just b/crates/cargo-anvil/templates/justfiles/anvil/versions.just
index 4e60c597..564a3b90 100644
--- a/crates/cargo-anvil/templates/justfiles/anvil/versions.just
+++ b/crates/cargo-anvil/templates/justfiles/anvil/versions.just
@@ -11,10 +11,9 @@
# - On install (`-install` recipes): exactly this version (`=` for
# cargo subcommands, exact ref for rustup toolchains). Pulling
# "latest-matching" at install time is a cloud-workflow reproducibility risk --
-# an upstream release between yesterday's green build and today's
-# PR can break things (cargo-spellcheck 0.15.7's em-dash regression
-# is the canonical case). The `=` constraint locks the install to
-# the version the catalog was validated against.
+# an upstream release between yesterday's green build and today's PR can
+# change behavior. The `=` constraint locks the install to the version the
+# catalog was validated against.
# - On validate-prereqs (`-validate-prereqs` recipes): the installed
# version must be `>= `. A user who has manually upgraded a
# tool for their own reasons (e.g. needing an unreleased bugfix)
diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs
index a30e4a0b..896fdd76 100644
--- a/crates/cargo-anvil/tests/recipe_contracts.rs
+++ b/crates/cargo-anvil/tests/recipe_contracts.rs
@@ -21,42 +21,9 @@ const BOLERO: &str = include_str!("../templates/justfiles/anvil/checks/bolero.ju
const LLVM_COV: &str = include_str!("../templates/justfiles/anvil/checks/llvm-cov.just");
const SEMVER: &str = include_str!("../templates/justfiles/anvil/checks/semver-check.just");
const EXTERNAL_TYPES: &str = include_str!("../templates/justfiles/anvil/checks/external-types.just");
+const TOOLS: &str = include_str!("../templates/justfiles/anvil/tools.just");
const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just");
-
-fn write(path: &Path, contents: &str) {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent).unwrap();
- }
- std::fs::write(path, contents).unwrap();
-}
-
-fn tools_available() -> bool {
- Command::new("just").arg("--version").output().is_ok() && Command::new("pwsh").arg("--version").output().is_ok()
-}
-
-fn fixture(imports: &[(&str, &str)], dependency_recipes: &[&str]) -> TempDir {
- let tmp = TempDir::new().unwrap();
- let mut justfile = String::from("set unstable\n\nrust_nightly := \"nightly-test\"\n\n");
- for (name, contents) in imports {
- write(&tmp.path().join(name), contents);
- writeln!(justfile, "import '{name}'").unwrap();
- }
- justfile.push('\n');
- for recipe in dependency_recipes {
- justfile.push_str(recipe);
- justfile.push_str(":\n\n");
- }
- write(&tmp.path().join("Justfile"), &justfile);
- write(
- &tmp.path().join("Cargo.toml"),
- "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
- );
-
- let bin = tmp.path().join("fake-bin");
- std::fs::create_dir_all(&bin).unwrap();
- write(
- &bin.join("cargo.ps1"),
- r#"
+const FAKE_CARGO_PS1: &str = r#"
$joined = $args -join ' '
if ($env:FAKE_CARGO_LOG) {
Add-Content -LiteralPath $env:FAKE_CARGO_LOG -Value $joined
@@ -123,9 +90,52 @@ if ($args -contains 'nextest') {
}
exit [int]$env:FAKE_NEXTEST_EXIT
}
+if ($args -contains 'binstall') {
+ exit [int]$env:FAKE_BINSTALL_EXIT
+}
+if ($args -contains 'install' -and $args -contains '--version') {
+ exit [int]$env:FAKE_INSTALL_EXIT
+}
exit 0
-"#,
+"#;
+
+fn write(path: &Path, contents: &str) {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).unwrap();
+ }
+ std::fs::write(path, contents).unwrap();
+}
+
+fn tools_available() -> bool {
+ Command::new("just").arg("--version").output().is_ok() && Command::new("pwsh").arg("--version").output().is_ok()
+}
+
+fn fixture(imports: &[(&str, &str)], dependency_recipes: &[&str]) -> TempDir {
+ let tmp = TempDir::new().unwrap();
+ let mut justfile = String::from("set unstable\n\n");
+ // Focused fixtures need this shared variable, but the real version catalog
+ // already defines it and Just rejects duplicate definitions.
+ if !imports.iter().any(|(name, _)| *name == "versions.just") {
+ justfile.push_str("rust_nightly := \"nightly-test\"\n\n");
+ }
+ for (name, contents) in imports {
+ write(&tmp.path().join(name), contents);
+ writeln!(justfile, "import '{name}'").unwrap();
+ }
+ justfile.push('\n');
+ for recipe in dependency_recipes {
+ justfile.push_str(recipe);
+ justfile.push_str(":\n\n");
+ }
+ write(&tmp.path().join("Justfile"), &justfile);
+ write(
+ &tmp.path().join("Cargo.toml"),
+ "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
);
+
+ let bin = tmp.path().join("fake-bin");
+ std::fs::create_dir_all(&bin).unwrap();
+ write(&bin.join("cargo.ps1"), FAKE_CARGO_PS1);
write(&bin.join("git.ps1"), "exit 0\n");
tmp
}
@@ -369,6 +379,96 @@ fn semver_exit_code_contract_is_executed() {
}
}
+#[test]
+fn install_tool_controls_source_fallback_and_prerequisite_ordering() {
+ if !tools_available() {
+ return;
+ }
+ let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]);
+ let justfile_path = tmp.path().join("Justfile");
+ let mut justfile = std::fs::read_to_string(&justfile_path).unwrap();
+ justfile.push_str(
+ r#"
+[script("pwsh", "-NoProfile")]
+source-prereq:
+ Add-Content -LiteralPath $env:FAKE_CARGO_LOG -Value 'source-prereq'
+ exit [int]$env:FAKE_PREREQ_EXIT
+"#,
+ );
+ write(&justfile_path, &justfile);
+ let log = tmp.path().join("cargo.log");
+
+ let fallback = run_just(
+ tmp.path(),
+ &["_install-tool", "cargo-spellcheck", "0.15.7", "binstall", "source-prereq"],
+ &[
+ ("FAKE_CARGO_LOG", log.as_os_str()),
+ ("FAKE_BINSTALL_EXIT", OsStr::new("7")),
+ ("FAKE_PREREQ_EXIT", OsStr::new("0")),
+ ("FAKE_INSTALL_EXIT", OsStr::new("0")),
+ ],
+ );
+ assert!(
+ fallback.status.success(),
+ "controlled source fallback should succeed:\n{}",
+ String::from_utf8_lossy(&fallback.stderr)
+ );
+ let log_contents = std::fs::read_to_string(&log).unwrap();
+ let lines = log_contents.lines().collect::>();
+ let binstall = lines
+ .iter()
+ .position(|line| line.contains("binstall --no-confirm --locked --disable-strategies compile"))
+ .expect("source-prerequisite tools must disable binstall compilation");
+ let prerequisite = lines
+ .iter()
+ .position(|line| *line == "source-prereq")
+ .expect("source prerequisite must run after binary installation fails");
+ let source_install = lines
+ .iter()
+ .position(|line| line.contains("install --locked cargo-spellcheck --version =0.15.7"))
+ .expect("Anvil must perform the controlled source install at the exact pin");
+ assert!(binstall < prerequisite && prerequisite < source_install);
+
+ std::fs::remove_file(&log).unwrap();
+ let prerequisite_failure = run_just(
+ tmp.path(),
+ &["_install-tool", "cargo-spellcheck", "0.15.7", "binstall", "source-prereq"],
+ &[
+ ("FAKE_CARGO_LOG", log.as_os_str()),
+ ("FAKE_BINSTALL_EXIT", OsStr::new("7")),
+ ("FAKE_PREREQ_EXIT", OsStr::new("9")),
+ ],
+ );
+ assert_failed(&prerequisite_failure, "source prerequisite failure");
+ let failed_log = std::fs::read_to_string(&log).unwrap();
+ assert!(failed_log.contains("source-prereq"));
+ assert!(
+ !failed_log.contains("install --locked cargo-spellcheck --version =0.15.7"),
+ "source installation must not run after prerequisite failure"
+ );
+
+ std::fs::remove_file(&log).unwrap();
+ let ordinary_tool = run_just(
+ tmp.path(),
+ &["_install-tool", "cargo-other", "1.2.3", "binstall", ""],
+ &[
+ ("FAKE_CARGO_LOG", log.as_os_str()),
+ ("FAKE_BINSTALL_EXIT", OsStr::new("7")),
+ ("FAKE_INSTALL_EXIT", OsStr::new("0")),
+ ],
+ );
+ assert!(ordinary_tool.status.success());
+ let ordinary_log = std::fs::read_to_string(&log).unwrap();
+ let ordinary_binstall = ordinary_log
+ .lines()
+ .find(|line| line.contains("binstall --no-confirm --locked"))
+ .expect("ordinary tool must attempt binstall");
+ assert!(
+ !ordinary_binstall.contains("--disable-strategies compile"),
+ "tools without source prerequisites retain binstall's compile strategy"
+ );
+}
+
#[test]
fn repository_constants_match_shared_anvil_versions() {
let constants_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../constants.env");
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
index b9b7c91d..2a02a25c 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
@@ -5604,9 +5604,9 @@ anvil-tool-pwsh-validate-prereqs:
# or no-op if it is already installed at or above that version. The
# `installer` parameter selects between:
# - "install" (cargo install --locked, pure-source). Default.
-# - "binstall" (cargo binstall --no-confirm --locked, with cargo install
-# fallback if binstall fails). Bootstraps cargo-binstall
-# itself if not on PATH.
+# - "binstall" (cargo binstall --no-confirm --locked, with a controlled
+# cargo install fallback for tools that declare source
+# prerequisites). Bootstraps cargo-binstall itself if not on PATH.
# `source_prereq`, when set, runs immediately before a source installation,
# including a binstall fallback.
[script("pwsh", "-NoProfile")]
@@ -5654,7 +5654,15 @@ _install-tool name version installer source_prereq="":
exit $LASTEXITCODE
}
}
- cargo binstall --no-confirm --locked $name --version "=$version"
+ # Keep source builds behind Anvil's prerequisite check instead of
+ # allowing binstall to compile before that check can run. Tools with
+ # no source prerequisite may still use binstall's compile strategy.
+ $binstallArgs = @('binstall', '--no-confirm', '--locked')
+ if ($sourcePrereq) {
+ $binstallArgs += @('--disable-strategies', 'compile')
+ }
+ $binstallArgs += @($name, '--version', "=$version")
+ cargo @binstallArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow
}
@@ -6177,10 +6185,9 @@ anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_
# - On install (`-install` recipes): exactly this version (`=` for
# cargo subcommands, exact ref for rustup toolchains). Pulling
# "latest-matching" at install time is a cloud-workflow reproducibility risk --
-# an upstream release between yesterday's green build and today's
-# PR can break things (cargo-spellcheck 0.15.7's em-dash regression
-# is the canonical case). The `=` constraint locks the install to
-# the version the catalog was validated against.
+# an upstream release between yesterday's green build and today's PR can
+# change behavior. The `=` constraint locks the install to the version the
+# catalog was validated against.
# - On validate-prereqs (`-validate-prereqs` recipes): the installed
# version must be `>= `. A user who has manually upgraded a
# tool for their own reasons (e.g. needing an unreleased bugfix)
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
index a3438bd3..d689844d 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
@@ -2043,6 +2043,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only pr-fast's pull
+# request scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: every multi-OS job below hardcodes its OS axis as
# an inline YAML array. Per-leg runner *labels* are inputs (so adopters
# can swap in self-hosted runners), but the OS axis itself is part of
@@ -2091,6 +2097,9 @@ jobs:
uses: ./.github/actions/anvil-impact
pr-fast:
+ permissions:
+ contents: read
+ pull-requests: write
# Cross-OS / cross-arch because pr-fast contains compile-sensitive
# checks (clippy, doc-build, udeps, semver-check, external-types)
# whose results can differ across host for crates that use
@@ -2269,6 +2278,9 @@ concurrency:
jobs:
anvil-pr:
uses: ./.github/workflows/anvil-pr-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores pull-requests:write
+ # only on pr-fast. See docs/design/github.md §9.
permissions:
contents: read
# Write needed so the pr-fast job can upsert/clear the sticky PR
@@ -2311,6 +2323,12 @@ on:
configured at Codecov; required for private repos.
required: false
+# The caller grants the maximum token scopes available to this reusable
+# workflow. Reset jobs to read-only here, then restore only the publisher's
+# issues scope below. See docs/design/github.md §9.
+permissions:
+ contents: read
+
# Note on matrices: see pr-impl-workflow.yml for the rationale. OS
# matrices are hardcoded; per-leg runner labels are inputs.
@@ -2403,6 +2421,74 @@ jobs:
lfs: true
- uses: ./.github/actions/anvil-scheduled-exhaustive
+ publish-failure:
+ name: Publish scheduled failure
+ needs:
+ - scheduled-test
+ - scheduled-advisories
+ - scheduled-runtime-analysis
+ - scheduled-exhaustive
+ if: ${{ always() && vars.ANVIL_PUBLISH_FAILURE_ISSUE != 'false'
+ && contains(needs.*.result, 'failure') }}
+ runs-on: ${{ inputs.linux_runner }}
+ permissions:
+ issues: write
+ steps:
+ - name: Create or update failure issue
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ ANVIL_JOB_RESULTS: ${{ toJSON(needs) }}
+ with:
+ script: |
+ const title = "[Anvil] Scheduled checks failed";
+ const marker = "";
+ const runUrl =
+ `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` +
+ `/actions/runs/${context.runId}`;
+ const results = JSON.parse(process.env.ANVIL_JOB_RESULTS);
+ const failedJobs = Object.entries(results)
+ .filter(([, job]) => job.result === "failure")
+ .map(([job]) => `- \`${job}\``)
+ .join("\n");
+ const body = [
+ marker,
+ "",
+ "The Anvil scheduled workflow failed.",
+ "",
+ "Failed jobs:",
+ failedJobs,
+ "",
+ `[View workflow run](${runUrl})`,
+ ].join("\n");
+
+ const query =
+ `repo:${context.repo.owner}/${context.repo.repo} ` +
+ `is:issue is:open in:body "anvil scheduled failure"`;
+ const { data: search } =
+ await github.rest.search.issuesAndPullRequests({
+ q: query,
+ per_page: 100,
+ });
+ const existing = search.items.find(
+ issue => issue.body?.includes(marker),
+ );
+
+ if (existing) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: existing.number,
+ body,
+ });
+ } else {
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title,
+ body,
+ });
+ }
+
=== .github/workflows/anvil-scheduled.yml ===
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
@@ -2422,8 +2508,12 @@ permissions:
jobs:
anvil-scheduled:
uses: ./.github/workflows/anvil-scheduled-impl.yml
+ # A called workflow cannot elevate beyond its caller. The implementation
+ # resets this upper bound to read-only and restores issues:write only on
+ # publish-failure. See docs/design/github.md §9.
permissions:
contents: read
+ issues: write
secrets: inherit
=== Cargo.toml ===
@@ -5541,9 +5631,9 @@ anvil-tool-pwsh-validate-prereqs:
# or no-op if it is already installed at or above that version. The
# `installer` parameter selects between:
# - "install" (cargo install --locked, pure-source). Default.
-# - "binstall" (cargo binstall --no-confirm --locked, with cargo install
-# fallback if binstall fails). Bootstraps cargo-binstall
-# itself if not on PATH.
+# - "binstall" (cargo binstall --no-confirm --locked, with a controlled
+# cargo install fallback for tools that declare source
+# prerequisites). Bootstraps cargo-binstall itself if not on PATH.
# `source_prereq`, when set, runs immediately before a source installation,
# including a binstall fallback.
[script("pwsh", "-NoProfile")]
@@ -5591,7 +5681,15 @@ _install-tool name version installer source_prereq="":
exit $LASTEXITCODE
}
}
- cargo binstall --no-confirm --locked $name --version "=$version"
+ # Keep source builds behind Anvil's prerequisite check instead of
+ # allowing binstall to compile before that check can run. Tools with
+ # no source prerequisite may still use binstall's compile strategy.
+ $binstallArgs = @('binstall', '--no-confirm', '--locked')
+ if ($sourcePrereq) {
+ $binstallArgs += @('--disable-strategies', 'compile')
+ }
+ $binstallArgs += @($name, '--version', "=$version")
+ cargo @binstallArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow
}
@@ -6114,10 +6212,9 @@ anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_
# - On install (`-install` recipes): exactly this version (`=` for
# cargo subcommands, exact ref for rustup toolchains). Pulling
# "latest-matching" at install time is a cloud-workflow reproducibility risk --
-# an upstream release between yesterday's green build and today's
-# PR can break things (cargo-spellcheck 0.15.7's em-dash regression
-# is the canonical case). The `=` constraint locks the install to
-# the version the catalog was validated against.
+# an upstream release between yesterday's green build and today's PR can
+# change behavior. The `=` constraint locks the install to the version the
+# catalog was validated against.
# - On validate-prereqs (`-validate-prereqs` recipes): the installed
# version must be `>= `. A user who has manually upgraded a
# tool for their own reasons (e.g. needing an unreleased bugfix)
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
index 72bf2202..6ae533ee 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
@@ -4344,9 +4344,9 @@ anvil-tool-pwsh-validate-prereqs:
# or no-op if it is already installed at or above that version. The
# `installer` parameter selects between:
# - "install" (cargo install --locked, pure-source). Default.
-# - "binstall" (cargo binstall --no-confirm --locked, with cargo install
-# fallback if binstall fails). Bootstraps cargo-binstall
-# itself if not on PATH.
+# - "binstall" (cargo binstall --no-confirm --locked, with a controlled
+# cargo install fallback for tools that declare source
+# prerequisites). Bootstraps cargo-binstall itself if not on PATH.
# `source_prereq`, when set, runs immediately before a source installation,
# including a binstall fallback.
[script("pwsh", "-NoProfile")]
@@ -4394,7 +4394,15 @@ _install-tool name version installer source_prereq="":
exit $LASTEXITCODE
}
}
- cargo binstall --no-confirm --locked $name --version "=$version"
+ # Keep source builds behind Anvil's prerequisite check instead of
+ # allowing binstall to compile before that check can run. Tools with
+ # no source prerequisite may still use binstall's compile strategy.
+ $binstallArgs = @('binstall', '--no-confirm', '--locked')
+ if ($sourcePrereq) {
+ $binstallArgs += @('--disable-strategies', 'compile')
+ }
+ $binstallArgs += @($name, '--version', "=$version")
+ cargo @binstallArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow
}
@@ -4917,10 +4925,9 @@ anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_
# - On install (`-install` recipes): exactly this version (`=` for
# cargo subcommands, exact ref for rustup toolchains). Pulling
# "latest-matching" at install time is a cloud-workflow reproducibility risk --
-# an upstream release between yesterday's green build and today's
-# PR can break things (cargo-spellcheck 0.15.7's em-dash regression
-# is the canonical case). The `=` constraint locks the install to
-# the version the catalog was validated against.
+# an upstream release between yesterday's green build and today's PR can
+# change behavior. The `=` constraint locks the install to the version the
+# catalog was validated against.
# - On validate-prereqs (`-validate-prereqs` recipes): the installed
# version must be `>= `. A user who has manually upgraded a
# tool for their own reasons (e.g. needing an unreleased bugfix)
diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just
index 7e7df78f..cb6aab14 100644
--- a/justfiles/anvil/tools.just
+++ b/justfiles/anvil/tools.just
@@ -173,9 +173,9 @@ anvil-tool-pwsh-validate-prereqs:
# or no-op if it is already installed at or above that version. The
# `installer` parameter selects between:
# - "install" (cargo install --locked, pure-source). Default.
-# - "binstall" (cargo binstall --no-confirm --locked, with cargo install
-# fallback if binstall fails). Bootstraps cargo-binstall
-# itself if not on PATH.
+# - "binstall" (cargo binstall --no-confirm --locked, with a controlled
+# cargo install fallback for tools that declare source
+# prerequisites). Bootstraps cargo-binstall itself if not on PATH.
# `source_prereq`, when set, runs immediately before a source installation,
# including a binstall fallback.
[script("pwsh", "-NoProfile")]
@@ -223,7 +223,15 @@ _install-tool name version installer source_prereq="":
exit $LASTEXITCODE
}
}
- cargo binstall --no-confirm --locked $name --version "=$version"
+ # Keep source builds behind Anvil's prerequisite check instead of
+ # allowing binstall to compile before that check can run. Tools with
+ # no source prerequisite may still use binstall's compile strategy.
+ $binstallArgs = @('binstall', '--no-confirm', '--locked')
+ if ($sourcePrereq) {
+ $binstallArgs += @('--disable-strategies', 'compile')
+ }
+ $binstallArgs += @($name, '--version', "=$version")
+ cargo @binstallArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow
}
diff --git a/justfiles/anvil/versions.just b/justfiles/anvil/versions.just
index 4e60c597..564a3b90 100644
--- a/justfiles/anvil/versions.just
+++ b/justfiles/anvil/versions.just
@@ -11,10 +11,9 @@
# - On install (`-install` recipes): exactly this version (`=` for
# cargo subcommands, exact ref for rustup toolchains). Pulling
# "latest-matching" at install time is a cloud-workflow reproducibility risk --
-# an upstream release between yesterday's green build and today's
-# PR can break things (cargo-spellcheck 0.15.7's em-dash regression
-# is the canonical case). The `=` constraint locks the install to
-# the version the catalog was validated against.
+# an upstream release between yesterday's green build and today's PR can
+# change behavior. The `=` constraint locks the install to the version the
+# catalog was validated against.
# - On validate-prereqs (`-validate-prereqs` recipes): the installed
# version must be `>= `. A user who has manually upgraded a
# tool for their own reasons (e.g. needing an unreleased bugfix)