ADR-0041: Regenerate committed artifacts on Dependabot pull requests with default-branch scripts behind a maintainer label
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-09-27 |
| Review by | 2027-09-27 |
| Schema version | 0.1.0 |
| Reversibility | two-way-door |
| Blast radius | team |
| Scope | org |
| Tags | ci, supply-chain, dependencies, security |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Relates to | 0007, 0010, 0016, 0035, 0036 |
| Affects | path:.github/workflows/regenerate-artifacts.yml, path:.github/dependabot.yml, path:packages/ci/dist/**, path:schema/adr.schema.json |
| Source | docs/adr/0041-regenerate-committed-artifacts-on-dependabot-pull-requests-with-default-branch-s.md |
Context
Section titled “Context”Two artifacts in this repository are generated from dependencies and committed, and CI requires each to match a fresh regeneration byte for byte:
packages/ci/dist/index.jsandqueue-action.js, the Action bundles. A JavaScript Action runs from the repository at the ref a consumer names, so the bundle has to exist at that commit — the rootaction.ymlalias (ADR-0036) and the nested@v0consumers both depend on it. The bundles inline 23 packages and mark each module with a source-path comment such as// ../../node_modules/.bun/yaml@2.9.0/…, so any version change to a bundled package rewrites them. Besides the direct dependencies (yaml,zod,semver,picomatch,@actions/*) that includes transitives no Dependabot pattern can name:undici,@octokit/*,tunnel,before-after-hook, and others.clean-clone-buildsfails ongit diff --exit-code packages/ci/dist.schema/adr.schema.json, emitted from the Zod source. Azodbump can change it, andclean-clone-buildsfails onschema:emit && git diff.
Dependabot edits manifests and bun.lock; it regenerates neither artifact. The
result is observed rather than predicted:
- #206, the
root-bungroup, is red onclean-clone-buildswith a diff consisting only ofyaml@2.9.0 → 2.9.1path comments in the bundle. It held@types/bunred with it. - #209,
zod, is red for the reasondependabot.ymlalready records. - #188, #194, #200, and #206 — every
root-bunPR since #188 — were closed rather than merged; thedependabot.ymlcomment records #200 as red onclean-clone-builds. The lastroot-bunPR to merge was #99, a types-only bump that touched nothing bundled.
The response so far has been to exclude each dependency from the group once it is
seen failing (zod, picomatch, and, in
#223, yaml). That isolates the
failure without fixing it: the excluded bump still arrives as its own red PR, and
the transitives cannot be excluded at all.
It does not even reliably isolate it. The first root-bun PR after #223,
#227, proposes only @types/bun, yet
its bun.lock also moves yaml 2.9.0 → 2.9.1 and it fails the same bundle diff.
yaml is declared as the latest dist-tag in packages/core and
packages/adapters/catalog-backstage, so re-resolving the lock for any update
floats it regardless of exclude-patterns. An exclusion list is a filter on what
Dependabot proposes, not on what the lockfile resolves.
Regenerating by hand is possible but not cheap. CONTRIBUTING.md documents it:
the bundle must be rebuilt under linux/amd64 Bun 1.3.14, because Bun’s CommonJS
interop output differs by host target and a Mac-built bundle fails the same gate.
The maintainer works on a Mac, so every regeneration today is a docker run --platform linux/amd64 oven/bun:1.3.14 …, a host reinstall, a commit, and a push
— per dependency PR, weekly.
Why this needs a decision, not a workflow
Section titled “Why this needs a decision, not a workflow”Automating that means a job that writes to a branch after installing and running
code from a bot-authored change. ADR-0035 is the relevant boundary, and it has to
be read precisely. It governs gates — jobs whose verdict certifies a PR — and
its rule that nothing privileged may “check out, install, build, or execute
pull-request code” is how those gates stay trustworthy. A regenerator is not a
gate; its output is judged by the gates, unchanged. But it is a privileged writer
operating on the same untrusted input, so every lesson ADR-0035’s three rounds of
security review paid for applies to it, and one of them applies directly:
gate-integrity blocks any PR touching packages/ci/** without the
gate-change-acknowledged label, and dismisses that label whenever the head
moves. A regenerated bundle is such a change.
Running dependency code is not hypothetical here. bun build bundles
@actions/* without executing it, but schema:emit runs
bun ./src/schema/emit.cli.ts, which executes the bumped zod.
What Dependabot and GitHub provide natively
Section titled “What Dependabot and GitHub provide natively”Checked against the vendor’s own documentation and tracker before choosing, because a first-party mechanism would beat anything built here:
- Dependabot has no post-update command.
dependabot.ymlhas no key that runs a build before the PR is opened. It has been requested since 2020 (dependabot-core#1758, #2004, both closed without it) and is requested again, still open as of this record, in #14549 — whose text names this repository’s exact failure: the workaround is a workflow that pushes the rebuild back, and aGITHUB_TOKENpush never retriggers the required checks.versioning-strategy,groups,ignore, andallowall shape which PRs exist; none shapes what is in them beyond manifests and lockfiles. - GitHub’s documented automation pattern is the one to avoid. GitHub’s
guidance for automating Dependabot PRs is a
pull_requestworkflow gated onif: github.actor == 'dependabot[bot]', where the token is read-only and only Dependabot secrets are available (troubleshooting Dependabot on Actions).github.actoris whoever caused the latest event, and published research shows an attacker can make Dependabot that actor on a PR it did not author, and inject through crafted branch names (Boost Security, “Weaponizing Dependabot”). An actor check is not an identity check. - Dependabot can keep maintaining a PR someone else has committed to. By
default it stops rebasing once extra commits land, but a commit whose message
contains
[dependabot skip]is one it is allowed to force-push over (managing Dependabot PRs).
The first finding is why something has to be built; the second is why it is not
built on github.actor; the third removes what would otherwise be this design’s
largest cost.
Decision
Section titled “Decision”We will regenerate committed artifacts on Dependabot pull requests with a
maintainer-triggered pull_request_target workflow that builds with the default
branch’s scripts in an unprivileged job and pushes the result, restricted to the
artifact paths, from a separate job holding a GitHub App token.
In .github/workflows/regenerate-artifacts.yml:
- Trigger: a maintainer applies the
regenerate-artifactslabel. Applying a label requires triage or write access, is recorded against whoever did it, and makes the maintainer — notdependabot[bot]— the triggering actor. That last property is why the label is not optional: GitHub restricts secrets for workflows Dependabot triggers, and a label applied by a person is how the job reaches an Actions secret at all. The job records the head SHA it was granted for and refuses if the head has moved. A final job,if: always(), removes the label whatever the outcome and confirms the removal with a live API read. It uses the workflow’s ownGITHUB_TOKENwithpull-requests: writeand nothing else — the App token hascontents: writeonly and cannot remove a label, and keeping the two credentials apart keeps each one minimal. No job keys anything ongithub.actor, and the head branch name is never interpolated into a command: it is read from the API and passed throughenv:, since a branch name is attacker-influenced text in the research cited above. - Eligibility, read from the API and not the event payload. The PR’s author
is
dependabot[bot], and its head repository is this repository — Dependabot branches are never forks. Every commit on it is either authored bydependabot[bot](checked by account login, not by the free-text email) or is this workflow’s own earlier regeneration commit, authored by the App and touching only the artifact paths in item 4 — so a second run on the same PR is possible. The committer is not checked: Dependabot creates commits through the API, and they carryweb-flowas committer (observed on #206, signature verified). Its changed files (paginated) are onlybun.lock,package.json, andpackages/**/package.json— plus the artifact paths in item 4, admitted only when every commit that touches them is an App regeneration commit. Without that second clause a re-run after an earlier regeneration would refuse its own output. Each changed manifest must differ from the default branch’s copy only independencies,devDependencies,peerDependencies, andoptionalDependencies— a path allowlist alone would pass a changedscriptsblock. Anything else refuses with a message naming what failed.site/and thegithub-actionsecosystem are out of scope by construction. - Build job: unprivileged.
permissions: contents: read, no secrets,persist-credentials: false. It checks out the default branch and, from the PR’s head commit fetched as objects, writesbun.lockverbatim and only the dependency fields of each eligible manifest ontomain’s copy — so the build and emit scripts that run aremain’s even ifmainchanged them after Dependabot branched, and the PR contributes nothing but dependency versions. Eligibility compares each manifest with its merge-base copy, which is what Dependabot started from. It runsbun install --frozen-lockfile --ignore-scriptsunder the pinned Bun, then, underscripts/run-network-denied.ts,bun run --filter='@adrkit/ci' buildandbun run schema:emit, onubuntu-24.04— the runnerclean-clone-buildspins, notubuntu-latest, because the bundle must match that job’s rebuild byte for byte and-latestmoves. It uploads the changed artifact files and a list of their paths, and nothing else; a rebuild that adds or removes an artifact file refuses rather than guessing. An empty diff is a result, not an error: the push job then creates no commit and reports the artifacts as already current. - Push job: privileged, and never checks anything out. ADR-0035’s rule lists
checkout beside install, build, and execute, so this job has no worktree at
all. It downloads the upload — untrusted, because the build job
ran dependency code — and rejects it unless every listed path is a top-level
packages/ci/dist/*.jsfile or exactlyschema/adr.schema.jsonand is a regular file, and writes the result through the Git Data API: a blob per changed file, a tree based on the recorded head SHA’s tree, a commit whose parent is that SHA and whose message carries aSigned-off-byfor the App’s bot identity (check-dcoalready accepts a bot sign-off) and[dependabot skip], so Dependabot keeps rebasing the PR and force-pushes over the regeneration when it does, then a non-forced ref update — which fails, as the lease should, if the branch has moved. Commits an App creates through the API are expected to be signed by GitHub and so show as verified like Dependabot’s; that is an expectation to observe (action item 2), not an assumption the design depends on — no gate here requires a signature. It authenticates with a GitHub App installation token scoped tocontents: writeon this repository, because a push made withGITHUB_TOKENdoes not trigger workflows and would leave the PR’s required checks attached to the old head. The job installs no Bun and runs no repository script. - The gates then judge the result unchanged. The push moves the head, so
gate-integritydismisses anygate-change-acknowledgedlabel and requires it again for thepackages/ci/distdiff;clean-clone-buildsrebuilds and diffs independently. The regenerator removes a mechanical step. It adds no authority.
dependabot.yml’s zod exclusion may be lifted once this has regenerated a real
zod bump green. The picomatch exclusion stays — its test records a version
observation that no regeneration updates — and so does the
@modelcontextprotocol/* pin, which needs a protocol run, not a rebuild.
What we are explicitly not doing
Section titled “What we are explicitly not doing”- Not triggering without the label. An automatic run on every Dependabot
synchronizewould havedependabot[bot]as the actor, lose access to the App secret, and remove the one human step that decides a given bump is worth building. Storing the App key as a Dependabot secret to get around that is Option G below. - Not regenerating
bun.lock. The stale-optional-binding lock seen on #221 is Bun 1.3.14’s own incremental resolution — plainbun installfrommain’s lock reproduces it byte for byte — and fixing it is a fresh resolve, which is a reviewable change to every transitive, not a mechanical one. It is tracked with the Starlight failure in #224. - Not touching
site/. It commits no dependency-derived artifact.
What implementation clarified
Section titled “What implementation clarified”Recorded here rather than folded silently into the ratified text above, which items 3 and 4 were edited to match. Neither changes what was decided; both narrow how.
- An overlay against the merge base, not a copy. Copying the pull request’s
manifests would revert any
scriptschangemainmade after Dependabot branched. The build writes only the dependency fields ontomain’s manifests, and eligibility compares each manifest with its merge-base copy — what Dependabot actually started from. - Files plus a path list, not a patch. The build uploads the changed artifact
files and their paths, and the push job validates both as untrusted, because
the build ran dependency code. Writing blobs from files needs no
git apply, and so no worktree, in the privileged job. Writing the validator’s tests found that a shellcasepattern’s*also matches/, so the nested-path refusal is the rule that stops traversal and has to come first.
Options considered
Section titled “Options considered”Option A: Label-triggered two-job pull_request_target workflow with a GitHub App token (chosen)
Section titled “Option A: Label-triggered two-job pull_request_target workflow with a GitHub App token (chosen)”| Dimension | Assessment |
|---|---|
| Removes the manual step | Yes, for every root-workspace bump, including transitives |
| New privilege | An App with contents: write on this repository; its key is a secret |
| Where dependency code runs | Only in the build job, which holds no write token and no secret |
| Human in the loop | One label per PR, plus the existing gate-change-acknowledged review |
| Honest limit | Whoever can label can cause a push; merge access remains the boundary, as in ADR-0035 |
Option B: Keep regenerating by hand in the pinned container
Section titled “Option B: Keep regenerating by hand in the pinned container”The status quo, already documented in CONTRIBUTING.md. It adds no privilege and
no secret, and a bun run regen:artifacts wrapper around the docker run would
make it one command.
Pros: nothing new to secure; the regeneration happens where the maintainer is
already reviewing.
Cons: requires Docker and a linux/amd64 emulation layer on the maintainer’s
machine for every bump; the host node_modules must be reinstalled afterwards;
the evidence is that bumps are closed rather than regenerated — every root-bun
PR since #188, four of them, closed unmerged. A control nobody runs is ADR-0016’s
subject.
Option B remains the fallback if the App is ever revoked, and the documented path for contributors.
Option C: Stop committing the bundle; build it at release time
Section titled “Option C: Stop committing the bundle; build it at release time”Pros: removes the bundle half of the problem entirely.
Cons: a JavaScript Action runs from the repository at the consumer’s ref, so
the bundle must exist at every tagged commit that @v0 or the root Marketplace
alias (ADR-0036) can resolve to. Building it only at release means a
release-only commit that main never contains, which breaks the recovery
workflow’s requirement that the tag peel to a commit on main. It also leaves
schema/adr.schema.json, which is served at its $id. Much larger than the
problem.
Option D: pull_request with contents: write granted to Dependabot’s token
Section titled “Option D: pull_request with contents: write granted to Dependabot’s token”GitHub lets a workflow raise GITHUB_TOKEN permissions on Dependabot-triggered
pull_request runs.
Pros: no App, no secret.
Cons: the workflow file comes from the PR’s merge ref, not the default branch
— the exact property ADR-0035 exists to remove; the write token and the executed
zod share a job; and the push does not re-trigger CI, so the PR stays attached
to red checks for the old head. Rejected on all three.
Option E: Exclude every bundled dependency from Dependabot
Section titled “Option E: Exclude every bundled dependency from Dependabot”Pros: no red PRs. Cons: #227 shows an exclusion does not stop a dist-tag-declared dependency from moving inside someone else’s PR; transitives cannot be named by pattern; security updates still arrive and still fail; and it converts “we cannot merge dependency updates” into “we do not see them”. Rejected.
Option F: Do nothing
Section titled “Option F: Do nothing”Dependency PRs keep failing, and keep being closed. The bundled undici and
@octokit/* versions that ship to every Action consumer drift until a security
advisory forces a manual rebuild under time pressure.
Option G: GitHub’s documented pattern — automatic, with the App key as a Dependabot secret
Section titled “Option G: GitHub’s documented pattern — automatic, with the App key as a Dependabot secret”A pull_request workflow gated on github.actor == 'dependabot[bot]', reading the
App key from a Dependabot secret so that no label is needed.
Pros: fully automatic; the pattern GitHub documents; nothing to remember.
Cons: it keys trust on github.actor, which the Boost Security research shows
can be made dependabot[bot] on a PR Dependabot did not author; the workflow
file comes from the PR’s merge ref rather than the default branch, the property
ADR-0035 removed; and a Dependabot secret is exposed to every Dependabot-triggered
run of every workflow, not just this one. Rejected. The label costs one click and
buys a trusted trigger.
Option H: Replace Dependabot with self-hosted Renovate and postUpgradeTasks
Section titled “Option H: Replace Dependabot with self-hosted Renovate and postUpgradeTasks”Renovate is the ecosystem’s standard answer to the missing feature: its
postUpgradeTasks runs allowlisted commands after an update and commits the
result into the same PR, with the checks triggered normally.
Pros: regeneration happens before the PR exists, so there is never a red PR
and no second workflow; one tool for every ecosystem.
Cons: postUpgradeTasks is available only when Renovate is self-hosted —
here, a scheduled workflow holding a write-capable token while it installs and
builds the updated dependencies, which is the co-residence this record’s two-job
split exists to avoid. It also replaces Dependabot outright, including the
security-update PRs tied to GitHub’s advisory database, the github-actions
SHA-pin updates, and every exclusion and comment in dependabot.yml: a migration,
not a fix. Revisit if Option A proves too costly to operate, or if
dependabot-core#14549
ships and makes both unnecessary.
Trade-offs
Section titled “Trade-offs”- A standing privileged credential. The App key can push to this repository.
It is scoped to one repository and
contents: write, used only in a job that executes nothing from the PR, and its use is gated on a maintainer’s label — but it exists, and a leak of it is a leak of push access. - A rebase discards the regeneration. Because the commit carries
[dependabot skip], Dependabot keeps rebasing and re-resolving the PR — which is the point — but each rebase force-pushes over the regenerated commit, and the label must be applied again. Without the marker the regeneration would survive, and Dependabot would stop maintaining the PR entirely; re-labelling is the cheaper of the two. - Two labels per bundled bump.
regenerate-artifactsto build, thengate-change-acknowledgedbecause the push touchedpackages/ci/**. Merging the two would let the regenerator acknowledge its own output, so they stay separate. - More workflow surface under ADR-0035’s gate. The new workflow is itself a gate-defining path, so every change to it needs acknowledgment. That is correct, and it is also a cost.
Consequences
Section titled “Consequences”- Easier: merging dependency updates, including the transitive ones in the
shipped Action bundle; lifting the
zodexclusion. - Harder: rotating and auditing an App credential; reasoning about one more privileged workflow.
- How we would know this was wrong: (a) any regenerated commit contains a path
outside
packages/ci/dist/orschema/adr.schema.json, or builds with a script that is notmain’s — either is a defect in the boundary, and the workflow is disabled until fixed; (b) byreviewBy, fewer than half of the Dependabot PRs that received the label were merged, meaning the automation did not unblock what it was built for; (c) the App token is used by anything other than this workflow. - Revisit if: GitHub gives
GITHUB_TOKENpushes the ability to trigger workflows (the App becomes unnecessary); the Action bundle stops being committed; or Bun’s output stops depending on host target, which would make Option B cheap enough; or Dependabot ships post-update commands (dependabot-core#14549), which would supersede this record with configuration.
Action items
Section titled “Action items”- Create a GitHub App with
contents: write, install it on this repository only, and store its id and private key as Actions secrets. Done by @mbeacom (adrkit-dep-gates), with the Client ID rather than the App ID, sincecreate-github-app-tokenv3 deprecatesapp-id, plus theREGENERATE_ARTIFACTS_APP_SLUGvariable. Left unticked only because the single-repository installation scope was reported, not verified here. - Confirm, by observing a labelled run on a Dependabot PR, that the App secret
is available when a maintainer applies the label, and unavailable when
dependabot[bot]is the actor. This record’s argument for the label rests on it. In the same run, confirm the Git Data API commit shows as verified and passes bothdcoandtrusted-dco, and that the next Dependabot rebase force-pushes over it as[dependabot skip]promises. Observed so far: the secret is available on a maintainer-applied label (#209 runs 36336189018 and 36336372382; #237 run 36361081418); the App’s commits areverifiedand passeddcoandtrusted-dcoon both pull requests. Not yet observed: the secret being unavailable withdependabot[bot]as actor, and a Dependabot rebase over the App commit. - Implement
.github/workflows/regenerate-artifacts.ymlper the Decision, with the eligibility checks in a tested script underscripts/that imports Node builtins only. - Observe each refusal failing before it counts (ADR-0016): a non-Dependabot
author, a commit authored by neither Dependabot nor the App, an App commit
touching a non-artifact path, a changed
scriptsblock, a file outside the allowlist, a head repository other than this one, a head moved after labelling, and a patch touching a path outside the artifact set. Also observe the positive case the review of this record found missing: a second run on a PR that already carries an App regeneration commit. The positive second run was observed live on #209, where it also exposed an empty-commit defect fixed in #234. Each refusal has a unit or contract test; the scripts-diff, author, force-push, nested/traversal, symlinked ancestor, Dependabot-path, and every-commit rules were each observed failing a test when disabled. The rest have not been. - Regenerate one real bundled bump end to end —
yamlis the waiting case — and merge it withclean-clone-buildsandgate-integritygreen. #237 (yaml2.9.0 → 2.9.1): one App commit rewrote both bundles, every check passed, merged as267ca00. - After a
zodbump regenerates green, remove thezodexclusion fromdependabot.ymland update its comment. #209 regenerated correctly but could not go green: zod 4.5 changes the publishedv0.1.0schema, which ADR-0011 makes immutable.zodis capped below 4.5 (#236) and this item now waits on schemav0.2.0(#235). #235 shipped schemav0.2.0and movedzodto 4.6 by hand, with the bundles rebuilt in the documented container rather than by the label, so nozodbump has yet regenerated green through this workflow. It removed the separateignoreforzod>=4.5 and updated the exclusion’s comment only; the nextzodDependabot PR is the evidence this item waits on. - Document the label in
CONTRIBUTING.mdbeside the existing container instructions, which remain the fallback.