Repository navigation
Expand file tree
/
Copy pathrelease.sh
More file actions
executable file
·382 lines (326 loc) · 10.9 KB
/
Copy pathrelease.sh
File metadata and controls
executable file
·382 lines (326 loc) · 10.9 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
373
374
375
376
377
378
379
380
381
382
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
declare -r EXIT_SUCCESS=0
declare -r EXIT_VALIDATION_ERROR=1
declare -r EXIT_EXECUTION_ERROR=2
GITHUB_REPO_PATH="gacela-project/container"
GITHUB_REPO_URL="https://github.com/${GITHUB_REPO_PATH}"
RELEASE_FILES=("CHANGELOG.md")
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
DRY_RUN=false
FORCE_MODE=false
SKIP_TESTS=false
WITH_GH_RELEASE=true
BACKUP_DIR=""
VERSION=""
CURRENT_VERSION=""
RELEASE_NOTES_FILE=""
log_info() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
log_success() { echo -e "${GREEN}[OK]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
log_dry() { echo -e "${YELLOW}[DRY-RUN]${NC} $1" >&2; }
show_usage() {
cat >&2 <<EOF
Usage: ./release.sh [version] [options]
Arguments:
version Semantic version X.Y.Z (e.g., 1.13.0).
Omitted → auto-bump minor from current version.
Options:
--dry-run Preview changes without modifying files or pushing.
--force Skip interactive confirmation (for CI).
--skip-tests Skip the CI-status verification for HEAD.
--without-gh-release
Tag and push, but do not create a GitHub release.
--rollback Restore CHANGELOG.md from latest backup.
-h, --help Show this message.
Examples:
./release.sh # Auto-bump minor, interactive
./release.sh 1.13.0 # Explicit version
./release.sh 1.13.0 --dry-run # Preview only
./release.sh --force --skip-tests # CI mode
./release.sh --rollback # Undo local file changes
EOF
}
confirm() {
[[ "$FORCE_MODE" == true ]] && return 0
local prompt=$1
echo -en "${YELLOW}${prompt} [y/N]: ${NC}" >&2
read -r response
[[ "$response" =~ ^[Yy]$ ]]
}
#########################
### PRE-FLIGHT CHECKS ###
#########################
preflight_gh_installed() {
command -v gh >/dev/null 2>&1 || {
log_error "gh CLI not installed. Install from https://cli.github.com/"
return 1
}
}
preflight_gh_auth() {
gh auth status >/dev/null 2>&1 || {
log_error "Not authenticated with GitHub. Run: gh auth login"
return 1
}
}
preflight_git_clean() {
[[ -z "$(git status --porcelain)" ]] || {
log_error "Working tree is dirty. Commit or stash changes first."
return 1
}
}
preflight_on_main() {
local branch
branch=$(git branch --show-current)
[[ "$branch" == "main" ]] || {
log_error "Not on main branch (on: $branch). Run: git checkout main"
return 1
}
}
preflight_up_to_date() {
git fetch --quiet origin main
local local_sha remote_sha
local_sha=$(git rev-parse HEAD)
remote_sha=$(git rev-parse origin/main)
[[ "$local_sha" == "$remote_sha" ]] || {
log_error "Local main is not in sync with origin/main. Pull or push first."
return 1
}
}
preflight_required_files() {
for f in "${RELEASE_FILES[@]}"; do
[[ -f "$f" ]] || { log_error "Missing required file: $f"; return 1; }
done
}
preflight_changelog_has_unreleased() {
grep -q "^## Unreleased$" CHANGELOG.md || {
log_error "CHANGELOG.md missing '## Unreleased' section."
return 1
}
local content
content=$(awk '/^## Unreleased$/{flag=1;next} /^## \[/{flag=0} flag' CHANGELOG.md \
| grep -v '^$' | head -1)
[[ -n "$content" ]] || {
log_error "CHANGELOG.md '## Unreleased' section is empty. Add release notes first."
return 1
}
}
run_preflight() {
local ok=true
log_info "Running pre-flight checks..."
for check in preflight_gh_installed preflight_gh_auth \
preflight_git_clean preflight_on_main preflight_up_to_date \
preflight_required_files preflight_changelog_has_unreleased; do
"$check" || ok=false
done
[[ "$ok" == true ]] || { log_error "Pre-flight checks failed."; exit $EXIT_VALIDATION_ERROR; }
log_success "Pre-flight checks passed."
}
#########################
### VERSION HANDLING ###
#########################
read_current_version() {
# Version is derived at runtime from Composer metadata (the git tag), so the
# latest tag — not a file — is the source of truth for the current version.
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | grep -oE "[0-9]+\.[0-9]+\.[0-9]+") || CURRENT_VERSION=""
[[ -n "$CURRENT_VERSION" ]] || {
log_error "Could not read current version from git tags."
exit $EXIT_VALIDATION_ERROR
}
}
bump_minor() {
local major minor
major=$(echo "$CURRENT_VERSION" | cut -d. -f1)
minor=$(echo "$CURRENT_VERSION" | cut -d. -f2)
echo "${major}.$((minor + 1)).0"
}
validate_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
log_error "Invalid version '$1'. Expected X.Y.Z."
exit $EXIT_VALIDATION_ERROR
}
if git rev-parse "refs/tags/$1" >/dev/null 2>&1; then
log_error "Tag $1 already exists."
exit $EXIT_VALIDATION_ERROR
fi
}
#########################
### BACKUP & ROLLBACK###
#########################
backup_init() {
BACKUP_DIR=".release-state/backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
for f in "${RELEASE_FILES[@]}"; do cp "$f" "$BACKUP_DIR/$(basename "$f")"; done
}
rollback_manual() {
local latest
latest=$(find .release-state -maxdepth 1 -type d -name 'backup-*' 2>/dev/null | sort -r | head -1)
[[ -n "$latest" ]] || { log_error "No backup found in .release-state."; exit $EXIT_VALIDATION_ERROR; }
log_info "Restoring from $latest"
cp "$latest/CHANGELOG.md" CHANGELOG.md
log_success "Files restored. Remember to review 'git status'."
}
#########################
### FILE UPDATES ###
#########################
capture_release_notes() {
local notes
notes=$(awk '
/^## Unreleased$/ {flag=1; next}
/^## \[/ {flag=0}
flag {lines[++n]=$0}
END {
start=1; while (start<=n && lines[start] ~ /^[[:space:]]*$/) start++
end=n; while (end>=start && lines[end] ~ /^[[:space:]]*$/) end--
for (i=start; i<=end; i++) print lines[i]
}
' CHANGELOG.md)
[[ -n "$notes" ]] || {
log_error "Captured release notes from '## Unreleased' are empty."
exit $EXIT_VALIDATION_ERROR
}
if [[ "$DRY_RUN" == true ]]; then
log_dry "Release notes (from CHANGELOG.md '## Unreleased'):"
printf '%s\n' "$notes" | sed 's/^/ /' >&2
RELEASE_NOTES_FILE="(dry-run)"
return
fi
RELEASE_NOTES_FILE=".release-state/release-notes-${VERSION}.md"
mkdir -p .release-state
printf '%s\n' "$notes" > "$RELEASE_NOTES_FILE"
log_success "Release notes captured → $RELEASE_NOTES_FILE"
}
update_changelog() {
local date today header
today=$(date +%Y-%m-%d)
header="## [${VERSION}](${GITHUB_REPO_URL}/compare/${CURRENT_VERSION}...${VERSION}) - ${today}"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would rewrite '## Unreleased' → '$header' and insert fresh '## Unreleased'"
return
fi
awk -v header="$header" '
/^## Unreleased$/ && !done {
print "## Unreleased"
print ""
print header
done = 1
next
}
{ print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
log_success "CHANGELOG.md updated with $VERSION"
}
#########################
### CI VERIFICATION ###
#########################
# Preflight already asserts local HEAD == origin/main, which CI has run against.
# Rather than re-run the full 'composer quality && composer test' locally (minutes
# CI already spent), confirm CI is green for this exact commit via GitHub check-runs.
verify_ci_green() {
[[ "$SKIP_TESTS" == true ]] && { log_warn "Skipping CI verification (--skip-tests)"; return; }
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would verify CI is green for HEAD via GitHub check-runs"
return
fi
local head total bad
head=$(git rev-parse HEAD)
log_info "Verifying CI status for $head..."
total=$(gh api "repos/${GITHUB_REPO_PATH}/commits/${head}/check-runs" \
--jq '.check_runs | length')
[[ "$total" -gt 0 ]] || {
log_error "No CI check-runs found for HEAD. Wait for CI to start, or use --skip-tests to override."
exit $EXIT_VALIDATION_ERROR
}
bad=$(gh api "repos/${GITHUB_REPO_PATH}/commits/${head}/check-runs" \
--jq '.check_runs[]
| select(.status != "completed"
or (.conclusion != "success"
and .conclusion != "skipped"
and .conclusion != "neutral"))
| "\(.name): \(.status)/\(.conclusion // "pending")"')
[[ -z "$bad" ]] || {
log_error "CI is not green for HEAD. Failing or pending checks:"
printf '%s\n' "$bad" | sed 's/^/ /' >&2
exit $EXIT_VALIDATION_ERROR
}
log_success "CI is green for HEAD ($total checks)."
}
#########################
### GIT & GH ###
#########################
git_commit_and_tag() {
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would: git add ${RELEASE_FILES[*]}; commit 'chore(release): $VERSION'; tag $VERSION"
return
fi
git add "${RELEASE_FILES[@]}"
git commit -m "chore(release): ${VERSION}"
git tag -s "$VERSION" -m "Release $VERSION"
log_success "Commit + tag $VERSION created."
}
git_push() {
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would: git push origin main && git push origin $VERSION"
return
fi
git push origin main
git push origin "$VERSION"
log_success "Pushed main + tag."
}
create_gh_release() {
[[ "$WITH_GH_RELEASE" == true ]] || { log_warn "Skipping gh release (--without-gh-release)"; return; }
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would: gh release create $VERSION --title $VERSION --notes-file $RELEASE_NOTES_FILE"
return
fi
gh release create "$VERSION" \
--title "$VERSION" \
--notes-file "$RELEASE_NOTES_FILE"
log_success "GitHub release $VERSION created."
}
#########################
### MAIN ###
#########################
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--force) FORCE_MODE=true ;;
--skip-tests) SKIP_TESTS=true ;;
--without-gh-release) WITH_GH_RELEASE=false ;;
--rollback) rollback_manual; exit $EXIT_SUCCESS ;;
-h|--help) show_usage; exit $EXIT_SUCCESS ;;
-*) log_error "Unknown option: $1"; show_usage; exit $EXIT_VALIDATION_ERROR ;;
*) VERSION="$1" ;;
esac
shift
done
}
main() {
parse_args "$@"
run_preflight
read_current_version
[[ -n "$VERSION" ]] || VERSION=$(bump_minor)
validate_version "$VERSION"
log_info "Current: $CURRENT_VERSION → New: $VERSION"
confirm "Proceed with release?" || { log_info "Aborted."; exit $EXIT_SUCCESS; }
verify_ci_green
[[ "$DRY_RUN" == true ]] || backup_init
capture_release_notes
update_changelog
git_commit_and_tag
git_push
create_gh_release
log_success "Release $VERSION complete."
if [[ "$DRY_RUN" == true ]]; then
log_warn "This was a DRY-RUN. No files or remote state changed."
fi
}
main "$@"