From 1d4d43442e05e7b050a02475a44a7c0900c1b78a Mon Sep 17 00:00:00 2001 From: Stoney Jackson Date: Mon, 27 Jul 2026 15:07:57 -0400 Subject: [PATCH] ci: only build a release candidate when a release will actually happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every push to main built and tested the image, including infra-only merges that release nothing — the App-token merge earlier today spent a full build to publish nothing at all. Path filtering cannot decide this: a releasing commit touching only docs would skip the build and leave the publish step with no image. The release decision is what matters, so semantic-release is asked in dry-run first and the build is guarded on its answer. The build still precedes the real release, so a broken image can never be published. Unlike plcc-ng-devcontainer this workflow is a single job, so the guard is on steps rather than a separate build job. Guarded with != 'false' so an unexpected or empty output still builds: skipping a needed build breaks publishing, an extra build only costs time. Also fails loudly if the dry run and the real release ever disagree. Without that the failure would be silent and wrong: with no image built, the publish step's `FROM ...:latest` would pull the previous release's image and push it under the new version tags. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e9a894..67b1c1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,35 @@ jobs: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} + # On main the only reason to build is to feed a release — CI already + # verified the code on the PR. Infra-only merges (ci:, docs:, chore:) + # release nothing, so building them spends minutes to produce an image + # that is then thrown away. Ask semantic-release first; this publishes + # nothing. + # + # Path filtering cannot answer this: a releasing commit that touches + # only docs would skip the build and then have no image to publish. + # The release decision is what matters, not the diff. + - name: Would this commit release? (dry run) + id: plan + uses: cycjimmy/semantic-release-action@v4 + with: + dry_run: true + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Report plan + run: | + echo "will_release=${{ steps.plan.outputs.new_release_published }}" + echo "version=${{ steps.plan.outputs.new_release_version }}" + + # Guarded with != 'false' rather than == 'true' so an empty or + # unexpected output still builds. Skipping a build that a release needs + # would leave the publish step with no image; an extra build only costs + # time. The build still precedes the real release, so a broken image + # cannot be published. - name: Log in to GHCR + if: steps.plan.outputs.new_release_published != 'false' uses: docker/login-action@v3 with: registry: ghcr.io @@ -42,6 +70,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and test devcontainer image + if: steps.plan.outputs.new_release_published != 'false' uses: devcontainers/ci@v0.3 with: imageName: ghcr.io/ourplcc/plcc-devcontainer @@ -54,6 +83,17 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + # The two runs see the same commits and should never disagree, but if + # they did the consequence would be silent and bad: no image was built, + # and the publish step's `FROM ...:latest` would pull the *previous* + # release's image and push it under the new version tags. Fail loudly + # instead of shipping stale content under a fresh version. + - name: Fail if the dry run and the real release disagreed + if: steps.semantic.outputs.new_release_published == 'true' && steps.plan.outputs.new_release_published == 'false' + run: | + echo "::error::semantic-release published ${{ steps.semantic.outputs.new_release_version }}, but the dry run predicted no release, so no image was built. Refusing to publish." + exit 1 + - name: Read PLCC version if: steps.semantic.outputs.new_release_published == 'true' id: plcc_version