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 alreadymain
parent
f32b62d616
commit
f314a2367a
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
Loading…
Reference in New Issue