Skip to content

Commit a0c22ca

Browse files
authored
ci: Automated canary release pipeline (#11618)
## Summary Adds an automated canary release pipeline that publishes to npm whenever PRs merge to `main`. This eliminates manual release steps for pre-release versions while maintaining the existing manual workflow for stable releases. - New `turborepo-canary.yml` workflow triggers on push to main - Refactored `turborepo-release.yml` to support `workflow_call` for reuse - Skip detection prevents infinite loops when release PRs merge back - Comprehensive documentation in `RELEASE.md` ## Key Changes **New Canary Workflow:** - Triggers on push to `main` when `crates/**`, `packages/**`, `cli/**`, or `.github/**` change - Skips when the push is from a release PR merge (prevents infinite loops) - Uses GitHub's concurrency to queue/squash rapid merges - Creates auto-merging PRs to land version bumps **Release Workflow Refactoring:** - Added `workflow_call` trigger with inputs/outputs for reuse - Added `is-canary` flag to differentiate canary vs manual releases - Exports `stage-branch`, `version`, `previous-tag`, `docs-url` for caller **Security Hardening:** - Fixed script injection by using environment variables instead of direct `${{ }}` interpolation - Added version format validation to prevent command injection - Improved error handling with proper URL validation **Documentation:** - Added comprehensive release process documentation - Added Troubleshooting & Recovery section covering failure scenarios - Added Security Considerations section ## Testing To verify the canary workflow: 1. Trigger manually with `dry_run: true` to test without publishing 2. Merge a small PR to `main` and observe the canary workflow ## Reviewer Notes The skip detection relies on matching `github.actor == "github-actions[bot]"` AND commit message starting with `release(turborepo):`. This prevents infinite loops when the auto-merge PR lands.
1 parent f725c62 commit a0c22ca

4 files changed

Lines changed: 572 additions & 129 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Canary Release Pipeline
2+
#
3+
# Automatically publishes a canary release when PRs merge to main.
4+
#
5+
# Key behaviors:
6+
# - Triggers on push to main (when relevant paths change)
7+
# - Skips if the push is from a release PR merge (bot actor + release commit message)
8+
# - Uses GitHub's concurrency to queue/squash multiple rapid merges
9+
# - Calls the release workflow, then opens an auto-merging PR
10+
11+
name: Canary Release
12+
13+
permissions:
14+
id-token: write
15+
contents: write
16+
pull-requests: write
17+
18+
on:
19+
push:
20+
branches: [main]
21+
paths:
22+
- "crates/**"
23+
- "packages/**"
24+
- "cli/**"
25+
- ".github/workflows/**"
26+
- ".github/actions/**"
27+
28+
concurrency:
29+
group: canary-release
30+
cancel-in-progress: false
31+
32+
jobs:
33+
check-skip:
34+
name: "Check Skip Conditions"
35+
runs-on: ubuntu-latest
36+
outputs:
37+
should_skip: ${{ steps.check.outputs.should_skip }}
38+
steps:
39+
- name: Check if release PR merge
40+
id: check
41+
env:
42+
ACTOR: ${{ github.actor }}
43+
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
44+
run: |
45+
if [[ "$ACTOR" == "github-actions[bot]" && "$COMMIT_MESSAGE" =~ ^release\(turborepo\): ]]; then
46+
echo "Skipping: This is a release PR merge"
47+
echo "should_skip=true" >> $GITHUB_OUTPUT
48+
else
49+
echo "should_skip=false" >> $GITHUB_OUTPUT
50+
fi
51+
52+
release:
53+
needs: [check-skip]
54+
if: ${{ needs.check-skip.outputs.should_skip != 'true' }}
55+
uses: ./.github/workflows/turborepo-release.yml
56+
with:
57+
increment: prerelease
58+
is-canary: true
59+
secrets: inherit
60+
61+
create-canary-pr:
62+
name: "Open Canary Release PR"
63+
needs: [check-skip, release]
64+
if: ${{ needs.check-skip.outputs.should_skip != 'true' }}
65+
runs-on: ubuntu-latest
66+
timeout-minutes: 30
67+
steps:
68+
- uses: actions/checkout@v4
69+
with:
70+
ref: ${{ needs.release.outputs.stage-branch }}
71+
fetch-depth: 0
72+
73+
- name: Fetch main and tags
74+
run: git fetch origin main --tags
75+
76+
- name: Validate version format
77+
env:
78+
VERSION: ${{ needs.release.outputs.version }}
79+
run: |
80+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
81+
echo "::error::Invalid version format: $VERSION"
82+
exit 1
83+
fi
84+
85+
- name: Build PR Body
86+
env:
87+
PREVIOUS_TAG: ${{ needs.release.outputs.previous-tag }}
88+
VERSION: ${{ needs.release.outputs.version }}
89+
DOCS_URL: ${{ needs.release.outputs.docs-url }}
90+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91+
run: |
92+
echo "## Canary Release" > pr-body.md
93+
echo "" >> pr-body.md
94+
95+
if [ -n "$DOCS_URL" ]; then
96+
echo "Versioned docs: ${DOCS_URL}" >> pr-body.md
97+
echo "" >> pr-body.md
98+
fi
99+
100+
echo "### Included Changes" >> pr-body.md
101+
echo "" >> pr-body.md
102+
103+
if [ -n "$PREVIOUS_TAG" ]; then
104+
git log ${PREVIOUS_TAG}..origin/main --pretty=format:"%H %s" | while read -r line; do
105+
SHA=$(echo "$line" | cut -d' ' -f1)
106+
SHORT_SHA=$(echo "$SHA" | cut -c1-7)
107+
MESSAGE=$(echo "$line" | cut -d' ' -f2-)
108+
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number' 2>/dev/null || echo "")
109+
110+
if [ -n "$PR_NUM" ]; then
111+
echo "- ${SHORT_SHA} - ${MESSAGE} (#${PR_NUM})" >> pr-body.md
112+
else
113+
echo "- ${SHORT_SHA} - ${MESSAGE}" >> pr-body.md
114+
fi
115+
done
116+
else
117+
echo "No previous tag found. This is the first canary release." >> pr-body.md
118+
fi
119+
120+
echo "" >> pr-body.md
121+
echo "---" >> pr-body.md
122+
echo "Release PR for turborepo v${VERSION}" >> pr-body.md
123+
124+
- name: Create pull request with auto-merge
125+
env:
126+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127+
VERSION: ${{ needs.release.outputs.version }}
128+
STAGE_BRANCH: ${{ needs.release.outputs.stage-branch }}
129+
run: |
130+
MAX_RETRIES=3
131+
RETRY_DELAY=10
132+
133+
for i in $(seq 1 $MAX_RETRIES); do
134+
if PR_URL=$(gh pr create \
135+
--title "release(turborepo): ${VERSION}" \
136+
--body-file pr-body.md \
137+
--head "${STAGE_BRANCH}" \
138+
--base main 2>&1); then
139+
break
140+
fi
141+
echo "PR creation attempt $i failed, retrying in ${RETRY_DELAY}s..."
142+
sleep $RETRY_DELAY
143+
done
144+
145+
if [ -z "$PR_URL" ] || [[ ! "$PR_URL" =~ ^https://github.com/.*/pull/[0-9]+$ ]]; then
146+
echo "::error::Failed to create PR after $MAX_RETRIES attempts. Output: $PR_URL"
147+
exit 1
148+
fi
149+
150+
echo "Created PR: $PR_URL"
151+
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
152+
153+
MERGE_SUCCESS=false
154+
for i in $(seq 1 $MAX_RETRIES); do
155+
if gh pr merge "$PR_NUM" --auto --squash; then
156+
MERGE_SUCCESS=true
157+
break
158+
fi
159+
echo "Auto-merge attempt $i failed, retrying in ${RETRY_DELAY}s..."
160+
sleep $RETRY_DELAY
161+
done
162+
163+
if [ "$MERGE_SUCCESS" != "true" ]; then
164+
echo "::warning::Failed to enable auto-merge after $MAX_RETRIES attempts. PR created but requires manual merge: $PR_URL"
165+
fi

0 commit comments

Comments
 (0)