#!/usr/bin/env bash # bump-release.sh — Canonical release-bump orchestrator for Red Bear OS. # # Policy (release-bump-pipeline.md): # - bump-release.sh DECIDES per fork. # - sync-versions.sh EXECUTES suffix-only label rewrites (single code path). # - refresh-fork-upstream-map.sh maintains the map's column-3 tag. # - upgrade-forks.sh --to= performs actual fork source bumps. # # Fork routing by map mode (release-bump-pipeline.md design constraint #2): # - snapshot/tracked → eligible for source bump via upgrade-forks.sh --to # - diverged → report-only; operator-manual rebase (Phase 2.4+). # Override with --force-diverged. # - base (tag=main) → always suffix-only; member crates untouched. # - bootloader → NEVER source-bumped (no merge-base with upstream; # one-time 'git replace --graft' is the documented path). # Upgrade candidates are resolved by latest upstream semver tag EXCLUDING any # tag already reachable from the fork's HEAD (ancestor tags are history, not # upgrades — see latest_tag_for_url). # # Modes: # (no args) Fork upstream discovery REPORT + label sync (delegates to # sync-versions.sh --no-regen) + hint to rerun with # --with-sources / --with-external when upgrades exist. # --with-sources For each fork whose map mode is snapshot/tracked AND whose # latest upstream semver tag is newer than the current base # (current base = fork Cargo.toml version stripped of +rb*): # upgrade-forks.sh --to= # On success: set fork Cargo.toml version to # "+rb", update the map row's column 3 in # place, regen that fork's Cargo.lock (best-effort), report # the rb-backup/* branch. On failure: report, leave label # and map untouched, continue with remaining forks. After # all source bumps: re-run sync-versions.sh --no-regen. # --with-external Invoke local/scripts/bump-graphics-recipes.sh (pass # through --dry-run / --no-fetch). Tolerate its absence # with a warning (a sibling unit is rewriting it # concurrently). # --check Fully read-only; print the same report; exit 1 if any # label drift (via sync-versions.sh --check) OR any fork # has an available source bump; exit 0 when fully synced. # --dry-run Print decisions, mutate nothing (implies no delegation # that mutates). # --no-fetch Skip all network (ls-remote); use the map's current tags # as "latest" (actions become report-only). # --regen Pass --regen instead of --no-regen to sync-versions.sh. # --force-diverged Pass --force-diverged to upgrade-forks.sh for diverged # forks (still reports them first). # --fork= Scope all fork operations to one fork. # # GUARDS: # - Current git branch must match ^[0-9]+\.[0-9]+\.[0-9]+$ (anchored semver). # Otherwise refuse — release bumps only happen on release branches. # - REDBEAR_CI=1: only --check / --dry-run are allowed. --with-sources and # --with-external are refused. # # NEVER AUTO-COMMITS. The script ends with an exact copy-paste commit guide # listing parent-repo files changed (map, Cargo.toml of cookbook + Cat 1 # crates) and per-fork git -C local/sources/ commit + push to # submodule/ reminders. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" SCRIPT_DIR="$ROOT/local/scripts" MAP="$ROOT/local/fork-upstream-map.toml" cd "$ROOT" # ---- argument parsing ------------------------------------------------------ WITH_SOURCES=0 WITH_EXTERNAL=0 CHECK_ONLY=0 DRY_RUN=0 NO_FETCH=0 REGEN=0 FORCE_DIVERGED=0 FORK_FILTER="" for arg in "$@"; do case "$arg" in --with-sources) WITH_SOURCES=1 ;; --with-external) WITH_EXTERNAL=1 ;; --check) CHECK_ONLY=1 ;; --dry-run) DRY_RUN=1 ;; --no-fetch) NO_FETCH=1 ;; --regen) REGEN=1 ;; --force-diverged) FORCE_DIVERGED=1 ;; --fork=*) FORK_FILTER="${arg#*=}" ;; -h|--help) sed -n '2,55p' "$0" exit 0 ;; *) echo "Unknown option: $arg" >&2; exit 1 ;; esac done # ---- branch guard ---------------------------------------------------------- BRANCH="$(git branch --show-current 2>/dev/null || echo "")" if [[ -z "$BRANCH" ]]; then echo "ERROR: cannot determine current git branch" >&2 exit 1 fi if [[ ! "$BRANCH" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "ERROR: branch '$BRANCH' is not anchored semver (X.Y.Z)" >&2 echo " Release bumps only happen on release branches." >&2 exit 1 fi # ---- CI guard -------------------------------------------------------------- if [[ "${REDBEAR_CI:-0}" == "1" ]]; then if [[ $WITH_SOURCES -eq 1 || $WITH_EXTERNAL -eq 1 ]]; then echo "ERROR: REDBEAR_CI=1 forbids --with-sources / --with-external" >&2 echo " Use --check or --dry-run only." >&2 exit 1 fi fi # Aggregate read-only flag: anything that must NOT mutate the tree. READ_ONLY=0 [[ $CHECK_ONLY -eq 1 || $DRY_RUN -eq 1 ]] && READ_ONLY=1 # ---- helpers --------------------------------------------------------------- SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+$' # Resolve the newest GENUINE upgrade tag for a remote via `git ls-remote`. # Output: "|" — tag empty when nothing qualifies. # Two filters: (a) tags already reachable from the fork's HEAD are history, # not upgrades (relibc's 0.6.0/2020 outranks its newer 0.2.x API line # lexically); (b) tags not version-greater than the current base are not # upgrades either (the base tag itself is usually an ancestor of HEAD). latest_tag_for_url() { local url="$1" fork="${2:-}" base="${3:-}" [[ $NO_FETCH -eq 0 ]] || { echo "|0"; return 0; } local tag forkdir="" raw=0 [[ -n "$fork" && -d "$ROOT/local/sources/$fork/.git" ]] \ && forkdir="$ROOT/local/sources/$fork" while IFS= read -r tag; do [[ -z "$tag" ]] && continue raw=$((raw + 1)) if [[ -n "$forkdir" ]] \ && git -C "$forkdir" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null 2>&1 \ && git -C "$forkdir" merge-base --is-ancestor "refs/tags/$tag" HEAD 2>/dev/null; then continue fi if [[ -n "$base" ]] && ! version_greater "$tag" "$base"; then continue fi echo "${tag}|${raw}" return 0 done < <(git ls-remote --tags --sort=-v:refname "$url" 2>/dev/null \ | awk '{print $2}' \ | sed 's|refs/tags/||' \ | sed 's|\^{}$||' \ | grep -E "$SEMVER_RE" || true) echo "|${raw}" return 0 } # version_greater → true iff a is version-greater than b (sort -V). version_greater() { [[ "$1" != "$2" ]] \ && [[ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | tail -1)" == "$1" ]] } # Compare two semvers. Echo -1 / 0 / 1. Echo "ERR" if either is non-semver. semver_cmp() { local a="$1" b="$2" if [[ ! "$a" =~ $SEMVER_RE || ! "$b" =~ $SEMVER_RE ]]; then echo "ERR"; return fi local a1 a2 a3 b1 b2 b3 IFS=. read -r a1 a2 a3 <<<"$a" IFS=. read -r b1 b2 b3 <<<"$b" if (( a1 < b1 )); then echo "-1"; return; fi if (( a1 > b1 )); then echo "1"; return; fi if (( a2 < b2 )); then echo "-1"; return; fi if (( a2 > b2 )); then echo "1"; return; fi if (( a3 < b3 )); then echo "-1"; return; fi if (( a3 > b3 )); then echo "1"; return; fi echo "0" } # Strip any +rb build-metadata suffix from a version string. strip_rb_suffix() { echo "$1" | sed -E 's/\+rb[0-9]+(\.[0-9]+\.[0-9]+)?$//' } # Read a fork's current base version from its Cargo.toml (stripped of +rb*). fork_current_base_version() { local fork="$1" local ct="$ROOT/local/sources/$fork/Cargo.toml" [[ -f "$ct" ]] || { echo ""; return; } local ver ver=$(grep -E '^version[[:space:]]*=' "$ct" | head -1 | sed 's/.*"\(.*\)".*/\1/' || true) strip_rb_suffix "$ver" } # ---- parse the map into 4 parallel arrays ---------------------------------- declare -a MAP_FORKS=() declare -a MAP_URLS=() declare -a MAP_TAGS=() declare -a MAP_MODES=() parse_map() { MAP_FORKS=() MAP_URLS=() MAP_TAGS=() MAP_MODES=() [[ -f "$MAP" ]] || return 0 while IFS=$'\t' read -r fork url tag mode; do [[ -z "$fork" ]] && continue MAP_FORKS+=("$fork") MAP_URLS+=("$url") MAP_TAGS+=("$tag") MAP_MODES+=("${mode:-}") done < <(awk ' /^[^#[:space:]]/ { fork = $1; url = $2; tag = $3; mode = $4 sub(/[ \t]*#.*/, "", mode) print fork "\t" url "\t" tag "\t" mode } ' "$MAP") } # In-place column-3 update for a single fork (byte-exact awk rewrite). # A sidecar tmp file is consumed by `mv`; no trap needed (if mv fails the # tmp file lingers harmlessly under $MAP.tmp.). update_map_tag_inplace() { local fork="$1" new_tag="$2" [[ -f "$MAP" ]] || return 0 local tmp="${MAP}.tmp.$$" awk -v fork="$fork" -v new_tag="$new_tag" ' $1 == fork && /^[^#[:space:]]/ { prefix_re = fork "[ \t]+[^ \t]+[ \t]+" if (match($0, prefix_re)) { prefix = substr($0, 1, RLENGTH) rest = substr($0, RLENGTH + 1) if (match(rest, /^[^ \t]+/)) { suffix = substr(rest, RLENGTH + 1) $0 = prefix new_tag suffix } } } { print } ' "$MAP" > "$tmp" && mv "$tmp" "$MAP" # Clean up a straggler if mv failed for some reason. [[ -e "$tmp" ]] && rm -f "$tmp" || true } # ---- counters -------------------------------------------------------------- SOURCE_BUMPS_AVAILABLE=0 SOURCE_BUMPS_APPLIED=0 SOURCE_BUMPS_FAILED=0 LABEL_DRIFT=0 # Latest-tag cache filled by phase_report; phase_source_bumps reuses it so # each fork costs exactly one ls-remote per run. declare -A LATEST_FOR=() # ---- phase: report --------------------------------------------------------- phase_report() { echo "=== Fork upstream discovery report ===" echo "branch=$BRANCH read_only=$READ_ONLY no_fetch=$NO_FETCH" echo "" local idx fork url current mode latest cargo_base basis cmp action for ((idx = 0; idx < ${#MAP_FORKS[@]}; idx++)); do fork="${MAP_FORKS[$idx]}" url="${MAP_URLS[$idx]}" current="${MAP_TAGS[$idx]}" mode="${MAP_MODES[$idx]}" if [[ -n "$FORK_FILTER" && "$fork" != "$FORK_FILTER" ]]; then continue fi if [[ "$current" == "main" ]]; then echo "fork=$fork mode=$mode current=$current latest=(skip) action=skip-main" continue fi if [[ "$fork" == "bootloader" ]]; then echo "fork=$fork mode=$mode current=$current latest=(skip) action=report-only note=no-merge-base-needs-git-replace-graft" continue fi if [[ "$mode" == "diverged" ]]; then echo "fork=$fork mode=diverged current=$current latest=(skip) action=report-only" continue fi # Decision basis: the fork's Cargo.toml base is ground truth; the map # tag is the fallback when the fork has no Cargo.toml version. cargo_base=$(fork_current_base_version "$fork") basis="$current" [[ -n "$cargo_base" ]] && basis="$cargo_base" local result raw latest="" if [[ $NO_FETCH -eq 0 ]]; then result=$(latest_tag_for_url "$url" "$fork" "$basis") latest="${result%%|*}" raw="${result##*|}" # Tags existed upstream but none qualified as newer → already current. [[ -z "$latest" && "$raw" -gt 0 ]] && latest="$basis" else latest="$basis" fi LATEST_FOR[$fork]="$latest" if [[ -z "$latest" ]]; then echo "fork=$fork mode=$mode current=$current latest=(unknown) action=skip-no-tag" continue fi cmp=$(semver_cmp "$basis" "$latest") case "$cmp" in -1) action="upgrade" SOURCE_BUMPS_AVAILABLE=$((SOURCE_BUMPS_AVAILABLE + 1)) ;; 0) action="ok" ;; 1) action="map-ahead-of-upstream" ;; *) action="skip-non-semver" ;; esac echo "fork=$fork mode=$mode current=$current latest=$latest cargo=$cargo_base basis=$basis action=$action" done echo "" echo "source_bumps_available=$SOURCE_BUMPS_AVAILABLE" } # ---- phase: label sync ----------------------------------------------------- phase_label_sync() { local sync_flag="--no-regen" [[ $REGEN -eq 1 ]] && sync_flag="--regen" if [[ $READ_ONLY -eq 1 ]]; then echo "" echo "=== Label sync (read-only: sync-versions.sh --check) ===" if "$SCRIPT_DIR/sync-versions.sh" --check >/tmp/br-label-sync.$$ 2>&1; then LABEL_DRIFT=0 echo " OK: no label drift" else LABEL_DRIFT=1 echo " DRIFT detected (sync-versions.sh --check exit non-zero)" grep -E '^(DRIFT|FAIL)' /tmp/br-label-sync.$$ | head -20 | sed 's/^/ /' || true fi rm -f /tmp/br-label-sync.$$ return fi echo "" echo "=== Label sync: sync-versions.sh $sync_flag ===" "$SCRIPT_DIR/sync-versions.sh" "$sync_flag" || true } # ---- phase: source bumps --------------------------------------------------- phase_source_bumps() { if [[ $WITH_SOURCES -eq 0 ]]; then return fi if [[ $READ_ONLY -eq 1 ]]; then echo "" echo "(--dry-run / --check: source bumps NOT executed)" if [[ $SOURCE_BUMPS_AVAILABLE -gt 0 ]]; then echo "(rerun with --with-sources to perform $SOURCE_BUMPS_AVAILABLE bump(s))" fi return fi echo "" echo "=== Fork source bumps (--with-sources) ===" local idx fork url current mode latest cargo_base basis cmp ct old_ver new_ver backup_branch local -a upgrade_args=() for ((idx = 0; idx < ${#MAP_FORKS[@]}; idx++)); do fork="${MAP_FORKS[$idx]}" url="${MAP_URLS[$idx]}" current="${MAP_TAGS[$idx]}" mode="${MAP_MODES[$idx]}" if [[ -n "$FORK_FILTER" && "$fork" != "$FORK_FILTER" ]]; then continue; fi if [[ "$current" == "main" ]]; then continue; fi # Bootloader is never source-bumped, even with --force-diverged: its # git history is genuinely unrelated to upstream (no merge-base), so # the rebase machinery cannot work. The documented path is a one-time # operator 'git replace --graft' intervention (RELEASE-BUMP-WORKFLOW.md). if [[ "$fork" == "bootloader" ]]; then echo " (bootloader: never auto-bumped — no merge-base with upstream;" echo " one-time operator 'git replace --graft' intervention required;" echo " see local/docs/RELEASE-BUMP-WORKFLOW.md)" continue fi case "$mode" in snapshot|tracked) ;; diverged) # phase_report already showed action=report-only; with # --force-diverged we could attempt, but the map's current # tag is the manual base. Skip unless --force-diverged. if [[ $FORCE_DIVERGED -eq 1 ]]; then echo " ($fork: diverged + --force-diverged; attempting)" else continue fi ;; *) continue ;; esac cargo_base=$(fork_current_base_version "$fork") basis="$current" [[ -n "$cargo_base" ]] && basis="$cargo_base" # Reuse the report phase's ls-remote result; only fetch when this fork # was not covered (e.g. report filtered it out). if [[ -v "LATEST_FOR[$fork]" ]]; then latest="${LATEST_FOR[$fork]}" elif [[ $NO_FETCH -eq 0 ]]; then local result raw result=$(latest_tag_for_url "$url" "$fork" "$basis") latest="${result%%|*}" raw="${result##*|}" [[ -z "$latest" && "$raw" -gt 0 ]] && latest="$basis" else latest="$basis" fi [[ -z "$latest" ]] && continue cmp=$(semver_cmp "$basis" "$latest") [[ "$cmp" == "-1" ]] || continue echo "" echo "--- Bumping $fork: $basis -> $latest ---" upgrade_args=(--to="$latest") [[ $FORCE_DIVERGED -eq 1 ]] && upgrade_args+=(--force-diverged) upgrade_args+=("$fork") if ! "$SCRIPT_DIR/upgrade-forks.sh" "${upgrade_args[@]}"; then echo " FAIL: upgrade-forks.sh ${upgrade_args[*]} failed" >&2 echo " Leaving label + map untouched for $fork" >&2 SOURCE_BUMPS_FAILED=$((SOURCE_BUMPS_FAILED + 1)) continue fi # On success: set fork Cargo.toml version to +rb. # Quote-splice via awk with literal index() — sed regexes break on the # '.'/'+' metacharacters present in versions like 0.9.0+rb0.3.1. ct="$ROOT/local/sources/$fork/Cargo.toml" if [[ -f "$ct" ]]; then old_ver=$(grep -E '^version[[:space:]]*=' "$ct" | head -1 | sed 's/.*"\(.*\)".*/\1/' || true) new_ver="${latest}+rb${BRANCH}" if [[ -n "$old_ver" ]]; then awk -v old="$old_ver" -v new="$new_ver" ' $0 ~ "^version[[:space:]]*=" { q1 = index($0, "\"") q2 = index(substr($0, q1 + 1), "\"") + q1 if (q1 > 0 && q2 > q1 && substr($0, q1 + 1, q2 - q1 - 1) == old) { $0 = substr($0, 1, q1) new substr($0, q2) } print; next } { print } ' "$ct" > "$ct.tmp.$$" && mv "$ct.tmp.$$" "$ct" [[ -e "$ct.tmp.$$" ]] && rm -f "$ct.tmp.$$" || true echo " Cargo.toml: $old_ver -> $new_ver" fi fi # Regen the fork's Cargo.lock (best-effort: warn on failure, continue) if [[ -f "$ROOT/local/sources/$fork/Cargo.toml" ]]; then if (cd "$ROOT/local/sources/$fork" && cargo generate-lockfile >/dev/null 2>&1); then echo " Cargo.lock: regenerated" else echo " WARN: cargo generate-lockfile failed for $fork (continuing)" >&2 fi fi # Update the map row's column 3 in place update_map_tag_inplace "$fork" "$latest" echo " Map: column 3 ($current -> $latest)" # Report the rb-backup/* branch created by upgrade-forks.sh backup_branch=$(git -C "$ROOT/local/sources/$fork" branch --list 'rb-backup/*' 2>/dev/null | tail -1 | sed 's/^[* ]*//' || true) if [[ -n "$backup_branch" ]]; then echo " Backup branch: $backup_branch" fi SOURCE_BUMPS_APPLIED=$((SOURCE_BUMPS_APPLIED + 1)) done # Re-sync labels after all source bumps land echo "" echo "=== Post-bump label sync (sync-versions.sh --no-regen) ===" "$SCRIPT_DIR/sync-versions.sh" --no-regen || true } # ---- phase: external bumps ------------------------------------------------- phase_external() { [[ $WITH_EXTERNAL -eq 0 ]] && return local script="$SCRIPT_DIR/bump-graphics-recipes.sh" if [[ ! -e "$script" ]]; then echo "" echo "WARN: bump-graphics-recipes.sh not present; skipping external bump" >&2 echo " (a sibling unit is rewriting it concurrently)" >&2 return fi local -a args=() [[ $DRY_RUN -eq 1 ]] && args+=(--dry-run) [[ $NO_FETCH -eq 1 ]] && args+=(--no-fetch) echo "" echo "=== External bumps (bump-graphics-recipes.sh ${args[*]:-}) ===" "$script" "${args[@]}" || true } # ---- phase: commit guide --------------------------------------------------- print_commit_guide() { echo "" echo "=== Commit guide (NEVER executed automatically by this script) ===" echo "" echo "Parent-repo files potentially changed:" echo " local/fork-upstream-map.toml (column-3 tag updates)" echo " Cargo.toml (cookbook root, on label drift)" echo " local/recipes/*/source/Cargo.toml (Cat 1 crates, on label drift)" echo " local/sources//Cargo.toml (fork base version, on source bump)" echo " local/sources//Cargo.lock (fork lockfile, on source bump)" echo "" echo "Commit them on branch '$BRANCH':" echo " git -C \"$ROOT\" add local/fork-upstream-map.toml Cargo.toml \\" echo " local/recipes/ local/sources/" echo " git -C \"$ROOT\" commit -m \"release: bump fork tags + labels to $BRANCH\"" echo "" echo "Per-fork commits + pushes (for source bumps; replace per fork):" if [[ -n "$FORK_FILTER" ]]; then echo " git -C \"$ROOT/local/sources/$FORK_FILTER\" add -A" echo " git -C \"$ROOT/local/sources/$FORK_FILTER\" commit -m \"bump: $FORK_FILTER to +rb$BRANCH\"" echo " git -C \"$ROOT/local/sources/$FORK_FILTER\" push origin submodule/$FORK_FILTER" else local idx fork mode current for ((idx = 0; idx < ${#MAP_FORKS[@]}; idx++)); do fork="${MAP_FORKS[$idx]}" mode="${MAP_MODES[$idx]}" current="${MAP_TAGS[$idx]}" [[ "$current" == "main" ]] && continue [[ "$mode" == "diverged" ]] && continue echo " # $fork (current tag: $current)" echo " git -C \"$ROOT/local/sources/$fork\" add -A" echo " git -C \"$ROOT/local/sources/$fork\" commit -m \"bump: $fork to +rb$BRANCH\"" echo " git -C \"$ROOT/local/sources/$fork\" push origin submodule/$fork" done fi } # ---- main ------------------------------------------------------------------ parse_map phase_report phase_label_sync phase_source_bumps phase_external print_commit_guide if [[ $CHECK_ONLY -eq 1 ]]; then echo "" if [[ $LABEL_DRIFT -gt 0 ]]; then echo "CHECK FAILED: label drift detected" exit 1 fi if [[ $SOURCE_BUMPS_AVAILABLE -gt 0 ]]; then echo "CHECK FAILED: $SOURCE_BUMPS_AVAILABLE fork(s) have available source bumps" exit 1 fi echo "CHECK PASSED: no drift, no available source bumps" exit 0 fi exit 0