From 335a604e994fc671c3dac8d1142e087bc46fba9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 25 Jun 2026 13:42:18 +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 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-releases` GitHub environment which requires a manual approval. Signed-off-by: Paweł Gronowski --- .github/workflows/sync-release-branch.yml | 138 ++++++++++++++++++++++ releases/scripts/sync-branch | 34 ++++++ releases/scripts/unmerged-tags | 22 ++++ 3 files changed, 194 insertions(+) create mode 100644 .github/workflows/sync-release-branch.yml create mode 100755 releases/scripts/sync-branch create mode 100755 releases/scripts/unmerged-tags diff --git a/.github/workflows/sync-release-branch.yml b/.github/workflows/sync-release-branch.yml new file mode 100644 index 0000000000..14002cf931 --- /dev/null +++ b/.github/workflows/sync-release-branch.yml @@ -0,0 +1,138 @@ +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 docker-v29.6.0 + required: true + type: string + dry_run: + description: Print the tag range without merging or pushing + 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 + if: ${{ !inputs.dry_run }} + 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) + releases/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 releases/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/releases/scripts/sync-branch b/releases/scripts/sync-branch new file mode 100755 index 0000000000..adb30f59ff --- /dev/null +++ b/releases/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 [--dry-run] 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/releases/scripts/unmerged-tags b/releases/scripts/unmerged-tags new file mode 100755 index 0000000000..7852f8db33 --- /dev/null +++ b/releases/scripts/unmerged-tags @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +release_branch="$1" +tag="$2" + +# Return early the requested tag is already merged into release branch. +if git merge-base --is-ancestor "$tag" "$release_branch"; then + exit 0 +fi + +# Get all docker release tags merged into master but not into release branch +git tag --merged upstream/master --no-merged "$release_branch" \ + | grep '^docker-v' \ + | grep -v -- "-rc." \ + | sort \ + | sed "/$tag/q"