mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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 }}"
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user