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; + }); +}