11#! /usr/bin/env bash
22
3+ set -Eeuo pipefail
4+
35# bundler-cache: true in setup-ruby enables frozen mode, which blocks
46# updating Gemfile.lock after the gemspec version bump. Disable it for
57# every bundler invocation in this script so the lockfile can be
68# regenerated to match the new version.
79export BUNDLE_FROZEN=false
810export BUNDLE_DEPLOYMENT=false
911
10- fetch () {
11- git fetch --all
12+ release_branch=" "
13+
14+ header () {
15+ echo " ==============================="
16+ echo " $1 "
17+ echo " ==============================="
18+ }
19+
20+ fail () {
21+ echo " Error: $1 " >&2
22+ exit 1
23+ }
24+
25+ on_exit () {
26+ local status=$?
27+ [ " $status " == " 0" ] && return
28+ echo " " >&2
29+ echo " Release failed (exit $status )." >&2
30+ if [ -n " $release_branch " ]; then
31+ echo " To reset and try again:" >&2
32+ echo " git reset --hard && git clean -fd" >&2
33+ echo " git switch main && git branch -D $release_branch " >&2
34+ fi
35+ }
36+ trap on_exit EXIT
37+
38+ # In-place file edits via perl, since `sed -i` is incompatible between
39+ # GNU (Linux/CI) and BSD (macOS) sed.
40+ inplace () {
41+ local expression=$1
42+ shift
43+ perl -pi -e " $expression " " $@ "
1244}
1345
1446branch_name () {
1547 git symbolic-ref --short HEAD
1648}
1749
18- local_history_is_clean () {
19- history=$( git rev-list --count --right-only @{u}...HEAD)
20- [ " $history " == " 0" ]
50+ working_tree_is_clean () {
51+ [ -z " $( git status --porcelain) " ]
2152}
2253
23- remote_history_is_clean () {
24- history=$( git rev-list --count --left-only @{u}...HEAD)
25- [ " $history " == " 0" ]
54+ commits_behind_remote () {
55+ git rev-list --count --left-only @{u}...HEAD
2656}
2757
28- tag_exists_on_remote () {
29- git rev-parse --quiet --verify refs/tags/ $1 . $2 . $3 > /dev/null
58+ commits_ahead_of_remote () {
59+ git rev-list --count --right-only @{u}...HEAD
3060}
3161
32- working_tree_is_clean () {
33- status=$( git status --porcelain)
34- [ " $status " == " " ]
62+ tag_exists () {
63+ git rev-parse --quiet --verify " refs/tags/v$1 " > /dev/null ||
64+ [ -n " $( git ls-remote --tags origin " refs/tags/v$1 " ) " ]
65+ }
66+
67+ prerequisite_checks () {
68+ header " Prerequisite Checks"
69+
70+ working_tree_is_clean || fail " unclean working tree"
71+ [ " $( branch_name) " == " main" ] || fail " can only make a release on the main branch"
72+
73+ git fetch --all --tags --quiet
74+
75+ [ " $( commits_ahead_of_remote) " == " 0" ] ||
76+ fail " changes exist that haven't been pushed to origin"
77+
78+ if [ " $( commits_behind_remote) " != " 0" ]; then
79+ echo " Pulling latest changes from origin..."
80+ git pull --ff-only --quiet
81+ fi
3582}
3683
37- create_release_branch () {
38- git switch -c release-$1 -$2 -$3
84+ prompt_for_version () {
85+ echo " Current version is $major .$minor .$patch ."
86+ echo " Type the number of an option to bump, or pick Manual to enter a version number"
87+ select bump in Major Minor Patch Manual; do
88+ case " $bump " in
89+ Major) major=$(( major + 1 )) ; minor=0; patch=0 ;;
90+ Minor) minor=$(( minor + 1 )) ; patch=0 ;;
91+ Patch) patch=$(( patch + 1 )) ;;
92+ * )
93+ read -r -p " What version? (Currently $major .$minor .$patch ): " new_version
94+ [[ " $new_version " =~ ^[0-9]+\. [0-9]+\. [0-9]+$ ]] || fail " version must look like X.Y.Z"
95+ [ " $new_version " != " $major .$minor .$patch " ] || fail " can't be the same version"
96+ IFS=. read -r major minor patch <<< " $new_version"
97+ ;;
98+ esac
99+ break
100+ done
39101}
40102
41- update_readme () {
42- sed -i " /## main/ {a\\
43- \\
44- ## $1 .$2 .$3
45- }" docs/CHANGELOG.md
103+ update_changelog () {
104+ VERSION=" $1 " perl -0777 -pi -e ' s/^## main\n/## main\n\n## $ENV{VERSION}\n/m' docs/CHANGELOG.md
105+ grep -q " ^## $1 \$ " docs/CHANGELOG.md || fail " failed to add $1 heading to docs/CHANGELOG.md"
46106}
47107
48108update_ruby_version () {
49- # Update version file
50- sed -i \
51- -e " s/MAJOR = [0-9]*/MAJOR = $1 /g" \
52- -e " s/MINOR = [0-9]*/MINOR = $2 /g" \
53- -e " s/PATCH = [0-9]*/PATCH = $3 /g" \
54- lib/view_component/version.rb
55-
56- # Update deprecation horizon version
57- major=$1
58- sed -i \
59- -e " s/DEPRECATION_HORIZON = \" [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\" /DEPRECATION_HORIZON = \" $(( major + 1 )) .0.0\" /g" \
60- lib/view_component/deprecation.rb
61- }
109+ inplace " s/MAJOR = \\ d+/MAJOR = $1 /; s/MINOR = \\ d+/MINOR = $2 /; s/PATCH = \\ d+/PATCH = $3 /" \
110+ lib/view_component/version.rb
111+ inplace " s/DEPRECATION_HORIZON = \"\\ d+\\ .\\ d+\\ .\\ d+\" /DEPRECATION_HORIZON = \" $(( $1 + 1 )) .0.0\" /" \
112+ lib/view_component/deprecation.rb
62113
63- update_gemfiles () {
64- major= $1
65- minor= $2
66- patch= $3
67- new_version= " $major . $minor . $patch "
114+ grep -q " MAJOR = $1 \$ " lib/view_component/version.rb &&
115+ grep -q " MINOR = $2 \$ " lib/view_component/version.rb &&
116+ grep -q " PATCH = $3 \$ " lib/view_component/version.rb ||
117+ fail " failed to update lib/view_component/version.rb "
118+ }
68119
120+ update_lockfiles () {
69121 # The only lockfile change during a release is the view_component
70- # version pinned by the path gemspec. Update it in place across every
71- # lockfile. Running `bundle lock` from a mismatched Ruby (e.g. ruby-head
72- # appraisals from Ruby 4.0) produces lockfiles CI cannot consume in
73- # frozen mode, so we edit them directly instead.
74- for lockfile in Gemfile.lock gemfiles/* .gemfile.lock; do
75- [ -f " $lockfile " ] || continue
76- sed -i \
77- -e " s/^\( view_component \)([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*)/\1($new_version )/" \
78- -e " s/^\( view_component \)([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*)/\1($new_version )/" \
79- " $lockfile "
122+ # version pinned by the path gemspec. Running `bundle install`/`bundle
123+ # exec` locally can add platform-specific entries (e.g. on a dev Ruby
124+ # without precompiled gems), and `bundle lock` from a mismatched Ruby
125+ # produces lockfiles CI cannot consume in frozen mode. So restore the
126+ # committed lockfiles and edit just the version in place.
127+ local lockfiles=(Gemfile.lock gemfiles/* .gemfile.lock)
128+ git restore --source=HEAD -- " ${lockfiles[@]} "
129+ VERSION=" $1 " perl -pi -e ' s/^( | )view_component \(\d+\.\d+\.\d+\)/$1view_component ($ENV{VERSION})/' " ${lockfiles[@]} "
130+
131+ local lockfile
132+ for lockfile in " ${lockfiles[@]} " ; do
133+ grep -q " view_component ($1 )" " $lockfile " || fail " failed to update $lockfile "
80134 done
81135}
82136
83137build_docs () {
84- ruby script/sync_contributors.rb || { echo " Error: sync_contributors.rb failed" ; exit 1; }
85- bundle exec rake docs:build || { echo " Error: rake docs:build failed" ; exit 1; }
138+ echo " version: $1 " > docs/_data/library.yml
139+ ruby script/sync_contributors.rb
140+ # Install after the version bump, which changes the resolved gemspec.
141+ bundle install --quiet
142+ bundle exec rake docs:build
86143}
87144
88- add_changed_files () {
145+ commit () {
89146 git add \
90147 docs/api.md \
91148 docs/CHANGELOG.md \
92149 docs/_data/contributors.yml \
93150 docs/_data/library.yml \
94151 Gemfile.lock \
95152 gemfiles/* .gemfile.lock \
153+ lib/view_component/deprecation.rb \
96154 lib/view_component/version.rb
97- }
98-
99- commit () {
100- git commit -m " release $1 .$2 .$3 "
155+ git commit --quiet -m " release $1 "
101156}
102157
103158push () {
159+ local version=$1
160+
161+ if [ -n " ${RELEASE_DRY_RUN:- } " ]; then
162+ echo " RELEASE_DRY_RUN set; skipping push and PR creation."
163+ return
164+ fi
165+
104166 # Force-push: the release-X-Y-Z branch is auto-generated and is safe
105167 # to overwrite. A previous failed run may have left the branch on the
106168 # remote, which would otherwise reject a non-fast-forward push.
107- git push --force origin release- $1 - $2 - $3
169+ git push --force origin " $release_branch "
108170
109171 # Open PR (or reuse an existing one from a previous failed run).
110- pr_url=$( gh pr view " release- $1 - $2 - $3 " \
172+ pr_url=$( gh pr view " $release_branch " \
111173 --repo ViewComponent/view_component \
112174 --json url --jq .url 2> /dev/null || true)
113175 if [ -z " $pr_url " ]; then
114176 pr_url=$( gh pr create \
115- --title " Release $1 . $2 . $3 " \
116- --body " Automated release PR for version $1 . $2 . $3 ." \
177+ --title " Release $version " \
178+ --body " Automated release PR for version $version ." \
117179 --base main \
118- --head release- $1 - $2 - $3 \
180+ --head " $release_branch " \
119181 --repo ViewComponent/view_component)
120182 else
121183 echo " Reusing existing PR: $pr_url "
@@ -138,120 +200,37 @@ push() {
138200}
139201
140202main () {
203+ prerequisite_checks
204+
141205 major=$( grep " MAJOR = " lib/view_component/version.rb | grep -o " [0-9]\+" )
142206 minor=$( grep " MINOR = " lib/view_component/version.rb | grep -o " [0-9]\+" )
143207 patch=$( grep " PATCH = " lib/view_component/version.rb | grep -o " [0-9]\+" )
144- pre=$( grep " PRE = " lib/view_component/version.rb | grep -o " \" [^\" ]*\" " | tr -d ' "' )
145208
146- # If arguments provided, use them (for CI/ workflow usage)
209+ # Arguments are used by the release workflow: MAJOR MINOR PATCH [PRE]
147210 if [ $# -gt 0 ]; then
211+ [ $# -ge 3 ] || fail " usage: script/release [MAJOR MINOR PATCH [PRE]]"
148212 major=$1
149213 minor=$2
150214 patch=$3
151- [ $# -gt 3 ] && pre=$4 || pre=" "
152215 else
153- # Interactive mode
154- echo " ==================="
155- echo " Prerequisite Checks"
156- echo " ==================="
157-
158- if ! working_tree_is_clean; then
159- echo " Error: unclean working tree"
160- exit 1
161- fi
162-
163- if [ " $( branch_name) " != " main" ]; then
164- echo " Error: can only make a release on the main branch"
165- exit 1
166- fi
167-
168- fetch
169-
170- if ! remote_history_is_clean; then
171- echo " Error: changes exist on origin not pulled into this branch. Please pull"
172- exit 1
173- fi
174-
175- if ! local_history_is_clean; then
176- echo " Error: changes exist that haven't been pushed to origin. Please pull"
177- exit 1
178- fi
179-
180- echo " Type the number of an option to bump, or pick Manual to enter a version number"
181- select bump in Major Minor Patch Manual
182- do
183- if [ " $bump " == " Major" ]; then
184- major=$(( major + 1 ))
185- minor=0
186- patch=0
187- elif [ " $bump " == " Minor" ]; then
188- minor=$(( minor + 1 ))
189- patch=0
190- elif [ " $bump " == " Patch" ]; then
191- patch=$(( patch + 1 ))
192- else
193- read -p " What version? (Currently $major .$minor .$patch ): " new_version
194- if [ " $new_version " == " $major .$minor .$patch " ]; then
195- echo " Error: Can't be the same version"
196- exit 1
197- fi
198-
199- new_version=(${new_version// ./ } )
200-
201- major=${new_version[0]}
202- minor=${new_version[1]}
203- patch=${new_version[2]}
204- fi
205-
206- break
207- done
216+ prompt_for_version
208217 fi
209218
210- echo " ==================="
211- echo " Prerequisite Checks"
212- echo " ==================="
219+ version=" $major .$minor .$patch "
213220
214- if ! working_tree_is_clean; then
215- echo " Error: unclean working tree"
216- exit 1
217- fi
221+ tag_exists " $version " && fail " tag v$version already exists"
218222
219- if [ " $( branch_name) " != " main" ]; then
220- echo " Error: can only make a release on the main branch"
221- exit 1
222- fi
223-
224- fetch
225-
226- if ! remote_history_is_clean; then
227- echo " Error: changes exist on origin not pulled into this branch. Please pull"
228- exit 1
229- fi
230-
231- if ! local_history_is_clean; then
232- echo " Error: changes exist that haven't been pushed to origin. Please pull"
233- exit 1
234- fi
235-
236- if tag_exists_on_remote $major $minor $patch ; then
237- echo " Error: tag exists on remote"
238- exit 1
239- fi
223+ release_branch=" release-$major -$minor -$patch "
240224
241- echo " ==============================="
242- echo " Creating release for $major .$minor .$patch "
243- echo " ==============================="
225+ header " Creating release for $version "
244226
245- create_release_branch $major $minor $patch
246- update_readme $major $minor $patch
247- update_ruby_version $major $minor $patch
248- update_gemfiles $major $minor $patch
249- echo " version: $major .$minor .$patch " > docs/_data/library.yml
250- build_docs
251- add_changed_files $major $minor $patch
252- commit $major $minor $patch
253- push $major $minor $patch
254- exit 0
227+ git switch --quiet -C " $release_branch "
228+ update_changelog " $version "
229+ update_ruby_version " $major " " $minor " " $patch "
230+ build_docs " $version "
231+ update_lockfiles " $version "
232+ commit " $version "
233+ push " $version "
255234}
256235
257236main " $@ "
0 commit comments