Skip the auto-remediation commit when the output is unchanged
Why
The gap
- After the workload, the service always commits the output.
- It never checks whether the open MR already holds that output.
- An identical commit still adds a diff version and an MR pipeline, and resets approvals.
- This repeats every cooldown for as long as the MR stays open.
- This is the bug in #632419.
Why this approach
- The new content is only known after the workload, so the check runs just before the commit.
- With
dependency_management_workload_ref_refreshon, !261586 (merged) leaves the branch untouched until then. - If the MR already holds the output, skipping the commit leaves the branch exactly as it was.
- Comparing against the merge request's own base means a moved default branch is not a change.
- Comparing git blob IDs computed locally avoids loading blob data.
What
- Skips the commit when all of these hold:
- an open MR exists, and its change touches only output files
- each output file's blob ID at the tip matches the output
- the tip's commit subject is the one this run would write
- the MR is confirmed mergeable on the branch tip
- To confirm it, the recorded head of the MR must be the branch tip, and a synchronous mergeability check must come back mergeable. Every push resets the merge status, so an async check could be reset before the next refresh read it.
- Still commits in any of these cases:
- a conflict, a merge status it cannot confirm, or an error from the check
- a fast-forward-only project whose branch does not contain
pipeline.sha - a different subject, such as after a move to Conventional Commits
- On a skip, it logs
skip_reason: :unchangedand updates the title and description, which pushes nothing. It then links the vulnerability, records the remediation and starts the cooldown. If the update fails, it returns the error and does none of these. - With
dependency_management_workload_ref_refreshon, a skip leaves the branch head as it was. The conflict refresh limit from !261586 (merged) would then hold that head for the cooldown period. A skip only happens once the MR is confirmed mergeable, so it releases the hold withRefreshCooldown.release_head, and a conflict found later is refreshed straight away. - No flag of its own. With
dependency_management_workload_ref_refreshoff, the reset leaves the merge request's change empty, which never counts as unchanged. .gitattributesdo not affect the comparison. A spec pins this.- The "head unchanged" specs run the second refresh a minute later, because identical commits made in the same second share a SHA.
References
- #632419
- Plan: #632419 (comment 3999033751)
- Handbook updates for the conflict refresh limit from !261586 (merged). They do not yet cover the release of the hold on a skip.
- ADR 005, refresh instead of rebase: gitlab-com/content-sites/handbook!21684 (merged)
- Auto-remediation handbook, remediation flow and glossary: https://gitlab.com/gitlab-org/security-products/dependency-management/handbook/-/merge_requests/8
Manual QA
These runs predate the synchronous mergeability check and the subject match. Specs cover those.
-
In
cargo-vuln-demowith the flag on, take an open remediation MRmrwhose last commit came from a refresh. Expire the cooldown and run the scheduler:Gitlab::Redis::SharedState.with { |r| r.del("dependency_management:remediation_refresh:#{mr.id}") } DependencyManagement::SecurityUpdate::SchedulerService.execute(project: project) -
Expect nothing pushed, and
skip_reason: unchangedinlog/application_json.log. -
Repeat after moving the default branch, or rebasing the MR: nothing pushed. After a commit that changes
Cargo.lock: one commit. After a conflicting change on the default branch: one commit, then nothing. -
With the flag off, the refresh commits as today.
GDK result on 5033dfa01. Starting values are taken after each case's setup.
| Case | Flag | Branch tip | Diff versions | MR pipelines | Approval | Log |
|---|---|---|---|---|---|---|
| Identical output | Off | 4876355e to f162bd5f |
66 to 68 | 32 to 33 | Reset | |
| Identical output | On | f162bd5f, unchanged |
68, unchanged | 33, unchanged | Kept | skip_reason: unchanged |
| Default branch moved, unrelated change | On | f162bd5f, unchanged |
68, unchanged | 33, unchanged | Kept | skip_reason: unchanged |
| Rebase by a person, then refresh | On | 3fdfbe23, unchanged |
69, unchanged | 34, unchanged | Kept | skip_reason: unchanged |
| Output differs from the branch | On | 28b4f46d to d6e68adb |
70 to 71 | 35 to 36 | One commit | |
| Conflict, first refresh | On | 4876355e to 85772fb5 |
74 to 75 | 36 to 37 | One commit, merge request mergeable | |
| Conflict, second refresh | On | 85772fb5, unchanged |
75, unchanged | 37, unchanged | skip_reason: unchanged |
Combined with the conflict refresh limit from !261586 (merged), GDK on 651f8dd5, flag on, a yaml-rust MR, with the conflict refresh's workload cancelled at once to stand in for a failure:
| Step | Workloads started | Branch head | Conflict claim TTL |
|---|---|---|---|
| Cooldown refresh, output unchanged | 1 | unchanged, commit skipped | none, the hold is released |
| Default branch changed so the MR conflicts, one scheduler run | 1, straight away | unchanged | 28797 s, the conflict refresh's claim |
Combined stack evidence
Cooldown refresh with unchanged output: the commit is skipped and the hold is released.
The MR made to conflict: one workload straight away.
Evidence
Flag off, before the refresh: 4876355e:
Flag off, after: a new identical commit f162bd5f:
Flag off, after: a new version:
Flag off, after: a new MR pipeline:
Flag off, after: the approval was reset:
Flag on, before the refresh: f162bd5f:
Flag on, after: still f162bd5f:
Flag on, after: no new version:
Flag on, after: no new MR pipeline:
Flag on, after: the approval is kept:
Default branch moved: still f162bd5f:
After a rebase by a person: the rebased commit 3fdfbe23 is kept:
Output differs from the branch: one new commit d6e68adb:
Conflict, after both refreshes: one commit 85772fb5:















