Add bin/omarchy-release: the interactive release front door

A release train has three human moments, each one command: start (release
branch on basecamp/omarchy + notes staging PR + edge→rc advance for
minor/major), rc (pin both PKGBUILDs to the branch head as X.Y.ZrcN on the
pkgs rc branch, trigger the rc channel build, wait for publish, optional RC
ISO), and ship (final pins → promote rc→stable → tag → pins to master → GitHub
release from the staging PR body → final ISO → website bump, each step
skip-if-done so a crashed run resumes).

Bare omarchy-release is the shepherd: it derives the train state from observed
reality (remote branches, rc-branch pins, published channel dbs, tags — no
state files) and offers the correct next step. Versions are inferred from
branch names (v4-0-2 ⇒ 4.0.2rcN ⇒ v4.0.2). ship refuses to promote a commit
no RC was cut from. pick is a multi-select over merged quattro PRs,
cherry-picking merge commits. doctor pre-flights every credential and
connection. self-test wired into CI.

bin/omarchy-pkgs stays as the pin engine, driven with its db URL pointed at
the rc channel and pins committed to the standing rc branch (rebuilt as
master + pins per cut and force-pushed; the server rc worktree follows with
reset --hard).
This commit is contained in:
Ryan Hughes
2026-08-27 01:10:33 -04:00
parent dac5ba2b45
commit 2cea400cd1
6 changed files with 950 additions and 4 deletions
+1
View File
@@ -28,4 +28,5 @@ jobs:
pacman -Syu --noconfirm jq
./bin/sync-upstream self-test
./bin/omarchy-pkgs self-test
./bin/omarchy-release self-test
'
+1
View File
@@ -34,3 +34,4 @@ pkgbuilds/symfony-cli/symfony*
pkgbuilds/yay/yay/
.srcdest/
.repo-host
.worktrees/
+21
View File
@@ -40,6 +40,27 @@ docker run --rm --platform linux/arm64 alpine:latest uname -m
## Quick Start
### Cutting an Omarchy release
`bin/omarchy-release` is the front door. A release train has three human
moments, each one command — and bare `omarchy-release` observes reality
(branches, pins, published channels, tags) and walks you to the next one:
```bash
bin/omarchy-release # shepherd: status + guided next step
bin/omarchy-release start 4.0.2 # open the train: branch v4-0-2 + staging PR
bin/omarchy-release pick # choose merged PRs to cherry-pick (multi-select)
bin/omarchy-release rc # publish the next 4.0.2rcN to the rc channel
bin/omarchy-release ship # final pins, promote rc → stable, tag,
# GitHub release, ISO, website — one swoop
bin/omarchy-release doctor # verify credentials/connections up front
```
Versions are inferred from branch names (`v4-0-2` ⇒ `4.0.2rcN` ⇒ tag
`v4.0.2`); `start` is the only place a version is typed. Every command is
idempotent — re-runs skip whatever is already done. `ship` refuses to promote
a commit no RC was cut from.
### Full release
Ship the tested rc channel to stable (copies packages + signatures, then
+6 -2
View File
@@ -2,8 +2,12 @@
# Omarchy release management for the omarchy + omarchy-settings package pair.
#
# Cuts a release by rewriting both PKGBUILDs in lockstep (same _tag/_commit/
# pkgver/sha256sums), committing, pushing to master, and poking the build host.
# RCs publish to edge only; stable receives finals via `bin/repo migrate`.
# pkgver/sha256sums), committing, pushing, and poking the build host.
#
# This is the pin ENGINE. The release front door is bin/omarchy-release, which
# drives it against the rc channel (OMARCHY_EDGE_DB_URL points at the rc db,
# pins commit to the rc branch) as part of the edge → rc → stable pipeline.
# Direct invocations work as before and target the current branch + edge.
#
# Versioning convention (see the PKGBUILD header comments):
# finals X.Y.Z from upstream tag vX.Y.Z
+916
View File
@@ -0,0 +1,916 @@
#!/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/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 whose final tag does not exist yet. Shipping
# creates the tag, so "tag exists" is what closes a train.
open_train_branch() {
local branch best_ver="" best_branch="" ver
while IFS= read -r branch; do
ver=$(branch_to_version "$branch") || continue
tag_exists "v$ver" && continue
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"
}
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
}
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
}
# --- 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 --------------------------------------------------------------
repo_host() { resolve_repo_host "$REPO_HOST_OVERRIDE"; }
host_or_print() { # prints the host, or prints manual instructions and fails
local host what="$1" cmd="$2"
if host=$(repo_host); then
echo "$host"
return 0
fi
print_warning "No build host configured (set OMARCHY_REPO_HOST, --host, or $BUILD_ROOT/.repo-host)"
echo "Run this on the build host to $what:" >&2
echo " $cmd" >&2
return 1
}
trigger_rc_build() {
local host
host=$(host_or_print "build the rc channel" \
"git -C /root/omarchy-pkgs-rc fetch origin rc && git -C /root/omarchy-pkgs-rc reset --hard origin/rc && touch /root/.state/.sync-needed-rc && systemctl start --no-block omarchy-auto-release-rc.service") || return 1
print_info "Triggering rc build on $host..."
ssh "$host" 'git -C /root/omarchy-pkgs-rc fetch origin rc && git -C /root/omarchy-pkgs-rc reset --hard origin/rc && mkdir -p /root/.state && touch /root/.state/.sync-needed-rc && systemctl start --no-block omarchy-auto-release-rc.service'
}
host_advance() { # host_advance <from> <to> [extra args...]
local from="$1" to="$2"
shift 2
local host
host=$(host_or_print "advance $from -> $to" \
"cd /root/omarchy-pkgs && bin/repo advance --from $from --to $to --skip-prod-check $*") || return 1
print_info "Advancing $from -> $to on $host..."
ssh "$host" "cd /root/omarchy-pkgs && git pull --ff-only && 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.
if [[ "$kind" != "patch" ]]; then
host_advance edge rc || true
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"
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
shas+=("$sha") labels+=("PR #$arg: $title")
else
shas+=("$arg") labels+=("commit $arg")
fi
done
else
# Interactive: merged dev-branch PRs whose merge commits are not on the
# release branch yet, 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) \(.title)"')
local -a cand_shas=() cand_labels=()
while IFS=$'\t' read -r sha label; do
[[ -z "$sha" || "$sha" == "null" ]] && continue
git -C "$WORK_CLONE" merge-base --is-ancestor "$sha" "origin/$branch" 2>/dev/null && 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
print_error "No open release train — nothing to ship"
exit 1
fi
version=$(branch_to_version "$branch")
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 a commit an RC was actually cut from may ship.
if [[ "$pin_ver" != "$version" ]]; then
if [[ -z "$pin" || ! "$pin_ver" =~ ^${version//./\\.}rc[0-9]+$ ]]; then
print_error "No RC has been cut for $version — 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 "Cut another candidate first: omarchy-release rc"
[[ "$ASSUME_YES" == true ]] && exit 1
confirm "Ship anyway from ${head:0:12} WITHOUT an RC of it? (not recommended)" || 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@${head: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 "$head"
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
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 "$head:refs/tags/v$version"
print_success "3/7 Tagged v$version at ${head: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
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
else
print_warning "no build host configured (OMARCHY_REPO_HOST / .repo-host) — 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"; 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
+5 -2
View File
@@ -6,8 +6,11 @@ Wants=network-online.target
[Service]
Type=oneshot
# The rc channel builds from the rc branch worktree, but publishes into the
# primary checkout's channel tree via OMARCHY_REPO_ROOT.
ExecStartPre=/usr/bin/git -C /root/omarchy-pkgs-rc pull --ff-only
# primary checkout's channel tree via OMARCHY_REPO_ROOT. The rc branch is
# rebuilt (master + pin commits) each release train and force-pushed, so this
# worktree follows with a hard reset rather than a fast-forward pull.
ExecStartPre=/usr/bin/git -C /root/omarchy-pkgs-rc fetch origin rc
ExecStartPre=/usr/bin/git -C /root/omarchy-pkgs-rc reset --hard origin/rc
ExecStart=/bin/bash -c 'source /root/.omarchy/build-credentials && /root/omarchy-pkgs-rc/bin/auto-release rc'
Environment=OMARCHY_STATE_DIR=/root/.state
Environment=OMARCHY_REPO_ROOT=/root/omarchy-pkgs/pkgs.omarchy.org