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..d84ec38e --- /dev/null +++ b/.github/workflows/anvil-regen.yml @@ -0,0 +1,241 @@ +# 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 + +defaults: + run: + shell: pwsh + +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: | + $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. + $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] + 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: | + 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 + 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: | + $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: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $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 + # 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. + $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 + 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: | + $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 = @( + 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 + } + + cargo build --locked -p cargo-anvil + $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 + } + + - name: Push the regenerated tree + run: | + $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 + $changed = @(git diff --cached --name-only) + if ($changed.Count -eq 0) { + Write-Host "Nothing to reconcile on pull request $env:PR." + exit 0 + } + + # 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. + $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 + } + + 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: {}