Files
omarchy-pkgs/bin/omarchy-release
T
Ryan Hughes a8c9e2982e Stop pick from hiding long-lived PRs behind a creation-ordered window
`gh pr list` orders by creation date, so `--limit 30` cut the candidate set by
when PRs were opened, not when they merged. The `sort_by(.mergedAt)` that
followed only reordered whatever survived that cut. A PR opened before the
window but merged inside it — exactly the kind a release branch still needs —
never reached the already-on-branch check at all. #7649 and #7709 were both
missing from v4-0-2's list for this reason.

The window is now bounded by the branch point instead of a count: pull a wide
page and keep what merged after the merge-base's commit date, since anything
merged into the dev branch before the release branch left it is already there
by ancestry. v4-0-2 went from 11 candidates to 31.

Widening it surfaced a second gap. A change re-applied by hand carries neither
a PR number nor a cherry-pick trailer, so already_on_branch could not see it
and offered it again (#6939, applied as 33d7363c). It now also compares the PR
title against the branch's subjects, with any trailing "(#N)" stripped.
2026-08-28 15:13:58 -04:00

1074 lines
41 KiB
Bash
Executable File

#!/bin/bash
# omarchy-release — the front door for cutting Omarchy releases.
#
# A release train has three human moments, each one command:
# start open the train: release branch + staging PR (+ advance edge -> rc)
# rc cut a candidate: pin PKGBUILDs to the branch head, publish to rc
# ship make it official: final pins, promote rc -> stable, tag, GH release,
# ISO, website
#
# Run bare `omarchy-release` to be shepherded: it observes reality (branches,
# pins, published channels, tags) and offers the correct next step. Every
# subcommand is idempotent — a re-run checks what is already done and skips it.
#
# Versions are inferred from branch names: branch v4-0-2 -> RCs 4.0.2rcN ->
# tag v4.0.2. `start` is the only place a version is typed.
set -e
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
source "$BUILD_ROOT/helpers/message-helpers.sh"
source "$BUILD_ROOT/helpers/paths.sh"
source "$BUILD_ROOT/helpers/host-helpers.sh"
UPSTREAM_URL="${OMARCHY_UPSTREAM_URL:-https://github.com/basecamp/omarchy.git}"
UPSTREAM_REPO="${OMARCHY_UPSTREAM_REPO:-basecamp/omarchy}"
SITE_REPO="${OMARCHY_SITE_REPO:-omacom-io/omarchy-site}"
ISO_REPO="${OMARCHY_ISO_REPO:-omacom-io/omarchy-iso}"
DEV_BRANCH="${OMARCHY_DEV_BRANCH:-quattro}"
PKGS_DB_BASE="${OMARCHY_PKGS_DB_BASE:-https://pkgs.omarchy.org}"
RC_DB_URL="$PKGS_DB_BASE/rc/x86_64/omarchy.db.tar.zst"
SRCDEST_DIR="$BUILD_ROOT/.srcdest"
MIRROR_CLONE="$SRCDEST_DIR/omarchy" # bare mirror (shared with bin/omarchy-pkgs)
WORK_CLONE="$SRCDEST_DIR/omarchy-work" # working clone for pick/cherry-pick
RC_WORKTREE="$BUILD_ROOT/.worktrees/rc" # pkgs repo rc branch worktree
ASSUME_YES=false
REPO_HOST_OVERRIDE=""
show_usage() {
cat <<EOF
Usage: $0 [command] [options]
Run with no command to be shepherded through whatever the next step is.
Commands:
start [X.Y.Z] Open a release train: create branch v X-Y-Z on $UPSTREAM_REPO
(from the previous tag for a patch, from $DEV_BRANCH for a
minor/major), open the release-notes staging PR, and for a
minor/major advance edge -> rc on the build host
pick [pr#|sha ...] Cherry-pick merged PRs onto the release branch; with no
args, choose from a list of merged $DEV_BRANCH PRs
rc Cut the next X.Y.ZrcN into the rc channel
ship Final pins, promote rc -> stable, tag, GitHub release,
ISO (prompted), website bump
status Show where the train stands (read-only)
doctor Verify every credential and connection the flow needs
self-test Run helper unit tests
Options:
--yes Skip confirmation prompts (for scripting/CI)
--host <host> Build host ssh destination (else \$OMARCHY_REPO_HOST or .repo-host)
--iso / --no-iso (rc, ship) Build the ISO without asking / skip it
--no-wait (rc, ship) Do not poll for the published build
-h, --help Show this help
EOF
}
confirm() {
local prompt="$1" reply
[[ "$ASSUME_YES" == true ]] && return 0
read -r -p "$prompt [y/N] " reply
[[ "$reply" =~ ^[Yy]$ ]]
}
# --- version <-> branch ------------------------------------------------------
version_to_branch() { # 4.0.2 -> v4-0-2
local v="${1#v}"
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
echo "v${v//./-}"
}
branch_to_version() { # v4-0-2 -> 4.0.2
local b="$1"
[[ "$b" =~ ^v([0-9]+)-([0-9]+)-([0-9]+)$ ]] || return 1
echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
}
version_is_patch() { # Z > 0
[[ "$1" =~ ^[0-9]+\.[0-9]+\.([0-9]+)$ ]] && ((BASH_REMATCH[1] > 0))
}
previous_patch_tag() { # 4.0.2 -> v4.0.1
[[ "$1" =~ ^([0-9]+\.[0-9]+)\.([0-9]+)$ ]] || return 1
echo "v${BASH_REMATCH[1]}.$((BASH_REMATCH[2] - 1))"
}
# --- upstream queries --------------------------------------------------------
ls_remote() { git ls-remote "$UPSTREAM_URL" "$@"; }
tag_exists() { [[ -n "$(ls_remote "refs/tags/$1" | head -1)" ]]; }
branch_head() { ls_remote "refs/heads/$1" | awk '{print $1}'; }
resolve_tag_commit() {
local tag="$1" peeled sha
peeled=$(ls_remote "refs/tags/$tag^{}" | awk '{print $1}')
sha=$(ls_remote "refs/tags/$tag" | awk '{print $1}')
echo "${peeled:-$sha}"
}
# Newest v*-*-* release branch, optionally only untagged ones. Shipping tags
# the version, so "tag exists" is what closes a train — but ship itself also
# needs to find a tagged train whose remaining steps (release, ISO, website)
# didn't finish, so it can resume.
newest_release_branch() { # newest_release_branch [--untagged]
local untagged_only=false
[[ "${1:-}" == "--untagged" ]] && untagged_only=true
local branch best_ver="" best_branch="" ver
while IFS= read -r branch; do
ver=$(branch_to_version "$branch") || continue
if [[ "$untagged_only" == true ]] && tag_exists "v$ver"; then
continue
fi
if [[ -z "$best_ver" ]] || [[ $(vercmp "$ver" "$best_ver") -gt 0 ]]; then
best_ver="$ver" best_branch="$branch"
fi
done < <(ls_remote 'refs/heads/v*' | awk -F/ '{print $3}')
[[ -n "$best_branch" ]] && echo "$best_branch"
}
open_train_branch() { newest_release_branch --untagged; }
# Reads go over anonymous HTTPS; pushes go over SSH like every checkout the
# operator owns. Pushing over HTTPS would drag in git's credential-helper
# config, which breaks the moment a stale absolute gh path is baked into it.
ssh_push_url() { # https://github.com/a/b.git -> git@github.com:a/b.git
local url="$1"
if [[ "$url" =~ ^https://github\.com/(.+)$ ]]; then
echo "git@github.com:${BASH_REMATCH[1]}"
else
echo "$url"
fi
}
UPSTREAM_PUSH_URL="${OMARCHY_UPSTREAM_PUSH_URL:-$(ssh_push_url "$UPSTREAM_URL")}"
ensure_mirror_clone() {
if [[ -d "$MIRROR_CLONE" ]]; then
git -C "$MIRROR_CLONE" fetch --quiet origin
else
mkdir -p "$SRCDEST_DIR"
print_info "Cloning $UPSTREAM_URL (cached in $SRCDEST_DIR)..."
git clone --mirror --quiet "$UPSTREAM_URL" "$MIRROR_CLONE"
fi
git -C "$MIRROR_CLONE" remote set-url --push origin "$UPSTREAM_PUSH_URL"
}
ensure_work_clone() {
if [[ -d "$WORK_CLONE" ]]; then
git -C "$WORK_CLONE" fetch --quiet origin
else
mkdir -p "$SRCDEST_DIR"
print_info "Cloning $UPSTREAM_URL working copy..."
git clone --quiet "$UPSTREAM_URL" "$WORK_CLONE"
fi
git -C "$WORK_CLONE" remote set-url --push origin "$UPSTREAM_PUSH_URL"
}
# --- published channel state -------------------------------------------------
# Prints omarchy's published version in a channel; empty when absent, rc 2 when
# the database cannot be read (callers must not mistake an outage for absence).
published_version() {
local channel="$1" tmp descs
tmp=$(mktemp) || return 2
if ! curl -sf "$PKGS_DB_BASE/$channel/x86_64/omarchy.db.tar.zst" -o "$tmp"; then
rm -f "$tmp"
return 2
fi
if ! descs=$(tar -xO --zstd -f "$tmp" --wildcards '*/desc' 2>/dev/null); then
rm -f "$tmp"
return 2
fi
rm -f "$tmp"
awk '
function emit() {
if (!found && name == "omarchy" && version != "") { print version; found = 1 }
name = ""; version = ""
}
$0 == "%FILENAME%" { emit(); next }
$0 == "%NAME%" { getline; name = $0; next }
$0 == "%VERSION%" { getline; version = $0; next }
END { emit() }
' <<<"$descs"
}
# The rc branch of THIS repo carries the current pins. Read them without
# touching the working tree.
rc_branch_pin() { # prints "pkgver commit", empty when no rc branch
local ref="origin/rc" pkgbuild
git -C "$BUILD_ROOT" fetch --quiet origin rc 2>/dev/null || true
pkgbuild=$(git -C "$BUILD_ROOT" show "$ref:pkgbuilds/omarchy/PKGBUILD" 2>/dev/null) || return 0
local pkgver commit
pkgver=$(grep -E '^pkgver=' <<<"$pkgbuild" | head -1 | cut -d= -f2)
commit=$(grep -E '^_commit=' <<<"$pkgbuild" | head -1 | cut -d= -f2 | tr -d "'\"")
[[ -n "$pkgver" ]] && echo "$pkgver $commit"
}
# --- build host --------------------------------------------------------------
#
# Every command runs from anywhere: when this machine IS the build host
# (on_repo_host — the published database lives here), host operations execute
# locally; otherwise they go over ssh to the configured destination. The
# destination is anything ssh accepts — root@<ip>, root@<hostname>, or an
# ~/.ssh/config Host alias — from --host, $OMARCHY_REPO_HOST, or .repo-host.
repo_host() { resolve_repo_host "$REPO_HOST_OVERRIDE"; }
print_no_host_help() { # print_no_host_help <what> <script>
print_warning "Not on the build host, and none configured (--host, OMARCHY_REPO_HOST, or $BUILD_ROOT/.repo-host — any ssh destination, e.g. root@<host> or an ssh-config alias)"
echo "Run this on the build host to $1:" >&2
echo "$2" >&2
}
# Builds the pinned release pair for the rc channel from the rc branch's own
# worktree, creating it on first use. OMARCHY_RC_PINS marks this as the one
# build allowed to set those packages' rc versions; OMARCHY_REPO_ROOT points
# the worktree at the primary checkout's channel tree so all three channels
# stay in one place. Fast-ring packages are not built here — the scheduled rc
# release covers those from master.
RC_TRIGGER_SCRIPT='
set -e
git -C /root/omarchy-pkgs fetch origin rc
if [ ! -d /root/omarchy-pkgs-rc ]; then
git -C /root/omarchy-pkgs worktree add /root/omarchy-pkgs-rc rc 2>/dev/null ||
git -C /root/omarchy-pkgs worktree add --track -b rc /root/omarchy-pkgs-rc origin/rc
fi
git -C /root/omarchy-pkgs-rc fetch origin rc
git -C /root/omarchy-pkgs-rc reset --hard origin/rc
cd /root/omarchy-pkgs-rc
OMARCHY_RC_PINS=1 OMARCHY_REPO_ROOT=/root/omarchy-pkgs/pkgs.omarchy.org \
bin/repo release --mirror rc --package omarchy omarchy-settings --skip-prod-check
'
trigger_rc_build() {
local host
if host=$(repo_host); then
print_info "Building the RC on $host (this takes a while)..."
ssh "$host" "source /root/.omarchy/build-credentials 2>/dev/null; $RC_TRIGGER_SCRIPT"
elif on_repo_host; then
print_info "Building the RC locally (this is the build host)..."
bash -c "$RC_TRIGGER_SCRIPT"
else
print_no_host_help "build the release candidate" "$RC_TRIGGER_SCRIPT"
return 1
fi
}
host_advance() { # host_advance <from> <to> [extra args...]
local from="$1" to="$2"
shift 2
# bin/repo is the remote control: with a host configured it forwards this
# over ssh itself; on the host it runs locally. Only the "neither" case —
# a workstation with no host — must be refused here, because running it
# against this machine's (likely stale) local tree would be wrong.
if ! resolve_repo_host "$REPO_HOST_OVERRIDE" >/dev/null && ! on_repo_host; then
print_no_host_help "advance $from -> $to" \
" cd /root/omarchy-pkgs && bin/repo advance --from $from --to $to --skip-prod-check $*"
return 1
fi
"$BUILD_ROOT/bin/repo" advance --from "$from" --to "$to" --skip-prod-check "$@"
}
wait_for_published() { # wait_for_published <channel> <version> [timeout-seconds]
local channel="$1" want="$2" timeout="${3:-3600}" waited=0 got
print_info "Waiting for $want to appear in the $channel channel (up to $((timeout / 60))m)..."
while ((waited < timeout)); do
got=$(published_version "$channel" 2>/dev/null) || got=""
if [[ "${got%-*}" == "$want" ]]; then
print_success "$channel now serves omarchy $got"
return 0
fi
sleep 60
waited=$((waited + 60))
printf '.' >&2
done
echo "" >&2
print_warning "Timed out waiting for $want in $channel (currently: ${got:-unknown})"
print_info "The build may still be running — re-run this command to resume."
return 1
}
# --- pkgs rc branch (pin commits) --------------------------------------------
# The rc branch is rebuilt as origin/master + pin commit(s) for each cut and
# force-pushed; the build host follows with reset --hard. History on it is
# disposable — the pins fully describe the release.
prepare_rc_worktree() {
git -C "$BUILD_ROOT" fetch --quiet origin
if [[ ! -d "$RC_WORKTREE" ]]; then
mkdir -p "$(dirname "$RC_WORKTREE")"
git -C "$BUILD_ROOT" worktree add --quiet -B rc "$RC_WORKTREE" origin/master
else
git -C "$RC_WORKTREE" checkout --quiet -B rc origin/master
git -C "$RC_WORKTREE" reset --quiet --hard origin/master
fi
}
cut_pins() { # cut_pins <omarchy-pkgs release args...>
prepare_rc_worktree
# The pin engine's "edge DB" is the ordering floor and rc auto-numbering
# source; in the pipeline model that floor is the rc channel.
(cd "$RC_WORKTREE" &&
OMARCHY_EDGE_DB_URL="$RC_DB_URL" bin/omarchy-pkgs release "$@" --no-push --yes)
git -C "$RC_WORKTREE" push --force-with-lease origin rc
}
# --- ISO ---------------------------------------------------------------------
iso_checkout() { # prints a usable omarchy-iso checkout, cloning to tmp if needed
if [[ -n "${OMARCHY_ISO_DIR:-}" && -d "${OMARCHY_ISO_DIR:-}" ]]; then
echo "$OMARCHY_ISO_DIR"
return 0
fi
if [[ -d "$BUILD_ROOT/../omarchy-iso/.git" ]]; then
realpath "$BUILD_ROOT/../omarchy-iso"
return 0
fi
local tmp="$SRCDEST_DIR/omarchy-iso"
if [[ -d "$tmp" ]]; then
git -C "$tmp" pull --ff-only --quiet || true
else
print_info "Cloning $ISO_REPO..." >&2
git clone --quiet "https://github.com/$ISO_REPO.git" "$tmp"
fi
echo "$tmp"
}
build_iso() { # build_iso <version> [--rc]
local version="$1" rc_flag="${2:-}" dir
dir=$(iso_checkout) || return 1
if ! command -v docker >/dev/null || ! docker info >/dev/null 2>&1; then
print_warning "Docker unavailable — cannot build the ISO here. Run on a Docker machine:"
echo " cd $dir && bin/omarchy-iso-release ${rc_flag:+$rc_flag }$version"
return 1
fi
print_info "Building ${rc_flag:+RC }ISO $version (this takes a while)..."
(cd "$dir" && PATH="$dir/bin:$PATH" bin/omarchy-iso-release ${rc_flag:+"$rc_flag"} "$version")
}
maybe_iso() { # maybe_iso <version> <rc|final> <iso_mode: ask|yes|no>
local version="$1" kind="$2" mode="$3" rc_flag=""
[[ "$kind" == "rc" ]] && rc_flag="--rc"
case "$mode" in
no) return 0 ;;
yes) build_iso "$version" "$rc_flag" ;;
ask)
if confirm "Build and upload the ${kind} ISO for $version?"; then
build_iso "$version" "$rc_flag"
fi
;;
esac
}
# --- website -----------------------------------------------------------------
update_website() { # update_website <version>
local version="$1" tmp="$SRCDEST_DIR/omarchy-site"
print_info "Updating $SITE_REPO ISO references to $version..."
if [[ -d "$tmp" ]]; then
git -C "$tmp" fetch --quiet origin && git -C "$tmp" reset --quiet --hard origin/HEAD
else
git clone --quiet "git@github.com:$SITE_REPO.git" "$tmp" || {
print_warning "Could not clone $SITE_REPO — update the ISO URL there manually"
return 1
}
fi
local matches
matches=$(grep -rIlE 'omarchy-[0-9]+\.[0-9]+\.[0-9]+(-rc)?\.iso' "$tmp" --exclude-dir=.git || true)
if [[ -z "$matches" ]]; then
print_warning "No omarchy-<version>.iso references found in $SITE_REPO — update it manually"
return 1
fi
echo "$matches" | while IFS= read -r f; do
sed -i -E "s/omarchy-[0-9]+\.[0-9]+\.[0-9]+\.iso/omarchy-$version.iso/g" "$f"
echo " updated: ${f#"$tmp"/}"
done
if git -C "$tmp" diff --quiet; then
print_info "Website already references $version"
return 0
fi
git -C "$tmp" --no-pager diff --stat
confirm "Push this ISO URL bump to $SITE_REPO?" || {
print_info "Left uncommitted in $tmp"
return 1
}
git -C "$tmp" commit --quiet -am "Point ISO download at omarchy-$version.iso" &&
git -C "$tmp" push --quiet origin HEAD
print_success "Website updated to omarchy-$version.iso"
}
# --- commands ----------------------------------------------------------------
cmd_start() {
local version="${1:-}"
if [[ -z "$version" ]]; then
read -r -p "Version to release (X.Y.Z): " version
fi
version="${version#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version '$version' (expected X.Y.Z)"
exit 1
fi
local branch
branch=$(version_to_branch "$version")
print_header "Open release train $version"
if tag_exists "v$version"; then
print_error "v$version is already tagged on $UPSTREAM_REPO — this train has shipped"
exit 1
fi
# Base: previous tag for a patch, dev branch head for a minor/major.
local base_ref base_sha kind
if version_is_patch "$version"; then
kind="patch"
base_ref=$(previous_patch_tag "$version")
if ! tag_exists "$base_ref"; then
print_error "Base tag $base_ref not found on $UPSTREAM_REPO"
exit 1
fi
base_sha=$(resolve_tag_commit "$base_ref")
else
kind="minor/major"
base_ref="$DEV_BRANCH"
base_sha=$(branch_head "$DEV_BRANCH")
fi
if [[ -n "$(branch_head "$branch")" ]]; then
print_success "Release branch $branch already exists — adopting it"
else
print_info "Release type: $kind — branching $branch from $base_ref (${base_sha:0:12})"
confirm "Create branch $branch on $UPSTREAM_REPO?" || exit 1
ensure_mirror_clone
git -C "$MIRROR_CLONE" push --quiet origin "$base_sha:refs/heads/$branch"
print_success "Created $branch"
fi
# Staging PR: the body is the release-notes staging ground. A branch with no
# commits beyond its base cannot carry a PR yet; created lazily by pick.
if command -v gh >/dev/null; then
if gh pr view "$branch" --repo "$UPSTREAM_REPO" >/dev/null 2>&1; then
print_success "Staging PR for $branch already open"
elif ! gh pr create --repo "$UPSTREAM_REPO" --draft --base "$DEV_BRANCH" --head "$branch" \
--title "Release v$version" \
--body "Staging ground for the v$version release notes. Edit this body — it becomes the GitHub release text." 2>/dev/null; then
print_info "Staging PR not created yet (no commits on $branch) — it opens after the first pick"
fi
fi
# Minor/major trains ship new edge packages: carry edge forward into rc so
# RC testing runs against the set stable users will get. A failed advance
# must not pass silently — RCs would test against the previous rc set.
if [[ "$kind" != "patch" ]]; then
if ! host_advance edge rc; then
print_error "edge → rc advance did not complete — the train is NOT fully open"
echo "Fix the advance (it is idempotent), then re-run: omarchy-release start $version"
echo "start is idempotent too — the branch and PR above are kept."
exit 1
fi
else
print_info "Patch train: rc already mirrors stable — nothing to advance"
fi
echo ""
print_success "Train $version is open"
print_info "Next: omarchy-release pick (cherry-pick fixes), then omarchy-release rc"
}
cmd_pick() {
local branch version
branch=$(open_train_branch) || true
if [[ -z "$branch" ]]; then
print_error "No open release train — run: omarchy-release start"
exit 1
fi
version=$(branch_to_version "$branch")
print_header "Pick changes onto $branch ($version)"
ensure_work_clone
git -C "$WORK_CLONE" checkout --quiet -B "$branch" "origin/$branch"
# Changes usually reach a release branch as BACKPORTS — cherry-picks with
# new SHAs — so ancestry of the original quattro merge commit proves nothing
# on a patch branch. Detect equivalence the way it is actually recorded:
# the branch's own commit messages since it left quattro name the PR
# ("backport of #N", squash titles "(#N)") or the source commit
# ("cherry picked from commit <sha>", which pick -x writes) — or, failing
# both, repeat its title verbatim.
local base_commit base_date branch_log
base_commit=$(git -C "$WORK_CLONE" merge-base "origin/$branch" "origin/$DEV_BRANCH" 2>/dev/null) || base_commit=""
base_date=""
if [[ -n "$base_commit" ]]; then
base_date=$(TZ=UTC git -C "$WORK_CLONE" log -1 --format=%cd \
--date=format-local:'%Y-%m-%dT%H:%M:%SZ' "$base_commit" 2>/dev/null) || base_date=""
fi
branch_log=$(git -C "$WORK_CLONE" log --format='%s %b' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null)
# Subjects alone, with any trailing "(#N)" stripped, so a change re-applied by
# hand — no PR number, no cherry-pick trailer — is still recognised by title.
local branch_subjects
branch_subjects=$(git -C "$WORK_CLONE" log --format='%s' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null |
sed -E 's/ \(#[0-9]+\)$//')
already_on_branch() { # already_on_branch <merge-sha> <pr-number-or-empty> <title-or-empty>
local sha="$1" number="$2" title="$3"
if [[ -n "$sha" ]]; then
git -C "$WORK_CLONE" merge-base --is-ancestor "$sha" "origin/$branch" 2>/dev/null && return 0
grep -q "cherry picked from commit $sha" <<<"$branch_log" && return 0
fi
if [[ -n "$number" ]]; then
grep -qE "#$number([^0-9]|$)" <<<"$branch_log" && return 0
fi
if [[ -n "$title" ]]; then
grep -Fxq "$title" <<<"$branch_subjects" && return 0
fi
return 1
}
local -a shas=() labels=()
if [[ $# -gt 0 ]]; then
local arg sha title
for arg in "$@"; do
if [[ "$arg" =~ ^[0-9]+$ ]]; then
sha=$(gh pr view "$arg" --repo "$UPSTREAM_REPO" --json mergeCommit --jq '.mergeCommit.oid' 2>/dev/null)
title=$(gh pr view "$arg" --repo "$UPSTREAM_REPO" --json title --jq '.title' 2>/dev/null)
if [[ -z "$sha" || "$sha" == "null" ]]; then
print_error "PR #$arg has no merge commit (not merged?)"
exit 1
fi
if already_on_branch "$sha" "$arg" "$title"; then
print_info "PR #$arg is already on $branch — skipping"
continue
fi
shas+=("$sha") labels+=("PR #$arg: $title")
else
if already_on_branch "$arg" "" ""; then
print_info "commit $arg is already on $branch — skipping"
continue
fi
shas+=("$arg") labels+=("commit $arg")
fi
done
else
# Interactive: merged dev-branch PRs not already on the release branch
# (by ancestry, backport reference, cherry-pick trailer, or title), oldest
# first so picks apply in merge order.
print_info "Loading merged $DEV_BRANCH PRs not yet on $branch..."
# gh pr list orders by CREATION date, so a plain --limit window silently
# drops long-lived PRs — opened early, merged recently — which are exactly
# the ones a release branch still needs. Pull a wide window and bound it by
# the branch point instead: anything merged into $DEV_BRANCH before $branch
# left it is already here by ancestry.
local list jq_pick
jq_pick='sort_by(.mergedAt) | .[] | "\(.mergeCommit.oid)\t\(.number)\t\(.title)"'
[[ -n "$base_date" ]] && jq_pick="[.[] | select(.mergedAt >= \"$base_date\")] | $jq_pick"
list=$(gh pr list --repo "$UPSTREAM_REPO" --state merged --base "$DEV_BRANCH" \
--limit 300 --json number,title,mergeCommit,mergedAt --jq "$jq_pick")
local -a cand_shas=() cand_labels=()
local sha number title
while IFS=$'\t' read -r sha number title; do
[[ -z "$sha" || "$sha" == "null" ]] && continue
already_on_branch "$sha" "$number" "$title" && continue
cand_shas+=("$sha") cand_labels+=("#$number $title")
done <<<"$list"
if [[ ${#cand_shas[@]} -eq 0 ]]; then
print_success "Nothing to pick — every $DEV_BRANCH PR merged since $branch opened is already on it"
exit 0
fi
echo ""
local i
for i in "${!cand_labels[@]}"; do
printf ' %2d) %s\n' "$((i + 1))" "${cand_labels[$i]}"
done
echo ""
local selection
read -r -p "Pick which? (e.g. 1,3-5 or 'all'): " selection
if [[ "$selection" == "all" ]]; then
shas=("${cand_shas[@]}") labels=("${cand_labels[@]}")
else
local part
for part in ${selection//,/ }; do
if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
for ((i = BASH_REMATCH[1]; i <= BASH_REMATCH[2]; i++)); do
shas+=("${cand_shas[$((i - 1))]}") labels+=("${cand_labels[$((i - 1))]}")
done
elif [[ "$part" =~ ^[0-9]+$ ]]; then
shas+=("${cand_shas[$((part - 1))]}") labels+=("${cand_labels[$((part - 1))]}")
fi
done
fi
fi
[[ ${#shas[@]} -eq 0 ]] && { print_info "Nothing selected."; exit 0; }
local i
for i in "${!shas[@]}"; do
print_info "Cherry-picking ${labels[$i]}..."
if ! git -C "$WORK_CLONE" cherry-pick -x -m 1 "${shas[$i]}" 2>/dev/null &&
! { git -C "$WORK_CLONE" cherry-pick --abort 2>/dev/null; git -C "$WORK_CLONE" cherry-pick -x "${shas[$i]}"; }; then
print_error "Cherry-pick conflict on ${labels[$i]}"
echo "Resolve it in $WORK_CLONE (git cherry-pick --continue), push, and re-run."
exit 1
fi
done
git -C "$WORK_CLONE" push --quiet origin "$branch"
print_success "Picked ${#shas[@]} change(s) onto $branch"
# The staging PR can exist now that the branch has commits of its own.
if command -v gh >/dev/null && ! gh pr view "$branch" --repo "$UPSTREAM_REPO" >/dev/null 2>&1; then
gh pr create --repo "$UPSTREAM_REPO" --draft --base "$DEV_BRANCH" --head "$branch" \
--title "Release v$version" \
--body "Staging ground for the v$version release notes. Edit this body — it becomes the GitHub release text." 2>/dev/null &&
print_success "Opened the release-notes staging PR" || true
fi
print_info "Next: omarchy-release rc"
}
cmd_rc() {
local iso_mode="$1" wait="$2"
local branch version
branch=$(open_train_branch) || true
if [[ -z "$branch" ]]; then
print_error "No open release train — run: omarchy-release start"
exit 1
fi
version=$(branch_to_version "$branch")
print_header "Cut RC for train $version"
local head
head=$(branch_head "$branch")
print_info "Branch $branch head: ${head:0:12}"
# Idempotence: if the current rc pin already matches the branch head and is
# published, there is nothing to cut.
local pin
pin=$(rc_branch_pin)
if [[ -n "$pin" ]]; then
local pin_ver="${pin%% *}" pin_commit="${pin##* }"
if [[ "$pin_commit" == "$head" && "$pin_ver" =~ ^${version//./\\.}rc[0-9]+$ ]]; then
local pub
pub=$(published_version rc 2>/dev/null) || pub=""
if [[ "${pub%-*}" == "$pin_ver" ]]; then
print_success "$pin_ver is already cut from this head and published to rc"
maybe_iso "$version" rc "$iso_mode"
return 0
fi
print_info "$pin_ver is pinned from this head but not published yet — re-triggering the build"
trigger_rc_build || true
[[ "$wait" == true ]] && wait_for_published rc "$pin_ver"
maybe_iso "$version" rc "$iso_mode"
return 0
fi
fi
confirm "Pin omarchy + omarchy-settings to $branch@${head:0:12} as the next ${version}rcN and publish to rc?" || exit 1
cut_pins rc --base "$version" --ref "$branch"
local new_pin new_ver
new_pin=$(rc_branch_pin)
new_ver="${new_pin%% *}"
print_success "Pinned $new_ver (rc branch pushed)"
trigger_rc_build || true
if [[ "$wait" == true ]]; then
wait_for_published rc "$new_ver" || return 1
fi
maybe_iso "$version" rc "$iso_mode"
echo ""
print_info "Test the candidate, then: omarchy-release ship (or pick + rc again)"
}
cmd_ship() {
local iso_mode="$1" wait="$2"
local branch version
branch=$(open_train_branch) || true
if [[ -z "$branch" ]]; then
# A tagged train whose later steps (release, ISO, website) failed is
# invisible to open_train_branch — find it so a re-run can resume.
branch=$(newest_release_branch) || true
if [[ -z "$branch" ]]; then
print_error "No release train found — nothing to ship"
exit 1
fi
version=$(branch_to_version "$branch")
local stable_now
stable_now=$(published_version stable 2>/dev/null) || stable_now=""
if [[ "${stable_now%-*}" == "$version" ]] &&
gh release view "v$version" --repo "$UPSTREAM_REPO" >/dev/null 2>&1; then
print_success "Nothing to ship — $version is tagged, released, and live on stable"
exit 0
fi
print_info "Resuming tagged-but-incomplete train $version"
else
version=$(branch_to_version "$branch")
fi
print_header "Ship $version"
local head pin pin_ver pin_commit
head=$(branch_head "$branch")
pin=$(rc_branch_pin)
pin_ver="${pin%% *}"
pin_commit="${pin##* }"
# The ship guard: only the exact commit an RC was cut from may ship. There
# is no override — a moved branch means an untested tree; cut another rc.
if [[ -z "$pin" ]]; then
print_error "No RC has been cut for $version — run: omarchy-release rc"
exit 1
fi
if [[ "$pin_ver" == "$version" ]]; then
# Final already pinned (resume path). The artifacts come from pin_commit;
# a branch that moved afterwards changes nothing already built or tagged.
if [[ -n "$head" && "$head" != "$pin_commit" ]]; then
print_warning "$branch moved after the final was pinned — shipping the pinned ${pin_commit:0:12}; newer commits need the next patch train"
fi
else
if [[ ! "$pin_ver" =~ ^${version//./\\.}rc[0-9]+$ ]]; then
print_error "No RC has been cut for $version (rc branch pins $pin_ver) — run: omarchy-release rc"
exit 1
fi
if [[ "$pin_commit" != "$head" ]]; then
local behind
behind=$(git -C "$WORK_CLONE" rev-list --count "$pin_commit..origin/$branch" 2>/dev/null || echo "?")
print_error "$branch has moved since $pin_ver was cut ($behind commit(s) untested)"
echo "Only a commit an RC was cut from can ship. Cut another candidate:"
echo " omarchy-release rc"
exit 1
fi
local pub
pub=$(published_version rc 2>/dev/null) || pub=""
if [[ "${pub%-*}" != "$pin_ver" ]]; then
print_error "$pin_ver is pinned but rc serves '${pub:-nothing}' — the candidate build hasn't published"
echo "Wait for it (or re-run: omarchy-release rc), then ship."
exit 1
fi
fi
echo ""
print_info "This will, in order (steps already done are skipped):"
echo " 1. Pin the final $version from $branch@${pin_commit:0:12} and publish it to rc"
echo " 2. Promote the rc channel to stable (packages + signatures + db)"
echo " 3. Tag v$version on $UPSTREAM_REPO"
echo " 4. Merge the final pins to master (edge overlap + record)"
echo " 5. Create the GitHub release from the staging PR body"
echo " 6. Build + upload the final ISO (${iso_mode})"
echo " 7. Point the website at the new ISO"
echo ""
confirm "Ship $version?" || exit 1
# 1. Final pins into rc
local rc_pub stable_pub
rc_pub=$(published_version rc 2>/dev/null) || rc_pub=""
if [[ "${rc_pub%-*}" == "$version" ]]; then
print_success "1/7 Final $version already published to rc"
else
if [[ "$pin_ver" != "$version" ]]; then
print_info "1/7 Pinning final $version..."
cut_pins "v$version" --commit "$pin_commit"
else
print_info "1/7 Final $version pinned — re-triggering build"
fi
trigger_rc_build || true
wait_for_published rc "$version" || exit 1
fi
# 2. Promote rc -> stable
stable_pub=$(published_version stable 2>/dev/null) || stable_pub=""
if [[ "${stable_pub%-*}" == "$version" ]]; then
print_success "2/7 Stable already serves $version"
else
host_advance rc stable || exit 1
stable_pub=$(published_version stable 2>/dev/null) || stable_pub=""
if [[ "${stable_pub%-*}" != "$version" ]]; then
print_error "Promotion ran but stable serves '${stable_pub:-nothing}' — investigate before continuing"
exit 1
fi
print_success "2/7 Promoted to stable: omarchy $stable_pub"
fi
# 3. Tag — at the pinned commit the artifacts were built from, never the
# branch head (they can differ on a resumed ship).
if tag_exists "v$version"; then
print_success "3/7 Tag v$version already exists"
else
ensure_mirror_clone
git -C "$MIRROR_CLONE" push --quiet origin "$pin_commit:refs/tags/v$version"
print_success "3/7 Tagged v$version at ${pin_commit:0:12}"
fi
# 4. Final pins onto master (keeps edge overlap publishing and the repo record)
local master_ver
master_ver=$(git -C "$BUILD_ROOT" show origin/master:pkgbuilds/omarchy/PKGBUILD 2>/dev/null | grep -E '^pkgver=' | head -1 | cut -d= -f2)
if [[ "$master_ver" == "$version" ]]; then
print_success "4/7 master already carries the $version pins"
else
local pin_sha
pin_sha=$(git -C "$BUILD_ROOT" rev-parse origin/rc 2>/dev/null || true)
print_info "4/7 Bringing the final pin commit to master..."
local sync_wt="$BUILD_ROOT/.worktrees/master-sync"
git -C "$BUILD_ROOT" fetch --quiet origin
if [[ ! -d "$sync_wt" ]]; then
git -C "$BUILD_ROOT" worktree add --quiet -B release-master-sync "$sync_wt" origin/master
else
git -C "$sync_wt" checkout --quiet -B release-master-sync origin/master
fi
if (cd "$sync_wt" && git cherry-pick "$pin_sha" && git push --quiet origin HEAD:master); then
print_success "4/7 Final pins merged to master"
else
(cd "$sync_wt" && git cherry-pick --abort 2>/dev/null || true)
print_warning "4/7 Could not fast-path the pins to master — cherry-pick $pin_sha onto master manually"
fi
fi
# 5. GitHub release from the staging PR body
if gh release view "v$version" --repo "$UPSTREAM_REPO" >/dev/null 2>&1; then
print_success "5/7 GitHub release v$version already exists"
else
local notes
notes=$(gh pr view "$branch" --repo "$UPSTREAM_REPO" --json body --jq '.body' 2>/dev/null) || notes=""
if [[ -z "$notes" || "$notes" == "null" ]]; then
notes="Omarchy $version"
print_warning "No staging PR body found — using a bare title; edit the release afterwards"
fi
gh release create "v$version" --repo "$UPSTREAM_REPO" --title "v$version" --notes "$notes"
print_success "5/7 GitHub release v$version created"
fi
# 6. ISO
maybe_iso "$version" final "$iso_mode" || true
# 7. Website (only meaningful once the final ISO exists)
if [[ "$iso_mode" != "no" ]]; then
update_website "$version" || true
else
print_info "7/7 --no-iso: leaving the website untouched"
fi
echo ""
print_success "Shipped $version — rc and stable are in parity for the next patch train"
print_info "Close the loop: merge or close the staging PR for $branch on $UPSTREAM_REPO"
}
# --- status / shepherd -------------------------------------------------------
gather_status() {
EDGE_VER=$(published_version edge 2>/dev/null) || EDGE_VER="<unreachable>"
RC_VER=$(published_version rc 2>/dev/null) || RC_VER="<unreachable>"
STABLE_VER=$(published_version stable 2>/dev/null) || STABLE_VER="<unreachable>"
TRAIN=$(open_train_branch 2>/dev/null) || TRAIN=""
TRAIN_VER=""
TRAIN_HEAD=""
PIN=""
if [[ -n "$TRAIN" ]]; then
TRAIN_VER=$(branch_to_version "$TRAIN")
TRAIN_HEAD=$(branch_head "$TRAIN")
PIN=$(rc_branch_pin)
fi
}
next_step() { # prints "<command>|<description>"
if [[ -z "$TRAIN" ]]; then
# A tagged train may still have unfinished ship steps (release/ISO/site).
local last last_ver
last=$(newest_release_branch 2>/dev/null) || last=""
if [[ -n "$last" && "$STABLE_VER" != "<unreachable>" ]]; then
last_ver=$(branch_to_version "$last")
if [[ "${STABLE_VER%-*}" != "$last_ver" ]] ||
{ command -v gh >/dev/null && ! gh release view "v$last_ver" --repo "$UPSTREAM_REPO" >/dev/null 2>&1; }; then
echo "ship|$last_ver is tagged but not fully shipped — resume ship"
return
fi
fi
echo "start|No open train — start the next release"
return
fi
local pin_ver="${PIN%% *}" pin_commit="${PIN##* }"
if [[ -z "$PIN" || ! "$pin_ver" =~ ^${TRAIN_VER//./\\.}(rc[0-9]+)?$ ]]; then
echo "rc|Cut the first candidate for $TRAIN_VER"
elif [[ "$pin_ver" == "$TRAIN_VER" ]]; then
echo "ship|Final $TRAIN_VER is pinned — finish shipping (re-runs are safe)"
elif [[ "$pin_commit" != "$TRAIN_HEAD" ]]; then
echo "rc|$TRAIN has commits newer than $pin_ver — cut the next candidate"
elif [[ "${RC_VER%-*}" != "$pin_ver" ]]; then
echo "rc|$pin_ver is pinned but not published — re-run rc to re-trigger/wait"
else
echo "ship|$pin_ver is published to rc — test it, then ship"
fi
}
print_status() {
print_header "Omarchy release status"
echo " Channels (omarchy):"
echo " edge: $EDGE_VER"
echo " rc: $RC_VER"
echo " stable: $STABLE_VER"
echo ""
if [[ -z "$TRAIN" ]]; then
echo " No open release train."
else
echo " Open train: $TRAIN_VER (branch $TRAIN @ ${TRAIN_HEAD:0:12})"
if [[ -n "$PIN" ]]; then
echo " Current pin: ${PIN%% *} from commit ${PIN##* }"
else
echo " Current pin: <none>"
fi
fi
echo ""
local step
step=$(next_step)
echo " Next: omarchy-release ${step%%|*} — ${step#*|}"
}
cmd_shepherd() {
gather_status
print_status
local step cmd
step=$(next_step)
cmd="${step%%|*}"
echo ""
if confirm "Run 'omarchy-release $cmd' now?"; then
case "$cmd" in
start) cmd_start ;;
pick) cmd_pick ;;
rc) cmd_rc "$ISO_MODE" "$WAIT" ;;
ship) cmd_ship "$ISO_MODE" "$WAIT" ;;
esac
fi
}
# --- doctor ------------------------------------------------------------------
cmd_doctor() {
print_header "omarchy-release doctor"
local failures=0
check() {
local label="$1"; shift
if "$@" >/dev/null 2>&1; then
print_success "$label"
else
print_error "$label"
failures=$((failures + 1))
fi
}
check "git available" command -v git
check "gh available" command -v gh
check "gh authenticated" gh auth status
check "gh can see $UPSTREAM_REPO" gh repo view "$UPSTREAM_REPO"
check "gh can see $SITE_REPO" gh repo view "$SITE_REPO"
check "vercmp available (pacman)" command -v vercmp
check "makepkg available (checksums)" command -v makepkg
check "curl available" command -v curl
check "upstream reachable ($UPSTREAM_URL)" git ls-remote "$UPSTREAM_URL" HEAD
local ch
for ch in edge stable; do
if published_version "$ch" >/dev/null 2>&1; then
print_success "$ch channel db readable"
else
print_error "$ch channel db readable"
failures=$((failures + 1))
fi
done
if published_version rc >/dev/null 2>&1; then
print_success "rc channel db readable"
else
print_warning "rc channel db not readable — bootstrap it first: bin/repo bootstrap-rc (on the build host)"
fi
local host
if host=$(repo_host); then
check "build host ssh ($host)" ssh -o ConnectTimeout=10 "$host" true
elif on_repo_host; then
print_success "this machine IS the build host — host operations run locally"
else
print_warning "no build host configured — set OMARCHY_REPO_HOST, --host, or write an ssh destination (root@<host> or an ssh-config alias) to $BUILD_ROOT/.repo-host; until then builds trigger on the 6h timer only"
fi
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
print_success "docker available (ISO builds possible here)"
else
print_warning "docker unavailable — ISO builds will print instructions instead"
fi
echo ""
if ((failures == 0)); then
print_success "Ready to release"
else
print_error "$failures check(s) failed"
exit 1
fi
}
# --- self-test ---------------------------------------------------------------
cmd_self_test() {
local failures=0
expect() { # expect <label> <got> <want>
if [[ "$2" == "$3" ]]; then
echo " ok: $1"
else
echo " FAIL: $1 — got '$2', want '$3'"
failures=$((failures + 1))
fi
}
print_header "omarchy-release self-test"
expect "version_to_branch 4.0.2" "$(version_to_branch 4.0.2)" "v4-0-2"
expect "version_to_branch v4.1.0" "$(version_to_branch v4.1.0)" "v4-1-0"
expect "version_to_branch garbage rejects" "$(version_to_branch 4.0 2>/dev/null || echo reject)" "reject"
expect "branch_to_version v4-0-2" "$(branch_to_version v4-0-2)" "4.0.2"
expect "branch_to_version v10-2-33" "$(branch_to_version v10-2-33)" "10.2.33"
expect "branch_to_version quattro rejects" "$(branch_to_version quattro 2>/dev/null || echo reject)" "reject"
expect "version_is_patch 4.0.2" "$(version_is_patch 4.0.2 && echo yes || echo no)" "yes"
expect "version_is_patch 4.1.0" "$(version_is_patch 4.1.0 && echo yes || echo no)" "no"
expect "version_is_patch 5.0.0" "$(version_is_patch 5.0.0 && echo yes || echo no)" "no"
expect "previous_patch_tag 4.0.2" "$(previous_patch_tag 4.0.2)" "v4.0.1"
expect "previous_patch_tag 4.0.10" "$(previous_patch_tag 4.0.10)" "v4.0.9"
echo ""
if ((failures == 0)); then
print_success "Self-test passed"
else
print_error "$failures self-test failure(s)"
exit 1
fi
}
# --- dispatch ----------------------------------------------------------------
COMMAND=""
ISO_MODE="ask"
WAIT=true
ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--yes) ASSUME_YES=true; shift ;;
--host)
REPO_HOST_OVERRIDE="$2"
export OMARCHY_REPO_HOST="$2" # child bin/repo invocations forward to it too
shift 2
;;
--iso) ISO_MODE="yes"; shift ;;
--no-iso) ISO_MODE="no"; shift ;;
--no-wait) WAIT=false; shift ;;
-h | --help) show_usage; exit 0 ;;
start | pick | rc | ship | status | doctor | self-test)
COMMAND="$1"; shift ;;
*)
ARGS+=("$1"); shift ;;
esac
done
case "$COMMAND" in
start) cmd_start "${ARGS[@]}" ;;
pick) cmd_pick "${ARGS[@]}" ;;
rc) cmd_rc "$ISO_MODE" "$WAIT" ;;
ship) cmd_ship "$ISO_MODE" "$WAIT" ;;
status) gather_status; print_status ;;
doctor) cmd_doctor ;;
self-test) cmd_self_test ;;
"") cmd_shepherd ;;
*) print_error "Unknown command: $COMMAND"; show_usage; exit 1 ;;
esac