Repository navigation
Expand file tree
/
Copy pathrelease.sh
More file actions
executable file
·372 lines (357 loc) · 19.3 KB
/
Copy pathrelease.sh
File metadata and controls
executable file
·372 lines (357 loc) · 19.3 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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
#!/usr/bin/env bash
# Cut a release of one artifact on main: bump its version, write its changelog
# section, then make a signed release commit and a signed tag and push both. There
# is no release PR; the tag push runs the artifact's release workflow.
#
# scripts/release.sh <node-collection|publisher-collection|chart>
# [auto|patch|minor|major|X.Y.Z]
# [--execute] [--no-verify] [--allow-unconventional]
#
# node-collection node-collection-vX.Y.Z ansible/galaxy/node/{galaxy.yml,CHANGELOG.md} + the example floor
# publisher-collection publisher-collection-vX.Y.Z ansible/galaxy/publisher/{galaxy.yml,CHANGELOG.md} + the example floor
# chart decdn-node-X.Y.Z charts/decdn-node/{Chart.yaml,CHANGELOG.md}
#
# A collection release also raises that collection's floor in
# examples/collection-fleet/requirements.yml to the new version, so a fleet copied
# from the example starts from it (make test-scripts checks they agree).
#
# Without --execute it is a dry run: it prints the new tag and the diff it would
# commit, and changes no file. The level defaults to `auto`, the bump git-cliff
# derives (cliff.toml) from the conventional commits since the artifact's last tag
# that touched what it ships: the roles in its galaxy/<collection>/roles.txt, the
# playbooks in its playbooks.txt with ansible/playbooks/group_vars/, that overlay,
# galaxy/build.sh, galaxy/stage-playbooks.py and LICENSE for a collection; the chart
# and monitoring/decdn-node/ for the chart.
# Before 1.0, a feature (or, from 0.1 on, a breaking change) bumps the minor and any
# other commit kept in the changelog bumps the patch. The section is git-cliff's
# rendering of those commits, under a `## [X.Y.Z] — YYYY-MM-DD` heading. A commit there
# that git-cliff cannot parse as a Conventional Commit would be dropped without a word,
# so the script has git-cliff list them (require_conventional) and refuses them unless
# --allow-unconventional.
#
# The first release (no tag yet, the manifest at the 0.0.0 placeholder) names its level
# and releases the changelog's hand-written `## [Unreleased]` section instead: without a
# previous tag, git-cliff would render the whole history. decdn.publisher depends on
# decdn.node, so each of its releases is refused until origin has a node-collection tag
# that satisfies the `decdn.node: ">=X.Y.Z"` its galaxy.yml declares (the publish job
# then checks Galaxy itself).
#
# --execute runs scripts/check-release-version.sh on the result (and, for the chart,
# scripts/chart-artifacthub-changes.py), then the artifact's make target (make
# galaxy-check-<collection> or make lint-helm) unless --no-verify: a tag that fails its workflow
# burns the version. If any of that or the commit fails, the files are restored. Run
# it on an up-to-date, clean main, with git configured to sign (user.signingkey), and
# with a bypass of the main ruleset (its PR rule and its required status checks): the
# release commit goes straight to main.
set -euo pipefail
usage() {
echo "usage: $0 <node-collection|publisher-collection|chart> [auto|patch|minor|major|X.Y.Z] [--execute] [--no-verify] [--allow-unconventional]" >&2
exit 2
}
die() { echo "release: $*" >&2; exit 1; }
artifact="" level="" execute=0 verify=1 allow_unconventional=0
for arg in "$@"; do
case "$arg" in
--execute) execute=1 ;;
--no-verify) verify=0 ;;
--allow-unconventional) allow_unconventional=1 ;;
-*) usage ;;
*)
if [[ -z "$artifact" ]]; then artifact="$arg"
elif [[ -z "$level" ]]; then level="$arg"
else usage
fi ;;
esac
done
level="${level:-auto}"
[[ "$level" =~ ^(auto|patch|minor|major|[0-9]+\.[0-9]+\.[0-9]+)$ ]] || usage
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo"
example=""
case "$artifact" in
node-collection|publisher-collection)
collection="${artifact%-collection}"
overlay="ansible/galaxy/$collection"
prefix="$artifact-v"
manifest="$overlay/galaxy.yml"
changelog="$overlay/CHANGELOG.md"
example="examples/collection-fleet/requirements.yml"
workflow="release-collection.yml"
check=(make -C ansible "galaxy-check-$collection")
# What `galaxy/build.sh <collection>` ships: the roles in its roles.txt, the
# playbooks in its playbooks.txt with their group_vars/, its overlay (the overlay
# playbooks/ included), and LICENSE; build.sh and stage-playbooks.py themselves
# too. The lists are the ones build.sh reads, so an entry added there is counted
# here too.
[[ -f "$overlay/roles.txt" ]] || die "$overlay/roles.txt is missing"
[[ -f "$overlay/playbooks.txt" ]] || die "$overlay/playbooks.txt is missing"
roles=()
while IFS= read -r role; do roles+=("$role"); done < <(grep -vE '^[[:space:]]*(#|$)' "$overlay/roles.txt")
((${#roles[@]} > 0)) || die "$overlay/roles.txt lists no role"
paths=("$overlay/**" 'ansible/galaxy/build.sh' 'ansible/galaxy/stage-playbooks.py'
'ansible/playbooks/group_vars/**' 'LICENSE')
for role in "${roles[@]}"; do paths+=("ansible/roles/$role/**"); done
playbooks=()
while IFS= read -r pb; do playbooks+=("$pb"); done < <(grep -vE '^[[:space:]]*(#|$)' "$overlay/playbooks.txt")
((${#playbooks[@]} > 0)) || die "$overlay/playbooks.txt lists no playbook"
for pb in "${playbooks[@]}"; do paths+=("ansible/playbooks/$pb"); done
;;
chart)
prefix="decdn-node-"
manifest="charts/decdn-node/Chart.yaml"
changelog="charts/decdn-node/CHANGELOG.md"
workflow="release-chart.yml"
check=(make lint-helm)
# The chart packages monitoring/decdn-node/ through its files/monitoring symlink.
paths=('charts/decdn-node/**' 'monitoring/decdn-node/**')
;;
*) usage ;;
esac
[[ -f "$manifest" && -f "$changelog" ]] || die "$manifest or $changelog is missing"
# What the release commit changes.
files=("$manifest" "$changelog")
if [[ -n "$example" ]]; then
[[ -f "$example" ]] || die "$example is missing"
files+=("$example")
fi
tmp="$(mktemp -d)"
written=0 committed=0 pushed=0 reported=0
cleanup() {
# Once --execute has written the files, any failure before the release commit
# exists puts them back. Nothing is restored before that: a refusal never touches
# the operator's own edits.
if ((written == 1 && committed == 0)); then
git checkout -q -- "${files[@]}" \
|| echo "release: could not restore ${files[*]}: run git checkout -- ${files[*]}" >&2
fi
# Stopped (Ctrl-C, say) between the release commit and the end of the push.
if ((committed == 1 && pushed == 0 && reported == 0)); then
echo "release: stopped after the release commit, before the push finished. Check what origin has:" >&2
echo " git ls-remote origin refs/heads/main refs/tags/$tag" >&2
echo "then, if it has neither, undo locally: git tag -d $tag; git reset --hard origin/main" >&2
fi
rm -rf "$tmp"
}
trap cleanup EXIT
# --- preflight ---------------------------------------------------------------------
branch="$(git symbolic-ref --quiet --short HEAD || true)"
[[ "$branch" == "main" ]] || die "releases are cut on main, not '${branch:-a detached HEAD}'"
status="$(git status --porcelain)" || die "git status failed"
[[ -z "$status" ]] || die "the working tree is not clean (git status)"
git fetch --quiet --tags origin main || die "git fetch origin failed"
[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]] \
|| die "main is not origin/main: pull or push first"
if ((execute == 1)); then
[[ -n "$(git config user.signingkey || true)" ]] \
|| die "git has no user.signingkey: the release commit and tag are signed"
# Sign a throwaway commit object now, not after the checks: an unusable key would
# otherwise fail only at the release commit.
git commit-tree -S -p HEAD -m "release.sh signing check" "HEAD^{tree}" >/dev/null 2>"$tmp/sign.err" \
|| { cat "$tmp/sign.err" >&2; die "git cannot sign with user.signingkey"; }
fi
ver_gt() { [[ "$1" != "$2" && "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" == "$1" ]]; }
tag_re="^${prefix}[0-9]+\.[0-9]+\.[0-9]+$"
# <tag names on stdin>: the highest X.Y.Z among this artifact's tags, or nothing.
highest() { { grep -E "$tag_re" || (($? == 1)); } | sed "s/^$prefix//" | sort -V | tail -n1; }
# The last release is origin's highest tag. A different local one (a tag left by a
# failed push, say) would move git-cliff's boundary, so it is refused.
remote_tags="$(git ls-remote --tags --refs origin "refs/tags/${prefix}*")" || die "git ls-remote origin failed"
remote_tags="$(awk '{sub(/^refs\/tags\//, "", $2); print $2}' <<<"$remote_tags")"
local_tags="$(git tag --list "${prefix}*")" || die "git tag failed"
prev="$(highest <<<"$remote_tags")"
local_prev="$(highest <<<"$local_tags")"
[[ "$local_prev" == "$prev" ]] \
|| die "the highest local tag, $prefix${local_prev:-?}, is not origin's (${prev:+$prefix}${prev:-none}): delete it with git tag -d"
current="$(sed -nE 's/^version:[[:space:]]*"?([^"#[:space:]]+)"?.*/\1/p' "$manifest" | head -n1)"
[[ -n "$current" ]] || die "$manifest has no top-level version: line"
today="$(date -u +%F)"
# Every decdn.publisher release must be installable: the decdn.node version its
# galaxy.yml requires has to be released already (origin's node-collection tags).
if [[ "$artifact" == "publisher-collection" ]]; then
need="$(sed -nE 's/^[[:space:]]+decdn\.node:[[:space:]]*">=([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$manifest")"
[[ -n "$need" ]] || die "$manifest declares no decdn.node: \">=X.Y.Z\" dependency"
node_tags="$(git ls-remote --tags --refs origin 'refs/tags/node-collection-v*')" || die "git ls-remote origin failed"
node_prev="$(awk '{sub(/^refs\/tags\//, "", $2); print $2}' <<<"$node_tags" \
| { grep -E '^node-collection-v[0-9]+\.[0-9]+\.[0-9]+$' || (($? == 1)); } | sed 's/^node-collection-v//' | sort -V | tail -n1)"
if [[ -z "$node_prev" ]] || ver_gt "$need" "$node_prev"; then
die "decdn.publisher needs decdn.node >=$need, and origin's highest node-collection tag is ${node_prev:-none}: release node-collection first"
fi
fi
# --- version and section -----------------------------------------------------------
if [[ -z "$prev" ]]; then
# First release: the hand-written [Unreleased] section becomes the release's.
[[ "$current" == "0.0.0" ]] || die "no ${prefix}X.Y.Z tag yet, but $manifest is $current, not the 0.0.0 placeholder"
case "$level" in
auto) die "the first release names its level: minor for 0.1.0" ;;
patch) version="0.0.1" ;;
minor) version="0.1.0" ;;
major) version="1.0.0" ;;
*) version="$level" ;;
esac
ver_gt "$version" "0.0.0" || die "$version is not above 0.0.0"
grep -qx '## \[Unreleased\]' "$changelog" \
|| die "$changelog has no '## [Unreleased]' section for the first release"
awk -v h="## [$version] — $today" '$0 == "## [Unreleased]" {print h; next} {print}' \
"$changelog" > "$tmp/changelog"
awk -v h="## [$version] — $today" '
$0 == h {on=1; next}
on && (/^## \[/ || /^<!--/ || /^\[[^]]+\]: /) {exit}
on' "$tmp/changelog" > "$tmp/section"
grep -q '^- ' "$tmp/section" || die "$changelog: the [Unreleased] section has no '- ' entries"
else
command -v git-cliff >/dev/null || die "git-cliff is not installed (cargo install git-cliff)"
cliff_version="$(git-cliff --version)" || die "git-cliff --version failed"
cliff_version="$(awk '{print $2}' <<<"$cliff_version")"
# 2.9 added require_conventional, which the unconventional-commit refusal below needs.
ver_gt "2.9.0" "$cliff_version" && die "git-cliff 2.9 or later is required, not ${cliff_version:-unknown}"
[[ "$current" == "$prev" ]] \
|| die "$manifest is $current, but the last tag is $prefix$prev: fix the manifest first"
if grep -q '^## \[Unreleased\]' "$changelog"; then
die "$changelog has an [Unreleased] section: sections are generated since the first release, so turn its entries into commit subjects or drop it"
fi
include=()
for p in "${paths[@]}"; do include+=(--include-path "$p"); done
# Every run is --unreleased: the commits since the last tag. (git-cliff's
# require_conventional would otherwise judge the whole history.)
cliff() {
git-cliff --config "$repo/cliff.toml" --tag-pattern "$tag_re" "${include[@]}" --unreleased "$@" 2>"$tmp/cliff.err" \
|| { cat "$tmp/cliff.err" >&2; die "git-cliff failed"; }
}
# git-cliff drops a commit it cannot parse as a Conventional Commit (cliff.toml,
# filter_unconventional) and only says so in a log line, so the bump and the section
# would leave it out unseen: an empty scope, say, or a body right under the subject.
# With require_conventional it fails instead, naming each one.
if ! GIT_CLIFF__GIT__REQUIRE_CONVENTIONAL=true git-cliff --config "$repo/cliff.toml" --tag-pattern "$tag_re" \
"${include[@]}" --unreleased --bumped-version >/dev/null 2>"$tmp/cliff.err"; then
esc="$(printf '\033')"
sed "s/$esc\[[0-9;]*m//g" "$tmp/cliff.err" > "$tmp/cliff.plain" # git-cliff colours its log even off a tty
grep -q 'UnconventionalCommitsError' "$tmp/cliff.plain" || { cat "$tmp/cliff.err" >&2; die "git-cliff failed"; }
echo "release: git-cliff cannot parse these commits that touched the $artifact as Conventional Commits, so its changelog and bump leave them out:" >&2
# "Commit <hash> is not conventional:", then the message, one "| " line per line.
awk '/Commit [0-9a-f]+ is not conventional:/ {match($0, /Commit [0-9a-f]+/); h=substr($0, RSTART+7, RLENGTH-7); next}
h != "" && sub(/^[[:space:]]*\| /, "") {print " " h " " $0; h=""}' "$tmp/cliff.plain" >&2
((allow_unconventional == 1)) || die "refusing; re-run with --allow-unconventional to release without them"
fi
case "$level" in
auto) bumped="$(cliff --bumped-version)" ;;
patch|minor|major) bumped="$(cliff --bump "$level" --bumped-version)" ;;
*) bumped="$prefix$level" ;;
esac
[[ "$bumped" =~ $tag_re ]] || die "git-cliff proposed '$bumped', not a ${prefix}X.Y.Z tag"
version="${bumped#"$prefix"}"
# Squeeze git-cliff's blank lines and trim the ends.
cliff --strip all | cat -s | sed '/./,$!d' | sed '${/^$/d;}' > "$tmp/section"
# Every heading must be a Keep a Changelog kind: the chart's section becomes
# artifacthub.io/changes, and chart-artifacthub-changes.py refuses anything else.
bad="$(grep '^### ' "$tmp/section" | grep -vxE '### (Added|Changed|Deprecated|Removed|Fixed|Security)' || true)"
[[ -z "$bad" ]] || die "git-cliff rendered a heading that is not a Keep a Changelog kind: $bad (fix cliff.toml's commit_parsers)"
if [[ "$level" == [0-9]* ]]; then
ver_gt "$version" "$prev" || die "$version is not above the last release, $prev"
fi
if [[ "$version" == "$prev" ]] || ! grep -q '^- ' "$tmp/section"; then
die "nothing to release: no commit since $prefix$prev that touched ${paths[*]} makes a changelog entry (ci, test, style, build and release commits do not, nor, with --allow-unconventional, unconventional ones)"
fi
ver_gt "$version" "$prev" || die "$version is not above the last release, $prev"
awk -v h="## [$version] — $today" -v s="$tmp/section" '
!done && /^## \[/ {
print h; print ""
while ((getline l < s) > 0) print l
print ""; done=1
}
{print}
END {
if (!done) {
print ""; print h; print ""
while ((getline l < s) > 0) print l
}
}' "$changelog" > "$tmp/changelog"
fi
tag="$prefix$version"
git rev-parse -q --verify "refs/tags/$tag" >/dev/null && die "tag $tag already exists"
! grep -qx "$tag" <<<"$remote_tags" || die "tag $tag already exists on origin"
awk -v v="$version" '
!done && /^version:/ { sub(/^version:[[:space:]]*"?[^"#[:space:]]+"?/, "version: " v); done=1 }
{print}' "$manifest" > "$tmp/manifest"
rc=0
cmp -s "$manifest" "$tmp/manifest" || rc=$?
((rc == 1)) || die "could not set the version in $manifest"
if [[ -n "$example" ]]; then
# The `version:` under the collection's `- name:` entry, and only that one.
awk -v name="decdn.$collection" -v v="$version" '
/^[[:space:]]*- name:/ { mine = ($3 == name) }
mine && /^[[:space:]]+version:/ { sub(/version:.*/, "version: \">=" v "\""); mine = 0; n++ }
{print}
END {print n + 0 > "/dev/stderr"; exit n == 1 ? 0 : 1}' "$example" > "$tmp/example" 2>"$tmp/example.n" \
|| die "$example has $(cat "$tmp/example.n") version: lines under decdn.$collection, not one"
rc=0
cmp -s "$example" "$tmp/example" || rc=$?
((rc != 0)) || die "$example already says decdn.$collection >=$version: was it edited by hand?"
((rc == 1)) || die "could not compare $example with its new floor"
fi
show_diff() { # <file> <new copy>
local rc=0
diff -u --label "a/$1" --label "b/$1" "$1" "$2" || rc=$?
((rc <= 1)) || die "diff failed on $1"
}
echo "release $artifact: ${prev:+$prefix$prev -> }$tag"
echo
show_diff "$manifest" "$tmp/manifest"
show_diff "$changelog" "$tmp/changelog"
[[ -z "$example" ]] || show_diff "$example" "$tmp/example"
echo
# Until PUBLISH_ENABLED is true, a tag push only builds (RELEASING.md), but the version
# is used up all the same: say so before it is.
publish=""
command -v gh >/dev/null && publish="$(GH_PROMPT_DISABLED=1 gh variable get PUBLISH_ENABLED 2>/dev/null || true)"
if [[ "$publish" != "true" ]]; then
echo "note: PUBLISH_ENABLED is not true (or gh cannot read it), so the $tag push builds it and publishes nothing"
echo
fi
if ((execute == 0)); then
echo "dry run: no file changed. --execute would run:"
echo " scripts/check-release-version.sh $tag"
[[ "$artifact" == "chart" ]] && echo " scripts/chart-artifacthub-changes.py $changelog $version"
((verify == 1)) && echo " ${check[*]}"
echo " git commit -S -m 'chore(release): $tag' -- ${files[*]}"
echo " git tag -s $tag -m $tag"
echo " git push --atomic origin main $tag"
exit 0
fi
# --- execute -----------------------------------------------------------------------
written=1
cp "$tmp/manifest" "$manifest"
cp "$tmp/changelog" "$changelog"
[[ -z "$example" ]] || cp "$tmp/example" "$example"
scripts/check-release-version.sh "$tag" || die "the release gate refused $tag: nothing was committed"
if [[ "$artifact" == "chart" ]]; then
scripts/chart-artifacthub-changes.py "$changelog" "$version" >/dev/null \
|| die "chart-artifacthub-changes.py refused the section: nothing was committed"
fi
if ((verify == 1)); then
"${check[@]}" || die "${check[*]} failed: nothing was committed"
fi
git commit --quiet -S -m "chore(release): $tag" -- "${files[@]}" \
|| die "git commit failed (a pre-commit hook, or signing): nothing was committed"
committed=1
git tag -s "$tag" -m "$tag" || { reported=1; die "tagging failed after the release commit. To undo: git reset --hard origin/main"; }
if ! git push --atomic origin "HEAD:refs/heads/main" "refs/tags/$tag"; then
# --atomic makes a rejected push all-or-nothing, but a dropped connection can leave
# it unknown: look before suggesting an undo.
if seen="$(git ls-remote origin refs/heads/main "refs/tags/$tag")" \
&& ! grep -q "refs/tags/$tag$" <<<"$seen" \
&& ! grep -q "^$(git rev-parse HEAD)[[:space:]]refs/heads/main$" <<<"$seen"; then
echo "release: the push failed; nothing reached origin. To undo locally:" >&2
echo " git tag -d $tag && git reset --hard origin/main" >&2
else
echo "release: the push failed, and origin may have the release commit or $tag:" >&2
echo " check git ls-remote origin before undoing anything" >&2
fi
reported=1
exit 1
fi
pushed=1
if [[ "$publish" == "true" ]]; then
echo "released $tag. Watch it: gh run list --workflow $workflow"
else
echo "tagged $tag, but PUBLISH_ENABLED is not true: $workflow builds it and publishes nothing. Watch it: gh run list --workflow $workflow"
fi