#!/usr/bin/env bash # # DreamStack Release Script # # Reads .changeset/*.md files (YAML frontmatter with package bump types), # bumps per-package versions, regenerates changelogs, commits, and tags. # # Usage: # ./scripts/release.sh # apply changesets # ./scripts/release.sh --dry-run # preview without committing # ./scripts/release.sh --all # bump all packages (patch|minor|major) # set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" WORKSPACE_TOML="$ROOT_DIR/Cargo.toml" CHANGELOG="$ROOT_DIR/CHANGELOG.md" CHANGESET_DIR="$ROOT_DIR/.changeset" DRY_RUN=false BUMP_ALL="" # ── Package registry ───────────────────────────────────────────────── # name:path pairs for all workspace crates declare -A PKG_PATH=( [ds-parser]="compiler/ds-parser" [ds-analyzer]="compiler/ds-analyzer" [ds-codegen]="compiler/ds-codegen" [ds-layout]="compiler/ds-layout" [ds-types]="compiler/ds-types" [ds-incremental]="compiler/ds-incremental" [ds-cli]="compiler/ds-cli" [ds-physics]="engine/ds-physics" [ds-stream]="engine/ds-stream" [ds-stream-wasm]="engine/ds-stream-wasm" ) # ── Parse flags ────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --all) shift if [[ -z "${1:-}" ]]; then echo -e "${RED}Error:${NC} --all requires a bump type (patch|minor|major)" exit 1 fi BUMP_ALL="$1"; shift ;; *) echo -e "${RED}Error:${NC} Unknown argument: $1"; exit 1 ;; esac done # ── Helpers ────────────────────────────────────────────────────────── get_pkg_version() { local cargo_toml="$1/Cargo.toml" grep '^version' "$cargo_toml" | head -1 | sed 's/.*"\(.*\)".*/\1/' } bump_version() { local current="$1" bump="$2" local major minor patch IFS='.' read -r major minor patch <<< "$current" case "$bump" in major) echo "$((major + 1)).0.0" ;; minor) echo "$major.$((minor + 1)).0" ;; patch) echo "$major.$minor.$((patch + 1))" ;; *) echo "" ;; esac } set_pkg_version() { local cargo_toml="$1/Cargo.toml" local old_ver="$2" new_ver="$3" sed -i "0,/^version = \"$old_ver\"/s//version = \"$new_ver\"/" "$cargo_toml" } # ── Parse changesets ───────────────────────────────────────────────── # Reads YAML frontmatter from .changeset/*.md files # Format: # --- # "ds-parser": minor # "ds-codegen": patch # --- # Description of the change. declare -A BUMPS # package -> highest bump (major > minor > patch) declare -a DESCRIPTIONS # changeset descriptions BUMP_RANK_patch=1 BUMP_RANK_minor=2 BUMP_RANK_major=3 higher_bump() { local current="$1" candidate="$2" if [[ -z "$current" ]]; then echo "$candidate"; return; fi local cur_rank="BUMP_RANK_$current" local cand_rank="BUMP_RANK_$candidate" if [[ "${!cand_rank}" -gt "${!cur_rank}" ]]; then echo "$candidate" else echo "$current" fi } parse_changesets() { shopt -s nullglob local files=("$CHANGESET_DIR"/*.md) shopt -u nullglob for f in "${files[@]}"; do [[ "$(basename "$f")" == "README.md" ]] && continue local in_frontmatter=false local description="" while IFS= read -r line; do if [[ "$line" == "---" ]]; then if [[ "$in_frontmatter" == false ]]; then in_frontmatter=true continue else in_frontmatter=false continue fi fi if [[ "$in_frontmatter" == true ]]; then # Parse: "pkg-name": bump_type local pkg bump pkg=$(echo "$line" | sed -n 's/^"\([^"]*\)":.*/\1/p') bump=$(echo "$line" | sed -n 's/^"[^"]*": *\(.*\)/\1/p' | tr -d ' ') if [[ -n "$pkg" && -n "$bump" ]]; then BUMPS[$pkg]=$(higher_bump "${BUMPS[$pkg]:-}" "$bump") fi else if [[ -n "$line" ]]; then description+="$line"$'\n' fi fi done < "$f" if [[ -n "$description" ]]; then DESCRIPTIONS+=("$description") fi done } # ── Collect bumps ──────────────────────────────────────────────────── DESCRIPTIONS=() if [[ -n "$BUMP_ALL" ]]; then for pkg in "${!PKG_PATH[@]}"; do BUMPS[$pkg]="$BUMP_ALL" done echo -e "${CYAN}Bumping all packages:${NC} $BUMP_ALL" else parse_changesets fi if [[ ${#BUMPS[@]} -eq 0 ]]; then echo -e "${YELLOW}No changesets found.${NC} Nothing to release." echo "Add changesets in .changeset/ or use --all " exit 0 fi # ── Preview ────────────────────────────────────────────────────────── echo -e "${CYAN}DreamStack Release${NC}" echo "" for pkg in $(echo "${!BUMPS[@]}" | tr ' ' '\n' | sort); do local_path="${PKG_PATH[$pkg]:-}" if [[ -z "$local_path" ]]; then echo -e " ${YELLOW}⚠${NC} Unknown package: $pkg (skipping)" continue fi current=$(get_pkg_version "$ROOT_DIR/$local_path") new=$(bump_version "$current" "${BUMPS[$pkg]}") echo -e " ${GREEN}$pkg${NC} $current → $new (${BUMPS[$pkg]})" done echo "" if [[ ${#DESCRIPTIONS[@]} -gt 0 ]]; then echo -e "${CYAN}Changeset descriptions:${NC}" for desc in "${DESCRIPTIONS[@]}"; do echo " - $(echo "$desc" | head -1)" done echo "" fi if [[ "$DRY_RUN" == true ]]; then echo -e "${YELLOW}[DRY RUN]${NC} No changes made." exit 0 fi # ── Check prerequisites ───────────────────────────────────────────── if ! command -v git-cliff &>/dev/null; then echo -e "${RED}Error:${NC} git-cliff not found. Install with: cargo install git-cliff" exit 1 fi if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then echo -e "${RED}Error:${NC} Working directory is not clean. Commit or stash changes first." exit 1 fi # ── Step 1: Bump per-package versions ──────────────────────────────── echo -e "${GREEN}→${NC} Bumping package versions..." HIGHEST_BUMP="" for pkg in "${!BUMPS[@]}"; do local_path="${PKG_PATH[$pkg]:-}" [[ -z "$local_path" ]] && continue pkg_dir="$ROOT_DIR/$local_path" current=$(get_pkg_version "$pkg_dir") new=$(bump_version "$current" "${BUMPS[$pkg]}") if [[ -z "$new" ]]; then echo -e " ${RED}✗${NC} $pkg — invalid bump '${BUMPS[$pkg]}'" continue fi set_pkg_version "$pkg_dir" "$current" "$new" echo -e " ${GREEN}✓${NC} $pkg $current → $new" HIGHEST_BUMP=$(higher_bump "$HIGHEST_BUMP" "${BUMPS[$pkg]}") done # ── Step 2: Bump workspace version ────────────────────────────────── WORKSPACE_VERSION=$(grep -A2 '^\[workspace\.package\]' "$WORKSPACE_TOML" \ | grep '^version' | sed 's/version = "\(.*\)"/\1/') NEW_WORKSPACE=$(bump_version "$WORKSPACE_VERSION" "$HIGHEST_BUMP") if [[ -n "$NEW_WORKSPACE" ]]; then sed -i "s/^version = \"$WORKSPACE_VERSION\"/version = \"$NEW_WORKSPACE\"/" "$WORKSPACE_TOML" echo -e " ${GREEN}✓${NC} workspace $WORKSPACE_VERSION → $NEW_WORKSPACE" fi VERSION_TAG="v${NEW_WORKSPACE:-$WORKSPACE_VERSION}" # ── Step 3: Generate root changelog ───────────────────────────────── echo -e "${GREEN}→${NC} Generating root changelog..." git-cliff --config "$ROOT_DIR/cliff.toml" --tag "$VERSION_TAG" --output "$CHANGELOG" # ── Step 4: Update per-package changelogs from changesets ───────────── # Writes changeset descriptions into each affected crate's CHANGELOG.md # under the new version heading. This avoids git-cliff's --include-path # limitation where tag boundaries are lost if the tag commit doesn't # touch the crate's files. echo -e "${GREEN}→${NC} Updating per-package changelogs..." updated=0 for pkg in "${!BUMPS[@]}"; do local_path="${PKG_PATH[$pkg]:-}" [[ -z "$local_path" ]] && continue pkg_dir="$ROOT_DIR/$local_path" pkg_changelog="$pkg_dir/CHANGELOG.md" new_ver=$(bump_version "$(get_pkg_version "$pkg_dir")" "${BUMPS[$pkg]}") today=$(date +%Y-%m-%d) # Collect descriptions from changesets that mention this package pkg_entries="" for desc in "${DESCRIPTIONS[@]}"; do pkg_entries+="- $desc" done # If --all with no changesets, add a generic entry if [[ -z "$pkg_entries" && -n "$BUMP_ALL" ]]; then pkg_entries="- Version bump"$'\n' fi # Build the new version section version_section="## [$new_ver] - $today"$'\n'$'\n' if [[ -n "$pkg_entries" ]]; then version_section+="### 🚀 Changes"$'\n'$'\n' version_section+="$pkg_entries"$'\n' fi if [[ -f "$pkg_changelog" ]]; then # Insert new version section after "## [Unreleased]" line TEMP_FILE=$(mktemp) awk -v section="$version_section" ' /^## \[Unreleased\]/ { print print "" printf "%s", section next } { print } ' "$pkg_changelog" > "$TEMP_FILE" mv "$TEMP_FILE" "$pkg_changelog" else # Create changelog from scratch cat > "$pkg_changelog" << EOF # Changelog All notable changes to this package will be documented in this file. ## [Unreleased] $version_section EOF fi updated=$((updated + 1)) echo -e " ${GREEN}✓${NC} $pkg → $new_ver" done echo -e " Updated ${GREEN}$updated${NC} package changelogs" # ── Step 5: Clean up changeset files ──────────────────────────────── shopt -s nullglob changeset_files=("$CHANGESET_DIR"/*.md) shopt -u nullglob cleaned=0 for f in "${changeset_files[@]}"; do [[ "$(basename "$f")" == "README.md" ]] && continue rm "$f" cleaned=$((cleaned + 1)) done if [[ "$cleaned" -gt 0 ]]; then echo -e "${GREEN}→${NC} Cleaned up $cleaned changeset files." fi # ── Step 6: Commit and tag ────────────────────────────────────────── echo -e "${GREEN}→${NC} Committing release..." git -C "$ROOT_DIR" add -A git -C "$ROOT_DIR" commit -m "chore(release): $VERSION_TAG" git -C "$ROOT_DIR" tag -a "$VERSION_TAG" -m "Release $VERSION_TAG" echo "" echo -e "${GREEN}✓ Released $VERSION_TAG${NC}" echo "" echo -e " Push with: ${CYAN}git push && git push --tags${NC}"