Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/publish-release.test.mjs
Original file line number Diff line number Diff line change
@@ -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/
);
});
69 changes: 69 additions & 0 deletions tools/publish-release.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading