From 50385ebcb50080e3e3dfd6326ca7a442e6fe3d30 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:17:09 -0600 Subject: [PATCH] Publish maintenance assets atomically --- .github/workflows/release.yml | 26 +++++++++++--------- scripts/ci/test-release-workflow-metadata.js | 16 +++++------- tests/ReleaseInstallerContractTest.php | 11 +++++---- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e08724..3a8a5c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1805,17 +1805,21 @@ jobs: EOF - name: Create GitHub Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 - with: - tag_name: ${{ needs.resolve-release.outputs.tag }} - name: ${{ needs.resolve-release.outputs.tag }} - body_path: release-notes.md - files: | - dist/* - generate_release_notes: true - fail_on_unmatched_files: true - prerelease: ${{ needs.resolve-release.outputs.prerelease }} - make_latest: ${{ needs.resolve-release.outputs.make_latest }} + # gh creates the release as a draft, uploads every asset, then + # publishes it, prereleases included, so a release is complete when it + # becomes visible and immutable releases accept it. + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} + PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }} + MAKE_LATEST: ${{ needs.resolve-release.outputs.make_latest }} + run: | + set -euo pipefail + flags=(--verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md --generate-notes "--latest=$MAKE_LATEST") + if [ "$PRERELEASE" = "true" ]; then + flags+=(--prerelease) + fi + gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" "${flags[@]}" dist/* verify-release: name: Verify published release diff --git a/scripts/ci/test-release-workflow-metadata.js b/scripts/ci/test-release-workflow-metadata.js index ff0e154..664a9b8 100644 --- a/scripts/ci/test-release-workflow-metadata.js +++ b/scripts/ci/test-release-workflow-metadata.js @@ -70,16 +70,12 @@ test('the release action consumes metadata from the resolved tag', () => { workflow.indexOf('- name: Create GitHub Release'), workflow.indexOf('- name: Verify public release downloads'), ); - assert.match( - releaseAction, - /prerelease: \$\{\{ needs\.resolve-release\.outputs\.prerelease \}\}/, - ); - assert.match( - releaseAction, - /make_latest: \$\{\{ needs\.resolve-release\.outputs\.make_latest \}\}/, - ); - assert.match(releaseAction, /body_path: release-notes\.md/); - assert.match(releaseAction, /files: \|\n\s+dist\/\*/); + assert.match(releaseAction, /PRERELEASE: \$\{\{ needs\.resolve-release\.outputs\.prerelease \}\}/); + assert.match(releaseAction, /MAKE_LATEST: \$\{\{ needs\.resolve-release\.outputs\.make_latest \}\}/); + assert.match(releaseAction, /--notes-file release-notes\.md --generate-notes "--latest=\$MAKE_LATEST"/); + assert.match(releaseAction, /flags\+=\(--prerelease\)/); + assert.match(releaseAction, /gh release create "\$RELEASE_TAG" --repo "\$GITHUB_REPOSITORY" "\$\{flags\[@\]\}" dist\/\*/); + assert.doesNotMatch(workflow, /softprops\/action-gh-release/); }); test('metadata reconciliation can only write from the main branch', () => { diff --git a/tests/ReleaseInstallerContractTest.php b/tests/ReleaseInstallerContractTest.php index e30aae1..388fb66 100644 --- a/tests/ReleaseInstallerContractTest.php +++ b/tests/ReleaseInstallerContractTest.php @@ -182,8 +182,8 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('raw_tag="$tag"', $releaseWorkflow); self::assertStringContainsString('node scripts/ci/release-version.js normalize "$raw_tag"', $releaseWorkflow); self::assertStringContainsString('node scripts/ci/release-version.js metadata "$tag"', $releaseWorkflow); - self::assertStringContainsString('prerelease: ${{ needs.resolve-release.outputs.prerelease }}', $releaseWorkflow); - self::assertStringContainsString('make_latest: ${{ needs.resolve-release.outputs.make_latest }}', $releaseWorkflow); + self::assertStringContainsString('PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }}', $releaseWorkflow); + self::assertStringContainsString('MAKE_LATEST: ${{ needs.resolve-release.outputs.make_latest }}', $releaseWorkflow); self::assertStringContainsString('Reconcile metadata without replacing the tag or assets', $releaseWorkflow); self::assertStringContainsString('scripts/ci/reconcile-release-metadata.sh "$RELEASE_TAG"', $releaseWorkflow); self::assertStringContainsString('Stable 2.0.0 publication requires a protected main-branch dispatch', $releaseWorkflow); @@ -205,14 +205,15 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('cp scripts/install.sh dist/install.sh', $releaseWorkflow); self::assertStringContainsString('cp scripts/install.ps1 dist/install.ps1', $releaseWorkflow); self::assertStringContainsString('cp scripts/verify-release.sh dist/verify-release.sh', $releaseWorkflow); - self::assertStringContainsString('tag_name: ${{ needs.resolve-release.outputs.tag }}', $releaseWorkflow); + self::assertStringContainsString('RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}', $releaseWorkflow); self::assertStringContainsString('Verify public release downloads', $releaseWorkflow); self::assertStringContainsString('public_asset_url()', $releaseWorkflow); self::assertStringContainsString('wait_for_asset()', $releaseWorkflow); self::assertStringContainsString('VERSION="$tag" DURABLE_WORKFLOW_INSTALL_DIR="$install_dir"', $releaseWorkflow); self::assertStringNotContainsString('scripts/verify-public-release-assets.sh "${{ needs.resolve-release.outputs.tag }}"', $releaseWorkflow); self::assertStringNotContainsString('continue-on-error: true', $releaseWorkflow); - self::assertStringContainsString('fail_on_unmatched_files: true', $releaseWorkflow); + self::assertStringContainsString('gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" "${flags[@]}" dist/*', $releaseWorkflow); + self::assertStringNotContainsString('softprops/action-gh-release', $releaseWorkflow); self::assertStringContainsString('Missing required release artifact: $artifact', $releaseWorkflow); self::assertStringContainsString('Public release asset is not downloadable: $url', $releaseWorkflow); self::assertStringContainsString("needs.build-binary-windows.result == 'success'", $releaseWorkflow); @@ -230,7 +231,7 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('Write release notes', $releaseWorkflow); self::assertStringContainsString('Durable Workflow CLI ${tag}', $releaseWorkflow); self::assertStringContainsString('SHA256SUMS for artifact verification', $releaseWorkflow); - self::assertStringContainsString('body_path: release-notes.md', $releaseWorkflow); + self::assertStringContainsString('--verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md --generate-notes', $releaseWorkflow); self::assertStringContainsString('SPC_DOWNLOAD_RETRY: \'5\'', $releaseWorkflow); self::assertStringContainsString('SPC_DOWNLOAD_OUTER_ATTEMPTS: \'4\'', $releaseWorkflow); self::assertStringContainsString('spc dependency download failed after ${SPC_DOWNLOAD_OUTER_ATTEMPTS} attempts', $releaseWorkflow);