From d024712ac61fa412b6e15a03a796f23a9d20c032 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:49:34 +0100 Subject: [PATCH 1/2] ci: reconcile anvil artifacts on Dependabot branches Dependabot cannot run a binary, so a pull request that bumps a GitHub Action leaves cargo-anvil's emitted tree and .anvil.lock stale. Merging one as-is is worse than useless: anvil keys each artifact on disk-vs-lock and template-vs-lock, so an edit it did not make marks the file as repository-customized and it stops updating that file for good. Add an anvil-regen workflow that reconciles those branches. It restores the emitted files to their base-branch state so the bump takes anvil's plain overwrite path, re-runs the generator, refreshes the insta snapshots, and pushes the result back onto the Dependabot branch. It runs on a schedule because workflows initiated by Dependabot get a read-only token and no secrets, and it needs its own push token because commits pushed with GITHUB_TOKEN do not start check runs. Point Dependabot at the templates as well as the workflows, grouped by dependency name so both move in one pull request, and widen regenerate-check so drift on an anvil-owned file is visible on the pull request instead of only in the merge queue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 21 ++- .github/workflows/anvil-regen.yml | 226 +++++++++++++++++++++++++ .github/workflows/regenerate-check.yml | 9 + 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/anvil-regen.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12a1bc90..d2242d41 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,11 +18,30 @@ updates: update-types: ["version-update:semver-patch"] - package-ecosystem: "github-actions" - directory: "/" + # "/" covers .github/workflows (both the hand-written workflows and the + # anvil-generated anvil-*.yml ones). The second directory covers + # cargo-anvil's templates, which are the source of truth for everything + # anvil emits -- including the composite actions under .github/actions, + # which Dependabot cannot see otherwise: for directory "/" it only reads + # an action.yml at the repository root, never .github/actions/*/action.yml. + # + # A generated workflow and its template must move together, or cargo-anvil + # sees the generated file as hand-edited and stops updating it (see + # crates/cargo-anvil/docs/design/updates.md section 5). group-by makes + # Dependabot raise one pull request per action spanning both directories. + # The anvil-regen workflow then reconciles the emitted tree and .anvil.lock + # on that branch. + directories: + - "/" + - "/crates/cargo-anvil/templates/github" schedule: interval: "monthly" commit-message: prefix: "chore: " + groups: + actions: + patterns: ["*"] + group-by: dependency-name # Wait 7 days after a release before proposing it, so the community has a # window to detect and report a compromised action release. cooldown: diff --git a/.github/workflows/anvil-regen.yml b/.github/workflows/anvil-regen.yml new file mode 100644 index 00000000..0c9f8944 --- /dev/null +++ b/.github/workflows/anvil-regen.yml @@ -0,0 +1,226 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Reconciles cargo-anvil's emitted tree on Dependabot branches. +# +# Dependabot keeps doing what it is good at -- discovering action releases, +# honouring the cooldown, writing the changelog -- but it cannot run a binary, +# so it can neither regenerate the files anvil owns nor refresh the checksums +# in .anvil.lock. A Dependabot pull request that touches an anvil artifact is +# therefore always incomplete, and merging it as-is is actively harmful: anvil +# compares disk, lock and template checksums, so an edit it did not make marks +# the file as repository-customized and it stops updating that file (see +# crates/cargo-anvil/docs/design/updates.md section 5). +# +# This workflow closes that gap. For every open Dependabot pull request that +# touches an anvil artifact it restores the emitted files to their base-branch +# state, re-runs anvil so the bump flows from the template through the +# generator, refreshes the insta snapshots, and pushes the result back onto the +# Dependabot branch. +# +# Why a schedule rather than a trigger on the pull request itself: workflows +# initiated by Dependabot get a read-only GITHUB_TOKEN and no access to Actions +# secrets, and that also holds for pull_request_target when the base ref was +# created by Dependabot. A scheduled run is not initiated by Dependabot, so it +# gets a normal token and can read secrets. +# +# ANVIL_REGEN_TOKEN is required and must NOT be the default GITHUB_TOKEN: +# pushes made with GITHUB_TOKEN deliberately do not start new workflow runs, so +# the pull request would keep the check results of the pre-regeneration commit +# and could never go green. Use a GitHub App installation token or a +# fine-grained PAT with "Contents: read and write" on this repository. +name: anvil-regen + +on: + schedule: + # Dependabot's own schedule is monthly, but a pull request that opens just + # after a run would otherwise sit broken until the next one. + - cron: '17 */3 * * *' + workflow_dispatch: + inputs: + pr: + description: Reconcile only this pull request number. + required: false + type: string + +permissions: + contents: read + pull-requests: read + +concurrency: + group: anvil-regen + cancel-in-progress: false + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + prs: ${{ steps.find.outputs.prs }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Find Dependabot pull requests touching anvil artifacts + id: find + env: + GH_TOKEN: ${{ github.token }} + ONLY_PR: ${{ inputs.pr }} + run: | + set -euo pipefail + + # Anvil owns every path recorded as a [[file]] entry in the lock, + # plus the templates those files are rendered from. Region entries + # ([[region]], keyed by "host") are deliberately excluded: their + # hosts are co-owned files whose non-region content belongs to the + # repository. + mapfile -t owned < <(sed -n 's/^path = "\(.*\)"$/\1/p' .anvil.lock) + owned+=("crates/cargo-anvil/templates/") + + if [[ -n "$ONLY_PR" ]]; then + numbers="$ONLY_PR" + else + numbers=$(gh pr list \ + --state open \ + --author 'app/dependabot' \ + --limit 100 \ + --json number \ + --jq '.[].number') + fi + + selected=() + for pr in $numbers; do + # --json files is capped at 100 entries per page by the API; an + # action bump is a handful of files, and a Dependabot pull request + # that large would not be one we want to touch unattended anyway. + mapfile -t changed < <(gh pr view "$pr" --json files --jq '.files[].path') + for file in "${changed[@]}"; do + for path in "${owned[@]}"; do + if [[ "$file" == "$path" || "$file" == "$path"* ]]; then + selected+=("$pr") + break 2 + fi + done + done + done + + if [[ ${#selected[@]} -eq 0 ]]; then + echo "No Dependabot pull requests touch anvil artifacts." + echo 'prs=[]' >> "$GITHUB_OUTPUT" + else + printf 'Selected pull requests: %s\n' "${selected[*]}" + printf 'prs=%s\n' "$(printf '%s\n' "${selected[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" + fi + + reconcile: + needs: [discover] + if: needs.discover.outputs.prs != '' && needs.discover.outputs.prs != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pr: ${{ fromJSON(needs.discover.outputs.prs) }} + env: + # Surfaced as env vars because the secrets context is not available in a + # step-level if:, and because nearly every step below needs the token. + GH_TOKEN: ${{ secrets.ANVIL_REGEN_TOKEN }} + PR: ${{ matrix.pr }} + steps: + - name: Require the push token + if: ${{ env.GH_TOKEN == '' }} + run: | + echo "::error::ANVIL_REGEN_TOKEN is not configured. Without it the" \ + "regeneration commit cannot start a new check run and the" \ + "pull request can never go green. See the header of this file." + exit 1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so the base branch is available to restore from, and + # the elevated token so the later push starts check runs. + fetch-depth: 0 + lfs: true + token: ${{ secrets.ANVIL_REGEN_TOKEN }} + + - name: Check out the Dependabot branch + run: | + set -euo pipefail + gh pr checkout "$PR" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Restore anvil-owned files to their base-branch state + run: | + set -euo pipefail + + base=$(gh pr view "$PR" --json baseRefName --jq '.baseRefName') + git fetch --no-tags origin "$base" + + # Anvil's decision table keys each artifact on disk-vs-lock and + # template-vs-lock, never disk-vs-template. Dependabot's edit to a + # generated file therefore reads as "the repository customized this" + # and earns a .anvil-proposed sibling instead of an overwrite -- even + # when its content is byte-identical to what anvil would render. + # Reverting the emitted files first puts disk back in step with the + # lock, so the template bump takes the plain overwrite path. + mapfile -t owned < <(sed -n 's/^path = "\(.*\)"$/\1/p' .anvil.lock) + if [[ ${#owned[@]} -gt 0 ]]; then + git checkout "origin/$base" -- "${owned[@]}" + fi + git checkout "origin/$base" -- .anvil.lock + + - name: Regenerate the anvil tree + run: cargo run --locked -p cargo-anvil -- anvil + + - name: Refresh snapshots + env: + INSTA_UPDATE: always + run: cargo test --locked -p cargo-anvil --test snapshots + + - name: Verify the tree is now consistent + run: | + set -euo pipefail + + # A proposal means anvil declined to take ownership of a file, so the + # bump did not actually land. Catch it explicitly: the dry-run below + # cannot, because writing a proposal also advances the lock, leaving + # the next run a clean no-op. + proposals=$(find . -name '*.anvil-proposed' -not -path './target/*') + if [[ -n "$proposals" ]]; then + echo "::error::anvil wrote proposals instead of updating its own" \ + "files; reconcile pull request $PR by hand." + echo "$proposals" + exit 1 + fi + + cargo build --locked -p cargo-anvil + export PATH="$PWD/target/debug:$PATH" + if ! cargo anvil --dry-run ; then + echo "::error::anvil still reports pending changes after" \ + "regeneration; reconcile pull request $PR by hand." + exit 1 + fi + + - name: Push the regenerated tree + run: | + set -euo pipefail + + # git checkout -- stages what it restores, so the + # comparison has to be against HEAD rather than against the index. + git add --all + if git diff --cached --quiet ; then + echo "Nothing to reconcile on pull request $PR." + exit 0 + fi + + # Guardrail: this job runs unattended against a branch nobody has + # reviewed, so it may only ever move anvil's own artifacts. Anything + # else means an assumption above no longer holds. + if git diff --cached --name-only \ + | grep -qvE '^(\.anvil\.lock|\.anvil/|\.github/workflows/anvil-|\.github/actions/anvil-|justfiles/anvil/|crates/cargo-anvil/tests/snapshots/)' ; then + echo "::error::Regeneration touched files outside anvil's" \ + "artifacts; refusing to push. Diff:" + git diff --cached --name-only + exit 1 + fi + + git commit --message 'chore: regenerate anvil artifacts' + git push diff --git a/.github/workflows/regenerate-check.yml b/.github/workflows/regenerate-check.yml index f1e21836..d9ea8710 100644 --- a/.github/workflows/regenerate-check.yml +++ b/.github/workflows/regenerate-check.yml @@ -21,6 +21,15 @@ on: paths: - "crates/cargo-anvil/**" - ".github/workflows/regenerate-check.yml" + # Anvil-owned artifacts. Editing one of these by hand (or via a + # Dependabot action bump) makes anvil treat the file as + # repository-customized and stop updating it, so the drift has to be + # visible on the pull request rather than only in the merge queue. + - ".github/workflows/anvil-*.yml" + - ".github/actions/anvil-*/**" + - "justfiles/anvil/**" + - ".anvil/**" + - ".anvil.lock" merge_group: {} workflow_dispatch: {} From 2fe15818e843eb88b1223a42a11bf03422b4d90c Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:10:50 +0100 Subject: [PATCH 2/2] ci: write anvil-regen steps in PowerShell Match the repository's shell of record: the Justfile sets script-interpreter to pwsh, so the recipes CI already runs are PowerShell. Set it as the workflow-level default rather than per step. The rewrite also drops two bash-isms that had no PowerShell counterpart and were carrying risk: jq string-wrapping to build the matrix JSON (ConvertTo-Json -InputObject does it without unrolling a single-element array), and `git diff --cached --quiet`, whose exit code 1 means "there are changes" and would have been read as a failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/anvil-regen.yml | 159 ++++++++++++++++-------------- 1 file changed, 87 insertions(+), 72 deletions(-) diff --git a/.github/workflows/anvil-regen.yml b/.github/workflows/anvil-regen.yml index 0c9f8944..d84ec38e 100644 --- a/.github/workflows/anvil-regen.yml +++ b/.github/workflows/anvil-regen.yml @@ -47,6 +47,10 @@ permissions: contents: read pull-requests: read +defaults: + run: + shell: pwsh + concurrency: group: anvil-regen cancel-in-progress: false @@ -65,50 +69,51 @@ jobs: GH_TOKEN: ${{ github.token }} ONLY_PR: ${{ inputs.pr }} run: | - set -euo pipefail + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true # Anvil owns every path recorded as a [[file]] entry in the lock, # plus the templates those files are rendered from. Region entries # ([[region]], keyed by "host") are deliberately excluded: their # hosts are co-owned files whose non-region content belongs to the # repository. - mapfile -t owned < <(sed -n 's/^path = "\(.*\)"$/\1/p' .anvil.lock) - owned+=("crates/cargo-anvil/templates/") - - if [[ -n "$ONLY_PR" ]]; then - numbers="$ONLY_PR" - else - numbers=$(gh pr list \ - --state open \ - --author 'app/dependabot' \ - --limit 100 \ - --json number \ - --jq '.[].number') - fi - - selected=() - for pr in $numbers; do - # --json files is capped at 100 entries per page by the API; an - # action bump is a handful of files, and a Dependabot pull request - # that large would not be one we want to touch unattended anyway. - mapfile -t changed < <(gh pr view "$pr" --json files --jq '.files[].path') - for file in "${changed[@]}"; do - for path in "${owned[@]}"; do - if [[ "$file" == "$path" || "$file" == "$path"* ]]; then - selected+=("$pr") - break 2 - fi - done - done - done - - if [[ ${#selected[@]} -eq 0 ]]; then - echo "No Dependabot pull requests touch anvil artifacts." - echo 'prs=[]' >> "$GITHUB_OUTPUT" - else - printf 'Selected pull requests: %s\n' "${selected[*]}" - printf 'prs=%s\n' "$(printf '%s\n' "${selected[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" - fi + $owned = @( + Select-String -Path '.anvil.lock' -Pattern '^path = "(.*)"$' | + ForEach-Object { $_.Matches[0].Groups[1].Value } + ) + $owned += 'crates/cargo-anvil/templates/' + + if ($env:ONLY_PR) { + $numbers = @($env:ONLY_PR) + } else { + $numbers = @( + gh pr list --state open --author 'app/dependabot' --limit 100 --json number --jq '.[].number' + ) + } + + $selected = @() + foreach ($pr in $numbers) { + $changed = @(gh pr view $pr --json files --jq '.files[].path') + $touches = $changed | Where-Object { + $file = $_ + $owned | Where-Object { $file -eq $_ -or $file.StartsWith($_) } + } + if ($touches) { + $selected += $pr + } + } + + if ($selected.Count -eq 0) { + Write-Host 'No Dependabot pull requests touch anvil artifacts.' + Add-Content -Path $env:GITHUB_OUTPUT -Value 'prs=[]' + } else { + Write-Host "Selected pull requests: $($selected -join ', ')" + # -InputObject rather than the pipeline: piping a single-element + # array unrolls it and would emit a bare string, which fromJSON + # cannot expand into a matrix. + $json = ConvertTo-Json -InputObject @($selected) -Compress + Add-Content -Path $env:GITHUB_OUTPUT -Value "prs=$json" + } reconcile: needs: [discover] @@ -127,9 +132,7 @@ jobs: - name: Require the push token if: ${{ env.GH_TOKEN == '' }} run: | - echo "::error::ANVIL_REGEN_TOKEN is not configured. Without it the" \ - "regeneration commit cannot start a new check run and the" \ - "pull request can never go green. See the header of this file." + Write-Host '::error::ANVIL_REGEN_TOKEN is not configured. Without it the regeneration commit cannot start a new check run and the pull request can never go green. See the header of this file.' exit 1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -142,17 +145,20 @@ jobs: - name: Check out the Dependabot branch run: | - set -euo pipefail - gh pr checkout "$PR" + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + gh pr checkout $env:PR git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - name: Restore anvil-owned files to their base-branch state run: | - set -euo pipefail + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true - base=$(gh pr view "$PR" --json baseRefName --jq '.baseRefName') - git fetch --no-tags origin "$base" + $base = gh pr view $env:PR --json baseRefName --jq '.baseRefName' + git fetch --no-tags origin $base # Anvil's decision table keys each artifact on disk-vs-lock and # template-vs-lock, never disk-vs-template. Dependabot's edit to a @@ -161,10 +167,13 @@ jobs: # when its content is byte-identical to what anvil would render. # Reverting the emitted files first puts disk back in step with the # lock, so the template bump takes the plain overwrite path. - mapfile -t owned < <(sed -n 's/^path = "\(.*\)"$/\1/p' .anvil.lock) - if [[ ${#owned[@]} -gt 0 ]]; then - git checkout "origin/$base" -- "${owned[@]}" - fi + $owned = @( + Select-String -Path '.anvil.lock' -Pattern '^path = "(.*)"$' | + ForEach-Object { $_.Matches[0].Groups[1].Value } + ) + if ($owned.Count -gt 0) { + git checkout "origin/$base" -- $owned + } git checkout "origin/$base" -- .anvil.lock - name: Regenerate the anvil tree @@ -177,50 +186,56 @@ jobs: - name: Verify the tree is now consistent run: | - set -euo pipefail + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true # A proposal means anvil declined to take ownership of a file, so the # bump did not actually land. Catch it explicitly: the dry-run below # cannot, because writing a proposal also advances the lock, leaving # the next run a clean no-op. - proposals=$(find . -name '*.anvil-proposed' -not -path './target/*') - if [[ -n "$proposals" ]]; then - echo "::error::anvil wrote proposals instead of updating its own" \ - "files; reconcile pull request $PR by hand." - echo "$proposals" + $proposals = @( + Get-ChildItem -Recurse -File -Force -Filter '*.anvil-proposed' | + Where-Object { $_.FullName -notmatch '[\\/]target[\\/]' } + ) + if ($proposals.Count -gt 0) { + Write-Host "::error::anvil wrote proposals instead of updating its own files; reconcile pull request $env:PR by hand." + $proposals | ForEach-Object { Write-Host $_.FullName } exit 1 - fi + } cargo build --locked -p cargo-anvil - export PATH="$PWD/target/debug:$PATH" - if ! cargo anvil --dry-run ; then - echo "::error::anvil still reports pending changes after" \ - "regeneration; reconcile pull request $PR by hand." + $env:PATH = "$PWD/target/debug" + [IO.Path]::PathSeparator + $env:PATH + try { + cargo anvil --dry-run + } catch { + Write-Host "::error::anvil still reports pending changes after regeneration; reconcile pull request $env:PR by hand." exit 1 - fi + } - name: Push the regenerated tree run: | - set -euo pipefail + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true # git checkout -- stages what it restores, so the # comparison has to be against HEAD rather than against the index. git add --all - if git diff --cached --quiet ; then - echo "Nothing to reconcile on pull request $PR." + $changed = @(git diff --cached --name-only) + if ($changed.Count -eq 0) { + Write-Host "Nothing to reconcile on pull request $env:PR." exit 0 - fi + } # Guardrail: this job runs unattended against a branch nobody has # reviewed, so it may only ever move anvil's own artifacts. Anything # else means an assumption above no longer holds. - if git diff --cached --name-only \ - | grep -qvE '^(\.anvil\.lock|\.anvil/|\.github/workflows/anvil-|\.github/actions/anvil-|justfiles/anvil/|crates/cargo-anvil/tests/snapshots/)' ; then - echo "::error::Regeneration touched files outside anvil's" \ - "artifacts; refusing to push. Diff:" - git diff --cached --name-only + $allowed = '^(\.anvil\.lock|\.anvil/|\.github/workflows/anvil-|\.github/actions/anvil-|justfiles/anvil/|crates/cargo-anvil/tests/snapshots/)' + $unexpected = @($changed | Where-Object { $_ -notmatch $allowed }) + if ($unexpected.Count -gt 0) { + Write-Host "::error::Regeneration touched files outside anvil's artifacts; refusing to push." + $unexpected | ForEach-Object { Write-Host $_ } exit 1 - fi + } git commit --message 'chore: regenerate anvil artifacts' git push