From 182f56fe8c587efab21a0e79952672d42ea28aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 8 Jul 2026 15:18:58 +0200 Subject: [PATCH] gha: Add release branch sync workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/sync-release-branch.yml | 137 ++++++++++++++++++++++ scripts/sync-branch | 34 ++++++ scripts/unmerged-tags | 48 ++++++++ 3 files changed, 219 insertions(+) create mode 100644 .github/workflows/sync-release-branch.yml create mode 100755 scripts/sync-branch create mode 100755 scripts/unmerged-tags diff --git a/.github/workflows/sync-release-branch.yml b/.github/workflows/sync-release-branch.yml new file mode 100644 index 000000000000..2bbdab4b96f9 --- /dev/null +++ b/.github/workflows/sync-release-branch.yml @@ -0,0 +1,137 @@ +name: Sync Docker release branch + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + tag: + description: Tag to sync from, for example v29.6.0 + required: true + type: string + dry_run: + description: Merge but don't push + required: true + default: false + type: boolean + +jobs: + sync-release-branch: + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + base_sha: ${{ steps.sync.outputs.base_sha }} + has_changes: ${{ steps.sync.outputs.has_changes }} + temporary_branch: ${{ steps.sync.outputs.temporary_branch }} + temporary_sha: ${{ steps.sync.outputs.temporary_sha }} + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Validate + env: + BRANCH: ${{ github.ref_name }} + run: | + if [ "$BRANCH" = "master" ]; then + echo "::error::This workflow is expected to be run on a release branch, not master" + exit 1 + fi + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync release branch to tag range + id: sync + env: + DRY_RUN: ${{ inputs.dry_run }} + RELEASE_BRANCH: ${{ github.ref_name }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + TAG: ${{ inputs.tag }} + run: | + set -o pipefail + base_sha=$(git rev-parse "origin/$RELEASE_BRANCH") + temporary_branch="process/sync-release-branch/$RUN_ID-$RUN_ATTEMPT" + echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" + echo "temporary_branch=$temporary_branch" >> "$GITHUB_OUTPUT" + + tags_file=$(mktemp) + scripts/unmerged-tags \ + "$RELEASE_BRANCH" \ + "$TAG" \ + > "$tags_file" + + echo >> "$GITHUB_STEP_SUMMARY" + echo "## Tags to sync" >> "$GITHUB_STEP_SUMMARY" + echo >> "$GITHUB_STEP_SUMMARY" + sed 's/^/- /' "$tags_file" >> "$GITHUB_STEP_SUMMARY" + + xargs -r scripts/sync-branch < "$tags_file" | tee -a "$GITHUB_STEP_SUMMARY" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ $(git rev-parse HEAD) == $(git rev-parse "origin/$RELEASE_BRANCH") ]]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No changes to push" + exit 0 + fi + + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git push origin "HEAD:refs/heads/$temporary_branch" + echo "temporary_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + push-release-branch: + needs: sync-release-branch + if: ${{ !inputs.dry_run && needs.sync-release-branch.outputs.has_changes == 'true' }} + runs-on: ubuntu-24.04 + environment: docker-releases + permissions: + contents: write + timeout-minutes: 10 + steps: + - name: Checkout release + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Push release branch + env: + BASE_SHA: ${{ needs.sync-release-branch.outputs.base_sha }} + RELEASE_BRANCH: ${{ github.ref_name }} + TEMPORARY_BRANCH: ${{ needs.sync-release-branch.outputs.temporary_branch }} + TEMPORARY_SHA: ${{ needs.sync-release-branch.outputs.temporary_sha }} + run: | + git fetch origin "$RELEASE_BRANCH" + current_sha=$(git rev-parse "origin/$RELEASE_BRANCH") + if [[ "$current_sha" != "$BASE_SHA" ]]; then + echo "$RELEASE_BRANCH changed from $BASE_SHA to $current_sha" + exit 1 + fi + + git fetch origin "$TEMPORARY_BRANCH" + current_temporary_sha=$(git rev-parse FETCH_HEAD) + if [[ "$current_temporary_sha" != "$TEMPORARY_SHA" ]]; then + echo "$TEMPORARY_BRANCH changed from $TEMPORARY_SHA to $current_temporary_sha" + exit 1 + fi + + git push origin "FETCH_HEAD:$RELEASE_BRANCH" + + - name: Delete temporary branch + env: + TEMPORARY_BRANCH: ${{ needs.sync-release-branch.outputs.temporary_branch }} + run: git push origin --delete "$TEMPORARY_BRANCH" diff --git a/scripts/sync-branch b/scripts/sync-branch new file mode 100755 index 000000000000..4da920c10b3c --- /dev/null +++ b/scripts/sync-branch @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Merge the given release tags. +set -Eeuo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 TAG..." >&2 + exit 1 +fi + +tags_to_sync=("$@") + +for tag_to_sync in "${tags_to_sync[@]}"; do + if git merge --no-ff "$tag_to_sync"; then + continue + fi + + if ! git rev-parse --verify --quiet MERGE_HEAD > /dev/null || git diff --quiet --diff-filter=U; then + exit 1 + fi + + git checkout "$tag_to_sync" -- '.' + git add --all + git merge --continue +done + +# Check that every tag is in the branch. +# This catches cases where a merge did not actually incorporate one of the +# requested release tags. +for tag_to_sync in "${tags_to_sync[@]}"; do + if ! git merge-base --is-ancestor "$tag_to_sync" HEAD; then + echo "tag $tag_to_sync is not contained in $(git rev-parse --abbrev-ref HEAD)" >&2 + exit 1 + fi +done diff --git a/scripts/unmerged-tags b/scripts/unmerged-tags new file mode 100755 index 000000000000..57b90666af3c --- /dev/null +++ b/scripts/unmerged-tags @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +release_branch="$1" +tag="$2" + +if [[ "$tag" == *"-rc."* ]]; then + echo "error: RC tags cannot be used as sync targets" >&2 + exit 1 +fi + +if [[ "$tag" != v* ]]; then + echo "error: Tag must start with 'v' (e.g. v29.6.0)" >&2 + exit 1 +fi + +if ! git rev-parse --verify --quiet "refs/tags/$tag" > /dev/null; then + echo "error: Tag $tag does not exist" >&2 + exit 1 +fi + +# Return early the requested tag is already merged into release branch. +if git merge-base --is-ancestor "$tag" "$release_branch"; then + exit 0 +fi + +if git rev-parse --verify --quiet upstream/master > /dev/null 2>&1; then + master="upstream/master" +else + master="origin/master" +fi + +if ! git merge-base --is-ancestor "$tag" "$master"; then + echo "error: Tag $tag is not in $master" >&2 + exit 1 +fi + +# Get all docker release tags merged into master but not into release branch +git tag --merged "$master" --no-merged "$release_branch" \ + | grep '^v' \ + | grep -v -- "-rc." \ + | sort -V \ + | awk -v tag="$tag" '{print} $0==tag{exit}'