From 1e9275be17d8a2cd995cf44f76c5b21838f27c5c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 22 Jan 2026 18:45:49 -0400 Subject: [PATCH 1/2] feat: add release workflow concurrency control and main-to-beta sync Add safeguards to prevent concurrent releases and ensure beta stays in sync: - Add concurrency group to prevent overlapping release runs - Enforce workflow runs only on main branch (fail if run elsewhere) - Explicitly checkout and push to main (not dynamic branch) - Fail if release tag already exists (was silently skipping) - Add sync_beta job that merges main back into beta after release - Add docs/guides/RELEASING.md with two --- .github/workflows/release.yml | 48 +++++++++++++++++--- docs/guides/RELEASING.md | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 docs/guides/RELEASING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2735622ee..4dbad3c4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +concurrency: + group: release-main + cancel-in-progress: false + on: workflow_dispatch: inputs: @@ -23,9 +27,19 @@ jobs: new_version: ${{ steps.compute.outputs.new_version }} tag: ${{ steps.tag.outputs.tag }} steps: + - name: Ensure workflow is running on main + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "This workflow must be run on the main branch. Current ref: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + - name: Checkout repository uses: actions/checkout@v6 with: + ref: main fetch-depth: 0 - name: Compute new version @@ -93,9 +107,8 @@ jobs: git commit -m "chore: bump version to ${NEW_VERSION}" fi - BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - echo "Pushing to branch: $BRANCH" - git push origin "$BRANCH" + echo "Pushing bump commit to main" + git push origin HEAD:main - name: Create and push tag env: @@ -106,8 +119,8 @@ jobs: echo "Preparing to create tag $TAG" if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then - echo "Tag $TAG already exists on remote. Skipping tag creation." - exit 0 + echo "Tag $TAG already exists on remote. Refusing to release." >&2 + exit 1 fi git tag -a "$TAG" -m "Version ${TAG#v}" @@ -120,6 +133,31 @@ jobs: name: ${{ steps.tag.outputs.tag }} generate_release_notes: true + sync_beta: + name: Merge main back into beta + needs: + - bump + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout beta + uses: actions/checkout@v6 + with: + ref: beta + fetch-depth: 0 + + - name: Merge main into beta and push + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + git fetch origin main + git merge --no-ff --no-edit origin/main + git push origin HEAD:beta + publish_docker: name: Publish Docker image needs: diff --git a/docs/guides/RELEASING.md b/docs/guides/RELEASING.md new file mode 100644 index 000000000..6fbb8b6dc --- /dev/null +++ b/docs/guides/RELEASING.md @@ -0,0 +1,85 @@ +# Releasing (Maintainers) + +This repo uses a two-branch flow to keep `main` stable for users: + +- `beta`: integration branch where feature PRs land +- `main`: stable branch that should match the latest release tag + +## Release checklist + +### 1) Promote `beta` to `main` via PR + +- Create a PR with: + - base: `main` + - compare: `beta` +- Ensure required CI checks are green. +- Merge the PR. + +Release note quality depends on how you merge: + +- Squash-merging feature PRs into `beta` is OK. +- Avoid squash-merging the `beta -> main` promotion PR. Prefer a merge commit (or rebase merge) so GitHub can produce better auto-generated release notes. + +### 2) Run the Release workflow (manual) + +- Go to **GitHub → Actions → Release** +- Click **Run workflow** +- Select: + - `patch`, `minor`, or `major` +- Run it on branch: `main` + +What the workflow does: + +- Updates version references across the repo +- Commits the version bump to `main` +- Creates an annotated tag `vX.Y.Z` on that commit +- Creates a GitHub Release for the tag +- Publishes artifacts (Docker / PyPI / MCPB) +- Merges `main` back into `beta` so `beta` includes the bump commit + +### 3) Verify release outputs + +- Confirm a new tag exists: `vX.Y.Z` +- Confirm a GitHub Release exists for the tag +- Confirm artifacts: + - Docker image published with version `X.Y.Z` + - PyPI package published (if configured) + - `unity-mcp-X.Y.Z.mcpb` attached to the GitHub Release + +## Required repo settings (Branch Protection) + +Because the release workflow pushes commits to `main` and `beta`, branch protection must allow GitHub Actions to push. + +Recommended: + +- Protect `main`: + - Require PR before merging (so humans promote via PR) + - Require approvals / status checks as desired + - Allow GitHub Actions (or `github-actions[bot]`) to bypass PR requirements (so the release workflow can push the bump commit and tag) + +- Protect `beta` (if protected): + - Allow GitHub Actions (or `github-actions[bot]`) to push (needed for the `main -> beta` sync after release) + +## Failure modes and recovery + +### Tag already exists + +The workflow fails if the computed tag already exists. Pick a different bump type or investigate why a tag already exists for that version. + +### Workflow fails after pushing the bump commit + +If the workflow pushes the version bump commit to `main` but fails before creating the tag/release: + +- Do not immediately rerun the workflow: rerunning will compute a *new* version and bump again. +- Preferred recovery: + - Fix the underlying issue. + - Manually create the expected tag on the bump commit and create a GitHub Release for it, or revert the bump commit and re-run the workflow. + +### Sync `main` back into `beta` fails + +If `main -> beta` merge has conflicts, the workflow will fail. + +Recovery: + +- Create a PR `main -> beta` and resolve conflicts there, or +- Resolve locally and push to `beta` (if allowed by branch protection). From b497a6211f0200141ad3f95a76530a731e2d580c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 22 Jan 2026 19:05:19 -0400 Subject: [PATCH 2/2] feat: use PRs for version bumps and beta sync instead of direct pushes For release notes to work we need for PRs from beta to main to not be squashed. We also want to enforce all changes to be via PRs, for humans. But that also limits GH Actions. An alternative is creating a GH App with bypass permissions but that felt like overkill Replace direct pushes to main/beta with PR-based workflow for better branch protection compatibility: - Create temporary release/vX.Y.Z branch for version bump - Open PR from release branch into main, enable auto-merge - Poll for PR merge completion (up to 2 minutes) before creating tag - Fetch merged main and create tag on merged commit - Clean up release branch after tag creation - Create PR to merge main into beta (skip if already --- .github/workflows/release.yml | 126 ++++++++++++++++++++++++++++++---- docs/guides/RELEASING.md | 78 ++++++++++++++------- 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dbad3c4c..fd5fd74b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,9 +23,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write outputs: new_version: ${{ steps.compute.outputs.new_version }} tag: ${{ steps.tag.outputs.tag }} + bump_branch: ${{ steps.bump_branch.outputs.name }} steps: - name: Ensure workflow is running on main shell: bash @@ -92,14 +94,19 @@ jobs: echo "Updating all version references to $NEW_VERSION" python3 tools/update_versions.py --version "$NEW_VERSION" - - name: Commit and push changes + - name: Commit version bump to a temporary branch + id: bump_branch env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} shell: bash run: | set -euo pipefail + BRANCH="release/v${NEW_VERSION}" + echo "name=$BRANCH" >> "$GITHUB_OUTPUT" + git config user.name "GitHub Actions" git config user.email "actions@github.com" + git checkout -b "$BRANCH" git add MCPForUnity/package.json manifest.json "Server/pyproject.toml" Server/README.md README.md docs/i18n/README-zh.md if git diff --cached --quiet; then echo "No version changes to commit." @@ -107,15 +114,59 @@ jobs: git commit -m "chore: bump version to ${NEW_VERSION}" fi - echo "Pushing bump commit to main" - git push origin HEAD:main + echo "Pushing bump branch $BRANCH" + git push origin "$BRANCH" + + - name: Create PR for version bump into main + id: bump_pr + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.compute.outputs.new_version }} + BRANCH: ${{ steps.bump_branch.outputs.name }} + shell: bash + run: | + set -euo pipefail + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: bump version to ${NEW_VERSION}" \ + --body "Automated version bump to ${NEW_VERSION}.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Enable auto-merge and merge PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.bump_pr.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + # Enable auto-merge (requires repo setting "Allow auto-merge") + gh pr merge "$PR_NUMBER" --merge --auto || true + # Wait for PR to be merged (poll up to 2 minutes) + for i in {1..24}; do + STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state') + if [[ "$STATE" == "MERGED" ]]; then + echo "PR merged successfully." + exit 0 + fi + echo "Waiting for PR to merge... (state: $STATE)" + sleep 5 + done + echo "PR did not merge in time. Attempting direct merge..." + gh pr merge "$PR_NUMBER" --merge - - name: Create and push tag + - name: Fetch merged main and create tag env: TAG: ${{ steps.tag.outputs.tag }} shell: bash run: | set -euo pipefail + git fetch origin main + git checkout main + git pull origin main + echo "Preparing to create tag $TAG" if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then @@ -126,6 +177,16 @@ jobs: git tag -a "$TAG" -m "Version ${TAG#v}" git push origin "$TAG" + - name: Clean up release branch + if: always() + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.bump_branch.outputs.name }} + shell: bash + run: | + set -euo pipefail + git push origin --delete "$BRANCH" || true + - name: Create GitHub release uses: softprops/action-gh-release@v2 with: @@ -134,29 +195,68 @@ jobs: generate_release_notes: true sync_beta: - name: Merge main back into beta + name: Merge main back into beta via PR needs: - bump runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - - name: Checkout beta + - name: Checkout main uses: actions/checkout@v6 with: - ref: beta + ref: main fetch-depth: 0 - - name: Merge main into beta and push + - name: Create PR to merge main into beta + id: sync_pr + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ needs.bump.outputs.new_version }} shell: bash run: | set -euo pipefail - git config user.name "GitHub Actions" - git config user.email "actions@github.com" + # Check if beta is behind main + git fetch origin beta + if git merge-base --is-ancestor origin/main origin/beta; then + echo "beta is already up to date with main. Skipping PR." + echo "skipped=true" >> "$GITHUB_OUTPUT" + exit 0 + fi - git fetch origin main - git merge --no-ff --no-edit origin/main - git push origin HEAD:beta + PR_URL=$(gh pr create \ + --base beta \ + --head main \ + --title "chore: sync main (v${NEW_VERSION}) into beta" \ + --body "Automated sync of version bump from main into beta.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "skipped=false" >> "$GITHUB_OUTPUT" + + - name: Enable auto-merge and merge sync PR + if: steps.sync_pr.outputs.skipped != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.sync_pr.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + # Enable auto-merge (requires repo setting "Allow auto-merge") + gh pr merge "$PR_NUMBER" --merge --auto || true + # Wait for PR to be merged (poll up to 2 minutes) + for i in {1..24}; do + STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state') + if [[ "$STATE" == "MERGED" ]]; then + echo "Sync PR merged successfully." + exit 0 + fi + echo "Waiting for sync PR to merge... (state: $STATE)" + sleep 5 + done + echo "Sync PR did not merge in time. Attempting direct merge..." + gh pr merge "$PR_NUMBER" --merge publish_docker: name: Publish Docker image diff --git a/docs/guides/RELEASING.md b/docs/guides/RELEASING.md index 6fbb8b6dc..89119922f 100644 --- a/docs/guides/RELEASING.md +++ b/docs/guides/RELEASING.md @@ -30,12 +30,15 @@ Release note quality depends on how you merge: What the workflow does: -- Updates version references across the repo -- Commits the version bump to `main` -- Creates an annotated tag `vX.Y.Z` on that commit -- Creates a GitHub Release for the tag -- Publishes artifacts (Docker / PyPI / MCPB) -- Merges `main` back into `beta` so `beta` includes the bump commit +1. Creates a temporary `release/vX.Y.Z` branch with the version bump commit +2. Opens a PR from that branch into `main` +3. Auto-merges the PR (or waits for required checks, then merges) +4. Creates an annotated tag `vX.Y.Z` on the merged commit +5. Creates a GitHub Release for the tag +6. Publishes artifacts (Docker / PyPI / MCPB) +7. Opens a PR to merge `main` back into `beta` (so `beta` gets the bump) +8. Auto-merges the sync PR +9. Cleans up the temporary release branch ### 3) Verify release outputs @@ -46,19 +49,36 @@ What the workflow does: - PyPI package published (if configured) - `unity-mcp-X.Y.Z.mcpb` attached to the GitHub Release -## Required repo settings (Branch Protection) +## Required repo settings -Because the release workflow pushes commits to `main` and `beta`, branch protection must allow GitHub Actions to push. +### Branch protection (Rulesets) -Recommended: +The release workflow uses PRs instead of direct pushes, so it works with strict branch protection. No bypass actors are required. -- Protect `main`: - - Require PR before merging (so humans promote via PR) - - Require approvals / status checks as desired - - Allow GitHub Actions (or `github-actions[bot]`) to bypass PR requirements (so the release workflow can push the bump commit and tag) +Recommended ruleset for `main`: -- Protect `beta` (if protected): - - Allow GitHub Actions (or `github-actions[bot]`) to push (needed for the `main -> beta` sync after release) +- Require PR before merging +- Allowed merge methods: `merge`, `rebase` (no squash for promotion PRs) +- Required approvals: `0` (so automated PRs can merge without human review) +- Optionally require status checks + +Recommended ruleset for `beta`: + +- Require PR before merging +- Allowed merge methods: `squash` (for feature PRs) +- Required approvals: `0` (so the sync PR can auto-merge) + +### Enable auto-merge (required) + +The workflow uses `gh pr merge --auto` to automatically merge PRs once checks pass. + +To enable: + +1. Go to **Settings → General** +2. Scroll to **Pull Requests** +3. Check **Allow auto-merge** + +Without this setting, the workflow will fall back to direct merge attempts, which may fail if branch protection requires checks. ## Failure modes and recovery @@ -66,20 +86,26 @@ Recommended: The workflow fails if the computed tag already exists. Pick a different bump type or investigate why a tag already exists for that version. -### Workflow fails after pushing the bump commit +### Bump PR fails to merge + +If the version bump PR cannot be merged (e.g., required checks fail): + +- The workflow will fail before creating a tag. +- Fix the issue, then either: + - Manually merge the PR and create the tag/release, or + - Close the PR, delete the `release/vX.Y.Z` branch, and re-run the workflow. -If the workflow pushes the version bump commit to `main` but fails before creating the tag/release: +### Sync PR (`main -> beta`) fails -- Do not immediately rerun the workflow: rerunning will compute a *new* version and bump again. -- Preferred recovery: - - Fix the underlying issue. - - Manually create the expected tag on the bump commit and create a GitHub Release for it, or revert the bump commit and re-run the workflow. +If the sync PR has merge conflicts: -### Sync `main` back into `beta` fails +- The workflow will fail after the release is published (artifacts are already out). +- Manually resolve conflicts in the sync PR and merge it. -If `main -> beta` merge has conflicts, the workflow will fail. +### Leftover release branch -Recovery: +If the workflow fails mid-run, a `release/vX.Y.Z` branch may remain. Delete it manually before re-running: -- Create a PR `main -> beta` and resolve conflicts there, or -- Resolve locally and push to `beta` (if allowed by branch protection). +```bash +git push origin --delete release/vX.Y.Z +```