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)