fix(release): keep releases draft until manifests are ready (#4201)

Create tag-triggered GitHub releases as drafts and publish them only after the desktop updater manifests are complete, stamped, and uploaded. Preserve beta prerelease semantics and safe retry behavior.\n\nCloses #4183
This commit is contained in:
Mohamed Boudra
2026-09-02 11:29:41 +02:00
committed by GitHub
parent 78f7796eb4
commit d67a6f5f6c
7 changed files with 404 additions and 60 deletions
+45 -27
View File
@@ -20,7 +20,50 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
ensure-release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
--draft
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
fi
publish-android-apk:
needs: [ensure-release]
permissions:
contents: write
packages: read
@@ -36,32 +79,6 @@ jobs:
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -129,4 +146,5 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
+64 -7
View File
@@ -75,7 +75,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
@@ -86,8 +86,10 @@ jobs:
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--notes "" \
--draft \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
}
fi
@@ -191,7 +193,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -299,7 +302,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -404,7 +408,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -424,7 +429,7 @@ jobs:
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -480,7 +485,7 @@ jobs:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
- name: Assemble manifests and publish release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -491,6 +496,33 @@ jobs:
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
expected_manifests=(
"${RELEASE_CHANNEL}-mac.yml"
"${RELEASE_CHANNEL}-linux.yml"
"${RELEASE_CHANNEL}.yml"
)
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
for manifest_name in "${expected_manifests[@]}"; do
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
case "$manifest_name" in
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
esac
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
gh release download "$release_lookup" \
--repo "${{ github.repository }}" \
--pattern "$manifest_name" \
--dir "$manifests_dir"
else
gh release delete-asset "$release_lookup" "$manifest_name" \
--repo "${{ github.repository }}" \
--yes
fi
fi
done
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
@@ -515,6 +547,7 @@ jobs:
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
# shellcheck disable=SC2016
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
@@ -536,4 +569,28 @@ jobs:
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
failed_builds=()
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
if (( ${#failed_builds[@]} > 0 )); then
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
exit 1
fi
missing_manifests=()
for manifest_name in "${expected_manifests[@]}"; do
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
done
if (( ${#missing_manifests[@]} > 0 )); then
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
exit 1
fi
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
if [[ "$IS_PRERELEASE" == "true" ]]; then
publish_args+=(--prerelease)
fi
gh "${publish_args[@]}"
+1 -2
View File
@@ -15,7 +15,7 @@ on:
required: false
type: string
create_if_missing:
description: "Create release if missing (normally only needed for tag events)."
description: "Create a draft release if missing (normally only needed for tag events)."
required: false
default: false
type: boolean
@@ -29,7 +29,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
+30 -7
View File
@@ -112,7 +112,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -180,10 +180,10 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
### Default behavior
@@ -375,7 +375,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
## Fixing a failed release build
@@ -404,6 +404,13 @@ you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
uploads manifests from successful platforms before it fails. A later
single-platform retry reuses those manifests, stamps the complete set with one
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
the same path against the existing draft.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
@@ -433,6 +440,22 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
If you decide to publish a release without working desktop builds, inspect its
assets first, then publish it manually:
```bash
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
# Keep a beta marked as a prerelease:
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
```
This bypasses the updater-manifest guarantee. Use it only when the release is
intentionally unavailable to desktop updater clients.
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
@@ -585,7 +608,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
@@ -608,7 +631,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
+108
View File
@@ -0,0 +1,108 @@
import { execFileSync as nodeExecFileSync } from "node:child_process";
function parseJson(output) {
return JSON.parse(output);
}
function isNotFoundError(error) {
return String(error?.stderr ?? "").includes("HTTP 404");
}
export function getGitHubRelease(repo, tag, execFileSync = nodeExecFileSync) {
try {
return parseJson(
execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
const expectedName = `Paseo ${tag}`;
const output = execFileSync(
"gh",
[
"api",
`repos/${repo}/releases?per_page=100`,
"--jq",
`[.[] | select(.draft == true and .name == ${JSON.stringify(expectedName)})] | sort_by(.id)`,
],
{
encoding: "utf8",
},
);
return parseJson(output)[0] ?? null;
}
}
export function getReleaseLookupTag(release) {
if (release.draft === true && release.html_url) {
return new URL(release.html_url).pathname.split("/").at(-1);
}
return release.tag_name;
}
function parseArgs(argv) {
let repo = "";
let tag = "";
let cleanupDuplicates = false;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--repo") {
repo = argv[index + 1] ?? "";
index += 1;
} else if (argv[index] === "--tag") {
tag = argv[index + 1] ?? "";
index += 1;
} else if (argv[index] === "--cleanup-duplicates") {
cleanupDuplicates = true;
} else {
throw new Error(`Unknown argument: ${argv[index]}`);
}
}
if (!repo || !tag) {
throw new Error("Usage: node scripts/github-release.mjs --repo <owner/repo> --tag <tag>");
}
return { cleanupDuplicates, repo, tag };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { cleanupDuplicates, repo, tag } = parseArgs(process.argv.slice(2));
let release = null;
for (let attempt = 0; attempt < 5 && !release; attempt += 1) {
release = getGitHubRelease(repo, tag);
if (!release && attempt < 4) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_000);
}
}
if (!release) {
process.exitCode = 1;
} else {
if (cleanupDuplicates && release.draft === true) {
const expectedName = `Paseo ${tag}`;
const duplicateIds = nodeExecFileSync(
"gh",
[
"api",
`repos/${repo}/releases?per_page=100`,
"--jq",
`.[] | select(.draft == true and .name == ${JSON.stringify(expectedName)} and .id != ${release.id}) | .id`,
],
{ encoding: "utf8" },
)
.trim()
.split("\n")
.filter(Boolean);
for (const duplicateId of duplicateIds) {
nodeExecFileSync("gh", [
"api",
"--method",
"DELETE",
`repos/${repo}/releases/${duplicateId}`,
]);
}
}
process.stdout.write(`${getReleaseLookupTag(release)}\n`);
}
}
+25 -15
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseChangelogEntries } from "./changelog-utils.mjs";
import { getGitHubRelease } from "./github-release.mjs";
import {
getReleaseInfoFromSourceTag,
normalizeReleaseTag,
@@ -16,7 +17,7 @@ Usage: node scripts/sync-release-notes-from-changelog.mjs [options]
Options:
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
--create-if-missing Create release if it does not already exist.
--create-if-missing Create a draft release if it does not already exist.
`;
process.stderr.write(usage.trimStart());
process.stderr.write("\n");
@@ -80,17 +81,6 @@ function parseChangelog(changelogText) {
});
}
function getRelease(tag, repo, execFileSync = nodeExecFileSync) {
try {
const output = execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], {
encoding: "utf8",
});
return JSON.parse(output);
} catch {
return null;
}
}
function runGh(args, execFileSync = nodeExecFileSync) {
execFileSync("gh", args, { stdio: "inherit" });
}
@@ -117,6 +107,11 @@ function exposeGitHubContributorMentions(notes) {
export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
const execFileSync = deps.execFileSync ?? nodeExecFileSync;
const sleepSync =
deps.sleepSync ??
((milliseconds) => {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
});
const args = parseArgs(argv);
const changelogPath = path.resolve("CHANGELOG.md");
const changelogText = readFileSync(changelogPath, "utf8");
@@ -150,11 +145,12 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
"--notes-file",
notesPath,
"--verify-tag",
"--draft",
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
];
try {
const release = getRelease(targetTag, args.repo, execFileSync);
const release = getGitHubRelease(args.repo, targetTag, execFileSync);
if (release) {
updateReleaseNotes({ releaseId: release.id, repo: args.repo, notesPath }, execFileSync);
console.log(`Updated release notes for ${targetTag}.`);
@@ -170,12 +166,11 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
try {
runGh(createArgs, execFileSync);
console.log(`Created release ${targetTag} with changelog notes.`);
} catch (createError) {
console.warn(
`Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`,
);
const raceRelease = getRelease(targetTag, args.repo, execFileSync);
const raceRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
if (!raceRelease) {
throw createError;
}
@@ -185,7 +180,22 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
if (createError instanceof Error) {
console.warn(createError.message);
}
return;
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const createdRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
if (createdRelease) {
updateReleaseNotes(
{ releaseId: createdRelease.id, repo: args.repo, notesPath },
execFileSync,
);
console.log(`Created release ${targetTag} with changelog notes.`);
return;
}
sleepSync(1_000);
}
throw new Error(`Created release ${targetTag} could not be resolved.`);
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { getGitHubRelease, getReleaseLookupTag } from "./github-release.mjs";
import { syncReleaseNotes } from "./sync-release-notes-from-changelog.mjs";
function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n") {
@@ -19,6 +20,37 @@ function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\
}
}
function notFoundError() {
return Object.assign(new Error("release not found"), { stderr: "gh: Not Found (HTTP 404)" });
}
test("uses the untagged URL slug for draft release CLI operations", () => {
assert.equal(
getReleaseLookupTag({
draft: true,
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
tag_name: "v0.1.60-beta.1",
}),
"untagged-draft",
);
});
test("does not treat GitHub authentication failures as missing releases", () => {
const calls = [];
const authError = Object.assign(new Error("authentication failed"), {
stderr: "gh: HTTP 401: Bad credentials",
});
assert.throws(
() =>
getGitHubRelease("getpaseo/paseo", "v0.1.60-beta.1", (command, args) => {
calls.push({ args, command });
throw authError;
}),
authError,
);
assert.equal(calls.length, 1, "authentication failures must not fall back to draft lookup");
});
test("updates an existing release body through the release id API", () => {
withTempChangelog(() => {
const calls = [];
@@ -27,7 +59,7 @@ test("updates an existing release body through the release id API", () => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
return JSON.stringify({ id: 311163621, draft: false });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
@@ -64,6 +96,103 @@ test("updates an existing release body through the release id API", () => {
});
});
test("updates a draft release body without publishing it", () => {
withTempChangelog(() => {
const calls = [];
const execFileSync = (command, args, options) => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
throw notFoundError();
}
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
return JSON.stringify([
{
id: 311163621,
draft: true,
name: "Paseo v0.1.60-beta.1",
tag_name: "untagged-draft",
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
},
]);
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
return "[]";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
execFileSync,
});
const updateCall = calls.find(
(call) => call.args[0] === "api" && call.args[1] === "-X" && call.args[2] === "PATCH",
);
assert.ok(updateCall, "the draft release should be updated");
assert.deepEqual(
updateCall.args.filter((arg) => arg.startsWith("draft=")),
[],
"updating notes must not change draft visibility",
);
});
});
test("creates missing beta releases as drafts", () => {
withTempChangelog(() => {
const calls = [];
let created = false;
const execFileSync = (command, args, options) => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
throw notFoundError();
}
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
return created
? JSON.stringify([
{
id: 311163621,
draft: true,
name: "Paseo v0.1.60-beta.1",
tag_name: "v0.1.60-beta.1",
},
])
: "[]";
}
if (args[0] === "release" && args[1] === "create") {
created = true;
return "";
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
return "";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(
["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1", "--create-if-missing"],
{ execFileSync },
);
const createCall = calls.find(
(call) => call.args[0] === "release" && call.args[1] === "create",
);
assert.ok(createCall, "the missing release should be created");
assert.equal(createCall.args.includes("--draft"), true);
assert.equal(createCall.args.includes("--prerelease"), true);
});
});
test("converts contributor profile links to mentions in synced release notes", () => {
const changelogText = [
"## 0.1.60-beta.1 - 2026-04-20",
@@ -77,7 +206,7 @@ test("converts contributor profile links to mentions in synced release notes", (
const execFileSync = (command, args) => {
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
return JSON.stringify({ id: 311163621, draft: false });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {