Files
2026-08-03 15:52:13 -07:00

439 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Release helper with three modes:
# - --prepare-fast: refresh target/selfdev before the release metadata commit.
# - --fast-local: package that prepared binary, publish Linux immediately, then
# let CI replace it with portable/signoff assets and add every other platform.
# - --prepare-fast-macos/--fast-macos-local: do the same for macOS arm64 using
# the local osxcross cache.
# - --remote: push the tag immediately and let CI gate publication.
# - default: build Linux + macOS locally and stage them on the CI-owned draft.
#
# Usage:
# scripts/quick-release.sh --prepare-fast v0.5.5 # warm selfdev before bump
# scripts/quick-release.sh --fast-local v0.5.5 # package it, public now
# scripts/quick-release.sh --prepare-fast-macos v0.5.5
# scripts/quick-release.sh --fast-macos-local v0.5.5
# scripts/quick-release.sh --remote v0.5.5 # tag now, CI-gated publication
# scripts/quick-release.sh v0.5.5 # local Linux + macOS draft
# scripts/quick-release.sh --dry-run v0.5.5 # standard local build only
MODE="standard"
DRY_RUN=false
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--fast|--fast-local)
[[ "$MODE" == "standard" ]] || { echo "Error: release modes cannot be combined." >&2; exit 1; }
MODE="fast-local"
;;
--prepare-fast)
[[ "$MODE" == "standard" ]] || { echo "Error: release modes cannot be combined." >&2; exit 1; }
MODE="prepare-fast"
;;
--fast-macos|--fast-macos-local)
[[ "$MODE" == "standard" ]] || { echo "Error: release modes cannot be combined." >&2; exit 1; }
MODE="fast-macos-local"
;;
--prepare-fast-macos)
[[ "$MODE" == "standard" ]] || { echo "Error: release modes cannot be combined." >&2; exit 1; }
MODE="prepare-fast-macos"
;;
--remote|--ci-only)
[[ "$MODE" == "standard" ]] || { echo "Error: release modes cannot be combined." >&2; exit 1; }
MODE="remote"
;;
--)
shift
break
;;
*)
echo "Error: Unknown option: $1" >&2
echo "Usage: scripts/quick-release.sh [--prepare-fast | --fast-local | --prepare-fast-macos | --fast-macos-local | --remote | --dry-run] <version> [title]" >&2
exit 1
;;
esac
shift
done
if $DRY_RUN && [[ "$MODE" == "remote" ]]; then
echo "Error: --dry-run cannot be combined with --remote." >&2
exit 1
fi
VERSION="${1:?Usage: scripts/quick-release.sh [--prepare-fast | --fast-local | --prepare-fast-macos | --fast-macos-local | --remote | --dry-run] <version> [title]}"
TITLE="${2:-$VERSION}"
VERSION_NUM="${VERSION#v}"
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format v0.5.4"
exit 1
fi
cd "$(git rev-parse --show-toplevel)"
required_commands=(git)
case "$MODE" in
remote) ;;
prepare-fast)
required_commands+=(cargo sha256sum)
;;
fast-local)
required_commands+=(file strip tar gzip sha256sum)
$DRY_RUN || required_commands+=(gh)
;;
prepare-fast-macos)
required_commands+=(cargo file sha256sum)
;;
fast-macos-local)
required_commands+=(file tar gzip sha256sum)
$DRY_RUN || required_commands+=(gh)
;;
standard)
required_commands+=(cargo docker file)
$DRY_RUN || required_commands+=(gh)
;;
esac
for cmd in "${required_commands[@]}"; do
command -v "$cmd" &>/dev/null || { echo "Error: $cmd not found."; exit 1; }
done
if [[ "$MODE" == "standard" || "$MODE" == "prepare-fast-macos" ]]; then
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
export PATH="$HOME/.osxcross/bin:$PATH"
if ! command -v aarch64-apple-darwin23.5-clang &>/dev/null; then
echo "Error: osxcross not found. Install at ~/.osxcross"
exit 1
fi
fi
working_tree_changes="$(git status --porcelain)"
if [[ -n "$working_tree_changes" ]]; then
case "$MODE" in
fast-local|prepare-fast|fast-macos-local|prepare-fast-macos)
echo "Error: --$MODE requires a clean working tree so the binary matches committed source." >&2
printf '%s\n' "$working_tree_changes" >&2
exit 1
;;
remote)
echo "Warning: working-tree changes are not included; only committed HEAD will be tagged."
;;
standard)
echo "Warning: uncommitted changes are present."
read -rp "Continue anyway? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 1
;;
esac
fi
if [[ "$MODE" == "fast-local" || "$MODE" == "prepare-fast" ]]; then
if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then
echo "Error: --fast-local currently publishes the Linux x86_64 asset and must run on Linux x86_64." >&2
exit 1
fi
fi
if [[ "$MODE" == "fast-macos-local" || "$MODE" == "prepare-fast-macos" ]]; then
if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then
echo "Error: fast macOS release currently uses osxcross and must run on Linux x86_64." >&2
exit 1
fi
fi
echo "=== Quick Release: $VERSION ($MODE) ==="
echo ""
DIST="$(mktemp -d)"
trap 'rm -rf "$DIST"' EXIT
OVERALL_START=$(date +%s)
TAG_PUSHED=false
elapsed() {
echo $(( $(date +%s) - OVERALL_START ))
}
tag_and_push() {
echo "▸ Tagging $VERSION..."
local head_commit remote_tags remote_commit local_commit
head_commit="$(git rev-parse HEAD)"
remote_tags="$(git ls-remote --tags origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}" 2>/dev/null || true)"
if [[ -n "$remote_tags" ]]; then
remote_commit="$(printf '%s\n' "$remote_tags" | awk '$2 ~ /\^\{\}$/ { print $1; found=1 } END { if (!found) print first } NR == 1 { first=$1 }')"
if [[ "$remote_commit" != "$head_commit" ]]; then
echo "Error: Remote tag $VERSION already points to a different commit." >&2
exit 1
fi
echo " Remote tag already exists at HEAD"
return
fi
if git tag -l "$VERSION" | grep -qx "$VERSION"; then
local_commit="$(git rev-list -n 1 "$VERSION")"
if [[ "$local_commit" != "$head_commit" ]]; then
echo "Error: Local tag $VERSION already points to a different commit." >&2
exit 1
fi
echo " Local tag already exists at HEAD"
else
git tag "$VERSION" -m "$TITLE"
fi
git push origin "$VERSION"
TAG_PUSHED=true
echo " Tag pushed"
}
generate_notes() {
NOTES_FILE="$DIST/release_notes.md"
if ! scripts/generate_release_notes.sh "$VERSION" > "$NOTES_FILE" || [[ ! -s "$NOTES_FILE" ]]; then
echo " Warning: release notes generation failed, using the release title"
printf '%s\n' "$TITLE" > "$NOTES_FILE"
fi
}
ensure_release_draft() {
generate_notes
if ! gh release view "$VERSION" >/dev/null 2>&1; then
if ! gh release create "$VERSION" \
--draft \
--title "$TITLE" \
--notes-file "$NOTES_FILE"; then
sleep 2
gh release view "$VERSION" >/dev/null
fi
fi
gh release edit "$VERSION" --title "$TITLE" --notes-file "$NOTES_FILE"
}
if [[ "$MODE" == "prepare-fast" ]]; then
echo "▸ Refreshing the warm selfdev Linux build before the version bump..."
JCODE_REMOTE_CARGO=0 scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode
source_bin="target/selfdev/jcode"
[[ -x "$source_bin" ]] || { echo "Error: selfdev binary not found: $source_bin" >&2; exit 1; }
prepared_marker="target/selfdev/fast-release-prepared"
{
printf 'version=%s\n' "$VERSION_NUM"
printf 'commit=%s\n' "$(git rev-parse HEAD)"
printf 'binary_sha256=%s\n' "$(sha256sum "$source_bin" | cut -d' ' -f1)"
} > "$prepared_marker"
echo ""
echo "=== Fast release prepared in $(elapsed)s ==="
echo " ✅ Warm selfdev binary recorded for $VERSION at $(git rev-parse --short HEAD)"
echo " Next: commit only Cargo.toml, Cargo.lock, and changelog release metadata, then run --fast-local."
exit 0
fi
if [[ "$MODE" == "prepare-fast-macos" ]]; then
echo "▸ Refreshing the macOS arm64 build before the version bump..."
JCODE_RELEASE_BUILD=1 JCODE_BUILD_SEMVER="$VERSION_NUM" \
CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" \
cargo build --release --target aarch64-apple-darwin --bin jcode
source_bin="target/aarch64-apple-darwin/release/jcode"
[[ -x "$source_bin" ]] || { echo "Error: macOS binary not found: $source_bin" >&2; exit 1; }
file "$source_bin" | grep -q 'Mach-O 64-bit' || { echo "Error: bad macOS binary" >&2; exit 1; }
prepared_marker="target/aarch64-apple-darwin/release/fast-macos-release-prepared"
{
printf 'version=%s\n' "$VERSION_NUM"
printf 'commit=%s\n' "$(git rev-parse HEAD)"
printf 'binary_sha256=%s\n' "$(sha256sum "$source_bin" | cut -d' ' -f1)"
} > "$prepared_marker"
echo ""
echo "=== Fast macOS release prepared in $(elapsed)s ==="
echo " ✅ macOS arm64 binary recorded for $VERSION at $(git rev-parse --short HEAD)"
echo " Next: commit only Cargo.toml, Cargo.lock, and changelog release metadata, then run --fast-macos-local."
exit 0
fi
if [[ "$MODE" == "remote" ]]; then
tag_and_push
echo ""
echo "=== Remote release triggered in $(elapsed)s ==="
if $TAG_PUSHED; then
echo " ✅ Tag pushed; release CI started"
else
echo " ✅ Tag already existed at HEAD; release CI was already triggered"
fi
echo " ⏳ CI will build, sign, checksum, and publish every platform"
echo ""
echo "No local build was run. Publication remains gated on the release workflow."
exit 0
fi
if [[ "$MODE" == "fast-local" ]]; then
echo "▸ Validating the prepared selfdev Linux build..."
build_start=$(date +%s)
source_bin="target/selfdev/jcode"
[[ -x "$source_bin" ]] || { echo "Error: selfdev binary not found: $source_bin" >&2; exit 1; }
prepared_marker="target/selfdev/fast-release-prepared"
[[ -f "$prepared_marker" ]] || {
echo "Error: fast release was not prepared. Run scripts/quick-release.sh --prepare-fast $VERSION before the release metadata commit." >&2
exit 1
}
prepared_version="$(sed -n 's/^version=//p' "$prepared_marker")"
prepared_commit="$(sed -n 's/^commit=//p' "$prepared_marker")"
prepared_sha256="$(sed -n 's/^binary_sha256=//p' "$prepared_marker")"
[[ "$prepared_version" == "$VERSION_NUM" ]] || {
echo "Error: prepared version $prepared_version does not match $VERSION_NUM." >&2
exit 1
}
release_parent="$(git rev-parse HEAD^)"
[[ "$prepared_commit" == "$release_parent" ]] || {
echo "Error: prepared binary commit $prepared_commit is not the parent of release commit $(git rev-parse HEAD)." >&2
exit 1
}
actual_sha256="$(sha256sum "$source_bin" | cut -d' ' -f1)"
[[ "$prepared_sha256" == "$actual_sha256" ]] || {
echo "Error: target/selfdev/jcode changed after fast-release preparation." >&2
exit 1
}
unexpected_release_files="$(git diff-tree --no-commit-id --name-only -r HEAD | grep -Ev '^(Cargo\.toml|Cargo\.lock|changelog/)' || true)"
[[ -z "$unexpected_release_files" ]] || {
echo "Error: the release metadata commit contains code or unsupported files:" >&2
printf '%s\n' "$unexpected_release_files" >&2
exit 1
}
cp "$source_bin" "$DIST/jcode-linux-x86_64.bin"
strip --strip-unneeded "$DIST/jcode-linux-x86_64.bin"
chmod +x "$DIST/jcode-linux-x86_64.bin"
cat > "$DIST/jcode-linux-x86_64" <<WRAPPER
#!/usr/bin/env sh
set -eu
export JCODE_RUNTIME_RELEASE_SEMVER="$VERSION_NUM"
export JCODE_RUNTIME_RELEASE_GIT_HASH="$(git rev-parse --short HEAD)"
export JCODE_RUNTIME_RELEASE_GIT_DATE="$(git log -1 --format=%ci)"
export JCODE_RUNTIME_RELEASE_GIT_TAG="$VERSION"
self_dir=\$(CDPATH= cd -- "\$(dirname -- "\$0")" && pwd)
exec "\$self_dir/jcode-linux-x86_64.bin" "\$@"
WRAPPER
chmod +x "$DIST/jcode-linux-x86_64"
file "$DIST/jcode-linux-x86_64.bin" | grep -q 'ELF 64-bit' || { echo "Error: bad Linux binary" >&2; exit 1; }
version_output="$("$DIST/jcode-linux-x86_64" --version)"
printf '%s\n' "$version_output" | grep -Fq "v$VERSION_NUM" || {
echo "Error: fast binary reports the wrong version: $version_output" >&2
exit 1
}
(cd "$DIST" && tar -cf - jcode-linux-x86_64 jcode-linux-x86_64.bin | gzip -1 > jcode-linux-x86_64.tar.gz)
(cd "$DIST" && sha256sum jcode-linux-x86_64.tar.gz > SHA256SUMS)
echo " ✅ Linux artifact ready ($(( $(date +%s) - build_start ))s validation/package, $(du -h "$DIST/jcode-linux-x86_64.tar.gz" | cut -f1))"
if $DRY_RUN; then
echo ""
echo "Fast dry run complete in $(elapsed)s. Artifacts in: $DIST"
trap - EXIT
exit 0
fi
tag_and_push
echo "▸ Publishing immediate Linux release..."
ensure_release_draft
gh release upload "$VERSION" \
"$DIST/jcode-linux-x86_64.tar.gz" \
"$DIST/SHA256SUMS" \
--clobber
gh release edit "$VERSION" --draft=false --latest
echo ""
echo "=== Fast release published in $(elapsed)s ==="
echo " ✅ Linux x86_64: public now from the warm selfdev cache"
echo " ⏳ CI: replacing Linux with the portable build and adding macOS, Windows, FreeBSD, signatures, and final checksums"
echo ""
echo "The immediate Linux asset targets this build host's runtime baseline until CI replaces it."
exit 0
fi
if [[ "$MODE" == "fast-macos-local" ]]; then
echo "▸ Validating the prepared macOS arm64 build..."
source_bin="target/aarch64-apple-darwin/release/jcode"
prepared_marker="target/aarch64-apple-darwin/release/fast-macos-release-prepared"
[[ -x "$source_bin" ]] || { echo "Error: macOS binary not found: $source_bin" >&2; exit 1; }
[[ -f "$prepared_marker" ]] || {
echo "Error: fast macOS release was not prepared. Run scripts/quick-release.sh --prepare-fast-macos $VERSION before the release metadata commit." >&2
exit 1
}
prepared_version="$(sed -n 's/^version=//p' "$prepared_marker")"
prepared_commit="$(sed -n 's/^commit=//p' "$prepared_marker")"
prepared_sha256="$(sed -n 's/^binary_sha256=//p' "$prepared_marker")"
[[ "$prepared_version" == "$VERSION_NUM" ]] || { echo "Error: prepared version $prepared_version does not match $VERSION_NUM." >&2; exit 1; }
[[ "$prepared_commit" == "$(git rev-parse HEAD^)" ]] || { echo "Error: prepared macOS binary commit is not the parent of the release commit." >&2; exit 1; }
[[ "$prepared_sha256" == "$(sha256sum "$source_bin" | cut -d' ' -f1)" ]] || { echo "Error: prepared macOS binary changed after preparation." >&2; exit 1; }
unexpected_release_files="$(git diff-tree --no-commit-id --name-only -r HEAD | grep -Ev '^(Cargo\.toml|Cargo\.lock|changelog/)' || true)"
[[ -z "$unexpected_release_files" ]] || {
echo "Error: the release metadata commit contains code or unsupported files:" >&2
printf '%s\n' "$unexpected_release_files" >&2
exit 1
}
cp "$source_bin" "$DIST/jcode-macos-aarch64"
chmod +x "$DIST/jcode-macos-aarch64"
file "$DIST/jcode-macos-aarch64" | grep -q 'Mach-O 64-bit' || { echo "Error: bad macOS binary" >&2; exit 1; }
(cd "$DIST" && tar czf jcode-macos-aarch64.tar.gz jcode-macos-aarch64)
(cd "$DIST" && sha256sum jcode-macos-aarch64.tar.gz > SHA256SUMS)
if $DRY_RUN; then
echo "Fast macOS dry run complete in $(elapsed)s. Artifacts in: $DIST"
trap - EXIT
exit 0
fi
tag_and_push
echo "▸ Publishing immediate macOS arm64 release..."
ensure_release_draft
gh release upload "$VERSION" "$DIST/jcode-macos-aarch64.tar.gz" "$DIST/SHA256SUMS" --clobber
gh release edit "$VERSION" --draft=false --latest
echo ""
echo "=== Fast macOS release published in $(elapsed)s ==="
echo " ✅ macOS arm64: public now from the prepared osxcross build"
echo " ⏳ CI: replacing it with the signoff build and adding Linux, macOS Intel, Windows, FreeBSD, signatures, and final checksums"
exit 0
fi
# Standard local distribution build: Linux + macOS in parallel.
echo "▸ Building Linux x86_64 + macOS aarch64 in parallel..."
(
JCODE_RELEASE_BUILD=1 JCODE_BUILD_SEMVER="$VERSION_NUM" scripts/build_linux_compat.sh "$DIST" >/dev/null
echo " ✅ Linux done ($(elapsed)s)"
) &
LINUX_PID=$!
(
JCODE_RELEASE_BUILD=1 JCODE_BUILD_SEMVER="$VERSION_NUM" \
CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" \
cargo build --release --target aarch64-apple-darwin --bin jcode 2>/dev/null
cp target/aarch64-apple-darwin/release/jcode "$DIST/jcode-macos-aarch64"
chmod +x "$DIST/jcode-macos-aarch64"
(cd "$DIST" && tar czf jcode-macos-aarch64.tar.gz jcode-macos-aarch64)
echo " ✅ macOS done ($(elapsed)s)"
) &
MACOS_PID=$!
wait $LINUX_PID || { echo "Error: Linux build failed"; exit 1; }
wait $MACOS_PID || { echo "Error: macOS build failed"; exit 1; }
echo ""
echo "Build time: $(elapsed)s"
ls -lh "$DIST"/*.tar.gz
file "$DIST/jcode-linux-x86_64.bin" | grep -q 'ELF 64-bit' || { echo "Error: bad Linux binary"; exit 1; }
head -1 "$DIST/jcode-linux-x86_64" | grep -q '^#!/' || { echo "Error: bad Linux wrapper"; exit 1; }
file "$DIST/jcode-macos-aarch64" | grep -q 'Mach-O 64-bit' || { echo "Error: bad macOS binary"; exit 1; }
if $DRY_RUN; then
echo ""
echo "Dry run complete. Binaries in: $DIST"
trap - EXIT
exit 0
fi
echo ""
tag_and_push
echo "▸ Staging GitHub draft release..."
ensure_release_draft
gh release upload "$VERSION" \
"$DIST/jcode-linux-x86_64.tar.gz" \
"$DIST/jcode-macos-aarch64.tar.gz" \
--clobber
echo ""
echo "=== Staged $VERSION in $(elapsed)s ==="
echo " ✅ Linux + macOS: attached to draft"
echo " ⏳ CI: building optional platforms, checksums, and publishing"
echo ""
echo "The release becomes visible when at least one platform asset and its checksum are ready."