diff --git a/cut-release.sh b/cut-release.sh new file mode 100755 index 000000000..bb954629c --- /dev/null +++ b/cut-release.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# cut-release.sh — cut a release branch from main and bump main to next minor. +# +# Usage: +# ./cut-release.sh +# +# Example: +# ./cut-release.sh 2.1.0 +# +# What it does: +# 1. Verifies you are on main, tree is clean, main is up to date with origin. +# 2. Verifies crosspaste-version.properties matches . +# 3. Creates release/ branch from current main HEAD, pushes it. +# 4. Switches back to main and creates a branch `chore/bump-to-` +# that bumps crosspaste-version.properties to the next minor (e.g. 2.2.0). +# Pushes that branch and prints the PR URL hint. +# +# After running this script you must: +# - Open the PR for the bump branch and merge it into main. +# - Switch to release/ and tag the actual release commit, +# e.g. `git tag 2.1.0. && git push origin 2.1.0.`. +# The CI workflow `build-release.yml` will pick it up. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 (e.g. 2.1.0)" >&2 + exit 2 +fi + +VERSION="$1" + +if [[ ! "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Error: version must be MAJOR.MINOR.PATCH (got: $VERSION)" >&2 + exit 2 +fi + +MAJOR="${BASH_REMATCH[1]}" +MINOR="${BASH_REMATCH[2]}" +PATCH="${BASH_REMATCH[3]}" + +if [[ "$PATCH" != "0" ]]; then + echo "Error: cut-release.sh is for cutting minor releases (X.Y.0)." >&2 + echo " For a patch release, work directly on release/$MAJOR.$MINOR." >&2 + exit 2 +fi + +RELEASE_BRANCH="release/${MAJOR}.${MINOR}" +NEXT_MINOR="${MAJOR}.$((MINOR + 1)).0" +BUMP_BRANCH="chore/bump-to-${NEXT_MINOR}" +PROPS_FILE="app/src/desktopMain/resources/crosspaste-version.properties" + +# --- preflight --- + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$REPO_ROOT" ]]; then + echo "Error: not inside a git repository." >&2 + exit 1 +fi +cd "$REPO_ROOT" + +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$CURRENT_BRANCH" != "main" ]]; then + echo "Error: must be on 'main' (currently on '$CURRENT_BRANCH')." >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: working tree is not clean. Commit or stash first." >&2 + exit 1 +fi + +echo "Fetching origin..." +git fetch origin --tags --prune + +if ! git merge-base --is-ancestor origin/main HEAD; then + echo "Error: local main is behind origin/main. Pull first." >&2 + exit 1 +fi +if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then + echo "Error: local main has unpushed commits. Push them first." >&2 + exit 1 +fi + +if git show-ref --verify --quiet "refs/heads/$RELEASE_BRANCH" \ + || git ls-remote --exit-code --heads origin "$RELEASE_BRANCH" >/dev/null 2>&1; then + echo "Error: branch $RELEASE_BRANCH already exists." >&2 + exit 1 +fi + +CURRENT_PROPS_VERSION="$(grep -E '^version=' "$PROPS_FILE" | head -n1 | cut -d= -f2 | tr -d '[:space:]')" +if [[ "$CURRENT_PROPS_VERSION" != "$VERSION" ]]; then + echo "Error: $PROPS_FILE has version=$CURRENT_PROPS_VERSION but you asked to cut $VERSION." >&2 + echo " Update the properties file on main first, then re-run." >&2 + exit 1 +fi + +# --- cut release branch --- + +echo +echo "==> Creating $RELEASE_BRANCH at $(git rev-parse --short HEAD)" +git branch "$RELEASE_BRANCH" +git push origin "$RELEASE_BRANCH" + +# --- bump main --- + +echo +echo "==> Bumping main to $NEXT_MINOR on $BUMP_BRANCH" +git checkout -b "$BUMP_BRANCH" + +# In-place edit, BSD/GNU sed compatible. +TMP="$(mktemp)" +awk -v new="$NEXT_MINOR" ' + BEGIN { done = 0 } + /^version=/ && !done { print "version=" new; done = 1; next } + { print } +' "$PROPS_FILE" > "$TMP" +mv "$TMP" "$PROPS_FILE" + +git add "$PROPS_FILE" +git commit -m ":construction_worker: bump main to $NEXT_MINOR after cutting $RELEASE_BRANCH" +git push -u origin "$BUMP_BRANCH" + +# --- next steps --- + +echo +echo "==> Done." +echo +echo "Next steps:" +echo " 1. Open a PR for $BUMP_BRANCH -> main and merge it." +echo " gh pr create --base main --head $BUMP_BRANCH \\" +echo " --title 'chore: bump main to $NEXT_MINOR' \\" +echo " --body 'Cut $RELEASE_BRANCH; main moves on to $NEXT_MINOR.'" +echo +echo " 2. Switch to $RELEASE_BRANCH and tag the release commit:" +echo " git checkout $RELEASE_BRANCH" +echo " REV=\$(git rev-list --count HEAD)" +echo " git tag $VERSION.\$REV" +echo " git push origin $VERSION.\$REV" +echo +echo " 3. After the build succeeds, append a row to doc/MOBILE_COMPAT.md" +echo " describing any commonMain API changes since the previous tag." diff --git a/doc/en/Releasing.md b/doc/en/Releasing.md new file mode 100644 index 000000000..afb231e65 --- /dev/null +++ b/doc/en/Releasing.md @@ -0,0 +1,234 @@ +# Releasing + +This document describes how CrossPaste Desktop versions are released, and the +discipline required so that: + +- `main` can keep moving even while a release line is being maintained. +- Multiple large features can land on `main` behind flags without blocking + smaller releases. +- The mobile project (which reuses `commonMain`) always has a stable, named + anchor to sync from. + +If you only want the recipes, jump to +[Cutting a minor release (X.Y.0)](#cutting-a-minor-release-xy0) +or [Cutting a patch release (X.Y.Z)](#cutting-a-patch-release-xyz). + +--- + +## Branch model + +| Branch | Purpose | +| --------------- | ---------------------------------------------------------- | +| `main` | Trunk. Always releasable. Small features and fixes go here directly. Large in-flight features live on feature branches and are merged behind flags ("dark merge"). | +| `release/X.Y` | Stable line for a given minor version. Cut from `main` when we decide to release `X.Y.0`. Only patch releases (`X.Y.Z`) are cut from here. | +| `feature/*` | Long-lived branches for big features. Merged into `main` only when the feature compiles and runs cleanly with its flag off. | + +### Why release branches? + +Tagging directly on `main` couples release stability to trunk velocity: a hotfix +on top of `vX.Y.0` is impossible without dragging in everything that landed on +`main` afterwards. Release branches give us a clean place to cherry-pick the +minimum set of fixes for a patch release. + +### Why dark-merge big features? + +Long-lived branches diverge from `main` and become painful to rebase. Merging +early — but keeping the feature dark behind a flag — keeps `main` integrated +without blocking the rest of the project on the feature being "done". + +A flag may be a compile-time toggle (e.g. `expect/actual` declared off) or a +runtime `AppEnv` setting. The rule is: with the flag off, the feature must be +invisible and zero-cost — no UI surfaces, no background work, no schema +migrations. + +--- + +## Tag scheme + +Tags follow `MAJOR.MINOR.PATCH[-rc.N].REVISION`: + +| Tag | Where it lives | Channel | `PRE_RELEASE` | +| ---------------------------- | ------------------------ | -------- | ------------- | +| `2.1.0.2281` | `release/2.1` | `stable` | `false` | +| `2.1.1.2284` | `release/2.1` | `stable` | `false` | +| `2.1.0-rc.1.2280` | `main` | `main` | `true` | + +- `REVISION` is `git rev-list --count HEAD` at the tagged commit. It is unique + **within its branch**, not globally. The OSS channel prefix prevents + collisions between branches that happen to land on the same revision number. +- Stable tags must be on a `release/X.Y` branch. +- RC tags must be on `main`. +- The CI workflow `.github/workflows/build-release.yml` enforces both of these + via a branch-containment guard. A tag on a feature branch will refuse to + build. + +--- + +## Version property file + +`app/src/desktopMain/resources/crosspaste-version.properties` holds the active +version for the branch it lives on. The CI script +`.github/scripts/validateAndUpdateVersion.js` cross-checks the tag against this +file and fails the build on mismatch. + +| Branch | What `version=` should be | +| -------------- | --------------------------------------------------------------------- | +| `main` | The **next minor** that will be cut from `main` (e.g. `2.2.0`). Bumped immediately after cutting a release branch. | +| `release/X.Y` | The latest `X.Y.Z` that has been (or is about to be) tagged on this branch. Bumped on the release branch before each patch tag. | + +Mismatches between this file and the tag are a hard error — never patch around +them, fix the file. + +--- + +## OSS / update channels + +| Channel | OSS prefix | Who consumes it | +| -------- | ------------------------------------------------ | --------------------------------------- | +| `stable` | `oss://crosspaste-desktop/stable/X.Y.Z.REV/` | End users on the default update channel | +| `main` | `oss://crosspaste-desktop/main/X.Y.Z-rc.N.REV/` | Users opted in to pre-release channel | + +The update manifest must serve these channels separately. A user on `stable` +must never be auto-updated to an RC build. + +--- + +## Cutting a minor release (X.Y.0) + +Run from a clean `main`: + +```bash +./cut-release.sh 2.1.0 +``` + +This script: + +1. Verifies you are on `main`, the working tree is clean, and `main` is in sync + with `origin/main`. +2. Verifies `crosspaste-version.properties` contains `version=2.1.0`. +3. Creates `release/2.1` from `main` HEAD and pushes it. +4. On `main`, creates a branch `chore/bump-to-2.2.0` that bumps the properties + file to `2.2.0`, and pushes it. You must then open a PR and merge that + branch into `main`. + +After the script prints its summary: + +```bash +git checkout release/2.1 +REV=$(git rev-list --count HEAD) +git tag 2.1.0.$REV +git push origin 2.1.0.$REV +``` + +The push triggers `build-release.yml`. When it succeeds, the artifacts are at +`oss://crosspaste-desktop/stable/2.1.0.$REV/`. + +Finally, append a row to `doc/MOBILE_COMPAT.md` describing any `commonMain` +public-API changes since the previous tag. + +--- + +## Cutting a patch release (X.Y.Z) + +Patches happen on the existing `release/X.Y` branch. Assume `release/2.1` +already exists and you want `2.1.1`. + +```bash +git checkout release/2.1 +git pull --ff-only + +# Cherry-pick the fix(es) from main, one at a time, into release/2.1: +git cherry-pick + +# Bump the property file (this is manual on purpose — only you can decide +# whether a given set of cherry-picks deserves a patch bump): +# version=2.1.1 +$EDITOR app/src/desktopMain/resources/crosspaste-version.properties +git add app/src/desktopMain/resources/crosspaste-version.properties +git commit -m ":construction_worker: bump release/2.1 to 2.1.1" +git push origin release/2.1 + +REV=$(git rev-list --count HEAD) +git tag 2.1.1.$REV +git push origin 2.1.1.$REV +``` + +If a fix is needed only on the release branch (very rare — usually you want it +on `main` too), write the fix on `release/2.1` directly and immediately +cherry-pick it onto `main`. The trunk-first rule prevents drift. + +--- + +## Pre-release / RC flow + +Used when you want to dogfood a build that includes a big feature before +committing it to a stable release. + +```bash +# On main, with the big feature's flag turned ON: +git checkout main +git pull --ff-only + +REV=$(git rev-list --count HEAD) +git tag 2.1.0-rc.1.$REV +git push origin 2.1.0-rc.1.$REV +``` + +The CI workflow detects the `-rc.` segment, sets `PRE_RELEASE=true`, and +uploads to `oss://crosspaste-desktop/main/2.1.0-rc.1.$REV/`. The update +manifest serves this only to the `main` channel. + +Iterate `rc.2`, `rc.3` … by pushing further commits to `main` and re-tagging. + +When you are ready to ship `2.1.0` for real, follow the +[minor release flow](#cutting-a-minor-release-xy0). The RC tags stay as +historical artifacts on `main`. + +--- + +## Mobile sync + +Mobile projects copy `commonMain` from this repo. The contract is: + +- Mobile **only** syncs from a tagged commit, never from `main` HEAD. +- After every stable release, append a row to `doc/MOBILE_COMPAT.md`: + - The tag (e.g. `2.1.0.2281`). + - Any breaking `commonMain` API changes since the previous stable tag. + - Whether mobile has been updated to consume it. +- Commits that change `commonMain` public API in a breaking way should use the + `:boom:` emoji prefix in their commit message, so the changes are easy to + audit when assembling the compatibility row. + +When in doubt about whether a `commonMain` change is breaking for mobile, +assume it is and document it. False positives cost nothing; missed breaks cost +a mobile release. + +--- + +## Rollback + +If a stable release turns out to be broken: + +1. **Do not delete the tag.** Tagged artifacts are immutable history. Users + with the bad version already have it; pulling the tag only hides the + problem. +2. Fix the bug on `main`, cherry-pick onto `release/X.Y`, and ship a patch + following [Cutting a patch release](#cutting-a-patch-release-xyz). +3. If the bug is severe enough that users should not stay on the bad version, + mark the bad version as withdrawn in the update manifest so the auto-updater + forces a jump to the patch release. + +--- + +## Checklist + +Before tagging a stable release: + +- [ ] All in-flight large features are either complete or guarded by a flag + that is off in the release build. +- [ ] `./gradlew app:desktopTest` passes locally on the release branch. +- [ ] `CHANGELOG.md` updated with the release notes. +- [ ] `crosspaste-version.properties` on the release branch matches the tag + version. +- [ ] `MOBILE_COMPAT.md` has a row for this tag (filled in after the build + succeeds). diff --git a/doc/zh/Releasing.md b/doc/zh/Releasing.md new file mode 100644 index 000000000..3f3ed4839 --- /dev/null +++ b/doc/zh/Releasing.md @@ -0,0 +1,188 @@ +# 发布流程 + +本文档说明 CrossPaste Desktop 的版本发布流程,以及为达成以下目标必须遵守的纪律: + +- `main` 在维护一条 release line 的同时可以继续推进。 +- 多个大特性可以以 flag 关闭状态合并进 `main`,而不阻塞小版本发布。 +- 复用 `commonMain` 的移动端项目始终有一个稳定的、有名字的锚点可以同步。 + +只想看操作步骤的,直接跳到 +[切小版本(X.Y.0)](#切小版本xy0) +或 [切补丁版本(X.Y.Z)](#切补丁版本xyz)。 + +--- + +## 分支模型 + +| 分支 | 用途 | +| --------------- | ---------------------------------------------------------- | +| `main` | 主干。始终可发。小特性和修复直接进来。大特性以独立分支进行,合并回 `main` 时必须 flag 关闭("dark merge")。 | +| `release/X.Y` | 某一小版本的稳定线。决定发 `X.Y.0` 时从 `main` 切出。补丁版本(`X.Y.Z`)只能从这里切。 | +| `feature/*` | 大特性的长存分支。只有当 flag 关闭也能干净编译和运行后,才能合并进 `main`。 | + +### 为什么需要 release 分支? + +直接在 `main` 上打 tag,意味着发版稳定性和主干迭代速度被耦合在一起:在 `vX.Y.0` 之上打 hotfix 必须把 `main` 后续合并的所有改动都带上。release 分支给我们一个干净的位置 cherry-pick 最少必要的修复,发出 patch 版本。 + +### 为什么大特性要 dark-merge? + +长存分支会和 `main` 越走越远,rebase 越来越痛苦。把它早早合进来、但用 flag 关掉,可以让 `main` 保持持续集成,同时不让整个项目被这一个特性"是否完成"卡住。 + +flag 可以是编译期开关(如 `expect/actual` 默认关闭),也可以是运行期 `AppEnv` 配置。规则是:当 flag 关闭时,这个特性必须**完全不可见、零成本**——没有 UI、没有后台任务、没有 schema 迁移。 + +--- + +## tag 命名规则 + +tag 遵循 `MAJOR.MINOR.PATCH[-rc.N].REVISION`: + +| tag | 所在分支 | channel | `PRE_RELEASE` | +| ---------------------------- | ----------------- | -------- | ------------- | +| `2.1.0.2281` | `release/2.1` | `stable` | `false` | +| `2.1.1.2284` | `release/2.1` | `stable` | `false` | +| `2.1.0-rc.1.2280` | `main` | `main` | `true` | + +- `REVISION` 是该 commit 处的 `git rev-list --count HEAD`。它只在**所在分支内**唯一,不是全局唯一。OSS channel 前缀避免不同分支恰好算出同一个 revision 时产物撞车。 +- stable tag 必须打在 `release/X.Y` 分支上。 +- RC tag 必须打在 `main` 上。 +- CI workflow `.github/workflows/build-release.yml` 会通过分支包含性检查强制这两点。打在 feature 分支上的 tag 会拒绝构建。 + +--- + +## 版本属性文件 + +`app/src/desktopMain/resources/crosspaste-version.properties` 保存的是当前分支的有效版本号。CI 脚本 `.github/scripts/validateAndUpdateVersion.js` 会用它和 tag 对账,不一致直接 fail。 + +| 分支 | `version=` 应当是什么 | +| -------------- | ---------------------------------------------------------------------- | +| `main` | 下一次会从 `main` 切出去的 minor(如 `2.2.0`)。切完 release 分支后立即 bump。 | +| `release/X.Y` | 这个分支上**即将打或最近打过**的 `X.Y.Z`。每次打 patch tag 前先 bump。 | + +属性文件和 tag 不一致是硬错误——永远是改文件,不是绕开校验。 + +--- + +## OSS / 更新 channel + +| channel | OSS 前缀 | 谁会拿到 | +| -------- | ------------------------------------------------ | -------------------------------------- | +| `stable` | `oss://crosspaste-desktop/stable/X.Y.Z.REV/` | 默认更新通道的终端用户 | +| `main` | `oss://crosspaste-desktop/main/X.Y.Z-rc.N.REV/` | 主动订阅了 pre-release 通道的用户 | + +更新 manifest 必须分通道发布。stable 用户绝不能被自动升级到 RC 构建。 + +--- + +## 切小版本(X.Y.0) + +在干净的 `main` 上运行: + +```bash +./cut-release.sh 2.1.0 +``` + +这个脚本做的事: + +1. 确认你在 `main`、工作区干净、`main` 已与 `origin/main` 同步。 +2. 确认 `crosspaste-version.properties` 是 `version=2.1.0`。 +3. 从 `main` HEAD 创建 `release/2.1` 分支并 push。 +4. 在 `main` 上创建 `chore/bump-to-2.2.0` 分支,把属性文件改成 `2.2.0` 并 push。你需要手动开 PR 合并它。 + +脚本输出 summary 之后: + +```bash +git checkout release/2.1 +REV=$(git rev-list --count HEAD) +git tag 2.1.0.$REV +git push origin 2.1.0.$REV +``` + +push 触发 `build-release.yml`。构建成功后,产物在 `oss://crosspaste-desktop/stable/2.1.0.$REV/`。 + +最后,在 `doc/MOBILE_COMPAT.md` 追加一行,记录这个 tag 起 `commonMain` 公开 API 的破坏性改动。 + +--- + +## 切补丁版本(X.Y.Z) + +补丁在已经存在的 `release/X.Y` 分支上做。假设 `release/2.1` 已存在,要发 `2.1.1`: + +```bash +git checkout release/2.1 +git pull --ff-only + +# 从 main cherry-pick 修复,一个一个来: +git cherry-pick + +# bump 属性文件(这一步故意手动——只有你能决定一组 cherry-pick 值不值得 bump patch): +# version=2.1.1 +$EDITOR app/src/desktopMain/resources/crosspaste-version.properties +git add app/src/desktopMain/resources/crosspaste-version.properties +git commit -m ":construction_worker: bump release/2.1 to 2.1.1" +git push origin release/2.1 + +REV=$(git rev-list --count HEAD) +git tag 2.1.1.$REV +git push origin 2.1.1.$REV +``` + +如果修复只对 release 分支有意义(极少见,通常 `main` 也需要),直接在 `release/2.1` 上写,然后立刻 cherry-pick 回 `main`。"trunk-first" 规则防止分支漂移。 + +--- + +## 预发布 / RC 流程 + +用于在确定发 stable 之前先发一版包含大特性的构建给真用户试。 + +```bash +# 在 main 上,把大特性的 flag 打开: +git checkout main +git pull --ff-only + +REV=$(git rev-list --count HEAD) +git tag 2.1.0-rc.1.$REV +git push origin 2.1.0-rc.1.$REV +``` + +CI 检测到 `-rc.` 段,自动设置 `PRE_RELEASE=true`,产物上传到 `oss://crosspaste-desktop/main/2.1.0-rc.1.$REV/`。更新 manifest 只对 `main` 通道发送。 + +通过往 `main` 推新 commit + 重新打 `rc.2`、`rc.3`… 来迭代。 + +最终要发 `2.1.0` stable 时,走 [小版本流程](#切小版本xy0)。RC tag 作为历史记录留在 `main` 上。 + +--- + +## 移动端同步 + +移动端项目把 `commonMain` 复制过去使用。契约是: + +- 移动端**只**从打过 tag 的 commit 同步,永远不从 `main` HEAD 拉。 +- 每次 stable 发版后,在 `doc/MOBILE_COMPAT.md` 追加一行: + - tag(如 `2.1.0.2281`)。 + - 距离上一次 stable tag 的 `commonMain` 破坏性 API 改动。 + - 移动端是否已经消费。 +- 改动 `commonMain` 公开 API 且属于破坏性的 commit,commit message 用 `:boom:` 前缀,方便整理兼容性表格时审计。 + +不确定某个 `commonMain` 改动是否对移动端造成破坏时,按"是"处理。误报零成本,漏报代价是一个移动端版本。 + +--- + +## 回滚 + +如果发出去的 stable 版本被发现有问题: + +1. **不要删 tag。** 已经打出去的 artifact 是不可变历史。已经升级到坏版本的用户不会因为 tag 消失而自动回滚,反而隐藏了问题。 +2. 在 `main` 修 bug,cherry-pick 到 `release/X.Y`,按 [补丁流程](#切补丁版本xyz) 发新版。 +3. 如果严重到不能让用户停留在坏版本上,在更新 manifest 里把坏版本标记为 withdrawn,强制自动升级跳到补丁版本。 + +--- + +## checklist + +打 stable tag 之前: + +- [ ] 所有进行中的大特性,要么已完成,要么有 flag 在发布构建里关闭。 +- [ ] release 分支上本地跑 `./gradlew app:desktopTest` 通过。 +- [ ] `CHANGELOG.md` 更新好发布说明。 +- [ ] release 分支上 `crosspaste-version.properties` 和 tag 版本一致。 +- [ ] `MOBILE_COMPAT.md` 有这个 tag 对应的行(构建成功后填)。