Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
241 changes: 241 additions & 0 deletions .github/workflows/anvil-regen.yml
Original file line number Diff line number Diff line change
@@ -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 <ref> -- <paths> 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
9 changes: 9 additions & 0 deletions .github/workflows/regenerate-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}

Expand Down
Loading