feat(release): add draft GitHub release flow

This commit is contained in:
Mohamed Boudra
2026-03-09 11:41:21 +07:00
parent 06f8722f25
commit 8a60dc30d6
8 changed files with 195 additions and 3 deletions

View File

@@ -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

View File

@@ -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[@]}"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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.`);

View File

@@ -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}`);

View File

@@ -13,6 +13,7 @@ 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.
--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) {