Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/finalize-release.yml
Original file line number Diff line number Diff line change
@@ -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"
184 changes: 184 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -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|.*<Version>\(.*\)</Version>.*|\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"
12 changes: 2 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,14 @@ name: Release
# 2. Push to `develop` (rolling pre-release smoke test)
# → Version = <Version> from Directory.Build.props + ".<github.run_number>"
# e.g. 2.1.1-develop.7
# 3. workflow_dispatch (manual run with explicit version)
#
# Both packages share a single <Version> 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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<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

Expand Down
3 changes: 2 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
Release workflow overrides via -p:Version=<computed>:
- tag push v1.2.3 → 1.2.3
- develop branch push → 2.1.1-develop.<github.run_number>
- 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.
-->
<Version>2.2.1-develop</Version>
<PackageProjectUrl>https://github.com/gui-cs/Editor</PackageProjectUrl>
Expand Down
Loading