gha: Add release branch sync workflow#7091
Conversation
cc66332 to
e802815
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🔴 CRITICAL
Two high-severity bugs completely disable the master-branch safety guard in the Validate step (BRANCH vs $branch case mismatch + trailing space in the comparison string). Several medium-severity issues were also found: git checkout -- '*' won't resolve subdirectory conflicts, sed "//q" uses unescaped regex with slash-injection risk, incorrect boolean logic in conflict detection, missing xargs -r guard, and a DRY_RUN check that fires after merges have already been applied.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
3 findings in 2 files — 1 high-severity (forced inline), 2 medium-severity (security + logic).
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🔴 CRITICAL
Two confirmed high-severity bugs in new code introduced by this PR.
Findings summary
- scripts/unmerged-tags:32 — Plain
sortsorts version tags lexicographically, causing wrong tag ordering (e.g.docker-v29.10.0beforedocker-v29.9.0). Theawkexit-on-target logic then cuts the list at the wrong point, syncing the wrong set of tags. - .github/workflows/sync-release-branch.yml:80 —
scripts/sync-branch(which runsgit merge) is called before theDRY_RUNcheck, so merges always execute even in dry-run mode. Thedry_runflag only prevents the push, contradicting the stated intent of "print the tag range without merging or pushing".
Add a manually dispatched workflow for maintainers to sync a Docker release branch to a selected release tag. The sync-release-branch job checks out the release branch, computes the list of unmerged tags up to the requested tag via scripts/unmerged-tags, merges them in order via scripts/sync-branch using git merge --no-ff (resolving conflicts by taking the tag's content), then pushes the result to a temporary branch. The push-release-branch job runs after manual approval via the docker-releases environment. It verifies that neither the release branch nor the temporary branch moved since the sync job ran before force-advancing the release branch and deleting the temporary branch. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
4 medium-severity findings confirmed or likely in the newly added scripts and workflow.
| if [[ "$tag" == *"-rc."* ]]; then | ||
| echo "error: RC tags cannot be used as sync targets" >&2 | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
[medium] No validation that $tag matches expected pattern; a tag starting with - could be treated as a git flag
$tag (from inputs.tag, a user-supplied workflow dispatch string) is passed directly to git merge-base --is-ancestor "$tag" "$release_branch" without checking that it matches an expected pattern like ^docker-v[0-9]. If a user supplies a value starting with - (e.g., --help, -f), git may interpret it as a flag rather than a ref name. The RC filter only excludes strings containing -rc. — it does not catch leading hyphens or other unexpected patterns.
Although workflow_dispatch requires repository write access, a simple defensive validation at the top of the script (e.g., [[ "$tag" =~ ^docker-v[0-9] ]]) would prevent accidental or intentional misuse:
| fi | |
| if [[ "$tag" == *"-rc."* ]]; then | |
| echo "error: RC tags cannot be used as sync targets" >&2 | |
| exit 1 | |
| fi | |
| if [[ ! "$tag" =~ ^docker-v[0-9] ]]; then | |
| echo "error: tag must match docker-v<version> format, got: $tag" >&2 | |
| exit 1 | |
| fi |
| Confidence | Score |
|---|---|
| 🟡 moderate | 62/100 |
| | grep '^docker-v' \ | ||
| | grep -v -- "-rc." \ | ||
| | sort -V \ | ||
| | awk -v tag="$tag" '{print} $0==tag{exit}' |
There was a problem hiding this comment.
[medium] If the requested tag is not in git tag output, awk emits all tags instead of stopping
The awk command '{print} $0==tag{exit}' prints every line until it matches $tag, then exits. If $tag is not present in the filtered/sorted output — e.g., because it was not yet merged into master, or due to a typo — awk falls off the end and emits all pending tags. This causes scripts/sync-branch to merge every outstanding release tag into the branch rather than just the intended range.
The early-exit guard at line 18 only handles the case where the tag is already merged into the release branch; it does not catch the case where the tag doesn't appear in the master-merged list. Adding a post-pipeline check (e.g., tracking whether the tag was seen) would prevent this silent over-sync:
| | awk -v tag="$tag" '{print} $0==tag{exit}' | |
| | awk -v tag="$tag" 'found || {print} $0==tag{found=1; exit} END{if (!found) {print "error: tag " tag " not found in tag list" > "/dev/stderr"; exit 1}}' |
Or equivalently, validate after the pipeline that the output contains the expected tag.
| Confidence | Score |
|---|---|
| 🟢 strong | 100/100 |
| exit 1 | ||
| fi | ||
|
|
||
| git checkout "$tag_to_sync" -- '*' |
There was a problem hiding this comment.
[medium] Glob '*' in git checkout does not match dotfiles — .dockerignore, .github/, etc. are skipped
git checkout "$tag_to_sync" -- '*' passes the literal asterisk * as a git pathspec. Like shell globs, git's pathspec * does not match files or directories whose names begin with ., so hidden files such as .dockerignore, .gitignore, and everything under .github/ would not be checked out from the tag when resolving a merge conflict. This means those files retain the conflict-state version, and the subsequent git add --all + git merge --continue would commit the wrong content for those paths.
Use . (current directory) to check out all files including dotfiles:
| git checkout "$tag_to_sync" -- '*' | |
| git checkout "$tag_to_sync" -- . |
| Confidence | Score |
|---|---|
| 🟢 strong | 97/100 |
| continue | ||
| fi | ||
|
|
||
| if ! git rev-parse --verify --quiet MERGE_HEAD > /dev/null || git diff --quiet --diff-filter=U; then |
There was a problem hiding this comment.
[medium] Wrong boolean operator || vs && in merge-conflict guard may cause unexpected exit
The condition on line 17 is:
if ! git rev-parse --verify --quiet MERGE_HEAD > /dev/null || git diff --quiet --diff-filter=U; then
exit 1
fi
Due to operator precedence this evaluates as:
(NOT in_merge_state) OR (no_unresolved_conflicts)
The guard's intent is to abort when something truly unexpected happened — i.e., when we are not in a merge state AND there are no unresolved conflicts. With ||, the condition is also true when MERGE_HEAD is absent alone, or when there are no unresolved conflicts alone. The problematic edge case: if a merge fails and MERGE_HEAD is present but auto-resolution leaves no U-status files (e.g., all conflicts were trivially resolved but merge still exited non-zero), git diff --quiet --diff-filter=U exits 0 (no output), making the overall condition FALSE OR TRUE = TRUE → the script exits 1 even though the state is a valid in-progress merge.
The intent is better expressed with && — exit only when BOTH conditions indicate an unexpected state:
| if ! git rev-parse --verify --quiet MERGE_HEAD > /dev/null || git diff --quiet --diff-filter=U; then | |
| if ! git rev-parse --verify --quiet MERGE_HEAD > /dev/null && git diff --quiet --diff-filter=U; then |
| Confidence | Score |
|---|---|
| 🟡 moderate | 75/100 |
| xargs -r scripts/sync-branch < "$tags_file" | tee -a "$GITHUB_STEP_SUMMARY" | ||
| if [[ "$DRY_RUN" == "true" ]]; then |
There was a problem hiding this comment.
[medium] Dry-run mode still executes git merge operations — it only skips the push
The dry_run input is described as "Print the tag range without merging or pushing," but xargs -r scripts/sync-branch < "$tags_file" on line 80 runs unconditionally — scripts/sync-branch performs real git merge --no-ff operations. The dry-run check on line 82 only prevents the subsequent git push. This means a dry-run invocation performs irreversible (locally) merges in the runner's working tree, which is discarded, but the behavior contradicts the documented intent.
If the intent is to allow a "preview without side effects" run (as the description states), the merge step should be gated:
if [[ "$DRY_RUN" != "true" ]]; then
xargs -r scripts/sync-branch < "$tags_file" | tee -a "$GITHUB_STEP_SUMMARY"
fi
If instead the intent is "merge but don't push" (a trial merge to validate feasibility), the dry_run description should be updated to say "Merge locally but do not push" to avoid confusion.
| Confidence | Score |
|---|---|
| 🟡 moderate | 75/100 |
Add a manually dispatched workflow for maintainers to sync a docker release branch to a selected docker release tag.
The job checks out the requested release branch, merges the tag with
git merge --no-ff, checks the worktree content out from that tag, stages the result, and pushes the updated branch.The actual push is guarded by the
docker-releasesGitHub environment which requires a manual approval.