You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ci(branches): merge base into parked PR-less branches on a schedule (#15306) - #15316
Three branch states, two already covered: a branch with a live session is rebased by session-lifecycle, a branch with an open PR is refreshed by auto-update-pr-branches.yml (one per push, #14022). A branch with neither had nothing, and that is most branches.
Re-measured against current base before writing anything (2026-08-30), because the issue's table was a day old:
kind
count
behind
ahead
PR-less work branches
32
101 – 2447
every one ≥ 1
rescued/stash-*
18
3174 – 3933
every one ≥ 1
open-PR branches (out of scope)
2
—
—
Merging, never rebasing, is the whole design. These branches are checked out in live sessions' worktrees; a rebase orphans that session's local HEAD and makes its next push non-fast-forward, silently, discovered only by whoever next tries to push. A merge commit keeps every commit reachable, so a session that pulls gets a clean fast-forward. This is the opposite of the rule for a branch you own — rebase your own, merge someone else's.
This costs no CI. Every push: trigger under .github/workflows/ is scoped to main/Dev_new_gui/develop, except image-sign.yml (push: tags:). PR workflows fire on pull_request, and these branches have no PR by definition. So it does not reintroduce the reset amplification #14022 fixed — that was specific to branches with an open PR, which are excluded here and stay the sole property of auto-update-pr-branches.yml.
One scope decision I did not make silently
rescued/stash-* is excluded, and I want that ratified rather than assumed. Full reasoning and the alternative are posted on #15306; the short version is that such a branch is a point-in-time snapshot of a rescued working tree, not a line of work. Merging 3,900 commits of base into one does not bring it forward — it destroys the thing it exists to record, would conflict on every run forever, and would put 18 permanent entries in every job summary, making this issue's own "no PR-less branch more than one commit behind" criterion unsatisfiable by construction. They need triage (land it or drop it), which is a judgement, not a merge.
The exclusion is one documented env var (EXCLUDED_PREFIXES), so reversing it is a one-line change if the decision goes the other way.
What Changed
.github/workflows/auto-merge-base-into-parked-branches.yml (new) — scheduled every 6h plus workflow_dispatch with a dry_run input. For each remote branch that is behind base, has no open PR, is not a protected trunk and is not excluded: merge base in, push without force, or on conflict abort and name it in the job summary. Reports updated / already-current / skipped-for-conflict / push-rejected / excluded counts.
repo_tests/parked_branch_merge_workflow_test.py (new) — asserts the property review cannot see once the file is long: never git rebase, never a force-push, never a protected branch as push target, no push: trigger, and a concurrency group.
Verification
Execution of repo code is out of scope for this session, so everything below is either static analysis or git run against branch metadata only:
The guard's regexes were exercised against positive and negative controls (as data, not by running the suite): catches git rebase …; ignores git merge --no-rebase, which is the opposite operation and the one the script actually uses; catches git push --force, --force-with-lease and -f; ignores a plain git push. Without that distinction the guard would fail on correct code.
The guard strips shell comments before scanning. The workflow's header explains at length why it merges rather than rebases and never forces; a raw-text scan would match that prose and fail on the text documenting the correct behaviour. Verified the parse yields 1 run: block containing git merge — a reach check, so a schema change cannot make every assertion pass over an empty list.
bash -e semantics checked directly, because Actions runs run: under it: confirmed [ "$X" = "true" ] && cmd does not abort the step when the test fails (-e is suppressed inside an AND-OR list). The script also ends in an explicit exit 0, so a conflict is a reported outcome rather than a red job — a check that is always red stops being read.
The loop is fed by done < <(…), not a pipe, so the accumulator arrays live in the current shell. Through a pipe every count would have reported 0.
yaml.safe_load on the workflow parses; ast.parse on the test file parses.
Branch counts above measured with git rev-list --count against freshly fetched refs.
Not verified here, and deliberately: the first real run's behaviour. AC6 ("after one run, no PR-less branch is more than one base-commit behind") can only be judged after this merges and the schedule fires, or after a manual workflow_dispatch. I would rather dispatch it once with dry_run: true post-merge and read the summary than assert the outcome now.
Model Used
Claude Opus 5
Refs #15306 — the workflow and its guard are complete; AC6 requires observing one real run, and the rescued/* scope question is posted on the issue for a decision, so this is Refs rather than Closes.
Self-review before merge caught a latent bug, fixed in this push.
The branch loop was fed by done < <(git for-each-ref …). A while read loop fed by process substitution hands its stdin to every command in the body — so any git command that reads stdin would consume branch names straight out of the list. The result would be branches silently skipped, with a green job, a plausible-looking summary, and no evidence that anything was missed. That is the worst failure shape for this workflow: it exists to guarantee coverage, so a silent gap defeats it entirely while looking like success.
Now the list is read into an array with mapfile before the loop runs, and the loop is a plain for over that array — no shared stdin, nothing to consume.
Verified: bash -n on the extracted step body passes, no done < <( construct remains anywhere in it, and the YAML still parses.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Three branch states, two already covered: a branch with a live session is rebased by
session-lifecycle, a branch with an open PR is refreshed byauto-update-pr-branches.yml(one per push, #14022). A branch with neither had nothing, and that is most branches.Re-measured against current base before writing anything (2026-08-30), because the issue's table was a day old:
rescued/stash-*Merging, never rebasing, is the whole design. These branches are checked out in live sessions' worktrees; a rebase orphans that session's local HEAD and makes its next push non-fast-forward, silently, discovered only by whoever next tries to push. A merge commit keeps every commit reachable, so a session that pulls gets a clean fast-forward. This is the opposite of the rule for a branch you own — rebase your own, merge someone else's.
This costs no CI. Every
push:trigger under.github/workflows/is scoped tomain/Dev_new_gui/develop, exceptimage-sign.yml(push: tags:). PR workflows fire onpull_request, and these branches have no PR by definition. So it does not reintroduce the reset amplification #14022 fixed — that was specific to branches with an open PR, which are excluded here and stay the sole property ofauto-update-pr-branches.yml.One scope decision I did not make silently
rescued/stash-*is excluded, and I want that ratified rather than assumed. Full reasoning and the alternative are posted on #15306; the short version is that such a branch is a point-in-time snapshot of a rescued working tree, not a line of work. Merging 3,900 commits of base into one does not bring it forward — it destroys the thing it exists to record, would conflict on every run forever, and would put 18 permanent entries in every job summary, making this issue's own "no PR-less branch more than one commit behind" criterion unsatisfiable by construction. They need triage (land it or drop it), which is a judgement, not a merge.The exclusion is one documented env var (
EXCLUDED_PREFIXES), so reversing it is a one-line change if the decision goes the other way.What Changed
.github/workflows/auto-merge-base-into-parked-branches.yml(new) — scheduled every 6h plusworkflow_dispatchwith adry_runinput. For each remote branch that is behind base, has no open PR, is not a protected trunk and is not excluded: merge base in, push without force, or on conflict abort and name it in the job summary. Reports updated / already-current / skipped-for-conflict / push-rejected / excluded counts.repo_tests/parked_branch_merge_workflow_test.py(new) — asserts the property review cannot see once the file is long: nevergit rebase, never a force-push, never a protected branch as push target, nopush:trigger, and a concurrency group.Verification
Execution of repo code is out of scope for this session, so everything below is either static analysis or
gitrun against branch metadata only:git rebase …; ignoresgit merge --no-rebase, which is the opposite operation and the one the script actually uses; catchesgit push --force,--force-with-leaseand-f; ignores a plaingit push. Without that distinction the guard would fail on correct code.run:block containinggit merge— a reach check, so a schema change cannot make every assertion pass over an empty list.bash -esemantics checked directly, because Actions runsrun:under it: confirmed[ "$X" = "true" ] && cmddoes not abort the step when the test fails (-eis suppressed inside an AND-OR list). The script also ends in an explicitexit 0, so a conflict is a reported outcome rather than a red job — a check that is always red stops being read.done < <(…), not a pipe, so the accumulator arrays live in the current shell. Through a pipe every count would have reported 0.yaml.safe_loadon the workflow parses;ast.parseon the test file parses.git rev-list --countagainst freshly fetched refs.Not verified here, and deliberately: the first real run's behaviour. AC6 ("after one run, no PR-less branch is more than one base-commit behind") can only be judged after this merges and the schedule fires, or after a manual
workflow_dispatch. I would rather dispatch it once withdry_run: truepost-merge and read the summary than assert the outcome now.Model Used
Claude Opus 5
Refs #15306 — the workflow and its guard are complete; AC6 requires observing one real run, and the
rescued/*scope question is posted on the issue for a decision, so this is Refs rather than Closes.