mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
chore(release): add rc prerelease flow
This commit is contained in:
@@ -33,16 +33,33 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve release tag
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
release_tag="v${BASH_REMATCH[2]}"
|
exit 0
|
||||||
else
|
fi
|
||||||
release_tag="$source_tag"
|
|
||||||
|
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
|
fi
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -107,24 +124,6 @@ jobs:
|
|||||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||||
echo "asset_path=$asset_path" >> "$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
|
- name: Upload APK to GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-rc.*'
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
|
- '!app-v*-rc.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -57,25 +57,7 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -110,24 +92,6 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
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
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -166,22 +130,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Resolve release tag
|
- name: Resolve release tag
|
||||||
shell: bash
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Download manifest artifacts
|
- name: Download manifest artifacts
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
@@ -276,25 +225,7 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -329,24 +260,6 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
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
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -377,25 +290,7 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -438,24 +333,6 @@ jobs:
|
|||||||
npx expo export --platform web
|
npx expo export --platform web
|
||||||
working-directory: packages/app
|
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
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
draft:
|
|
||||||
description: "Create missing release as draft."
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
@@ -48,7 +43,6 @@ jobs:
|
|||||||
REF: ${{ github.ref }}
|
REF: ${{ github.ref }}
|
||||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
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_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -70,8 +64,4 @@ jobs:
|
|||||||
args+=(--create-if-missing)
|
args+=(--create-if-missing)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
|
||||||
args+=(--draft)
|
|
||||||
fi
|
|
||||||
|
|
||||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||||
|
|||||||
+3
-1
@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
|
|||||||
|
|
||||||
## Cloud build + submit (EAS)
|
## 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)
|
- `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)
|
- `.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
|
### Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+15
-11
@@ -21,19 +21,20 @@ npm run release:publish # Publish to npm
|
|||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Draft release flow
|
## Release candidate flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||||
# ... test builds from the draft release assets ...
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
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
|
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- 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
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- `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`
|
- 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
|
## 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
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
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.
|
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
|
- `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`
|
- `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
|
- 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
|
## 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
|
## 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)
|
- [ ] 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
|
- [ ] 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 `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same 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
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
+14
-9
@@ -41,18 +41,23 @@
|
|||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"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: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: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: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",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||||
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||||
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||||
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"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: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: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"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
- "!v*-rc.*"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -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 <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`);
|
||||||
|
}
|
||||||
@@ -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.`);
|
|
||||||
@@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, "..");
|
|||||||
const rootPackagePath = path.join(rootDir, "package.json");
|
const rootPackagePath = path.join(rootDir, "package.json");
|
||||||
|
|
||||||
function usageAndExit(code = 0) {
|
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 <name>]\n`);
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +20,32 @@ function runQuiet(cmd, args) {
|
|||||||
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
|
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function getRemoteTagCommit(tag) {
|
||||||
const args = { draftRelease: false };
|
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) {
|
function parseArgs(argv) {
|
||||||
if (arg === "--draft-release") {
|
const args = {
|
||||||
args.draftRelease = true;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (arg === "--help" || arg === "-h") {
|
if (arg === "--help" || arg === "-h") {
|
||||||
@@ -37,22 +57,13 @@ function parseArgs(argv) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRepoSlug(remoteUrl) {
|
const args = parseArgs(process.argv.slice(2));
|
||||||
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 rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
|
||||||
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
|
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
|
||||||
if (!version) {
|
if (!version) {
|
||||||
throw new Error('Root package.json must contain a valid "version"');
|
throw new Error('Root package.json must contain a valid "version"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
const tag = `v${version}`;
|
const tag = `v${version}`;
|
||||||
const headCommit = runQuiet("git", ["rev-parse", "HEAD"]);
|
const headCommit = runQuiet("git", ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
@@ -71,33 +82,25 @@ if (localTagCommit !== headCommit) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
run("git", ["push", "origin", "HEAD"]);
|
const currentBranch = runQuiet("git", ["branch", "--show-current"]);
|
||||||
|
const branchRef = args.branch || currentBranch;
|
||||||
let remoteTagExists = false;
|
if (!branchRef) {
|
||||||
try {
|
throw new Error("Cannot determine branch to push. Pass --branch <name>.");
|
||||||
runQuiet("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`]);
|
|
||||||
remoteTagExists = true;
|
|
||||||
} catch {
|
|
||||||
remoteTagExists = false;
|
|
||||||
}
|
}
|
||||||
|
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`);
|
console.log(`Tag ${tag} already exists on origin`);
|
||||||
} else {
|
} else {
|
||||||
run("git", ["push", "origin", tag]);
|
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}`);
|
console.log(`Release push complete: branch HEAD and tag ${tag}`);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
const versionPattern = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?$/;
|
||||||
|
const sourceTagPattern =
|
||||||
|
/^(?:(?:desktop(?:-(?:windows|linux|macos))?|android)-)?v(?<version>\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\.(?<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}".`);
|
||||||
|
}
|
||||||
@@ -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 <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" },
|
||||||
|
);
|
||||||
@@ -2,6 +2,11 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
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*$/;
|
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
|
||||||
|
|
||||||
@@ -13,7 +18,6 @@ Options:
|
|||||||
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
|
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
|
||||||
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
|
--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 release if it does not already exist.
|
||||||
--draft Create missing release as draft.
|
|
||||||
`;
|
`;
|
||||||
process.stderr.write(usage.trimStart());
|
process.stderr.write(usage.trimStart());
|
||||||
process.stderr.write("\n");
|
process.stderr.write("\n");
|
||||||
@@ -25,7 +29,6 @@ function parseArgs(argv) {
|
|||||||
repo: process.env.GITHUB_REPOSITORY || "",
|
repo: process.env.GITHUB_REPOSITORY || "",
|
||||||
tag: "",
|
tag: "",
|
||||||
createIfMissing: false,
|
createIfMissing: false,
|
||||||
draft: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
@@ -54,12 +57,6 @@ function parseArgs(argv) {
|
|||||||
args.createIfMissing = true;
|
args.createIfMissing = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg === "--draft") {
|
|
||||||
args.draft = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
usageAndExit();
|
usageAndExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +67,6 @@ function parseArgs(argv) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTag(rawTag) {
|
|
||||||
const trimmed = rawTag.trim().replace(/^refs\/tags\//, "");
|
|
||||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChangelog(changelogText) {
|
function parseChangelog(changelogText) {
|
||||||
const lines = changelogText.split(/\r?\n/);
|
const lines = changelogText.split(/\r?\n/);
|
||||||
const headings = [];
|
const headings = [];
|
||||||
@@ -140,23 +132,51 @@ function runGh(args) {
|
|||||||
execFileSync("gh", args, { stdio: "inherit" });
|
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() {
|
function main() {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const changelogPath = path.resolve("CHANGELOG.md");
|
const changelogPath = path.resolve("CHANGELOG.md");
|
||||||
const changelogText = readFileSync(changelogPath, "utf8");
|
const changelogText = readFileSync(changelogPath, "utf8");
|
||||||
const entries = parseChangelog(changelogText);
|
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);
|
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.`);
|
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
|
||||||
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
|
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
|
||||||
writeFileSync(notesPath, targetEntry.notes);
|
writeFileSync(notesPath, notes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (hasRelease(targetTag, args.repo)) {
|
if (hasRelease(targetTag, args.repo)) {
|
||||||
@@ -184,7 +204,7 @@ function main() {
|
|||||||
"--notes-file",
|
"--notes-file",
|
||||||
notesPath,
|
notesPath,
|
||||||
"--verify-tag",
|
"--verify-tag",
|
||||||
...(args.draft ? ["--draft"] : []),
|
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
|
||||||
]);
|
]);
|
||||||
console.log(`Created release ${targetTag} with changelog notes.`);
|
console.log(`Created release ${targetTag} with changelog notes.`);
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user