diff --git a/.github/workflows/android-apk-release.yml b/.github/workflows/android-apk-release.yml index 08eeccd340..a02f0dba0c 100644 --- a/.github/workflows/android-apk-release.yml +++ b/.github/workflows/android-apk-release.yml @@ -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 }}" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 8f55263366..89f2f83ae6 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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[@]}" diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml index 545657f678..f95e3724fe 100644 --- a/.github/workflows/release-notes-sync.yml +++ b/.github/workflows/release-notes-sync.yml @@ -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: diff --git a/docs/release.md b/docs/release.md index 2d57bd29ce..6261d22980 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs new file mode 100644 index 0000000000..cb166ef189 --- /dev/null +++ b/scripts/github-release.mjs @@ -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 --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`); + } +} diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 8d566f51a1..fb1018c181 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -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 Repository slug. Defaults to $GITHUB_REPOSITORY. --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 }); } diff --git a/scripts/sync-release-notes-from-changelog.test.mjs b/scripts/sync-release-notes-from-changelog.test.mjs index 6fc27f4d2d..5cb172df2e 100644 --- a/scripts/sync-release-notes-from-changelog.test.mjs +++ b/scripts/sync-release-notes-from-changelog.test.mjs @@ -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") {