diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0eb40d9..4aaea2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,4 +28,5 @@ jobs: pacman -Syu --noconfirm jq ./bin/sync-upstream self-test ./bin/omarchy-pkgs self-test + ./bin/omarchy-release self-test ' diff --git a/.gitignore b/.gitignore index fce5f3c..d5f201f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ pkgbuilds/symfony-cli/symfony* pkgbuilds/yay/yay/ .srcdest/ .repo-host +.worktrees/ diff --git a/README.md b/README.md index f537686..1644495 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/omarchy-pkgs b/bin/omarchy-pkgs index 94dad91..f72ecc8 100755 --- a/bin/omarchy-pkgs +++ b/bin/omarchy-pkgs @@ -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 diff --git a/bin/omarchy-release b/bin/omarchy-release new file mode 100755 index 0000000..d33b61c --- /dev/null +++ b/bin/omarchy-release @@ -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 < 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 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 [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 [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 + 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 [--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 + 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 + 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-.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="" + RC_VER=$(published_version rc 2>/dev/null) || RC_VER="" + STABLE_VER=$(published_version stable 2>/dev/null) || STABLE_VER="" + 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 "|" + 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: " + 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