Update CI flow so that we bump from beta to main, and sync back (#614)

* 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

* 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
main
Marcus Sanatan 2026-01-22 21:36:54 -04:00 committed by GitHub
parent f32b62d616
commit f314a2367a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 255 additions and 6 deletions

View File

@ -1,5 +1,9 @@
name: Release name: Release
concurrency:
group: release-main
cancel-in-progress: false
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@ -19,13 +23,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
pull-requests: write
outputs: outputs:
new_version: ${{ steps.compute.outputs.new_version }} new_version: ${{ steps.compute.outputs.new_version }}
tag: ${{ steps.tag.outputs.tag }} tag: ${{ steps.tag.outputs.tag }}
bump_branch: ${{ steps.bump_branch.outputs.name }}
steps: 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 - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
ref: main
fetch-depth: 0 fetch-depth: 0
- name: Compute new version - name: Compute new version
@ -78,14 +94,19 @@ jobs:
echo "Updating all version references to $NEW_VERSION" echo "Updating all version references to $NEW_VERSION"
python3 tools/update_versions.py --version "$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: env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }} NEW_VERSION: ${{ steps.compute.outputs.new_version }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
BRANCH="release/v${NEW_VERSION}"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
git config user.email "actions@github.com" 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 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 if git diff --cached --quiet; then
echo "No version changes to commit." echo "No version changes to commit."
@ -93,26 +114,79 @@ jobs:
git commit -m "chore: bump version to ${NEW_VERSION}" git commit -m "chore: bump version to ${NEW_VERSION}"
fi fi
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" echo "Pushing bump branch $BRANCH"
echo "Pushing to branch: $BRANCH"
git push origin "$BRANCH" git push origin "$BRANCH"
- name: Create and push tag - 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: Fetch merged main and create tag
env: env:
TAG: ${{ steps.tag.outputs.tag }} TAG: ${{ steps.tag.outputs.tag }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git fetch origin main
git checkout main
git pull origin main
echo "Preparing to create tag $TAG" echo "Preparing to create tag $TAG"
if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
echo "Tag $TAG already exists on remote. Skipping tag creation." echo "Tag $TAG already exists on remote. Refusing to release." >&2
exit 0 exit 1
fi fi
git tag -a "$TAG" -m "Version ${TAG#v}" git tag -a "$TAG" -m "Version ${TAG#v}"
git push origin "$TAG" 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 - name: Create GitHub release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -120,6 +194,70 @@ jobs:
name: ${{ steps.tag.outputs.tag }} name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true generate_release_notes: true
sync_beta:
name: Merge main back into beta via PR
needs:
- bump
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- 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
# 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
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: publish_docker:
name: Publish Docker image name: Publish Docker image
needs: needs:

111
docs/guides/RELEASING.md Normal file
View File

@ -0,0 +1,111 @@
# 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:
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
- 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 (Rulesets)
The release workflow uses PRs instead of direct pushes, so it works with strict branch protection. No bypass actors are required.
Recommended ruleset for `main`:
- 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
### 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.
### 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.
### Sync PR (`main -> beta`) fails
If the sync PR has merge conflicts:
- The workflow will fail after the release is published (artifacts are already out).
- Manually resolve conflicts in the sync PR and merge it.
### Leftover release branch
If the workflow fails mid-run, a `release/vX.Y.Z` branch may remain. Delete it manually before re-running:
```bash
git push origin --delete release/vX.Y.Z
```