From ed953b63709fb687fd215c8d3754b2c72adbeff1 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Fri, 18 Sep 2026 16:30:28 +0200 Subject: [PATCH] ci(build): the release images exist before the release can be merged The version bump landed on main and the images were built minutes later, so Flux applied a version whose images did not exist yet and the new pod waited in ImagePullBackOff while the old one served. A push to the release branch now builds and publishes the full set under the release tag, taken from the manifest release-please has already bumped there. One step resolves the tag for the whole run, so no later job needs to know which trigger fired. promote stays gated on the workflow input rather than the resolved tag: the branch build publishes the tag early, but until that branch merges there is no release, and moving :latest would deploy one that does not exist. --- .github/workflows/build.yml | 45 ++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80fc39a90..201f18591 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,22 +6,23 @@ name: Build # into a manifest list, so neither arch waits on QEMU emulation and each gets # its own GHA cache scope. # -# main tags :sha- only. release.yml calls this with release-tag set, -# which additionally publishes :, the tag Flux pins api and frontend to. +# main tags :sha- only. A push to the release branch additionally +# publishes :, the tag Flux pins api and frontend to, so the images exist +# before the release pull request can be merged. release.yml calls this again +# once the release is cut; the tags are the same and the layers are cached. # It is called rather than triggered by `release: published` because a release # cut with GITHUB_TOKEN raises no event. on: push: - branches: [main] + branches: [main, release-please--branches--main] workflow_call: inputs: release-tag: description: Tag to publish, e.g. v1.2.0. Empty means an ordinary main build. type: string default: '' - # Same input as workflow_call, so a release that half-finished can be promoted again - # by hand without cutting another one. `inputs.release-tag` reads the same under both - # triggers, and is empty on a push, so nothing downstream needs to know which fired. + # Same input as workflow_call, so a release that half-finished can be republished + # by hand without cutting another one. workflow_dispatch: inputs: release-tag: @@ -68,15 +69,35 @@ jobs: build-matrix: ${{ steps.compute.outputs.build-matrix }} services: ${{ steps.compute.outputs.services }} has-matrix: ${{ steps.compute.outputs.has-matrix }} + release-tag: ${{ steps.tag.outputs.release-tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 + # The one place the tag is resolved, so no later job needs to know which + # trigger fired. On the release branch it comes from the manifest + # release-please has already bumped there. + - name: Resolve the release tag + id: tag + env: + INPUT_TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + if [[ -n "$INPUT_TAG" ]]; then + tag="$INPUT_TAG" + elif [[ "$GITHUB_REF_NAME" == "release-please--branches--main" ]]; then + tag="v$(jq -er '.["."]' .release-please-manifest.json)" + else + tag="" + fi + echo "release-tag=$tag" >> "$GITHUB_OUTPUT" + echo "::notice::release tag for this run: ${tag:-none, ordinary main build}" + # Skipped on a release: the tag needs a complete set of images. - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter - if: inputs.release-tag == '' + if: steps.tag.outputs.release-tag == '' with: base: ${{ github.event.before }} filters: | @@ -109,7 +130,7 @@ jobs: FILTER_BUILD_LOGIC: ${{ steps.filter.outputs.build-logic }} FILTER_LIBS: ${{ steps.filter.outputs.libs }} FILTER_GRADLE_CONFIG: ${{ steps.filter.outputs.gradle-config }} - IS_RELEASE: ${{ inputs.release-tag != '' }} + IS_RELEASE: ${{ steps.tag.outputs.release-tag != '' }} run: | set -euo pipefail any() { for v in "$@"; do [[ "$v" == "true" ]] && echo "true" && return; done; echo "false"; } @@ -286,7 +307,7 @@ jobs: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service }} SHORT: ${{ steps.sha.outputs.short }} # Empty unless release-please called this workflow for a release. - RELEASE_TAG: ${{ inputs.release-tag }} + RELEASE_TAG: ${{ needs.detect-changes.outputs.release-tag }} run: | set -euo pipefail sources=() @@ -323,6 +344,10 @@ jobs: # Moves :latest for the Keel-driven images only. api and frontend deploy # from the tag pinned in git, so a :latest for them would be a second, # racing trigger on the same Deployments. + # + # Gated on the input rather than the resolved tag on purpose: a release + # branch build publishes the tag early, but until that branch merges there + # is no release, and moving :latest would deploy one that does not exist. name: Promote release to :latest needs: [detect-changes, merge] if: needs.detect-changes.outputs.has-matrix == 'true' && inputs.release-tag != '' @@ -347,7 +372,7 @@ jobs: - name: Point :latest at the release tag env: SERVICES: ${{ needs.detect-changes.outputs.services }} - RELEASE_TAG: ${{ inputs.release-tag }} + RELEASE_TAG: ${{ needs.detect-changes.outputs.release-tag }} # Images whose Deployment carries keel.sh annotations. KEEL_SERVICES: '["stalwart-tools"]' run: |