From 8a60dc30d6d9ea291f9d34e4762e774ee06c828f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Mar 2026 11:41:21 +0700 Subject: [PATCH] feat(release): add draft GitHub release flow --- .github/workflows/desktop-release.yml | 45 ++++++++++++- .github/workflows/release-notes-sync.yml | 10 +++ CHANGELOG.md | 4 ++ CLAUDE.md | 15 +++++ package.json | 5 ++ scripts/finalize-current-release.mjs | 66 +++++++++++++++++++ scripts/push-current-release-tag.mjs | 45 +++++++++++++ scripts/sync-release-notes-from-changelog.mjs | 8 +++ 8 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 scripts/finalize-current-release.mjs diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 43691e3d9..1f04683a9 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -99,6 +99,19 @@ jobs: fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`); NODE + - name: Detect existing GitHub release state + 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 + : + else + release_draft="false" + fi + echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" + - name: Build and publish macOS Tauri release uses: tauri-apps/tauri-action@v0 env: @@ -116,7 +129,7 @@ jobs: tagName: ${{ env.RELEASE_TAG }} releaseName: Paseo ${{ env.RELEASE_TAG }} releaseBody: See the assets to download and install this version. - releaseDraft: false + releaseDraft: ${{ env.RELEASE_DRAFT }} prerelease: false args: --target ${{ matrix.rust_target }} @@ -195,6 +208,19 @@ jobs: fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`); NODE + - name: Detect existing GitHub release state + 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 + : + else + release_draft="false" + fi + echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" + - name: Build and publish Linux Tauri release uses: tauri-apps/tauri-action@v0 env: @@ -206,7 +232,7 @@ jobs: tagName: ${{ env.RELEASE_TAG }} releaseName: Paseo ${{ env.RELEASE_TAG }} releaseBody: See the assets to download and install this version. - releaseDraft: false + releaseDraft: ${{ env.RELEASE_DRAFT }} prerelease: false args: --bundles appimage @@ -280,6 +306,19 @@ jobs: fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`); NODE + - name: Detect existing GitHub release state + 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 + : + else + release_draft="false" + fi + echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" + - name: Build and publish Windows Tauri release uses: tauri-apps/tauri-action@v0 env: @@ -291,6 +330,6 @@ jobs: tagName: ${{ env.RELEASE_TAG }} releaseName: Paseo ${{ env.RELEASE_TAG }} releaseBody: See the assets to download and install this version. - releaseDraft: false + releaseDraft: ${{ env.RELEASE_DRAFT }} prerelease: false args: --bundles nsis,msi diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml index 583dad9e1..49dd9c54d 100644 --- a/.github/workflows/release-notes-sync.yml +++ b/.github/workflows/release-notes-sync.yml @@ -19,6 +19,11 @@ 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 }} @@ -43,6 +48,7 @@ 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 @@ -64,4 +70,8 @@ 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/CHANGELOG.md b/CHANGELOG.md index 39948067c..714a17322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.19 - 2026-03-09 +### Added +- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release. + ## 0.1.18 - 2026-03-06 ### Added - Added a desktop `Mod+W` shortcut to close the current tab. diff --git a/CLAUDE.md b/CLAUDE.md index 5038894c9..bae925101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,21 @@ npm run release:publish npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows) ``` +### Draft release flow + +```bash +# Stage a draft GitHub release with assets, but do not publish npm yet. +npm run draft-release:patch + +# Publish npm and promote the same GitHub draft release to final. +npm run release:finalize +``` + +Behavior: +- `draft-release:patch` bumps the version, runs release checks, pushes `HEAD` and the new `v*` tag, and creates the matching GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to that same draft release. +- `release:finalize` requires that the current tag already has a GitHub draft release, publishes the npm packages for that exact version, and promotes the same GitHub Release from draft to published. +- Use the same semver tag for both draft and final states; do not cut a second tag just to publish the release. + Notes: - `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created. - `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks. diff --git a/package.json b/package.json index 97af9109b..0ffb67cfc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "release:publish:dry-run": "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/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 version:all:patch && npm run release:check && npm run draft-release:push", + "draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push", + "draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push", + "release:finalize": "node scripts/finalize-current-release.mjs", "release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push", "release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push", "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" diff --git a/scripts/finalize-current-release.mjs b/scripts/finalize-current-release.mjs new file mode 100644 index 000000000..38ba350e6 --- /dev/null +++ b/scripts/finalize-current-release.mjs @@ -0,0 +1,66 @@ +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 98595c5a3..a0d2ec6e9 100644 --- a/scripts/push-current-release-tag.mjs +++ b/scripts/push-current-release-tag.mjs @@ -7,6 +7,11 @@ 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/push-current-release-tag.mjs [--draft-release]\n`); + process.exit(code); +} + function run(cmd, args) { execFileSync(cmd, args, { cwd: rootDir, stdio: "inherit" }); } @@ -15,12 +20,39 @@ function runQuiet(cmd, args) { return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim(); } +function parseArgs(argv) { + const args = { draftRelease: false }; + + for (const arg of argv) { + if (arg === "--draft-release") { + args.draftRelease = true; + continue; + } + if (arg === "--help" || arg === "-h") { + usageAndExit(0); + } + usageAndExit(1); + } + + 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 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"]); @@ -55,4 +87,17 @@ if (remoteTagExists) { 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/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 842ddfc76..34d1afcbc 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -13,6 +13,7 @@ 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"); @@ -24,6 +25,7 @@ function parseArgs(argv) { repo: process.env.GITHUB_REPOSITORY || "", tag: "", createIfMissing: false, + draft: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -53,6 +55,11 @@ function parseArgs(argv) { continue; } + if (arg === "--draft") { + args.draft = true; + continue; + } + usageAndExit(); } @@ -186,6 +193,7 @@ function main() { "--notes-file", notesPath, "--verify-tag", + ...(args.draft ? ["--draft"] : []), ]); console.log(`Created release ${targetTag} with changelog notes.`); } catch (createError) {