From 9fd8d5b865202509d1ffa258592ab92fed61eaea Mon Sep 17 00:00:00 2001 From: Patrick Dodgen Date: Fri, 17 Apr 2026 16:01:32 -0600 Subject: [PATCH 1/4] feat: add promote-release-candidate reusable workflow Reusable workflow that promotes a -main.N tag to its release version: - Resolves source tag (explicit input or latest -main.N in the caller repo) - Computes pretty semver (v1.5.0-main.3 -> v1.5.0) - Resets the 'stage' branch to the source commit - Creates the pretty git tag - Retags docker images via 'docker buildx imagetools create' (no rebuild) - Creates a GitHub release Per-repo image variation is handled via the image_tag_prefixes JSON array input. Used by DEVEX-1087 release train workflows. --- .../workflows/promote-release-candidate.yaml | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .github/workflows/promote-release-candidate.yaml diff --git a/.github/workflows/promote-release-candidate.yaml b/.github/workflows/promote-release-candidate.yaml new file mode 100644 index 0000000..47e4e7e --- /dev/null +++ b/.github/workflows/promote-release-candidate.yaml @@ -0,0 +1,138 @@ +name: Promote Release Candidate +on: + workflow_call: + inputs: + source_tag: + description: "Source -main.N tag to promote (blank = latest -main.N tag in the repo)" + required: false + default: "" + type: string + image_tag_prefixes: + description: "JSON array of image tag prefixes to retag (e.g. '[\"\", \"nginx-\"]'). Use an empty string for the base image." + required: true + type: string + registry: + description: "Docker registry hosting the images" + required: false + default: "ghcr.io" + type: string + secrets: + repo_write_pat: + description: "PAT with contents:write for force-pushing the stage branch and pushing tags" + required: true + outputs: + source_tag: + description: "The -main.N tag that was promoted" + value: ${{ jobs.promote.outputs.source_tag }} + pretty_tag: + description: "The promoted pretty (release) tag" + value: ${{ jobs.promote.outputs.pretty_tag }} + +jobs: + promote: + runs-on: ubuntu-latest + outputs: + source_tag: ${{ steps.resolve.outputs.source_tag }} + pretty_tag: ${{ steps.compute.outputs.pretty_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.repo_write_pat }} + - name: Resolve source tag + id: resolve + env: + INPUT_TAG: ${{ inputs.source_tag }} + run: | + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + else + TAG=$(git tag --sort=-creatordate --list '*-main.*' | head -n1) + if [ -z "$TAG" ]; then + echo "::error::No -main.N tag found in this repo" + exit 1 + fi + fi + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag '$TAG' does not exist" + exit 1 + fi + echo "source_tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Resolved source tag: $TAG" + - name: Compute pretty version + id: compute + env: + SRC: ${{ steps.resolve.outputs.source_tag }} + run: | + PRETTY="${SRC%-main.*}" + if [ "$PRETTY" = "$SRC" ]; then + echo "::error::Source tag '$SRC' doesn't match '*-main.N' pattern. Pass a -main.N tag via source_tag, or leave blank to use the latest." + exit 1 + fi + if git rev-parse "$PRETTY" >/dev/null 2>&1; then + { + echo "## Release Candidate promotion blocked" + echo "" + echo "Pretty tag \`$PRETTY\` already exists — source \`$SRC\` has already been promoted." + echo "" + echo "### What to do" + echo "- **New changes on \`main\`?** Wait for the Build workflow to produce a fresh \`-main.N\` tag, then re-run." + echo "- **Need to redeploy the existing RC to QA?** Run the \`QA EKS Deploy\` workflow with \`image_tag=$PRETTY\`." + echo "- **Want to promote a specific \`-main.N\` tag?** Re-run and set \`source_tag\` explicitly." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Pretty tag '$PRETTY' already exists (source '$SRC' has already been promoted). See job summary for next steps." + exit 1 + fi + echo "pretty_tag=$PRETTY" >> "$GITHUB_OUTPUT" + echo "Pretty tag: $PRETTY" + - name: Configure git + run: | + git config user.name github-actions + git config user.email github-actions@github.com + - name: Reset stage branch to source tag + env: + SRC: ${{ steps.resolve.outputs.source_tag }} + run: | + git push origin "+refs/tags/$SRC:refs/heads/stage" + - name: Create pretty tag + env: + SRC: ${{ steps.resolve.outputs.source_tag }} + PRETTY: ${{ steps.compute.outputs.pretty_tag }} + run: | + git tag "$PRETTY" "$SRC" + git push origin "$PRETTY" + - name: Login to container registry + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Retag docker images + env: + REGISTRY: ${{ inputs.registry }} + IMAGE_NAME: ${{ github.repository }} + SRC: ${{ steps.resolve.outputs.source_tag }} + PRETTY: ${{ steps.compute.outputs.pretty_tag }} + PREFIXES_JSON: ${{ inputs.image_tag_prefixes }} + run: | + count=$(echo "$PREFIXES_JSON" | jq 'length') + if [ "$count" -eq 0 ]; then + echo "::error::image_tag_prefixes must not be empty" + exit 1 + fi + echo "$PREFIXES_JSON" | jq -r '.[]' | while IFS= read -r prefix; do + echo "Retagging ${prefix}${SRC} -> ${prefix}${PRETTY}" + docker buildx imagetools create \ + --tag "$REGISTRY/$IMAGE_NAME:${prefix}${PRETTY}" \ + "$REGISTRY/$IMAGE_NAME:${prefix}${SRC}" + done + - name: Create GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.compute.outputs.pretty_tag }} + name: Release Candidate ${{ steps.compute.outputs.pretty_tag }} + body: "Promoted from `${{ steps.resolve.outputs.source_tag }}`." + prerelease: false From ed7cfe86ee93d053d4c935bb6db525a8ab59888a Mon Sep 17 00:00:00 2001 From: Patrick Dodgen Date: Fri, 17 Apr 2026 16:13:32 -0600 Subject: [PATCH 2/4] chore: parameterize target branch in promote-release-candidate, default to rc --- .github/workflows/promote-release-candidate.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-release-candidate.yaml b/.github/workflows/promote-release-candidate.yaml index 47e4e7e..2f6a482 100644 --- a/.github/workflows/promote-release-candidate.yaml +++ b/.github/workflows/promote-release-candidate.yaml @@ -16,6 +16,11 @@ on: required: false default: "ghcr.io" type: string + target_branch: + description: "Branch to reset to the source tag's commit (used as the release candidate branch)" + required: false + default: "rc" + type: string secrets: repo_write_pat: description: "PAT with contents:write for force-pushing the stage branch and pushing tags" @@ -90,11 +95,12 @@ jobs: run: | git config user.name github-actions git config user.email github-actions@github.com - - name: Reset stage branch to source tag + - name: Reset target branch to source tag env: SRC: ${{ steps.resolve.outputs.source_tag }} + TARGET: ${{ inputs.target_branch }} run: | - git push origin "+refs/tags/$SRC:refs/heads/stage" + git push origin "+refs/tags/$SRC:refs/heads/$TARGET" - name: Create pretty tag env: SRC: ${{ steps.resolve.outputs.source_tag }} From 6476a047f49d0bc131dcf4ee4e97bd579788cc48 Mon Sep 17 00:00:00 2001 From: Patrick Dodgen Date: Fri, 17 Apr 2026 16:20:56 -0600 Subject: [PATCH 3/4] fix: address Cursor Bugbot review on promote-release-candidate - prerelease: true on the promoted release so the prod deploy can meaningfully flip it to false (matches helm-deploy-eks.yaml's allowUpdates pattern) - Atomic tag+release creation: let ncipollo/release-action create the pretty git tag alongside the release instead of pushing it earlier. Prevents the 'tag exists but images don't' state when a later step fails, so re-runs work cleanly. - Add dedicated gh_token secret for container registry auth, matching the convention used by other reusable workflows in this repo (php-build-push, etc.). --- .../workflows/promote-release-candidate.yaml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/promote-release-candidate.yaml b/.github/workflows/promote-release-candidate.yaml index 2f6a482..c8d714a 100644 --- a/.github/workflows/promote-release-candidate.yaml +++ b/.github/workflows/promote-release-candidate.yaml @@ -23,7 +23,10 @@ on: type: string secrets: repo_write_pat: - description: "PAT with contents:write for force-pushing the stage branch and pushing tags" + description: "PAT with contents:write for force-pushing the target branch and pushing tags" + required: true + gh_token: + description: "PAT with packages:write on the caller repo's container registry (used to retag images)" required: true outputs: source_tag: @@ -101,19 +104,12 @@ jobs: TARGET: ${{ inputs.target_branch }} run: | git push origin "+refs/tags/$SRC:refs/heads/$TARGET" - - name: Create pretty tag - env: - SRC: ${{ steps.resolve.outputs.source_tag }} - PRETTY: ${{ steps.compute.outputs.pretty_tag }} - run: | - git tag "$PRETTY" "$SRC" - git push origin "$PRETTY" - name: Login to container registry uses: docker/login-action@v3 with: registry: ${{ inputs.registry }} username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.gh_token }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Retag docker images @@ -135,10 +131,11 @@ jobs: --tag "$REGISTRY/$IMAGE_NAME:${prefix}${PRETTY}" \ "$REGISTRY/$IMAGE_NAME:${prefix}${SRC}" done - - name: Create GitHub release + - name: Create pretty tag and GitHub release uses: ncipollo/release-action@v1 with: tag: ${{ steps.compute.outputs.pretty_tag }} + commit: ${{ steps.resolve.outputs.source_tag }} name: Release Candidate ${{ steps.compute.outputs.pretty_tag }} body: "Promoted from `${{ steps.resolve.outputs.source_tag }}`." - prerelease: false + prerelease: true From ba3da7e675500cd33108f393bbe4a59eec1ede91 Mon Sep 17 00:00:00 2001 From: Patrick Dodgen Date: Mon, 20 Apr 2026 10:25:37 -0600 Subject: [PATCH 4/4] fix: pass repo_write_pat to release-action so it can create the tag The GitHub Release step uses GITHUB_TOKEN by default. When the target tag doesn't exist yet, ncipollo/release-action creates it via the GitHub API, which requires contents:write. If the default GITHUB_TOKEN is restricted (read-only), the step fails. The repo_write_pat secret is already declared and used for git pushes; pass it to the release action too. --- .github/workflows/promote-release-candidate.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/promote-release-candidate.yaml b/.github/workflows/promote-release-candidate.yaml index c8d714a..4c7c256 100644 --- a/.github/workflows/promote-release-candidate.yaml +++ b/.github/workflows/promote-release-candidate.yaml @@ -134,6 +134,7 @@ jobs: - name: Create pretty tag and GitHub release uses: ncipollo/release-action@v1 with: + token: ${{ secrets.repo_write_pat }} tag: ${{ steps.compute.outputs.pretty_tag }} commit: ${{ steps.resolve.outputs.source_tag }} name: Release Candidate ${{ steps.compute.outputs.pretty_tag }}