mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
228 lines
11 KiB
YAML
228 lines
11 KiB
YAML
name: cut-patch-release
|
||
# Every Thursday 09:00 Asia/Shanghai, cut the next PATCH release — but only once
|
||
# this week's minor (the branch cut-release created on Tuesday) has actually
|
||
# shipped stable.
|
||
#
|
||
# This is the Tuesday cut-release flow with three deltas:
|
||
# 1. It bumps the PATCH, not the minor: highest release/vX.Y.Z -> X.Y.(Z+1)
|
||
# (e.g. 0.14.0 -> 0.14.1). Like cut-release, the branch is cut from main HEAD.
|
||
# 2. A pre-flight guard: the current line's minor base X.Y.0 must be a PUBLISHED
|
||
# stable GitHub Release (tag open-design-vX.Y.0, isDraft=false, isPrerelease=false
|
||
# — i.e. release-stable ran `gh release edit --draft=false --latest`). If it is
|
||
# not published yet, do NOT cut a branch or launch a prerelease publication;
|
||
# instead post a Feishu notice that the patch cut was skipped, and stop. The
|
||
# independent scheduled Windows canary remains an advisory product-smoke
|
||
# signal and does not block this branch cut or its prerelease package.
|
||
# 3. On the happy path it is otherwise identical to cut-release: create
|
||
# release/vX.Y.(Z+1), bump apps/packaged/package.json, push with the App token
|
||
# (which triggers notify-release-feishu -> prerelease build + Feishu card), and
|
||
# create the "backport release/vX.Y.(Z+1)" label.
|
||
#
|
||
# "Cut a patch" here means "cut a new point-release branch + build a prerelease +
|
||
# announce it", exactly like Tuesday. It deliberately does NOT auto-promote to
|
||
# stable; that stays a human-gated release-stable dispatch.
|
||
#
|
||
# NOTE: schedule triggers only fire from the default branch (main), so this file
|
||
# must live on main to run on its cron.
|
||
|
||
on:
|
||
schedule:
|
||
- cron: '0 1 * * 4' # 01:00 UTC = 09:00 Asia/Shanghai, Thursday
|
||
workflow_dispatch:
|
||
inputs:
|
||
version:
|
||
description: 'Full patch version x.y.z (empty = bump patch of the highest release branch)'
|
||
required: false
|
||
default: ''
|
||
force:
|
||
description: 'Skip the "Tuesday minor is published" guard and cut anyway'
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
|
||
permissions:
|
||
contents: read # branch/label writes still go through the App token below
|
||
|
||
concurrency:
|
||
group: cut-patch-release
|
||
cancel-in-progress: false
|
||
|
||
jobs:
|
||
cut:
|
||
if: github.repository == 'nexu-io/open-design'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/create-github-app-token@v2
|
||
id: app
|
||
with:
|
||
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
|
||
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
||
|
||
- uses: actions/checkout@v4
|
||
with:
|
||
# Smoke is advisory; release cuts always use the current main tip.
|
||
ref: main
|
||
fetch-depth: 0
|
||
token: ${{ steps.app.outputs.token }}
|
||
|
||
- uses: actions/setup-node@v4
|
||
with:
|
||
node-version: 24
|
||
|
||
- name: Compute next patch version
|
||
id: ver
|
||
env:
|
||
# Never interpolate the raw input into the shell; pass via env and validate.
|
||
INPUT_VERSION: ${{ inputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Decide the final version V FIRST: a validated manual override wins,
|
||
# otherwise bump the patch of the highest release branch.
|
||
if [ -n "$INPUT_VERSION" ]; then
|
||
# Strict x.y.z, digits only — rejects shell metacharacters, newlines, etc.
|
||
if ! printf '%s' "$INPUT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||
echo "::error::Invalid version '$INPUT_VERSION' (expected x.y.z, digits only)"
|
||
exit 1
|
||
fi
|
||
V="$INPUT_VERSION"
|
||
else
|
||
latest=$(git ls-remote --heads origin 'release/v*' \
|
||
| sed -E 's#.*/release/v##' \
|
||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
||
| sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
|
||
if [ -z "$latest" ]; then
|
||
echo "::error::No release/vX.Y.Z branch found to base a patch on."
|
||
exit 1
|
||
fi
|
||
major=${latest%%.*}; rest=${latest#*.}; minor=${rest%%.*}; patch=${rest#*.}
|
||
V="${major}.${minor}.$((patch+1))"
|
||
fi
|
||
# V is now guaranteed to match ^[0-9]+\.[0-9]+\.[0-9]+$.
|
||
# Gate on the minor base of the FINAL V — never the highest branch — so a
|
||
# manual `version=` on a different line is checked against ITS own minor
|
||
# (e.g. version=0.15.1 gates open-design-v0.15.0, not the latest 0.14.0).
|
||
vmajor=${V%%.*}; vrest=${V#*.}; vminor=${vrest%%.*}
|
||
MINOR_BASE="${vmajor}.${vminor}.0"
|
||
{
|
||
echo "version=$V"
|
||
echo "branch=release/v$V"
|
||
echo "minor_base=$MINOR_BASE"
|
||
echo "minor_tag=open-design-v$MINOR_BASE"
|
||
} >> "$GITHUB_OUTPUT"
|
||
echo "Cutting patch v$V (branch release/v$V); gating on stable v$MINOR_BASE"
|
||
|
||
# Guard: the minor this patch sits on (the Tuesday cut) must already be a
|
||
# PUBLISHED stable GitHub Release. release-stable publishes with
|
||
# `gh release edit --draft=false --latest` and rolls the tag back on failure,
|
||
# so a non-draft, non-prerelease release is the ground-truth "shipped" signal.
|
||
- name: Check the Tuesday minor is published
|
||
id: guard
|
||
env:
|
||
GH_TOKEN: ${{ steps.app.outputs.token }}
|
||
MINOR_TAG: ${{ steps.ver.outputs.minor_tag }}
|
||
FORCE: ${{ inputs.force }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [ "${FORCE:-false}" = "true" ]; then
|
||
echo "force=true: skipping the publish guard"
|
||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
# "published" = the release exists AND is neither a draft nor a prerelease.
|
||
# gh's --jq evaluates `(.isDraft or .isPrerelease) | not` to true only when
|
||
# both are false. A missing release makes `gh release view` exit non-zero,
|
||
# so the `|| published=false` fallback treats "not found" as "not published".
|
||
published=$(gh release view "$MINOR_TAG" \
|
||
--repo nexu-io/open-design \
|
||
--json isDraft,isPrerelease \
|
||
--jq '(.isDraft or .isPrerelease) | not' 2>/dev/null) || published=false
|
||
echo "release $MINOR_TAG -> published=$published"
|
||
echo "published=$published" >> "$GITHUB_OUTPUT"
|
||
|
||
# ---- Skip path: minor not published yet -> no branch/release build; notify and stop ----
|
||
# feishu-notice.ts imports only node:crypto (no workspace deps), so the
|
||
# setup-node above is all it needs — no pnpm install, same as feishu.ts.
|
||
- name: Notify Feishu that the patch cut was skipped
|
||
if: steps.guard.outputs.published != 'true'
|
||
env:
|
||
FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }}
|
||
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }}
|
||
NOTICE_TITLE: "⏭️ 周四小版本已跳过"
|
||
NOTICE_TEMPLATE: "orange"
|
||
NOTICE_BODY: |
|
||
本周周二的 release **v${{ steps.ver.outputs.minor_base }}** 还没发布成功(stable GitHub Release 尚未 published),本次周四小版本 **v${{ steps.ver.outputs.version }}** 的自动切分支与构建已跳过。
|
||
|
||
待 v${{ steps.ver.outputs.minor_base }} promote 到 stable 后,可手动重新触发本工作流(或用 `force` 强制切分支)。
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: node --experimental-strip-types tools/release/src/notifications/feishu-notice.ts
|
||
|
||
- name: Stop here when skipping
|
||
if: steps.guard.outputs.published != 'true'
|
||
run: |
|
||
echo "::notice::Tuesday minor v${{ steps.ver.outputs.minor_base }} is not published; patch cut skipped."
|
||
exit 0
|
||
|
||
# ---- Happy path: minor published -> cut the patch, same as cut-release ----
|
||
- name: Bail out if the branch already exists
|
||
if: steps.guard.outputs.published == 'true'
|
||
env:
|
||
BRANCH: ${{ steps.ver.outputs.branch }}
|
||
run: |
|
||
set -euo pipefail
|
||
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
|
||
echo "::error::$BRANCH already exists, skipping"
|
||
exit 1
|
||
fi
|
||
|
||
- name: Create branch + bump version + push
|
||
if: steps.guard.outputs.published == 'true'
|
||
env:
|
||
VERSION: ${{ steps.ver.outputs.version }}
|
||
BRANCH: ${{ steps.ver.outputs.branch }}
|
||
run: |
|
||
set -euo pipefail
|
||
git config user.name 'open-design-release-bot[bot]'
|
||
git config user.email 'open-design-release-bot[bot]@users.noreply.github.com'
|
||
git switch -c "$BRANCH"
|
||
( cd apps/packaged && npm pkg set version="$VERSION" )
|
||
# main leads stable by exactly this patch in steady state (finalize-release
|
||
# bumps main to X.Y.(Z+1) after X.Y.Z ships), so apps/packaged is often
|
||
# already at $VERSION and the bump is a no-op. --allow-empty commits a
|
||
# release marker anyway instead of dying on "nothing to commit"; either way
|
||
# the branch is cut + pushed, which is what triggers notify-release-feishu.
|
||
git commit --allow-empty -am "chore(release): v$VERSION"
|
||
git push origin "$BRANCH" # App token push -> triggers notify-release-feishu (prerelease + Feishu)
|
||
|
||
- name: Create backport label
|
||
if: steps.guard.outputs.published == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ steps.app.outputs.token }}
|
||
VERSION: ${{ steps.ver.outputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
gh label create "backport release/v$VERSION" \
|
||
--repo nexu-io/open-design \
|
||
--color 0E8A16 \
|
||
--description "Backport this fix to release/v$VERSION" \
|
||
--force
|
||
|
||
# Post an immediate "branch cut" card so the team is notified the moment the
|
||
# patch branch exists, without waiting ~20-40 min for notify-release-feishu to
|
||
# finish the prerelease build. The download card still follows from that build.
|
||
# feishu-notice.ts only imports node:crypto, so the setup-node above suffices.
|
||
- name: Notify Feishu that the branch was cut
|
||
if: steps.guard.outputs.published == 'true'
|
||
env:
|
||
FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }}
|
||
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }}
|
||
NOTICE_TITLE: "🌿 已切出 release 分支 v${{ steps.ver.outputs.version }}(小版本 patch)"
|
||
NOTICE_TEMPLATE: "green"
|
||
NOTICE_BODY: |
|
||
**分支**:[`release/v${{ steps.ver.outputs.version }}`](${{ github.server_url }}/${{ github.repository }}/tree/release/v${{ steps.ver.outputs.version }})(从 main 切出,小版本 / patch)
|
||
|
||
**Backport label**:`backport release/v${{ steps.ver.outputs.version }}` —— 要回灌到这个版本的修复,给 PR 打这个 label。
|
||
|
||
Prerelease 打包已开始(约 20–40 分钟);完成后会再发一张带各平台下载链接的卡片。
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: node --experimental-strip-types tools/release/src/notifications/feishu-notice.ts
|