Skip to content

Commit 8855008

Browse files
Add manual release tag workflow
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f5ab1b4 commit 8855008

3 files changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Create release tag
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
release_type:
7+
description: Release type to create
8+
required: true
9+
type: choice
10+
options:
11+
- fix
12+
- minor
13+
- major
14+
- prerelease
15+
- freeform
16+
prerelease_base:
17+
description: Base bump for prereleases
18+
required: false
19+
default: minor
20+
type: choice
21+
options:
22+
- fix
23+
- minor
24+
- major
25+
prerelease_channel:
26+
description: Prerelease channel
27+
required: false
28+
default: beta
29+
type: choice
30+
options:
31+
- alpha
32+
- beta
33+
- rc
34+
- canary
35+
- next
36+
- custom
37+
custom_prerelease_channel:
38+
description: Custom prerelease channel, used when prerelease_channel is custom
39+
required: false
40+
type: string
41+
freeform_version:
42+
description: Exact semver version/tag, used when release_type is freeform
43+
required: false
44+
type: string
45+
major_confirmation:
46+
description: Exact computed tag required for major releases, for example v2.0.0
47+
required: false
48+
type: string
49+
dry_run:
50+
description: Compute and summarize without pushing a tag
51+
required: false
52+
default: false
53+
type: boolean
54+
55+
permissions:
56+
contents: read
57+
58+
concurrency:
59+
group: create-release-tag
60+
cancel-in-progress: false
61+
62+
jobs:
63+
create-tag:
64+
runs-on: ubuntu-latest
65+
steps:
66+
- name: Code checkout
67+
uses: actions/checkout@v4
68+
with:
69+
fetch-depth: 0
70+
token: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
71+
72+
- name: Fetch tags
73+
run: git fetch --force --tags origin
74+
75+
- name: Configure git author
76+
run: |
77+
git config user.name "algolia-ci"
78+
git config user.email "noreply@algolia.com"
79+
80+
- name: Create release tag
81+
env:
82+
RELEASE_TYPE: ${{ inputs.release_type }}
83+
PRERELEASE_BASE: ${{ inputs.prerelease_base }}
84+
PRERELEASE_CHANNEL: ${{ inputs.prerelease_channel }}
85+
CUSTOM_PRERELEASE_CHANNEL: ${{ inputs.custom_prerelease_channel }}
86+
FREEFORM_VERSION: ${{ inputs.freeform_version }}
87+
MAJOR_CONFIRMATION: ${{ inputs.major_confirmation }}
88+
DRY_RUN: ${{ inputs.dry_run }}
89+
run: ./scripts/create-release-tag.sh

.github/workflows/releases.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@ jobs:
5353
VERSION: ${{ github.ref_name }}
5454
run: ./scripts/npm-publish.sh
5555
- name: Docs checkout
56+
if: ${{ !contains(github.ref_name, '-') }}
5657
uses: actions/checkout@v4
5758
with:
5859
repository: algolia/docs-new
5960
path: docs
6061
fetch-depth: 0
6162
token: ${{secrets.GORELEASER_GITHUB_TOKEN}}
6263
- name: Update docs
64+
if: ${{ !contains(github.ref_name, '-') }}
6365
env:
6466
GIT_COMMITTER_NAME: algolia-ci
6567
GIT_AUTHOR_NAME: algolia-ci

scripts/create-release-tag.sh

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
RELEASE_TYPE="${RELEASE_TYPE:-}"
5+
PRERELEASE_BASE="${PRERELEASE_BASE:-minor}"
6+
PRERELEASE_CHANNEL="${PRERELEASE_CHANNEL:-beta}"
7+
CUSTOM_PRERELEASE_CHANNEL="${CUSTOM_PRERELEASE_CHANNEL:-}"
8+
FREEFORM_VERSION="${FREEFORM_VERSION:-}"
9+
MAJOR_CONFIRMATION="${MAJOR_CONFIRMATION:-}"
10+
DRY_RUN="${DRY_RUN:-false}"
11+
12+
SEMVER_RE='^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
13+
STABLE_TAG_RE='^v([0-9]+)\.([0-9]+)\.([0-9]+)$'
14+
15+
fail() {
16+
echo "Error: $*" >&2
17+
exit 1
18+
}
19+
20+
is_true() {
21+
case "$1" in
22+
true | TRUE | True | 1 | yes | YES | Yes) return 0 ;;
23+
*) return 1 ;;
24+
esac
25+
}
26+
27+
validate_release_type() {
28+
case "$RELEASE_TYPE" in
29+
fix | minor | major | prerelease | freeform) ;;
30+
"") fail "RELEASE_TYPE is required." ;;
31+
*) fail "Unsupported RELEASE_TYPE '$RELEASE_TYPE'." ;;
32+
esac
33+
}
34+
35+
validate_base() {
36+
case "$PRERELEASE_BASE" in
37+
fix | minor | major) ;;
38+
*) fail "Unsupported PRERELEASE_BASE '$PRERELEASE_BASE'." ;;
39+
esac
40+
}
41+
42+
normalize_freeform_tag() {
43+
local version="$1"
44+
45+
[[ -n "$version" ]] || fail "FREEFORM_VERSION is required when RELEASE_TYPE is freeform."
46+
[[ "$version" =~ $SEMVER_RE ]] || fail "FREEFORM_VERSION must be semver, for example v1.12.0 or v1.12.0-beta.1; got '$version'."
47+
48+
version="${version#v}"
49+
echo "v$version"
50+
}
51+
52+
latest_stable_tag() {
53+
local latest=""
54+
local latest_major=-1
55+
local latest_minor=-1
56+
local latest_patch=-1
57+
local tag major minor patch
58+
59+
while IFS= read -r tag; do
60+
if [[ "$tag" =~ $STABLE_TAG_RE ]]; then
61+
major="${BASH_REMATCH[1]}"
62+
minor="${BASH_REMATCH[2]}"
63+
patch="${BASH_REMATCH[3]}"
64+
65+
if (( major > latest_major )) ||
66+
(( major == latest_major && minor > latest_minor )) ||
67+
(( major == latest_major && minor == latest_minor && patch > latest_patch )); then
68+
latest="$tag"
69+
latest_major="$major"
70+
latest_minor="$minor"
71+
latest_patch="$patch"
72+
fi
73+
fi
74+
done < <(git tag --list)
75+
76+
[[ -n "$latest" ]] || fail "No stable release tags found."
77+
echo "$latest"
78+
}
79+
80+
bump_tag() {
81+
local base_tag="$1"
82+
local bump="$2"
83+
84+
[[ "$base_tag" =~ $STABLE_TAG_RE ]] || fail "Cannot bump invalid stable tag '$base_tag'."
85+
86+
local major="${BASH_REMATCH[1]}"
87+
local minor="${BASH_REMATCH[2]}"
88+
local patch="${BASH_REMATCH[3]}"
89+
90+
case "$bump" in
91+
fix)
92+
patch=$((patch + 1))
93+
;;
94+
minor)
95+
minor=$((minor + 1))
96+
patch=0
97+
;;
98+
major)
99+
major=$((major + 1))
100+
minor=0
101+
patch=0
102+
;;
103+
*)
104+
fail "Unsupported bump '$bump'."
105+
;;
106+
esac
107+
108+
echo "v$major.$minor.$patch"
109+
}
110+
111+
resolve_prerelease_channel() {
112+
local channel="$PRERELEASE_CHANNEL"
113+
114+
if [[ "$channel" == "custom" ]]; then
115+
channel="$CUSTOM_PRERELEASE_CHANNEL"
116+
fi
117+
118+
[[ -n "$channel" ]] || fail "A custom prerelease channel is required when PRERELEASE_CHANNEL is custom."
119+
[[ "$channel" =~ ^[A-Za-z][0-9A-Za-z-]*$ ]] || fail "Prerelease channel must start with a letter and contain only letters, numbers, or hyphens; got '$channel'."
120+
121+
echo "$channel"
122+
}
123+
124+
next_prerelease_tag() {
125+
local base_tag="$1"
126+
local channel="$2"
127+
local base_version="${base_tag#v}"
128+
local highest=0
129+
local tag escaped_channel
130+
131+
escaped_channel="${channel//-/\\-}"
132+
133+
while IFS= read -r tag; do
134+
if [[ "$tag" =~ ^v${base_version}-${escaped_channel}\.([0-9]+)$ ]]; then
135+
if (( BASH_REMATCH[1] > highest )); then
136+
highest="${BASH_REMATCH[1]}"
137+
fi
138+
fi
139+
done < <(git tag --list)
140+
141+
echo "v${base_version}-${channel}.$((highest + 1))"
142+
}
143+
144+
tag_exists_local() {
145+
git rev-parse -q --verify "refs/tags/$1" >/dev/null
146+
}
147+
148+
tag_exists_remote() {
149+
local tag="$1"
150+
local status
151+
152+
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
153+
return 0
154+
else
155+
status=$?
156+
fi
157+
158+
if [[ "$status" == "2" ]]; then
159+
return 1
160+
fi
161+
162+
return "$status"
163+
}
164+
165+
ensure_tag_available() {
166+
local tag="$1"
167+
168+
if tag_exists_local "$tag"; then
169+
fail "Tag '$tag' already exists locally."
170+
fi
171+
172+
local remote_status
173+
if tag_exists_remote "$tag"; then
174+
fail "Tag '$tag' already exists on origin."
175+
else
176+
remote_status=$?
177+
fi
178+
179+
if (( remote_status > 1 )); then
180+
fail "Unable to check whether tag '$tag' exists on origin."
181+
fi
182+
}
183+
184+
write_outputs() {
185+
local latest_stable="$1"
186+
local tag="$2"
187+
local channel="$3"
188+
local target_sha="$4"
189+
local pushed="$5"
190+
191+
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
192+
{
193+
echo "latest_stable=$latest_stable"
194+
echo "tag=$tag"
195+
echo "prerelease_channel=$channel"
196+
echo "target_sha=$target_sha"
197+
echo "pushed=$pushed"
198+
} >>"$GITHUB_OUTPUT"
199+
fi
200+
201+
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
202+
{
203+
echo "## Release tag summary"
204+
echo
205+
echo "- Release type: \`$RELEASE_TYPE\`"
206+
echo "- Latest stable tag: \`$latest_stable\`"
207+
echo "- Computed tag: \`$tag\`"
208+
echo "- Target SHA: \`$target_sha\`"
209+
echo "- Dry run: \`$DRY_RUN\`"
210+
echo "- Pushed: \`$pushed\`"
211+
if [[ -n "$channel" ]]; then
212+
echo "- Prerelease channel: \`$channel\`"
213+
fi
214+
} >>"$GITHUB_STEP_SUMMARY"
215+
fi
216+
}
217+
218+
validate_release_type
219+
latest_stable="$(latest_stable_tag)"
220+
prerelease_channel=""
221+
222+
case "$RELEASE_TYPE" in
223+
fix | minor | major)
224+
tag="$(bump_tag "$latest_stable" "$RELEASE_TYPE")"
225+
;;
226+
prerelease)
227+
validate_base
228+
prerelease_channel="$(resolve_prerelease_channel)"
229+
base_tag="$(bump_tag "$latest_stable" "$PRERELEASE_BASE")"
230+
tag="$(next_prerelease_tag "$base_tag" "$prerelease_channel")"
231+
;;
232+
freeform)
233+
tag="$(normalize_freeform_tag "$FREEFORM_VERSION")"
234+
;;
235+
esac
236+
237+
if [[ "$RELEASE_TYPE" == "major" && "$MAJOR_CONFIRMATION" != "$tag" ]]; then
238+
fail "Major releases require MAJOR_CONFIRMATION to exactly match '$tag'."
239+
fi
240+
241+
ensure_tag_available "$tag"
242+
243+
target_sha="$(git rev-parse --verify HEAD)"
244+
pushed="false"
245+
246+
if ! is_true "$DRY_RUN"; then
247+
git tag -a "$tag" "$target_sha" -m "$tag"
248+
git push origin "$tag"
249+
pushed="true"
250+
fi
251+
252+
echo "Latest stable tag: $latest_stable"
253+
echo "Computed tag: $tag"
254+
echo "Target SHA: $target_sha"
255+
echo "Dry run: $DRY_RUN"
256+
echo "Pushed: $pushed"
257+
258+
write_outputs "$latest_stable" "$tag" "$prerelease_channel" "$target_sha" "$pushed"

0 commit comments

Comments
 (0)