From f7fd41a5f811e38c3bd911e4c75d5a2081f6d6d6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 15:33:27 +0700 Subject: [PATCH] chore(release): add rc prerelease flow --- .github/workflows/android-apk-release.yml | 47 +++-- .github/workflows/deploy-app.yml | 2 + .github/workflows/deploy-website.yml | 1 + .github/workflows/desktop-release.yml | 131 +------------- .github/workflows/release-notes-sync.yml | 10 -- docs/ANDROID.md | 4 +- docs/RELEASE.md | 26 +-- package.json | 23 ++- .../app/.eas/workflows/release-mobile.yml | 1 + scripts/emit-release-env.mjs | 49 ++++++ scripts/finalize-current-release.mjs | 66 ------- scripts/push-current-release-tag.mjs | 79 +++++---- scripts/release-version-utils.mjs | 166 ++++++++++++++++++ scripts/set-release-version.mjs | 74 ++++++++ scripts/sync-release-notes-from-changelog.mjs | 54 ++++-- 15 files changed, 430 insertions(+), 303 deletions(-) create mode 100644 scripts/emit-release-env.mjs delete mode 100644 scripts/finalize-current-release.mjs create mode 100644 scripts/release-version-utils.mjs create mode 100644 scripts/set-release-version.mjs diff --git a/.github/workflows/android-apk-release.yml b/.github/workflows/android-apk-release.yml index 0c349cffd..57b8d56b9 100644 --- a/.github/workflows/android-apk-release.yml +++ b/.github/workflows/android-apk-release.yml @@ -33,16 +33,33 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} - name: Resolve release tag + 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 - source_tag="${SOURCE_TAG}" - if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - release_tag="v${BASH_REMATCH[2]}" - else - release_tag="$source_tag" + + 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" + --generate-notes + ) + + if [[ "$IS_PRERELEASE" == "true" ]]; then + release_args+=(--prerelease) + fi + + if ! gh "${release_args[@]}"; then + echo "Release creation raced with another workflow; continuing." fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 @@ -107,24 +124,6 @@ jobs: echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT" echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT" - - name: Wait for GitHub release tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - for attempt in $(seq 1 90); do - if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Found release for tag $RELEASE_TAG" - exit 0 - fi - echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)." - sleep 20 - done - - echo "Timed out waiting for GitHub release tag $RELEASE_TAG." - exit 1 - - name: Upload APK to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 04fd61c41..2c25b909d 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -4,7 +4,9 @@ on: push: tags: - 'v*' + - '!v*-rc.*' - 'app-v*' + - '!app-v*-rc.*' workflow_dispatch: jobs: diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 64c04b83b..54d49dfa8 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -15,6 +15,7 @@ on: jobs: deploy: + if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 5ed0da700..b1848b89b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -57,25 +57,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} - name: Resolve release metadata - shell: bash - run: | - set -euo pipefail - source_tag="${SOURCE_TAG}" - if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - release_tag="v${BASH_REMATCH[3]}" - else - release_tag="$source_tag" - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - version="${release_tag#v}" - echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV" - - if [[ "$source_tag" == *gha-smoke* ]]; then - echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV" - else - echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV" - fi + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 @@ -110,24 +92,6 @@ jobs: - name: Build web app for desktop run: npm run build:web --workspace=@getpaseo/app - - name: Detect existing GitHub release state - if: env.IS_SMOKE_TAG != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then - if [[ "$release_draft" == "true" ]]; then - release_type="draft" - else - release_type="release" - fi - else - release_type="draft" - fi - echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" - - name: Build desktop release shell: bash env: @@ -166,22 +130,7 @@ jobs: steps: - name: Resolve release tag - shell: bash - run: | - set -euo pipefail - source_tag="${SOURCE_TAG}" - if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - release_tag="v${BASH_REMATCH[3]}" - else - release_tag="$source_tag" - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - if [[ "$source_tag" == *gha-smoke* ]]; then - echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV" - else - echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV" - fi + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Download manifest artifacts if: env.IS_SMOKE_TAG != 'true' @@ -276,25 +225,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} - name: Resolve release metadata - shell: bash - run: | - set -euo pipefail - source_tag="${SOURCE_TAG}" - if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - release_tag="v${BASH_REMATCH[3]}" - else - release_tag="$source_tag" - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - version="${release_tag#v}" - echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV" - - if [[ "$source_tag" == *gha-smoke* ]]; then - echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV" - else - echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV" - fi + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 @@ -329,24 +260,6 @@ jobs: - name: Build web app for desktop run: npm run build:web --workspace=@getpaseo/app - - name: Detect existing GitHub release state - if: env.IS_SMOKE_TAG != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then - if [[ "$release_draft" == "true" ]]; then - release_type="draft" - else - release_type="release" - fi - else - release_type="draft" - fi - echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" - - name: Build desktop release shell: bash env: @@ -377,25 +290,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} - name: Resolve release metadata - shell: bash - run: | - set -euo pipefail - source_tag="${SOURCE_TAG}" - if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - release_tag="v${BASH_REMATCH[3]}" - else - release_tag="$source_tag" - fi - echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" - - version="${release_tag#v}" - echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV" - - if [[ "$source_tag" == *gha-smoke* ]]; then - echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV" - else - echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV" - fi + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 @@ -438,24 +333,6 @@ jobs: npx expo export --platform web working-directory: packages/app - - name: Detect existing GitHub release state - if: env.IS_SMOKE_TAG != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then - if [[ "$release_draft" == "true" ]]; then - release_type="draft" - else - release_type="release" - fi - else - release_type="draft" - fi - echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV" - - name: Build desktop release shell: bash env: diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml index 49dd9c54d..583dad9e1 100644 --- a/.github/workflows/release-notes-sync.yml +++ b/.github/workflows/release-notes-sync.yml @@ -19,11 +19,6 @@ on: required: false default: false type: boolean - draft: - description: "Create missing release as draft." - required: false - default: false - type: boolean concurrency: group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} @@ -48,7 +43,6 @@ jobs: REF: ${{ github.ref }} INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }} INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }} - INPUT_DRAFT: ${{ github.event.inputs.draft }} shell: bash run: | set -euo pipefail @@ -70,8 +64,4 @@ jobs: args+=(--create-if-missing) fi - if [ "${INPUT_DRAFT:-false}" = "true" ]; then - args+=(--draft) - fi - node scripts/sync-release-notes-from-changelog.mjs "${args[@]}" diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 1261ee098..fd6fa1db2 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png ## Cloud build + submit (EAS) -Tag pushes like `v0.1.0` trigger: +Stable tag pushes like `v0.1.0` trigger: - `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit) - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release) +Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. + ### Useful commands ```bash diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5168d49df..096b08e74 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -21,19 +21,20 @@ npm run release:publish # Publish to npm npm run release:push # Push HEAD + tag (triggers CI workflows) ``` -## Draft release flow +## Release candidate flow ```bash -npm run draft-release:patch # Bump, push tag, create draft GitHub Release -# ... test builds from the draft release assets ... -npm run release:finalize # Publish npm, promote draft to published +npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag +# ... test desktop and APK prerelease assets from GitHub Releases ... +npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ... +npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z ``` -- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it -- `release:finalize` publishes npm and promotes the same draft release -- Use the same semver tag for both; don't cut a second tag +- RC tags are published GitHub prereleases like `v0.1.41-rc.1` +- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows +- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag - Desktop assets now come from the Electron package at `packages/desktop` -- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets. +- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release. ## Fixing a failed release build @@ -54,6 +55,9 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1. # Android APK git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force + +# RC +git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force ``` This ensures the checkout ref matches the actual code on `main` with the fix included. @@ -64,11 +68,11 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc - `release:prepare` refreshes workspace `node_modules` links to prevent stale types - `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop` - If `release:publish` partially fails, re-run it — npm skips already-published versions -- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts. +- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target. ## Changelog format -The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed: +Stable release notes depend on the changelog heading format. The heading **must** be strictly followed: ``` ## X.Y.Z - YYYY-MM-DD @@ -80,7 +84,7 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors) - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format -- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully +- [ ] `npm run release:patch` or `npm run release:promote` completes successfully - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] EAS `release-mobile.yml` workflow for the same tag is green diff --git a/package.json b/package.json index 0c2770452..cf5d1a6cf 100644 --- a/package.json +++ b/package.json @@ -41,18 +41,23 @@ "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", "release:prepare": "npm install --workspaces --include-workspace-root", - "version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"", - "version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"", - "version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"", - "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", + "version:all:patch": "node scripts/set-release-version.mjs --mode patch", + "version:all:minor": "node scripts/set-release-version.mjs --mode minor", + "version:all:major": "node scripts/set-release-version.mjs --mode major", + "version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch", + "version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor", + "version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major", + "version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next", + "version:all:promote": "node scripts/set-release-version.mjs --mode promote", + "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:push": "node scripts/push-current-release-tag.mjs", - "draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release", - "draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push", - "draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push", - "draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push", - "release:finalize": "node scripts/finalize-current-release.mjs", + "release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push", + "release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push", + "release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push", + "release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push", + "release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push", "release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push", "release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push", "release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push" diff --git a/packages/app/.eas/workflows/release-mobile.yml b/packages/app/.eas/workflows/release-mobile.yml index 130d268fa..35fe47e6b 100644 --- a/packages/app/.eas/workflows/release-mobile.yml +++ b/packages/app/.eas/workflows/release-mobile.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + - "!v*-rc.*" workflow_dispatch: {} jobs: diff --git a/scripts/emit-release-env.mjs b/scripts/emit-release-env.mjs new file mode 100644 index 000000000..ca4bd60f3 --- /dev/null +++ b/scripts/emit-release-env.mjs @@ -0,0 +1,49 @@ +import { getReleaseInfoFromSourceTag } from "./release-version-utils.mjs"; + +function usageAndExit(code = 1) { + process.stderr.write(`Usage: node scripts/emit-release-env.mjs --source-tag \n`); + process.exit(code); +} + +function parseArgs(argv) { + let sourceTag = ""; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--source-tag") { + sourceTag = argv[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + usageAndExit(0); + } + usageAndExit(); + } + + if (!sourceTag) { + usageAndExit(); + } + + return sourceTag; +} + +const sourceTag = parseArgs(process.argv.slice(2)); +const info = getReleaseInfoFromSourceTag(sourceTag); + +const entries = [ + ["SOURCE_TAG", info.sourceTag], + ["RELEASE_TAG", info.releaseTag], + ["RELEASE_VERSION", info.version], + ["RELEASE_BASE_VERSION", info.baseVersion], + ["RELEASE_PRERELEASE", info.prerelease ?? ""], + ["IS_PRERELEASE", info.isPrerelease ? "true" : "false"], + ["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"], + ["RELEASE_TYPE", info.releaseType], + ["DESKTOP_VERSION", info.version], + ["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"], +]; + +for (const [key, value] of entries) { + process.stdout.write(`${key}=${value}\n`); +} diff --git a/scripts/finalize-current-release.mjs b/scripts/finalize-current-release.mjs deleted file mode 100644 index 38ba350e6..000000000 --- a/scripts/finalize-current-release.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, ".."); -const rootPackagePath = path.join(rootDir, "package.json"); - -function usageAndExit(code = 0) { - process.stderr.write(`Usage: node scripts/finalize-current-release.mjs\n`); - process.exit(code); -} - -function run(cmd, args) { - execFileSync(cmd, args, { cwd: rootDir, stdio: "inherit" }); -} - -function runQuiet(cmd, args) { - return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim(); -} - -function parseRepoSlug(remoteUrl) { - const trimmed = remoteUrl.trim().replace(/\.git$/, ""); - const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/); - if (httpsMatch) { - return httpsMatch[1]; - } - throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`); -} - -if (process.argv.length > 2) { - const arg = process.argv[2]; - if (arg === "--help" || arg === "-h") { - usageAndExit(0); - } - usageAndExit(1); -} - -const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")); -const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : ""; -if (!version) { - throw new Error('Root package.json must contain a valid "version"'); -} - -const tag = `v${version}`; -const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"])); -const releaseJson = runQuiet("gh", [ - "release", - "view", - tag, - "--repo", - repo, - "--json", - "isDraft,tagName,url", -]); -const release = JSON.parse(releaseJson); - -if (!release.isDraft) { - throw new Error(`Release ${tag} is already published. Refusing to finalize a non-draft release.`); -} - -run("npm", ["run", "release:publish"]); -run("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"]); - -console.log(`Finalized release ${tag}: npm packages published and GitHub draft release promoted.`); diff --git a/scripts/push-current-release-tag.mjs b/scripts/push-current-release-tag.mjs index 24ef139bc..267e622b0 100644 --- a/scripts/push-current-release-tag.mjs +++ b/scripts/push-current-release-tag.mjs @@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, ".."); const rootPackagePath = path.join(rootDir, "package.json"); function usageAndExit(code = 0) { - process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--draft-release]\n`); + process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--branch ]\n`); process.exit(code); } @@ -20,12 +20,32 @@ function runQuiet(cmd, args) { return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim(); } -function parseArgs(argv) { - const args = { draftRelease: false }; +function getRemoteTagCommit(tag) { + try { + return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}^{}`]) + .split(/\s+/)[0] + .trim(); + } catch { + try { + return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}`]) + .split(/\s+/)[0] + .trim(); + } catch { + return ""; + } + } +} - for (const arg of argv) { - if (arg === "--draft-release") { - args.draftRelease = true; +function parseArgs(argv) { + const args = { + branch: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--branch") { + args.branch = argv[index + 1] ?? ""; + index += 1; continue; } if (arg === "--help" || arg === "-h") { @@ -37,22 +57,13 @@ function parseArgs(argv) { return args; } -function parseRepoSlug(remoteUrl) { - const trimmed = remoteUrl.trim().replace(/\.git$/, ""); - const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/); - if (httpsMatch) { - return httpsMatch[1]; - } - throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`); -} - +const args = parseArgs(process.argv.slice(2)); const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")); const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : ""; if (!version) { throw new Error('Root package.json must contain a valid "version"'); } -const args = parseArgs(process.argv.slice(2)); const tag = `v${version}`; const headCommit = runQuiet("git", ["rev-parse", "HEAD"]); @@ -71,33 +82,25 @@ if (localTagCommit !== headCommit) { ); } -run("git", ["push", "origin", "HEAD"]); - -let remoteTagExists = false; -try { - runQuiet("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`]); - remoteTagExists = true; -} catch { - remoteTagExists = false; +const currentBranch = runQuiet("git", ["branch", "--show-current"]); +const branchRef = args.branch || currentBranch; +if (!branchRef) { + throw new Error("Cannot determine branch to push. Pass --branch ."); } +run("git", ["push", "origin", `HEAD:${branchRef}`]); -if (remoteTagExists) { +const remoteTagCommit = getRemoteTagCommit(tag); + +if (remoteTagCommit) { + if (remoteTagCommit !== localTagCommit) { + throw new Error( + `Remote tag ${tag} points to ${remoteTagCommit}, but local tag points to ${localTagCommit}. ` + + "Refusing to reuse an existing release tag for a different commit.", + ); + } console.log(`Tag ${tag} already exists on origin`); } else { run("git", ["push", "origin", tag]); } -if (args.draftRelease) { - const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"])); - run("node", [ - path.join("scripts", "sync-release-notes-from-changelog.mjs"), - "--repo", - repo, - "--tag", - tag, - "--create-if-missing", - "--draft", - ]); -} - console.log(`Release push complete: branch HEAD and tag ${tag}`); diff --git a/scripts/release-version-utils.mjs b/scripts/release-version-utils.mjs new file mode 100644 index 000000000..777b3ce85 --- /dev/null +++ b/scripts/release-version-utils.mjs @@ -0,0 +1,166 @@ +const versionPattern = /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?[0-9A-Za-z.-]+))?$/; +const sourceTagPattern = + /^(?:(?:desktop(?:-(?:windows|linux|macos))?|android)-)?v(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +function assertInteger(value, label) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Invalid ${label}: ${value}`); + } +} + +export function parseReleaseVersion(version) { + const trimmed = version.trim(); + const match = trimmed.match(versionPattern); + if (!match?.groups) { + throw new Error( + `Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-rc.1.`, + ); + } + + const major = Number.parseInt(match.groups.major, 10); + const minor = Number.parseInt(match.groups.minor, 10); + const patch = Number.parseInt(match.groups.patch, 10); + const prerelease = match.groups.prerelease ?? null; + const rcMatch = prerelease?.match(/^rc\.(?\d+)$/) ?? null; + const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null; + + assertInteger(major, "major version"); + assertInteger(minor, "minor version"); + assertInteger(patch, "patch version"); + if (rcNumber !== null) { + assertInteger(rcNumber, "release candidate number"); + } + + return { + version: trimmed, + major, + minor, + patch, + prerelease, + baseVersion: `${major}.${minor}.${patch}`, + isPrerelease: prerelease !== null, + isReleaseCandidate: rcNumber !== null, + rcNumber, + }; +} + +export function formatReleaseVersion({ major, minor, patch, prerelease = null }) { + assertInteger(major, "major version"); + assertInteger(minor, "minor version"); + assertInteger(patch, "patch version"); + return prerelease ? `${major}.${minor}.${patch}-${prerelease}` : `${major}.${minor}.${patch}`; +} + +export function normalizeReleaseTag(rawTag) { + const trimmed = rawTag.trim().replace(/^refs\/tags\//, ""); + const match = trimmed.match(sourceTagPattern); + if (!match?.groups?.version) { + throw new Error( + `Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-rc.N, desktop-v..., or android-v...`, + ); + } + return `v${match.groups.version}`; +} + +export function getReleaseInfoFromSourceTag(sourceTag) { + const releaseTag = normalizeReleaseTag(sourceTag); + const parsed = parseReleaseVersion(releaseTag.slice(1)); + return { + sourceTag, + releaseTag, + version: parsed.version, + baseVersion: parsed.baseVersion, + prerelease: parsed.prerelease, + isPrerelease: parsed.isPrerelease, + isReleaseCandidate: parsed.isReleaseCandidate, + rcNumber: parsed.rcNumber, + releaseType: parsed.isPrerelease ? "prerelease" : "release", + isSmokeTag: sourceTag.includes("gha-smoke"), + }; +} + +export function computeNextReleaseVersion(currentVersion, mode) { + const parsed = parseReleaseVersion(currentVersion); + + if (mode === "patch" || mode === "minor" || mode === "major") { + if (parsed.isPrerelease) { + throw new Error( + `Cannot cut a stable ${mode} release from prerelease version ${currentVersion}. Promote it first.`, + ); + } + if (mode === "patch") { + return formatReleaseVersion({ + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch + 1, + }); + } + if (mode === "minor") { + return formatReleaseVersion({ + major: parsed.major, + minor: parsed.minor + 1, + patch: 0, + }); + } + return formatReleaseVersion({ + major: parsed.major + 1, + minor: 0, + patch: 0, + }); + } + + if (mode === "rc-patch" || mode === "rc-minor" || mode === "rc-major") { + if (parsed.isPrerelease) { + throw new Error( + `Cannot start a new RC line from prerelease version ${currentVersion}. Use rc-next or promote.`, + ); + } + if (mode === "rc-patch") { + return formatReleaseVersion({ + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch + 1, + prerelease: "rc.1", + }); + } + if (mode === "rc-minor") { + return formatReleaseVersion({ + major: parsed.major, + minor: parsed.minor + 1, + patch: 0, + prerelease: "rc.1", + }); + } + return formatReleaseVersion({ + major: parsed.major + 1, + minor: 0, + patch: 0, + prerelease: "rc.1", + }); + } + + if (mode === "rc-next") { + if (!parsed.isReleaseCandidate || parsed.rcNumber === null) { + throw new Error( + `Cannot advance RC number from ${currentVersion}. Expected a version like 0.1.41-rc.1.`, + ); + } + return formatReleaseVersion({ + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + prerelease: `rc.${parsed.rcNumber + 1}`, + }); + } + + if (mode === "promote") { + if (!parsed.isReleaseCandidate) { + throw new Error( + `Cannot promote ${currentVersion}. Expected a release candidate version like 0.1.41-rc.1.`, + ); + } + return parsed.baseVersion; + } + + throw new Error(`Unsupported release mode "${mode}".`); +} diff --git a/scripts/set-release-version.mjs b/scripts/set-release-version.mjs new file mode 100644 index 000000000..a56ca6151 --- /dev/null +++ b/scripts/set-release-version.mjs @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeNextReleaseVersion } from "./release-version-utils.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const rootPackagePath = path.join(rootDir, "package.json"); + +function usageAndExit(code = 1) { + process.stderr.write(`Usage: node scripts/set-release-version.mjs --mode [--print]\n`); + process.stderr.write( + "Modes: patch, minor, major, rc-patch, rc-minor, rc-major, rc-next, promote\n", + ); + process.exit(code); +} + +function parseArgs(argv) { + const args = { + mode: "", + print: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--mode") { + args.mode = argv[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--print") { + args.print = true; + continue; + } + if (arg === "--help" || arg === "-h") { + usageAndExit(0); + } + usageAndExit(); + } + + if (!args.mode) { + usageAndExit(); + } + + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")); +const currentVersion = typeof rootPackage.version === "string" ? rootPackage.version.trim() : ""; + +if (!currentVersion) { + throw new Error('Root package.json must contain a valid "version".'); +} + +const nextVersion = computeNextReleaseVersion(currentVersion, args.mode); + +if (args.print) { + process.stdout.write(`${nextVersion}\n`); + process.exit(0); +} + +execFileSync( + "npm", + [ + "version", + nextVersion, + "--include-workspace-root", + "--message", + "chore(release): cut %s", + ], + { cwd: rootDir, stdio: "inherit" }, +); diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 8f0e0fc08..506ea8425 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -2,6 +2,11 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { + getReleaseInfoFromSourceTag, + normalizeReleaseTag, + parseReleaseVersion, +} from "./release-version-utils.mjs"; const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/; @@ -13,7 +18,6 @@ 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. - --draft Create missing release as draft. `; process.stderr.write(usage.trimStart()); process.stderr.write("\n"); @@ -25,7 +29,6 @@ function parseArgs(argv) { repo: process.env.GITHUB_REPOSITORY || "", tag: "", createIfMissing: false, - draft: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -54,12 +57,6 @@ function parseArgs(argv) { args.createIfMissing = true; continue; } - - if (arg === "--draft") { - args.draft = true; - continue; - } - usageAndExit(); } @@ -70,11 +67,6 @@ function parseArgs(argv) { return args; } -function normalizeTag(rawTag) { - const trimmed = rawTag.trim().replace(/^refs\/tags\//, ""); - return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; -} - function parseChangelog(changelogText) { const lines = changelogText.split(/\r?\n/); const headings = []; @@ -140,23 +132,51 @@ function runGh(args) { execFileSync("gh", args, { stdio: "inherit" }); } +function buildPrereleaseNotes({ releaseTag, version }) { + const today = new Date().toISOString().slice(0, 10); + const lines = [ + `## ${version} - ${today}`, + "", + "Release candidate build for testing from `main`.", + "", + `- Tag: \`${releaseTag}\``, + ]; + + if (process.env.GITHUB_SHA) { + lines.push(`- Commit: \`${process.env.GITHUB_SHA}\``); + } + + lines.push("- This prerelease does not replace the website's latest stable download."); + return `${lines.join("\n")}\n`; +} + function main() { const args = parseArgs(process.argv.slice(2)); const changelogPath = path.resolve("CHANGELOG.md"); const changelogText = readFileSync(changelogPath, "utf8"); const entries = parseChangelog(changelogText); - const targetTag = args.tag ? normalizeTag(args.tag) : entries[0].tag; + const targetTag = args.tag ? normalizeReleaseTag(args.tag) : entries[0].tag; + const releaseInfo = getReleaseInfoFromSourceTag(targetTag); const targetEntry = entries.find((entry) => entry.tag === targetTag); - if (!targetEntry) { + let notes = targetEntry?.notes ?? null; + + if (!notes && releaseInfo.isPrerelease) { + notes = buildPrereleaseNotes({ + releaseTag: releaseInfo.releaseTag, + version: releaseInfo.version, + }); + } + + if (!notes) { console.log(`No matching changelog section found for ${targetTag}. Skipping.`); return; } const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-")); const notesPath = path.join(tempDir, `${targetTag}-notes.md`); - writeFileSync(notesPath, targetEntry.notes); + writeFileSync(notesPath, notes); try { if (hasRelease(targetTag, args.repo)) { @@ -184,7 +204,7 @@ function main() { "--notes-file", notesPath, "--verify-tag", - ...(args.draft ? ["--draft"] : []), + ...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []), ]); console.log(`Created release ${targetTag} with changelog notes.`); } catch (createError) {