Files
nexu-io-open-design/.github/workflows/finalize-release.yml
lefarcen 3fb3e3ca4a ci(release): finalize-release — bump main + drop backport label after a stable ships (#4936)
* ci(release): finalize-release — bump main + drop backport label after a stable ships

* ci(release): arm merge queue + request core-maintainer review on the bump PR

main enforces require_code_owner_review and the bump touches CODEOWNERS-owned
release manifests, so a bot approval can't merge it hands-free (unlike
backport-automerge / bake-plugin-previews-automerge, whose targets/diffs avoid
code-owner review). Open the PR, arm the squash merge queue (gh pr merge --auto,
proven on main by bake-plugin-previews-automerge), and request the
@nexu-io/core-maintainers review that the code-owner rule needs.

* ci(release): trigger finalize via workflow_run, not release:published

release-stable.yml promotes its own draft to published with gh release edit
using GITHUB_TOKEN, and a release event minted by GITHUB_TOKEN does not trigger
other workflows — so a release:published trigger would never fire. React to
release-stable completing instead (the backport-automerge / bake pattern), and
gate on the open-design-vX.Y.Z release actually being published (draft=false)
so dry-runs (metadata/prepublish) correctly no-op.

* ci(release): resolve version from latest published release, not head_branch

PerishCode review (blocking): release-stable supports building one ref while
dispatched from another via its `ref` input, so workflow_run.head_branch can be
the dispatch ref, not the built release branch — parsing it would miss-fire or
read the wrong version. Read the version from the latest published release
instead (release-stable marks it --latest), which is branch-independent. Drop
the head_branch job-if guard; the draft/prerelease/X.Y.Z guards + bump
idempotency keep dry-runs a clean no-op.

* test(release): pin finalize-release workflow topology

PerishCode review (blocking): .github/AGENTS.md asks cross-workflow behavior
changes to update the topology tests, not rely on YAML review alone. Add a
focused [P2] test in packaged-smoke-workflow.test.ts (alongside the backport /
bake automerge coverage) that pins finalize-release's trusted-consumer contract:
workflows: [release-stable] + success/dispatch gating, App-token use, version
resolved via gh release view (latest/tag) with NO workflow_run.head_branch
dependency, draft/prerelease/X.Y.Z + skip handling, forward-only synchronized
bump, core-maintainer review request, and SHA-pinned merge-queue arming.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>
2026-06-30 09:04:49 +00:00

205 lines
11 KiB
YAML

name: finalize-release
# Post-publish bookend to cut-release.yml. When a STABLE release ships, this:
# 1. Deletes the now-obsolete "backport release/vX.Y.Z" label — a shipped
# release takes no more backports, so the label only invites mistakes.
# 2. Opens a PR bumping main's synchronized workspace versions to X.Y.(Z+1),
# keeping main strictly ahead of the just-shipped stable tag (the build
# guard requires apps/packaged version > the latest open-design-v* tag),
# arms it for the merge queue (gh pr merge --auto enqueues on green), and
# requests a @nexu-io/core-maintainers review.
#
# Trigger: workflow_run on release-stable completing — NOT `release: published`.
# release-stable.yml promotes its own draft to published with `gh release edit
# --draft=false` using GITHUB_TOKEN, and a release event minted by GITHUB_TOKEN
# does not trigger other workflows (GitHub's loop guard). So we react to the
# build finishing instead, exactly like backport-automerge / bake-plugin-
# previews-automerge react to ci. The version is read from the LATEST published
# release — release-stable promotes its release with `--latest`, so after a real
# publish that IS the just-shipped stable. We deliberately do NOT parse
# workflow_run.head_branch: release-stable can be dispatched from one ref while
# building another via its `ref` input, so head_branch may be the dispatch ref,
# not the built release branch. A dry-run (metadata/prepublish) publishes nothing,
# so `--latest` stays the previous (already-finalized) stable and every step
# no-ops by idempotency. The draft/prerelease/X.Y.Z guards add belt-and-braces.
#
# Why the bump PR still needs ONE human approval (unlike the other automerge
# bots, which merge hands-free): main enforces require_code_owner_review, and the
# bump touches CODEOWNERS-owned release manifests (apps/packaged, apps/desktop,
# packages/platform, packages/sidecar*, tools/pack, tools/dev). A bot approval
# can't satisfy the code-owner gate, so a core-maintainer must approve. Once they
# do, the armed merge queue squashes it in automatically — no manual file editing,
# label cleanup, or enqueue. (release/v* has require_code_owner_review=false,
# which is why backports CAN auto-approve.)
on:
workflow_run:
workflows: ["release-stable"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Stable release tag to finalize, e.g. open-design-v0.12.0'
required: true
default: ''
permissions:
contents: read # actual writes go through the App token below
jobs:
finalize:
if: >-
github.repository == 'nexu-io/open-design' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
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 }}
- name: Resolve the shipped version (latest published stable, or the dispatch tag)
id: ver
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
EVENT: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# workflow_dispatch backfill -> finalize the exact tag asked for.
# workflow_run -> the latest published release, which release-stable marked --latest;
# NEVER parse workflow_run.head_branch (it can be the dispatch ref, not the built
# release branch, when release-stable runs with a `ref` input).
if [ "$EVENT" = "workflow_dispatch" ]; then
req="$INPUT_TAG"
case "$req" in open-design-v*) : ;; *) req="open-design-v$req" ;; esac # allow a bare X.Y.Z
state=$(gh release view "$req" --repo nexu-io/open-design --json tagName,isDraft,isPrerelease 2>/dev/null || echo '')
else
state=$(gh release view --repo nexu-io/open-design --json tagName,isDraft,isPrerelease 2>/dev/null || echo '')
fi
if [ -z "$state" ]; then
echo "No release to finalize (no published stable / dry-run / not shipped)."
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0
fi
tag=$(printf '%s' "$state" | jq -r '.tagName')
draft=$(printf '%s' "$state" | jq -r '.isDraft')
pre=$(printf '%s' "$state" | jq -r '.isPrerelease')
ver="${tag#open-design-v}"
# Only a published (draft=false), non-prerelease, open-design-vX.Y.Z release finalizes.
# On a dry-run the latest published release is the previous (already-finalized) stable,
# which the bump idempotency + tolerant label delete below turn into a clean no-op.
if [ "$draft" != "false" ] || [ "$pre" != "false" ] || ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Latest release '$tag' is draft=$draft prerelease=$pre — not a stable X.Y.Z; skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0
fi
major=${ver%%.*}; rest=${ver#*.}; minor=${rest%%.*}; patch=${rest##*.}
next="${major}.${minor}.$((patch+1))"
{
echo "skip=false"
echo "version=$ver"
echo "next=$next"
echo "branch=release-bot/bump-main-v$next"
} >> "$GITHUB_OUTPUT"
echo "Shipped stable v$ver -> bump main to v$next"
- name: Checkout main
if: steps.ver.outputs.skip != 'true'
uses: actions/checkout@v4
with:
ref: main # we bump main, regardless of which ref the release tag points at
fetch-depth: 0
token: ${{ steps.app.outputs.token }}
- uses: actions/setup-node@v4
if: steps.ver.outputs.skip != 'true'
with:
node-version: 24
- name: Delete the shipped release's backport label
if: steps.ver.outputs.skip != 'true'
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
label="backport release/v$VERSION"
if gh label list --repo nexu-io/open-design --json name --jq '.[].name' | grep -qxF "$label"; then
gh label delete "$label" --repo nexu-io/open-design --yes
echo "Deleted label: $label"
else
echo "Label '$label' not present; nothing to delete."
fi
- name: Bump main's synchronized workspace versions
id: bump
if: steps.ver.outputs.skip != 'true'
env:
NEXT: ${{ steps.ver.outputs.next }}
run: |
set -euo pipefail
current=$(node -p "require('./package.json').version")
# Idempotency: only ever move main forward. If main is already at or ahead
# of NEXT (re-run, or a later cut already moved it), do nothing.
lower=$(printf '%s\n%s\n' "$current" "$NEXT" | sort -V | head -1)
if [ "$current" = "$NEXT" ] || [ "$lower" = "$NEXT" ]; then
echo "main is at $current (>= $NEXT); no bump needed."
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Bump only the synchronized set: every workspace package.json whose version
# equals the current monorepo version. Independently-versioned packages
# (telemetry-worker, components, agui-adapter, tools/serve, …) keep their own
# version. `npm pkg set version` touches only the top-level "version" field,
# never a pinned dependency that happens to share the number (e.g. @xterm/addon-fit).
changed=0
for f in package.json apps/*/package.json packages/*/package.json tools/*/package.json e2e/package.json; do
[ -f "$f" ] || continue
v=$(node -p "require('./$f').version" 2>/dev/null || echo '')
if [ "$v" = "$current" ]; then
( cd "$(dirname "$f")" && npm pkg set version="$NEXT" )
changed=$((changed+1))
fi
done
echo "Bumped $changed manifests from $current to $NEXT."
echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Open the bump PR, arm the merge queue, request review
if: steps.ver.outputs.skip != 'true' && steps.bump.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
VERSION: ${{ steps.ver.outputs.version }}
NEXT: ${{ steps.ver.outputs.next }}
BRANCH: ${{ steps.ver.outputs.branch }}
run: |
set -euo pipefail
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "::warning::$BRANCH already exists; a bump PR is likely already open. Skipping."
exit 0
fi
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"
git commit -am "chore(release): bump main to $NEXT ahead of stable $VERSION"
git push origin "$BRANCH"
head_sha=$(git rev-parse HEAD)
body=$(printf '%s\n' \
"Automated by \`finalize-release\` after publishing \`open-design-v$VERSION\`." \
"" \
"Keeps \`main\` strictly ahead of the shipped stable tag — the build guard requires \`apps/packaged\` version to be greater than the latest \`open-design-v*\` tag. Bumps only the synchronized workspace manifests (those already sharing the monorepo version); independently-versioned packages are left untouched." \
"" \
"The \`backport release/v$VERSION\` label was already deleted by this same run." \
"" \
"**Needs one core-maintainer approval:** this touches CODEOWNERS-owned release manifests, so \`main\`'s code-owner rule requires a human review (a bot approval can't satisfy it). The merge queue is already armed — approve and it squashes in automatically.")
pr=$(gh pr create --repo nexu-io/open-design --base main --head "$BRANCH" \
--title "chore(release): bump main to $NEXT ahead of stable $VERSION" \
--body "$body")
echo "Opened $pr"
# Request the code owners who must approve (best-effort; never fail finalize on a hiccup).
gh pr edit "$pr" --repo nexu-io/open-design --add-reviewer nexu-io/core-maintainers || \
echo "::warning::could not request nexu-io/core-maintainers review on $pr"
# main is a squash merge queue; --auto arms enqueue-on-green (proven by
# bake-plugin-previews-automerge — allow_auto_merge=false does not block the queue).
# --match-head-commit pins the armed SHA so a later push isn't merged untested.
gh pr merge "$pr" --repo nexu-io/open-design --squash --auto --match-head-commit "$head_sha"