From ff276ac40c30f6a5393fd57bb26b9465e688e2bb Mon Sep 17 00:00:00 2001 From: dandoingdev <3135215+dandoingdev@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:15:16 +0100 Subject: [PATCH] Make release publication safe to rerun Serialize publishers for each tag and leave complete published releases intact. Keep API errors and incomplete uploads visible instead of treating every failed lookup as a missing release. --- .github/workflows/release.yml | 12 ++-- CHANGELOG.md | 4 ++ tests/unit/publish-release.test.mjs | 103 ++++++++++++++++++++++++++++ tools/publish-release.mjs | 69 +++++++++++++++++++ 4 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 tests/unit/publish-release.test.mjs create mode 100644 tools/publish-release.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae63f47..b8f9cf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: permissions: contents: write +# A repeated push or rerun for a tag must wait for its publisher to finish. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -65,9 +70,4 @@ jobs: - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} - run: | - FLAGS="" - case "$GITHUB_REF_NAME" in *-*) FLAGS="--prerelease" ;; esac - gh release create "$GITHUB_REF_NAME" dist/release/houseview.js \ - dist/release/houseview.js.map dist/release/houseview.js.sha256 \ - --title "HouseView ${GITHUB_REF_NAME#v}" --notes-file dist/release/NOTES.md $FLAGS + run: node tools/publish-release.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e881d8..72b553c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The release workflow publishes the section of the tagged version as the release ## [Unreleased] +### Fixed + +* Duplicate tag pushes and release workflow reruns leave an already published release intact and succeed when all release assets are present. + ## [1.9.0] - 2026-10-21 ### Added diff --git a/tests/unit/publish-release.test.mjs b/tests/unit/publish-release.test.mjs new file mode 100644 index 0000000..2e8bc70 --- /dev/null +++ b/tests/unit/publish-release.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {publishRelease} from '../../tools/publish-release.mjs'; +import {RELEASE_FILES} from '../../tools/release.mjs'; + +function fixture(status, release = {}) { + const calls = []; + return { + calls, + options: { + repository: 'example/houseview', + tag: 'v1.9.0', + token: 'test-token', + request: async () => ({ok: status === 200, status, json: async () => release}), + run: (...args) => calls.push(args) + } + }; +} + +test('a duplicate run leaves a complete published release untouched', async () => { + const {options, calls} = fixture(200, { + draft: false, + assets: RELEASE_FILES.map((name) => ({name, state: 'uploaded'})) + }); + await publishRelease(options); + assert.deepEqual(calls, []); +}); + +test('only a missing release creates the release with the staged assets and notes', async () => { + const {options, calls} = fixture(404); + await publishRelease(options); + assert.deepEqual(calls, [ + [ + 'gh', + [ + 'release', + 'create', + 'v1.9.0', + 'dist/release/houseview.js', + 'dist/release/houseview.js.map', + 'dist/release/houseview.js.sha256', + '--repo', + 'example/houseview', + '--verify-tag', + '--title', + 'HouseView 1.9.0', + '--notes-file', + 'dist/release/NOTES.md' + ], + {stdio: 'inherit'} + ] + ]); +}); + +test('prerelease tags keep the prerelease flag', async () => { + const {options, calls} = fixture(404); + await publishRelease({...options, tag: 'v2.0.0-beta.1'}); + assert.equal(calls[0][1].at(-1), '--prerelease'); +}); + +test('API errors and network failures cannot be mistaken for a missing release', async () => { + for (const status of [401, 403, 429, 500]) { + const {options, calls} = fixture(status); + await assert.rejects(publishRelease(options), new RegExp(`HTTP ${status}`)); + assert.deepEqual(calls, []); + } + const {options, calls} = fixture(404); + await assert.rejects( + publishRelease({ + ...options, + request: async () => { + throw new Error('network unavailable'); + } + }), + /network unavailable/ + ); + assert.deepEqual(calls, []); +}); + +test('drafts and incomplete uploads require inspection and are never overwritten', async () => { + for (const release of [ + {draft: true, assets: RELEASE_FILES.map((name) => ({name, state: 'uploaded'}))}, + {draft: false, assets: []}, + {draft: false, assets: RELEASE_FILES.map((name) => ({name, state: 'starter'}))} + ]) { + const {options, calls} = fixture(200, release); + await assert.rejects(publishRelease(options), /draft or has missing assets/); + assert.deepEqual(calls, []); + } +}); + +test('publication failures still fail the run', async () => { + const {options} = fixture(404); + await assert.rejects( + publishRelease({ + ...options, + run: () => { + throw new Error('gh exited 1'); + } + }), + /gh exited 1/ + ); +}); diff --git a/tools/publish-release.mjs b/tools/publish-release.mjs new file mode 100644 index 0000000..24ec426 --- /dev/null +++ b/tools/publish-release.mjs @@ -0,0 +1,69 @@ +// Publish once per tag. Duplicate tag pushes and workflow reruns must leave a +// complete published release alone; API failures must still fail the job. +import {execFileSync} from 'node:child_process'; +import {pathToFileURL} from 'node:url'; +import {RELEASE_FILES} from './release.mjs'; + +export async function publishRelease({ + repository = process.env.GITHUB_REPOSITORY, + tag = process.env.GITHUB_REF_NAME, + token = process.env.GH_TOKEN, + request = fetch, + run = execFileSync +} = {}) { + if (!repository || !tag || !token) { + throw new Error('GITHUB_REPOSITORY, GITHUB_REF_NAME and GH_TOKEN are required'); + } + const response = await request( + `https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, + { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: globalThis.AbortSignal.timeout(30_000) + } + ); + if (response.ok) { + const release = await response.json(); + const complete = RELEASE_FILES.every((name) => + release.assets?.some((asset) => asset.name === name && asset.state === 'uploaded') + ); + if (release.draft || !complete) { + throw new Error( + `Release ${tag} exists but is a draft or has missing assets; inspect it before retrying` + ); + } + console.log(`Release ${tag} is already published with all assets; nothing to publish`); + return; + } + if (response.status !== 404) { + throw new Error(`Could not check release ${tag}: GitHub returned HTTP ${response.status}`); + } + run( + 'gh', + [ + 'release', + 'create', + tag, + ...RELEASE_FILES.map((name) => `dist/release/${name}`), + '--repo', + repository, + '--verify-tag', + '--title', + `HouseView ${tag.replace(/^v/, '')}`, + '--notes-file', + 'dist/release/NOTES.md', + ...(tag.includes('-') ? ['--prerelease'] : []) + ], + {stdio: 'inherit'} + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + publishRelease().catch((error) => { + console.error(`release: ${error.message}`); + process.exitCode = 1; + }); +}