-
Notifications
You must be signed in to change notification settings - Fork 1
235 lines (214 loc) · 8.45 KB
/
auto-release.yml
File metadata and controls
235 lines (214 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
name: Auto Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
first_release:
description: "Bootstrap: publish the version currently in pyproject.toml. Bypasses the auto:release label gate and skips version bumping. Use only for the first release or manual recovery."
type: boolean
default: false
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false
env:
PYTHON_VERSION: "3.11"
UV_VERSION: "0.7.13"
AUTO_VERSION: "11.3.6"
RELEASE_BOT_NAME: "github-actions[bot]"
RELEASE_BOT_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"
jobs:
gate:
name: Gate on merged PR label
runs-on: ubuntu-latest
# Prevent infinite loops from the bot's "chore(release)" commit.
if: github.actor != 'github-actions[bot]'
outputs:
should_release: ${{ steps.find_pr.outputs.should_release }}
pr_number: ${{ steps.find_pr.outputs.pr_number }}
steps:
- name: Find merged PR and check labels
id: find_pr
uses: actions/github-script@v7
env:
FIRST_RELEASE: ${{ github.event.inputs.first_release }}
with:
script: |
// Manual bootstrap: bypass the PR label check entirely.
if (context.eventName === 'workflow_dispatch' && process.env.FIRST_RELEASE === 'true') {
core.notice('Manual first_release=true; bypassing PR label check.');
core.setOutput('should_release', 'true');
core.setOutput('pr_number', '');
return;
}
const { owner, repo } = context.repo;
const sha = context.sha;
const maxAttempts = 6;
let pulls;
// GitHub can briefly lag in associating a merge commit with its PR, so retry.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, repo, commit_sha: sha,
});
if (pulls.data.length) break;
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, 10000));
}
}
if (!pulls.data || !pulls.data.length) {
core.notice(`No PR associated with ${sha}. Not releasing.`);
core.setOutput('should_release', 'false');
core.setOutput('pr_number', '');
return;
}
const pr = pulls.data.find((p) => p.merged_at && p.base?.ref === 'main') ?? pulls.data[0];
const labels = (pr.labels || []).map((l) => l.name);
const should = labels.includes('auto:release');
core.setOutput('pr_number', String(pr.number));
core.setOutput('should_release', should ? 'true' : 'false');
core.notice(`PR #${pr.number} labels: ${labels.join(', ')}`);
core.notice(`should_release=${should}`);
release:
name: Tag, release, and publish
runs-on: ubuntu-latest
needs: gate
if: needs.gate.outputs.should_release == 'true'
env:
# 'true' for manual bootstrap runs; 'false' or empty otherwise.
FIRST_RELEASE: ${{ github.event.inputs.first_release || 'false' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
fetch-tags: true
# RELEASE_PAT (a PAT with repo + workflow scopes) lets the bot push to
# protected branches and trigger other workflows. Falls back to
# GITHUB_TOKEN for repos without branch protection.
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
python-version: ${{ env.PYTHON_VERSION }}
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install auto
run: |
set -euo pipefail
curl -L "https://github.com/intuit/auto/releases/download/v${AUTO_VERSION}/auto-linux.gz" -o auto-linux.gz
gunzip auto-linux.gz
chmod +x auto-linux
sudo mv auto-linux /usr/local/bin/auto
auto --version
- name: Sanity build (pre-tag)
run: uv build
- name: Capture previous tag
id: previous_tag
run: |
set -euo pipefail
echo "tag=$(git describe --tags --abbrev=0 2>/dev/null || echo '')" >> "$GITHUB_OUTPUT"
- name: Resolve next version (auto)
id: resolve_version
if: env.FIRST_RELEASE != 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RAW_VERSION="$(auto shipit --name "${RELEASE_BOT_NAME}" --email "${RELEASE_BOT_EMAIL}" --dry-run --quiet | tail -n1 | tr -d '\r')"
VERSION="${RAW_VERSION#v}"
if [ -z "$VERSION" ]; then
echo "Could not resolve release version from auto."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved version: $VERSION"
- name: Apply version to pyproject.toml
if: env.FIRST_RELEASE != 'true'
run: |
set -euo pipefail
VERSION="${{ steps.resolve_version.outputs.version }}"
sed -i "s/^version = \".*\"$/version = \"${VERSION}\"/" pyproject.toml
grep '^version = ' pyproject.toml
- name: Commit version bump
if: env.FIRST_RELEASE != 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if git diff --quiet -- pyproject.toml; then
echo "No pyproject version change to commit."
else
git config user.name "${RELEASE_BOT_NAME}"
git config user.email "${RELEASE_BOT_EMAIL}"
git add pyproject.toml
# [skip ci] avoids re-running this workflow on the bot's own commit.
git commit -m "chore(release): v${{ steps.resolve_version.outputs.version }} [skip ci]"
git push origin HEAD:main
fi
- name: Read final version from pyproject.toml
id: final_version
run: |
set -euo pipefail
VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)".*/\1/')"
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Final version: $VERSION"
- name: Capture release commit SHA
id: release_commit
run: |
set -euo pipefail
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Create labels (idempotent)
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
run: auto create-labels
- name: Create and push tag
run: |
set -euo pipefail
TAG="v${{ steps.final_version.outputs.version }}"
TARGET_SHA="${{ steps.release_commit.outputs.sha }}"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
EXISTING_SHA="$(git rev-list -n1 "$TAG")"
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG already exists at $TARGET_SHA. Skipping."
exit 0
fi
echo "Tag $TAG exists at $EXISTING_SHA but expected $TARGET_SHA."
exit 1
fi
git tag "$TAG" "$TARGET_SHA"
git push origin "$TAG"
- name: Create GitHub release with auto notes
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--to "${{ steps.release_commit.outputs.sha }}" --use-version "v${{ steps.final_version.outputs.version }}")
if [ -n "${{ steps.previous_tag.outputs.tag }}" ]; then
args=(--from "${{ steps.previous_tag.outputs.tag }}" "${args[@]}")
fi
auto release "${args[@]}"
- name: Build final artifacts
run: |
set -euo pipefail
rm -rf dist
uv build
- name: Publish to PyPI
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI }}
run: uv publish