diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml
new file mode 100644
index 0000000..1125be1
--- /dev/null
+++ b/.github/workflows/finalize-release.yml
@@ -0,0 +1,123 @@
+name: Finalize Release
+
+# Runs automatically when a release PR is merged into main.
+# Creates the git tag, GitHub Release, and a back-merge PR to develop.
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+
+jobs:
+ finalize-release:
+ name: Tag, Release, and Back-merge
+ if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout release merge commit
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
+ fetch-depth: 0
+ token: ${{ secrets.RELEASE_PAT }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Extract release info from branch name
+ id: release
+ env:
+ RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }}
+ shell: bash
+ run: |
+ BRANCH="$RELEASE_BRANCH"
+ TAG="${BRANCH#release/}"
+ SEMVER="${TAG#v}"
+
+ if echo "$SEMVER" | grep -q '-'; then
+ PRERELEASE="true"
+ else
+ PRERELEASE="false"
+ fi
+
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT"
+ echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT"
+
+ - name: Check if tag already exists
+ shell: bash
+ run: |
+ if git rev-parse "${{ steps.release.outputs.tag }}" >/dev/null 2>&1; then
+ echo "::error::Tag ${{ steps.release.outputs.tag }} already exists."
+ exit 1
+ fi
+
+ - name: Create annotated tag
+ shell: bash
+ run: |
+ TAG="${{ steps.release.outputs.tag }}"
+ git tag -a "$TAG" -m "Release $TAG"
+ git push origin "$TAG"
+ echo "Created and pushed tag: $TAG"
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ TAG: ${{ steps.release.outputs.tag }}
+ PRERELEASE: ${{ steps.release.outputs.prerelease }}
+ shell: bash
+ run: |
+ PRERELEASE_FLAG=""
+ if [ "$PRERELEASE" = "true" ]; then
+ PRERELEASE_FLAG="--prerelease"
+ fi
+
+ gh release create "$TAG" \
+ --title "$TAG" \
+ --generate-notes \
+ $PRERELEASE_FLAG
+
+ - name: Delete release branch
+ env:
+ RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }}
+ shell: bash
+ run: |
+ BRANCH="$RELEASE_BRANCH"
+ git push origin --delete "$BRANCH" || true
+ echo "Deleted branch: $BRANCH"
+
+ - name: Create back-merge PR
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ TAG: ${{ steps.release.outputs.tag }}
+ shell: bash
+ run: |
+ BACKMERGE_BRANCH="backmerge/${TAG}"
+
+ git checkout -b "$BACKMERGE_BRANCH" "${{ github.event.pull_request.merge_commit_sha }}"
+ git push origin "$BACKMERGE_BRANCH"
+
+ gh pr create \
+ --base develop \
+ --head "$BACKMERGE_BRANCH" \
+ --title "Back-merge ${TAG} from main into develop" \
+ --body "Automatic back-merge after release ${TAG}. Merge this to keep \`develop\` in sync with \`main\`."
+
+ - name: Summary
+ run: |
+ echo "## Release Finalized" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Tag:** ${{ steps.release.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Version:** ${{ steps.release.outputs.semver }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Pre-release:** ${{ steps.release.outputs.prerelease }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "### What happened" >> "$GITHUB_STEP_SUMMARY"
+ echo "1. Tag \`${{ steps.release.outputs.tag }}\` created" >> "$GITHUB_STEP_SUMMARY"
+ echo "2. GitHub Release created with auto-generated notes" >> "$GITHUB_STEP_SUMMARY"
+ echo "3. Release workflow triggered for NuGet publish" >> "$GITHUB_STEP_SUMMARY"
+ echo "4. Back-merge PR opened to develop" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 0000000..a9e6205
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,184 @@
+name: Prepare Release
+
+# Creates a release PR from develop -> main.
+# Maintainers trigger this from Actions -> Prepare Release -> Run workflow.
+# After reviewing, merge the PR; the Finalize Release workflow creates the tag,
+# GitHub Release, and back-merge PR.
+
+on:
+ workflow_dispatch:
+ inputs:
+ release_type:
+ description: 'Release type'
+ required: true
+ type: choice
+ options:
+ - beta
+ - rc
+ - stable
+ default: stable
+ version_override:
+ description: 'Version override (optional, e.g., 2.2.4). Defaults to Directory.Build.props without -develop.'
+ required: false
+ type: string
+
+concurrency:
+ group: prepare-release
+ cancel-in-progress: false
+
+jobs:
+ prepare-release:
+ name: Create Release PR
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout develop
+ uses: actions/checkout@v5
+ with:
+ ref: develop
+ fetch-depth: 0
+ token: ${{ secrets.RELEASE_PAT }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Compute release version
+ id: version
+ shell: bash
+ run: |
+ if [ -n "${{ github.event.inputs.version_override }}" ]; then
+ VERSION="${{ github.event.inputs.version_override }}"
+ else
+ VERSION=$(sed -n 's|.*\(.*\).*|\1|p' Directory.Build.props | head -1)
+ VERSION="${VERSION%%-*}"
+ fi
+
+ RELEASE_TYPE="${{ github.event.inputs.release_type }}"
+
+ if [ -z "$VERSION" ]; then
+ echo "::error::Could not resolve release version."
+ exit 1
+ fi
+
+ if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "::error::Release version must be a stable SemVer core like 2.2.4; got '$VERSION'."
+ exit 1
+ fi
+
+ if [ "$RELEASE_TYPE" = "stable" ]; then
+ SEMVER="$VERSION"
+ TAG="v${VERSION}"
+ else
+ LATEST_TAG=$(git tag -l "v${VERSION}-${RELEASE_TYPE}.*" --sort=-v:refname | head -1)
+ if [ -z "$LATEST_TAG" ]; then
+ NEXT_NUM=1
+ else
+ CURRENT_NUM="${LATEST_TAG##*.}"
+ NEXT_NUM=$((CURRENT_NUM + 1))
+ fi
+
+ SEMVER="${VERSION}-${RELEASE_TYPE}.${NEXT_NUM}"
+ TAG="v${SEMVER}"
+ fi
+
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT"
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "release_type=${RELEASE_TYPE}" >> "$GITHUB_OUTPUT"
+ echo "::notice::Computed version: ${SEMVER} (tag: ${TAG})"
+
+ - name: Check for conflicts
+ shell: bash
+ run: |
+ TAG="${{ steps.version.outputs.tag }}"
+ BRANCH="release/${TAG}"
+
+ if git rev-parse "${TAG}" >/dev/null 2>&1; then
+ echo "::error::Tag ${TAG} already exists."
+ exit 1
+ fi
+
+ if git ls-remote --heads origin "${BRANCH}" | grep -q .; then
+ echo "::error::Branch ${BRANCH} already exists on the remote."
+ exit 1
+ fi
+
+ if curl -fsS https://api.nuget.org/v3-flatcontainer/terminal.gui.editor/index.json \
+ | jq -r '.versions[]' | grep -Fxq "${{ steps.version.outputs.semver }}"; then
+ echo "::error::Terminal.Gui.Editor ${{ steps.version.outputs.semver }} already exists on NuGet.org."
+ echo "::error::Choose a new version_override."
+ exit 1
+ fi
+
+ if gh pr list --state open --base main --head "${BRANCH}" --json number --jq 'length' | grep -vq '^0$'; then
+ echo "::error::An open release PR already exists for ${BRANCH}."
+ exit 1
+ fi
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+
+ - name: Create release branch
+ shell: bash
+ run: |
+ BRANCH="release/${{ steps.version.outputs.tag }}"
+ git checkout -b "$BRANCH"
+ git push origin "$BRANCH"
+
+ - name: Create Pull Request
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ SEMVER: ${{ steps.version.outputs.semver }}
+ TAG: ${{ steps.version.outputs.tag }}
+ RELEASE_TYPE: ${{ steps.version.outputs.release_type }}
+ shell: bash
+ run: |
+ BRANCH="release/${TAG}"
+
+ if [ "$RELEASE_TYPE" = "stable" ]; then
+ PRERELEASE_NOTE=""
+ else
+ PRERELEASE_NOTE="This is a **${RELEASE_TYPE}** pre-release."
+ fi
+
+ cat > /tmp/pr_body.md << EOF
+ ## Release ${TAG}
+
+ ${PRERELEASE_NOTE}
+
+ **Version:** \`${SEMVER}\`
+ **NuGet Package:** \`Terminal.Gui.Editor ${SEMVER}\`
+
+ ### What happens when this PR is merged
+
+ 1. The **Finalize Release** workflow creates tag \`${TAG}\`
+ 2. The **Release** workflow builds and pushes \`Terminal.Gui.Editor ${SEMVER}\` to NuGet.org
+ 3. A **GitHub Release** is created with auto-generated notes
+ 4. A **back-merge PR** from \`main\` -> \`develop\` is opened
+
+ ### Checklist
+
+ - [ ] CI passes on this PR
+ - [ ] Version looks correct: \`${SEMVER}\`
+ - [ ] Release notes reviewed
+ EOF
+
+ gh pr create \
+ --base main \
+ --head "$BRANCH" \
+ --title "Release ${TAG}" \
+ --body-file /tmp/pr_body.md
+
+ - name: Summary
+ run: |
+ echo "## Release PR Created" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Version:** ${{ steps.version.outputs.semver }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Tag:** ${{ steps.version.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Type:** ${{ steps.version.outputs.release_type }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Branch:** release/${{ steps.version.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "Review and merge the PR to trigger the release." >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index de0294a..596a1ed 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,20 +8,14 @@ name: Release
# 2. Push to `develop` (rolling pre-release smoke test)
# → Version = from Directory.Build.props + "."
# e.g. 2.1.1-develop.7
-# 3. workflow_dispatch (manual run with explicit version)
#
-# Both packages share a single declared in Directory.Build.props.
+# The package version is declared in Directory.Build.props.
# The computed value overrides that base via `-p:Version=...`.
on:
push:
tags: ['v*']
branches: [develop]
- workflow_dispatch:
- inputs:
- version:
- description: 'Package version (e.g. 2.1.0 or 2.1.1-develop.42). Overrides Directory.Build.props.'
- required: true
permissions:
contents: write
@@ -42,9 +36,7 @@ jobs:
id: v
shell: bash
run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- VERSION="${{ github.event.inputs.version }}"
- elif [ "${{ github.ref_type }}" = "tag" ]; then
+ if [ "${{ github.ref_type }}" = "tag" ]; then
# Tag form: v2.1.0 → 2.1.0
VERSION="${GITHUB_REF_NAME#v}"
elif [ "${{ github.ref }}" = "refs/heads/develop" ]; then
diff --git a/CLAUDE.md b/CLAUDE.md
index d48e01d..3d80710 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,10 +12,10 @@ Active development happens on **`develop`**. `main` is the release/stable branch
- Work on `develop`. During pre-alpha, direct commits and pushes to `develop` are allowed — no PRs required for routine work.
- Do not push directly to `main`. Promotion from `develop` to `main` is a deliberate release step.
-- Two paths trigger `.github/workflows/release.yml`, which builds + tests cross-platform, then packs and pushes both NuGet packages:
+- Two paths trigger `.github/workflows/release.yml`, which builds + tests cross-platform, then packs and pushes the NuGet package:
- **Push a `v*` tag** (e.g. `v2.1.0`) — canonical stable release; version = tag minus leading `v`.
- **Push to `develop`** — rolling pre-release; version = `` from `Directory.Build.props` + `.${github.run_number}`. With base `2.1.1-develop`, the first run publishes `2.1.1-develop.1`, etc.
- - `workflow_dispatch` is also available with a verbatim version input.
+- Stable releases are created through **Prepare Release** (`.github/workflows/prepare-release.yml`), which opens a release PR from `develop` to `main`. Merging that PR triggers **Finalize Release** (`.github/workflows/finalize-release.yml`) to create the `v*` tag, GitHub Release, and back-merge PR to `develop`; the tag push triggers NuGet publishing.
## Versioning
diff --git a/Directory.Build.props b/Directory.Build.props
index cd1dc74..1cba0cd 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -18,7 +18,8 @@
Release workflow overrides via -p:Version=:
- tag push v1.2.3 → 1.2.3
- develop branch push → 2.1.1-develop.
- - workflow_dispatch → input.version (verbatim)
+ Stable releases are prepared with .github/workflows/prepare-release.yml,
+ which opens a release PR. Merging that PR creates the v* tag.
-->
2.2.1-develop
https://github.com/gui-cs/Editor