Skip to content

Merge eligible Dependabot updates and repair failing Cargo updates - #168

Open
morgaesis wants to merge 8 commits into
mainfrom
fix/dependency-recovery-20261008
Open

morgaesis wants to merge 8 commits into
mainfrom
fix/dependency-recovery-20261008

Conversation

@morgaesis

Copy link
Copy Markdown
Owner

Dependabot pull requests in this repository pass a required classification check, but nothing merges them and nothing repairs one whose checks fail or whose branch conflicts with main, so routine updates stay open. This change adds a scheduled Dependabot reconcile workflow that merges eligible updates and repairs failing Cargo updates, as documented under Dependency updates. A Cargo patch or minor update, or a GitHub Actions update pinned to a full commit SHA, merges without a maintainer review once every required check succeeded for its exact head commit, that head contains the current main commit, and every review thread is resolved. A major Cargo update or an unclassified update still needs an approval of its exact head commit. Repair is limited to Cargo updates and to their manifests, lockfiles, and Rust source, and a repaired update gets its required checks again before it merges under the same conditions. The workflow runs only the main copies of its scripts and never checks out pull request content with a repository token. A repair is split across three jobs: a language model, run through a pinned OpenCode release, edits files without the ability to run commands; Cargo applies the lockfile updates in a job that holds neither the model credential nor a repository token; a third job publishes the result as a signed commit.

The change also updates rustls from 0.23.42 to 0.23.45 for RUSTSEC-2026-0285, in which TLS 1.3 handshake messages are incorrectly accepted across encryption level boundaries. That lockfile change is the output of a local run of the repair script with its fixed model and OpenCode release, and cargo audit reports no advisory after it. It also clears the warnings that the Rust 1.99 toolchain reports, which fail the Clippy step that denies warnings. The policy script has 449 fixture checks that run in CI, and the 1631 tests of the Cargo suite pass locally. The reconcile workflow has not run on GitHub Actions: the token rights it needs, the signatures of the commits it creates, and reruns of Dependabot-triggered runs are covered only by fixtures.

Dependabot pull requests had no merge actor, so updates that passed every
required check stayed open. A scheduled and manually dispatchable workflow
runs the default-branch policy script, classifies each open Dependabot pull
request from GitHub API metadata, and squash-merges one only when it is a
verified single Dependabot commit confined to one manifest set, every update
is a compatible patch or minor version, and every required status check
succeeded for the exact head commit. Major updates, 0.x minor updates, Docker
updates, and anything unclassified stay open for manual review. The workflow
has no pull request trigger and never checks out pull request content.

The review gate reports the same classification and succeeds for updates that
need manual review, so a reviewed major update is no longer blocked by a
required check that cannot pass.

Merges made with the Actions token start no push workflow. The reconcile
workflow dispatches CI and the security audit for the main head when no run
exists for it, and release validation accepts a dispatched CI run on main for
the exact release commit.

Dependabot groups the CodeQL actions, which fail when updated separately,
groups the remaining action updates by update type, and updates transitive
Cargo dependencies so an advisory against a lockfile-only crate is resolved
by a routine update. The cooldown and the required status check names are
unchanged.
A Cargo update whose required checks fail, or whose branch conflicts with
main, had no path to a merge. The reconcile workflow now plans one repair per
run and carries it through three separated jobs. A generate job runs OpenCode
with the model credential; the model reads the failure logs and edits Cargo
manifests and Rust source, but cannot run commands, so no dependency code runs
beside the credential. An apply job without the credential runs the requested
`cargo update` operations and verifies that the lockfile resolves. A publish
job commits the validated files to the pull request branch through the Git
database API, which GitHub signs, and advances the branch only as a
fast-forward once the signature is confirmed. A conflict is resolved by
merging main into the branch, never by a rebase or a forced update. The same
repair runs locally with `scripts/dependency-repair.sh candidate`.

A branch update made with the Actions token leaves pull request workflows
waiting for approval, so a repaired head gets its required checks by dispatch.
The review gate and Dependency Review accept that dispatch, and it is refused
unless the branch workflows equal the main workflows.

The classifier accepts verified repair commits by github-actions[bot] on top of
the Dependabot commit. A repair confined to the lockfile keeps the update
routine; one that changes a manifest or source needs an approving review. Cargo
patch and minor updates, including 0.x versions, and GitHub Actions updates
pinned to a full commit SHA are routine again. The review gate fails for a
major or unclassified update until a repository owner, member, or collaborator
approves the exact head commit, and reruns when a review is submitted.

Before a merge, the reconcile workflow now compares the effective branch rules
with the declared required checks and fails closed on a rule it does not
verify, requires every review thread to be resolved, and confirms that the
pull request metadata and main are unchanged. It merges one update per run,
holds merges until the required checks completed for the current main commit,
and confirms that each dispatched run exists for the intended commit.

Workflow updates are not repaired: the Actions token cannot write workflow
files. The Dependabot configuration comment no longer states that updating
transitive dependencies resolves an advisory.
…el run

Merging and recovery:
- Merge a Dependabot update only when its head contains the current main
  commit, so its required checks ran on the tree that the squash merge
  produces. Refresh one branch per run that lacks the main commit through the
  branch merge API, with the marker that lets Dependabot keep rebasing it.
- Dispatch validation for a refreshed workflow update whose only difference
  from the main workflows is its own pinned actions.
- Rerun a workflow run that was cancelled or failed once. A failed check
  counts as a defect of the update once a rerun reproduced it.
- Keep a repaired patch or minor update routine when the repair stays inside
  its manifest set and Rust source. Major Cargo updates still need review.
- Record a repair by Dependabot commit, main commit, and failing checks, for
  published repairs and for repairs that cannot proceed in any stage, in
  place of a fixed repair count.
- Verify author, committer, signature, and parents of a repair commit before
  the branch advances.

Repair engine:
- Remove the model credential from the environment at entry and export it
  only to the model process. Run Cargo with a reduced environment.
- Record the tree before the model runs and compare files afterwards without
  running Git on the edited checkout. Any change besides the content of a
  repairable tracked file fails the stage.
- Fix the model and require the pinned OpenCode release.
- Refuse a newly locked version published on crates.io within the last hour.
- Resolve stage state through the Git directory of the checkout, so a linked
  worktree works. Drop the file count, request count, and file size limits.
- Exit with status 3 when a repair cannot proceed, apart from operational
  failures.

Tests sign their fixture commits with a key generated for the run.
The refresh of a stale Dependabot branch merged main directly into the
pull request branch, so the branch advanced before the merge commit was
checked. The merge is now made on a scratch branch that the run creates
at the pull request head. The pull request branch advances only after the
commit is a verified github-actions[bot] merge of main into that head and
the pull request is unchanged, as a fast-forward. The scratch branch is
removed in every outcome; one that cannot be removed is reported by name.
A refresh that did not advance a branch no longer uses the one refresh of
a run, so the next stale branch is tried.

A model run that reaches its time limit is an operational failure rather
than a repair that cannot proceed, so an unreachable provider leaves no
record and the plan is tried again. The model process keeps TMPDIR, and
the internal credential variable is never exported.

A failed check counts as reproduced only when the same job failed or
timed out in the attempt before, and a failed workflow run is rerun until
every failing job of its latest attempt meets that condition. A first
failure after a cancelled attempt, or a job that first fails on a rerun,
is rerun instead of repaired or left as a finding.
Comment on lines +212 to +220
- name: Fetch the pull request head as data
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.plan.outputs.head_sha }}
fetch-depth: 0
persist-credentials: false
path: work

- name: Download the plan

@postil-dev postil-dev Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error 2 blocking findings open
2 findings posted inline

info 1 suppressed

Restore lockfile conflicts using package records

scripts/dependency-repair.sh:543

Suppressed: cites a line the named construct does not sit on.

Severity error, confidence 0.99.

The prepare stage records only the conflicted lockfile path, but this loop reads each record as a package name and version. For a recorded Cargo.lock, version is empty, so the validation at the next lines skips every restoration and the candidate retains the main branch's lockfile instead of the planned dependency updates. Record package/version pairs when preparing the conflict, or iterate the plan's updates directly and verify that every planned update is restored.

Review details

Comment on lines +217 to +323
is_ancestor "$parent" "$BRANCH" || merges_ok=false
done < <(jq -r '.[1:][] | select((.parents | length) == 2) | .parents[1]' "$dir/commits.json")
fi

fingerprint=$(jq -cS -n \
--slurpfile pull "$dir/pull.json" \
--slurpfile commits "$dir/commits.json" \
--slurpfile files "$dir/files.json" '{
pull: ($pull[0] | {state, draft, user: .user.login, base: .base.ref,
base_repo: .base.repo.full_name, head: .head.sha, head_ref: .head.ref,
head_repo: .head.repo.full_name, commits, changed_files}),
commits: $commits[0],
files: $files[0]
}' | sha256sum | cut -d ' ' -f 1) || return 2

jq -n \
--argjson number "$number" \
--arg repo "$REPO" \
--arg bot "$BOT_LOGIN" \
--arg repair_bot "$REPAIR_LOGIN" \
--arg branch "$BRANCH" \
--arg fingerprint "$fingerprint" \
--argjson merges_ok "$merges_ok" \
--slurpfile pull "$dir/pull.json" \
--slurpfile commits "$dir/commits.json" \
--slurpfile files "$dir/files.json" \
--slurpfile reviews "$dir/reviews.json" '
def metadata:
split("\n") as $lines
| [range(0; $lines | length)
| select($lines[.] == "---" and $lines[. + 1] == "updated-dependencies:")] as $starts
| if ($starts | length) != 1 then null else
($starts[0] + 2) as $from
| ([range($from; $lines | length) | select($lines[.] == "...")] | first) as $to
| if $to == null then null else
reduce $lines[$from:$to][] as $line ({ok: true, items: []};
if .ok | not then .
elif $line | test("^- dependency-name: \\S") then
.items += [{"dependency-name": ($line | sub("^- dependency-name: "; ""))}]
elif ((.items | length) > 0) and ($line | test("^ [a-z-]+: \\S")) then
($line | capture("^ (?<key>[a-z-]+): (?<value>.*)$")) as $pair
| .items[-1][$pair.key] = $pair.value
else .ok = false
end)
| if .ok and (.items | length) > 0 then .items else null end
end
end;

def level:
(."update-type" // "") as $type
| (."dependency-version" // "") as $version
| if $version | test("^[0-9]+(\\.[0-9]+){0,2}(\\+[0-9A-Za-z.-]+)?$") | not then "unclassified"
elif $type == "version-update:semver-patch" or $type == "version-update:semver-minor"
then "compatible"
elif $type == "version-update:semver-major" then "major"
else "unclassified"
end;

def file_class:
if test("^Cargo\\.(toml|lock)$") then "cargo-root"
elif test("^fuzz/Cargo\\.(toml|lock)$") then "cargo-fuzz"
elif test("^\\.github/workflows/[^/]+\\.ya?ml$") then "github-actions"
else "other"
end;

def is_source($class):
if $class == "cargo-root"
then test("^(src|tests|benches)/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$")
elif $class == "cargo-fuzz"
then test("^fuzz/fuzz_targets/([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.rs$")
else false
end;

def branch_prefix:
if . == "github-actions" then "dependabot/github_actions/" else "dependabot/cargo/" end;

def changed_lines:
if . == null then null else [split("\n")[] | select(test("^[+-]"))] end;

# Every added line is an action reference pinned to a full commit SHA.
def pinned_actions:
all(.[]; .patch != null
and (.patch | changed_lines | map(select(startswith("+"))) | length > 0)
and all(.patch | changed_lines[] | select(startswith("+"));
test("^\\+\\s*(- )?uses: [^\\s@]+@[0-9a-f]{40}(\\s+#.*)?$")));

def trust:
$pull[0] as $p
| $commits[0] as $c
| if $p.number != $number or $p.state != "open" or $p.draft != false then
"the pull request is not open and ready"
elif $p.user.login != $bot or $p.user.type != "Bot" then
"the pull request is not a Dependabot update"
elif $p.base.ref != $branch or $p.base.repo.full_name != $repo then
"the pull request does not target the protected branch"
elif $p.head.repo.full_name != $repo or (($p.head.ref // "") | startswith("dependabot/") | not) then
"the head branch is not a Dependabot branch in this repository"
elif ($c | length) == 0 or $p.commits != ($c | length) or $c[-1].sha != $p.head.sha then
"the commit list is empty, incomplete, or does not end at the head"
elif $c[0].author != $bot or $c[0].committer != "web-flow" or $c[0].verified != true then
"the first commit is not a verified Dependabot commit"
elif any(range(1; $c | length); . as $i
| $c[$i].author != $repair_bot
or $c[$i].verified != true
or (($c[$i].committer == "web-flow" or $c[$i].committer == $repair_bot) | not)
or (($c[$i].parents | length) < 1 or ($c[$i].parents | length) > 2)
or $c[$i].parents[0] != $c[$i - 1].sha) then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error Require every repair commit to be contained in main
error · confidence 0.94 · kind: risk

The provenance check calls is_ancestor only for later commits with exactly two parents, while trust accepts one-parent repair commits as long as their author, signature, and parent chain look valid. Such a commit can contain arbitrary repair changes, remain eligible, and be auto-merged even though it is not present in main; this defeats the stated boundary that every repair merged must already be in main. Verify every commit after the Dependabot commit is an ancestor of main, or otherwise reject non-main repair commits before eligibility.

Comment thread src/server/execute.rs

fn reserve_bounded_output(total: &AtomicUsize, amount: usize) -> Result<(), BoundedOutputError> {
let reserved = total.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
let reserved = total.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error Use the available atomic update API
error · confidence 0.94 · kind: risk

AtomicUsize provides fetch_update, but this change calls try_update at both output-reservation sites. Unless an extension trait is added elsewhere, the production crate no longer compiles; the same replacement also breaks the integration test. Restore fetch_update or add and import a verified try_update implementation, then compile the affected targets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants