-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrelease.sh
More file actions
executable file
·282 lines (229 loc) · 7.44 KB
/
release.sh
File metadata and controls
executable file
·282 lines (229 loc) · 7.44 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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage:
./release.sh patch
./release.sh minor
./release.sh major
./release.sh publish
EOF
exit 1
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: '$1' is required but was not found in PATH." >&2
exit 1
fi
}
ensure_clean_worktree() {
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: working tree must be clean before running release.sh." >&2
exit 1
fi
if [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
echo "Error: working tree must be clean before running release.sh." >&2
exit 1
fi
}
parse_package_version_from_stdin() {
node -e "
let input = '';
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
const pkg = JSON.parse(input);
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
console.error('Error: package.json is missing a version.');
process.exit(1);
}
process.stdout.write(pkg.version);
});
"
}
get_local_package_version() {
node -p "require('./package.json').version"
}
get_package_version_at_ref() {
local ref="$1"
git show "${ref}:package.json" | parse_package_version_from_stdin
}
calculate_next_version() {
local current_version="$1"
local release_type="$2"
node -e "
const currentVersion = process.argv[1];
const releaseType = process.argv[2];
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part) || part < 0)) {
console.error('Error: package.json version must use major.minor.patch format.');
process.exit(1);
}
let [major, minor, patch] = parts;
switch (releaseType) {
case 'patch':
patch += 1;
break;
case 'minor':
minor += 1;
patch = 0;
break;
case 'major':
major += 1;
minor = 0;
patch = 0;
break;
default:
console.error('Error: invalid release type.');
process.exit(1);
}
process.stdout.write([major, minor, patch].join('.'));
" "$current_version" "$release_type"
}
ensure_remote_tag_absent() {
local tag="$1"
if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then
echo "Error: tag '$tag' already exists locally." >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
echo "Error: tag '$tag' already exists on origin." >&2
exit 1
else
local remote_tag_status=$?
if [[ "$remote_tag_status" -ne 2 ]]; then
echo "Error: unable to verify whether tag '$tag' exists on origin." >&2
exit 1
fi
fi
}
ensure_release_absent() {
local tag="$1"
if gh release view "$tag" >/dev/null 2>&1; then
echo "Error: GitHub release '$tag' already exists." >&2
exit 1
fi
}
prepare_release() {
local release_type="$1"
local current_branch start_branch main_version next_version tag release_branch
local current_version new_version pr_url pr_title pr_body
# Avoid syncing tags here; stale or divergent local tags can make fetch fail,
# and remote tag existence is checked explicitly with git ls-remote below.
git fetch --no-tags origin main >/dev/null
main_version="$(get_package_version_at_ref "origin/main")"
next_version="$(calculate_next_version "$main_version" "$release_type")"
tag="v${next_version}"
release_branch="release/${tag}"
current_branch="$(git branch --show-current)"
if [[ -z "$current_branch" ]]; then
echo "Error: release.sh must be run from a branch, not detached HEAD." >&2
exit 1
fi
start_branch="$current_branch"
if git show-ref --verify --quiet "refs/heads/$release_branch"; then
echo "Error: local branch '$release_branch' already exists." >&2
exit 1
fi
if git ls-remote --exit-code --heads origin "$release_branch" >/dev/null 2>&1; then
echo "Error: remote branch '$release_branch' already exists." >&2
exit 1
else
local remote_branch_status=$?
if [[ "$remote_branch_status" -ne 2 ]]; then
echo "Error: unable to verify whether branch '$release_branch' exists on origin." >&2
exit 1
fi
fi
echo "Switching current worktree from ${start_branch} to ${release_branch} based on origin/main."
git switch -c "$release_branch" origin/main >/dev/null
current_branch="$release_branch"
current_version="$(get_local_package_version)"
if [[ "$current_version" != "$main_version" ]]; then
echo "Error: local package.json version does not match origin/main after switching to '$release_branch'." >&2
exit 1
fi
ensure_remote_tag_absent "$tag"
npm version "$release_type" --no-git-tag-version >/dev/null
new_version="$(get_local_package_version)"
if [[ "$new_version" != "$next_version" ]]; then
echo "Error: expected version '$next_version' but found '$new_version' after bump." >&2
exit 1
fi
if [[ -f "package-lock.json" ]]; then
git add package.json package-lock.json
else
git add package.json
fi
git commit -m "Bump version from ${current_version} to ${new_version}"
git push -u origin "HEAD:${current_branch}"
pr_url="$(
gh pr list --head "$current_branch" --base main --state open --json url --jq '.[0].url // empty'
)"
if [[ -z "$pr_url" ]]; then
pr_title="Bump version from ${current_version} to ${new_version}"
pr_body=$(
cat <<EOF
This PR prepares release v${new_version}.
After this PR is merged into \`main\`, run \`./release.sh publish\` from a clean checkout to create the tag and GitHub release.
EOF
)
pr_url="$(gh pr create --base main --head "$current_branch" --title "$pr_title" --body "$pr_body")"
echo "Created release PR: $pr_url"
else
echo "Updated existing release PR: $pr_url"
fi
echo "Release preparation complete for v${new_version} on branch ${current_branch}."
echo "Current worktree remains on ${current_branch}."
echo "After manual approval and merge into main, run ./release.sh publish."
}
publish_release() {
local main_sha main_version tag
# Avoid syncing tags here; stale or divergent local tags can make fetch fail,
# and remote tag existence is checked explicitly with git ls-remote below.
git fetch --no-tags origin main >/dev/null
main_sha="$(git rev-parse origin/main)"
main_version="$(get_package_version_at_ref "origin/main")"
tag="v${main_version}"
ensure_remote_tag_absent "$tag"
ensure_release_absent "$tag"
git tag -a "$tag" "$main_sha" -m "Release $tag"
git push origin "$tag"
if ! gh release create "$tag" --verify-tag --generate-notes; then
echo "Error: failed to create GitHub release '$tag'. The tag was pushed, so the release workflow may still create or update it." >&2
exit 1
fi
echo "Created $tag from origin/main at $main_sha."
}
if [[ $# -ne 1 ]]; then
usage
fi
release_action="$1"
case "$release_action" in
patch|minor|major|publish)
;;
*)
usage
;;
esac
require_command git
require_command npm
require_command node
require_command gh
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
if ! git remote get-url origin >/dev/null 2>&1; then
echo "Error: git remote 'origin' is not configured." >&2
exit 1
fi
ensure_clean_worktree
if ! gh auth status >/dev/null 2>&1; then
echo "Error: GitHub CLI is not authenticated. Run 'gh auth login' and try again." >&2
exit 1
fi
if [[ "$release_action" == "publish" ]]; then
publish_release
else
prepare_release "$release_action"
fi