Files
omarchy-pkgs/bin/omarchy-release
T
Ryan Hughes dbb5e72051 Build fast-ring for rc as rc, instead of copying stable's artifacts
The rc channel's Arch base can sit anywhere between stable's snapshot and
edge's, so a package built against stable's libraries is not necessarily
correct for rc. Copying stable's fast-ring artifacts into rc therefore shipped
possibly-mislinked packages to RC testers. Fast-ring packages now build
natively for all three channels, each in its own image against its own base
mirror, and the stable release's replication step is gone.

That required separating 'may be built here' from 'whose version wins'. The
release pair is now marked "pinned": its version is set per release on the rc
branch, so it builds for rc only from that branch's worktree
(OMARCHY_RC_PINS=1, set by omarchy-release rc) — master's shipped pins can
never overwrite an in-flight RC, even though check-versions now discovers rc
work like it does for edge and stable.
2026-08-27 12:39:29 -04:00

1053 lines
39 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).
local base_commit branch_log
base_commit=$(git -C "$WORK_CLONE" merge-base "origin/$branch" "origin/$DEV_BRANCH" 2>/dev/null) || base_commit=""
branch_log=$(git -C "$WORK_CLONE" log --format='%s %b' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null)
already_on_branch() { # already_on_branch <merge-sha> <pr-number-or-empty>
local sha="$1" number="$2"
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
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"; 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, or cherry-pick trailer), oldest first
# so picks apply in merge order.
print_info "Loading merged $DEV_BRANCH PRs not yet on $branch..."
local list
list=$(gh pr list --repo "$UPSTREAM_REPO" --state merged --base "$DEV_BRANCH" \
--limit 30 --json number,title,mergeCommit,mergedAt \
--jq 'sort_by(.mergedAt) | .[] | "\(.mergeCommit.oid)\t\(.number)\t#\(.number) \(.title)"')
local -a cand_shas=() cand_labels=()
while IFS=$'\t' read -r sha number label; do
[[ -z "$sha" || "$sha" == "null" ]] && continue
already_on_branch "$sha" "$number" && continue
cand_shas+=("$sha") cand_labels+=("$label")
done <<<"$list"
if [[ ${#cand_shas[@]} -eq 0 ]]; then
print_success "Nothing to pick — every recent merged PR is already on $branch"
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