From 839e57e96528a1ae9fa6fe8a0ab9fbed4b7a0702 Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 21 May 2026 10:41:26 -0600 Subject: [PATCH 1/2] Port prepare release workflow Add TG-style prepare/finalize release workflows so stable releases are created through a release PR, then finalized with a tag, GitHub Release, and back-merge PR. Remove manual stable publishing from the NuGet release workflow so releases cannot bypass tags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/finalize-release.yml | 123 +++++++++++++++++ .github/workflows/prepare-release.yml | 184 +++++++++++++++++++++++++ .github/workflows/release.yml | 12 +- CLAUDE.md | 4 +- Directory.Build.props | 3 +- 5 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/finalize-release.yml create mode 100644 .github/workflows/prepare-release.yml diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml new file mode 100644 index 00000000..22e55d2c --- /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 main + uses: actions/checkout@v5 + with: + ref: main + 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" main + 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 00000000..a9e6205f --- /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 de0294a9..596a1eda 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 d48e01dd..3d807107 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 cd1dc745..1cba0cdc 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 From 0ca001f20f90ef51ecb83c01a94d839ed50f7729 Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 21 May 2026 10:49:00 -0600 Subject: [PATCH 2/2] Anchor release finalization to merge commit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/finalize-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 22e55d2c..1125be12 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -17,10 +17,10 @@ jobs: contents: write pull-requests: write steps: - - name: Checkout main + - name: Checkout release merge commit uses: actions/checkout@v5 with: - ref: main + ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} @@ -99,7 +99,7 @@ jobs: run: | BACKMERGE_BRANCH="backmerge/${TAG}" - git checkout -b "$BACKMERGE_BRANCH" main + git checkout -b "$BACKMERGE_BRANCH" "${{ github.event.pull_request.merge_commit_sha }}" git push origin "$BACKMERGE_BRANCH" gh pr create \