scripts/manage-release.sh
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env bash
#
# manage-release.sh — drive a monorepo product through its release steps.
#
# Usage: ./scripts/manage-release.sh <subcommand> <project>
#
# See usage() for the subcommands. The <project> is one of the monorepo's
# releasable products; its version is read from that product's canonical
# manifest (Cargo.toml for the Rust products, package.json for the SDK) and its
# git tag / GitHub release is <project>/v<version> (the slash namespaces each
# product's tags; releases before July 2026 used <project>_v<version>).
# mapfile, inherit_errexit, and safe empty-array expansion under `set -u` all
# need bash >= 4.4; macOS's /bin/bash is 3.2, so fail fast before half-running.
if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ] \
|| { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -lt 4 ]; }; then
>&2 echo "manage-release.sh requires bash >= 4.4 (macOS: brew install bash)"
exit 1
fi
set -euo pipefail
# Without this, a failure inside $(release_body) is silently swallowed and the
# release is created with broken notes.
shopt -s inherit_errexit
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO="Start9Labs/start-technologies"
STARTOS_ALPHA_REGISTRY="${STARTOS_ALPHA_REGISTRY:-https://alpha-registry-x.start9.com}"
STARTOS_SOURCE_REGISTRY="${STARTOS_SOURCE_REGISTRY:-https://beta-registry.start9.com}"
STARTOS_TARGET_REGISTRY="${STARTOS_TARGET_REGISTRY:-https://registry.start9.com}"
S3_BUCKET="s3://startos-images"
S3_CDN="https://startos-images.nyc3.cdn.digitaloceanspaces.com"
# The origin behind S3_CDN, read when deciding what is already published.
S3_ORIGIN="https://startos-images.nyc3.digitaloceanspaces.com"
START9_GPG_KEY="2D63C217"
SDK_NPM_PACKAGE="@start9labs/start-sdk"
CHANGELOG_SUMMARY="<summary>Full changelog</summary>"
# The S3 origin, deliberately NOT the `*.cdn.*` host that apt/start9*.list point
# clients at. Do not "harmonize" the two. A promotion has to see the suite as it
# is right now: a cached InRelease is still a validly signed InRelease, so every
# signature and hash check below would pass while quietly promoting whatever
# build the edge happened to be holding. Signatures prove authenticity, not
# freshness. End users keep the CDN — apt is built to tolerate a stale mirror.
APT_BASE_URL="${APT_BASE_URL:-https://start9-debs.nyc3.digitaloceanspaces.com}"
# Belt and braces for any intermediary between here and the origin.
APT_NO_CACHE=(-H 'Cache-Control: no-cache' -H 'Pragma: no-cache')
APT_SUITE="stable"
# CI publishes every master build into `alpha` (.github/workflows/apt-publish-alpha.yml).
# A deb release promotes from there rather than rebuilding trust from a CI run,
# so the bytes testers have been running are the bytes that reach stable — the
# same source -> target promotion the OS and StartWRT releases use.
APT_ALPHA_SUITE="alpha"
APT_COMPONENT="main"
# StartWRT publishes flashable images to its own registry pair + S3 bucket. The
# chain mirrors the OS, minus the alpha tier: the CI deploy job (start-wrt.yaml)
# uploads a build's images to S3 AND registers + indexes them into the source
# (beta) registry, where beta routers (UCI `startwrt.system.registry` pointed at
# it) soak the version; the full `release` promotes source -> target
# (production). `register` is the manual fallback for CI's register/index steps
# (same commands; keep them in sync — see root AGENTS.md "Coupled changes"). It
# ships two gzipped images: an sdcard image (fresh install -> the registry `img`
# slot) and a sysupgrade image (OTA update -> the `squashfs` slot; see
# cmd_register for the hardlink trick that maps the .img.gz names onto those
# slots). Override either registry per run.
STARTWRT_SOURCE_REGISTRY="${STARTWRT_SOURCE_REGISTRY:-https://startwrt-beta-registry.start9.com}"
STARTWRT_TARGET_REGISTRY="${STARTWRT_TARGET_REGISTRY:-https://startwrt-registry.start9.com}"
STARTWRT_S3_BUCKET="s3://startwrt-images"
STARTWRT_S3_CDN="https://startwrt-images.nyc3.cdn.digitaloceanspaces.com"
STARTWRT_PLATFORM="spacemit,k1-x"
# The CI artifact (start-wrt.yaml `image` job) holding both images.
STARTWRT_BUILD_ARTIFACT="startwrt-openwrt-image"
# Compat floor for `registry os version add`: the oldest installed version
# allowed to upgrade to this one. An explicit beta floor (not a `^` caret) keeps
# beta prerelease tags in range. The upper bound (<=$VERSION) is added in
# cmd_register. Mirrored in start-wrt.yaml's register step.
STARTWRT_COMPAT_FLOOR="${STARTWRT_COMPAT_FLOOR:->=0.1.0-beta.1}"
# Every OS image platform. Most ship an iso + squashfs; raspberrypi ships a
# flashable img + squashfs (no iso). See os_image_exts.
OS_PLATFORMS="x86_64 x86_64-nonfree x86_64-nvidia aarch64 aarch64-nonfree aarch64-nvidia raspberrypi riscv64 riscv64-nonfree"
CLI_TRIPLES="x86_64-unknown-linux-musl x86_64-apple-darwin aarch64-unknown-linux-musl aarch64-apple-darwin riscv64gc-unknown-linux-musl"
DEB_ARCHES="x86_64 aarch64 riscv64"
project_kind() {
case "$1" in
start-os) echo os ;;
start-cli) echo cli ;;
start-tunnel | start-registry) echo deb ;;
start-sdk) echo npm ;;
start-wrt) echo wrt ;;
*) return 1 ;;
esac
}
derive_version() {
(cd "$REPO_ROOT" && node scripts/changelog.mjs version "projects/$1")
}
check_changelog_version() (
cd "$REPO_ROOT"
git fetch --tags origin
node scripts/changelog.mjs check-version "projects/$PROJECT" "$VERSION"
)
notes_path() { echo "$REPO_ROOT/projects/$1/release-notes/${VERSION}.md"; }
pre_update_notes_path() {
local notes
notes=$(notes_path "$1")
echo "${notes%.md}.pre-update.md"
}
composed_notes() (
cd "$REPO_ROOT"
node scripts/changelog.mjs release-notes "projects/$PROJECT" "$VERSION" \
${NOTES_REF:+--ref "$NOTES_REF"}
)
registry_notes() (
cd "$REPO_ROOT"
node scripts/changelog.mjs registry-notes "projects/$PROJECT" "$VERSION" \
${NOTES_REF:+--ref "$NOTES_REF"} ${CHANGELOG_REF:+--changelog-ref "$CHANGELOG_REF"}
)
curated_notes() {
registry_notes | jq -j '.releaseNotes | if type == "string" then . else .en_US end'
}
project_display_name() {
case "$1" in
start-os) echo "StartOS" ;;
start-wrt) echo "StartWRT" ;;
start-tunnel) echo "StartTunnel" ;;
start-sdk) echo "Start SDK" ;;
*) echo "$1" ;;
esac
}
cli_asset_name() {
case "$1" in
x86_64-unknown-linux-musl) echo x86_64-linux ;;
aarch64-unknown-linux-musl) echo aarch64-linux ;;
riscv64gc-unknown-linux-musl) echo riscv64-linux ;;
x86_64-apple-darwin) echo x86_64-macos ;;
aarch64-apple-darwin) echo aarch64-macos ;;
*) return 1 ;;
esac
}
deb_arch() {
case "$1" in
x86_64) echo amd64 ;;
aarch64) echo arm64 ;;
riscv64) echo riscv64 ;;
*) return 1 ;;
esac
}
# One row of the release notes' download table, in the order they are offered:
# hardware | image | platform. A reader knows what they own, not which platform
# tuple it is, so the hardware column leads and names the Start9 product where
# there is one. generated_sections fails on a platform with no row here, so a new
# image variant cannot ship undescribed.
OS_DOWNLOAD_ROWS=(
"**Server One**, and most other Intel and AMD desktops, laptops, and mini PCs|x86_64 (AMD64), standard|x86_64-nonfree"
"**Server Pure**, and other hardware that runs without proprietary firmware|x86_64 (AMD64), slim|x86_64"
"An Intel or AMD server with an NVIDIA GPU|x86_64 (AMD64), NVIDIA|x86_64-nvidia"
"ARM64 servers and single-board computers|aarch64 (ARM64), standard|aarch64-nonfree"
"ARM64 hardware that runs without proprietary firmware|aarch64 (ARM64), slim|aarch64"
"**NVIDIA DGX Spark**, and other ARM64 servers with an NVIDIA GPU|aarch64 (ARM64), NVIDIA|aarch64-nvidia"
"**Raspberry Pi 4** — flashed to a microSD card, not a USB drive|Raspberry Pi|raspberrypi"
"RISC-V servers and boards|RISC-V (RVA23), standard|riscv64-nonfree"
"RISC-V hardware that runs without proprietary firmware|RISC-V (RVA23), slim|riscv64"
)
# One row of the StartWRT download table: hardware | image | asset slot |
# tooltip. The K1 is the only platform it builds for, so the rows differ by slot
# rather than by hardware — img is the fresh-install sdcard image, squashfs the
# sysupgrade payload. Mirrors OS_DOWNLOAD_ROWS.
WRT_DOWNLOAD_ROWS=(
"**Start9 router**, and other BananaPi BPI-F3 boards (SpaceMiT K1, RISC-V)|microSD card — fresh install or reflash|img|gzip-compressed — balenaEtcher flashes it without unpacking"
"A router already running StartWRT|Sysupgrade — the payload an in-app update fetches for itself|squashfs|"
)
# The image extensions a platform ships: squashfs everywhere, plus iso (most) or
# a flashable img (raspberrypi).
os_image_exts() {
case "$1" in
raspberrypi) echo "squashfs img" ;;
*) echo "squashfs iso" ;;
esac
}
# Load a registry's OS index into $_INDEX_JSON, refetching only when asked for a
# different registry than the one held — a release reads a dozen asset URLs off
# it and each fetch is a round trip. Call it once at the top of a scope that
# loops over assets: asset_url runs inside $(...), so a fetch it does itself is
# discarded with that subshell, while one the caller did is inherited.
_INDEX_REGISTRY=""
_INDEX_JSON=""
load_registry_index() {
if [ "$1" != "$_INDEX_REGISTRY" ]; then
_INDEX_JSON=$(start-cli --registry="$1" registry os index 2>/dev/null || echo '{}')
_INDEX_REGISTRY=$1
fi
}
# The published URL of an indexed asset: <registry> <slot> <platform>. Empty if
# that registry carries no such asset for $VERSION.
#
# This — never a URL rebuilt from the bucket layout plus a local filename — is
# what a download link must point at. The published basename carries the build's
# commit hash (startos-0.4.0-514af0c_x86_64.iso), which nothing local
# reproduces, so a reconstructed URL 403s.
asset_url() {
load_registry_index "$1"
jq -r --arg v "$VERSION" --arg s "$2" --arg p "$3" \
'.versions[$v][$s][$p].urls[0] // empty' <<< "$_INDEX_JSON"
}
# Every asset this version ships to a registry, one "<slot> <platform>" per line.
registry_slots() {
local platform ext
case "$KIND" in
os)
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do echo "$ext $platform"; done
done
;;
wrt) printf '%s\n' "img $STARTWRT_PLATFORM" "squashfs $STARTWRT_PLATFORM" ;;
esac
}
# The assets a registry lacks for this version, space-separated.
missing_registry_assets() {
local slot platform missing=""
while read -r slot platform; do
[ -n "$(asset_url "$1" "$slot" "$platform")" ] || missing="${missing} ${platform}.${slot}"
done < <(registry_slots)
echo "$missing"
}
resolve_os_commit() {
local hash
load_registry_index "$STARTOS_SOURCE_REGISTRY"
hash=$(jq -r --arg v "$VERSION" '.versions[$v].gitHash // empty' <<< "$_INDEX_JSON")
if [ -z "$hash" ]; then
>&2 echo " ✗ ${STARTOS_SOURCE_REGISTRY} has no gitHash for OS ${VERSION}; publish its image metadata first."
return 1
fi
resolve_artifact_commit "$hash" "$STARTOS_SOURCE_REGISTRY" || return
COMMIT=$hash
}
release_security() {
(cd "$REPO_ROOT" && node scripts/changelog.mjs security "projects/$PROJECT" "$VERSION" ${1:+--ref "$1"})
}
check_registry_notes_cli() {
local help
help=$(start-cli registry os version add --help) || return
if ! grep -Fq -- '<RELEASE_NOTES_OR_JSON>' <<< "$help"; then
>&2 echo " ✗ start-cli lacks LocaleString release notes; update it before publishing registry notes."
return 1
fi
}
refresh_registry_notes() {
local registry=$1 range payload notes hash security metadata_args=()
payload=$(registry_notes) || return
check_registry_notes_cli || return
notes=$(jq -r '.releaseNotes' <<< "$payload")
load_registry_index "$registry"
range=$(jq -r --arg v "$VERSION" '.versions[$v].sourceVersion // empty' <<< "$_INDEX_JSON")
if [ -z "$range" ]; then
>&2 echo " ✗ ${registry} has no ${VERSION} entry to carry release notes"
return 1
fi
if [ "$KIND" = os ]; then
hash=$(jq -r --arg v "$VERSION" '.versions[$v].gitHash // empty' <<< "$_INDEX_JSON")
security=$(release_security "${hash:-${NOTES_REF:-}}") || return
if [ -n "$hash" ]; then metadata_args+=(--git-hash "$hash" --expected-git-hash "$hash"); fi
if [ "$security" = true ]; then metadata_args+=(--security); fi
fi
echo "Setting ${VERSION} release notes on ${registry}..."
start-cli --registry="$registry" registry os version add \
"${metadata_args[@]}" \
"$VERSION" "v${VERSION}" "$notes" "$range"
_INDEX_REGISTRY=""
}
# The signed blake3 commitment of an indexed asset, as hex (b3sum's output
# format): <registry> <slot> <platform>. Empty if the asset is not indexed.
asset_commitment_b3() {
load_registry_index "$1"
local b64
b64=$(jq -r --arg v "$VERSION" --arg s "$2" --arg p "$3" \
'.versions[$v][$s][$p].commitment.hash // empty' <<< "$_INDEX_JSON")
[ -n "$b64" ] || return 0
# The index stores the hash unpadded-base64; base64 -d wants the padding.
case $((${#b64} % 4)) in
2) b64="${b64}==" ;;
3) b64="${b64}=" ;;
esac
printf '%s' "$b64" | base64 -d | od -An -v -tx1 | tr -d ' \n'
}
parse_run_id() {
local val="$1"
if [[ "$val" =~ /actions/runs/([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "$val"
fi
}
# The commit the tag will point at ($COMMIT, default HEAD), as a full sha.
tag_commit_sha() { (cd "$REPO_ROOT" && git rev-parse --verify "${COMMIT:-HEAD}^{commit}"); }
release_dir() { echo "$HOME/Downloads/${TAG//\//_}"; }
# Marker recording which commit the release dir's GHA artifacts were built from.
gha_commit_file() { echo "$(release_dir)/.gha-commit"; }
ensure_release_dir() {
local dir
dir=$(release_dir)
if [ "${CLEAN:-}" = "1" ]; then
rm -rf "$dir"
fi
mkdir -p "$dir"
cd "$dir"
}
enter_release_dir() {
local dir
dir=$(release_dir)
if [ ! -d "$dir" ]; then
>&2 echo "Release directory $dir does not exist. Run 'pull-gha' or 'pull' first."
exit 1
fi
cd "$dir"
}
# List the CLI binaries in the (current) release dir, one per line.
cli_binaries() {
local f
for f in start-cli_*; do
case "$f" in *.asc | *.deb) continue ;; esac
[ -f "$f" ] && echo "$f"
done
}
# List the .deb packages in the (current) release dir, one per line.
deb_files() {
local f
for f in *.deb; do [ -f "$f" ] && echo "$f"; done
}
# Compress every raw .img in the release dir beside itself.
#
# The registry keeps indexing the image bare: an installer streams it and the
# signed commitment is over the raw bytes. But a raw card image is mostly
# zeroes, and the release notes hand it to a human on a browser — so the notes
# link the .gz, and it is hashed and signed alongside the bare image.
#
# Idempotent: skips a .gz already newer than its source, and stages through a
# temp file so an interrupted run can't leave a truncated one looking current.
# A no-op outside the os kind — nothing else ships a raw .img.
ensure_img_gz() {
local img
for img in *.img; do
[ -f "$img" ] || continue
if [ -f "${img}.gz" ] && [ ! "$img" -nt "${img}.gz" ]; then
continue
fi
>&2 echo " compressing ${img} -> ${img}.gz"
gzip -c "$img" > "${img}.gz.tmp"
mv -f "${img}.gz.tmp" "${img}.gz"
done
}
# List every file the project ships (for signing / checksums), one per line.
release_files() {
local f
case "$KIND" in
os) for f in *.iso *.img *.img.gz *.squashfs; do [ -f "$f" ] && echo "$f"; done ;;
cli) cli_binaries; deb_files ;;
deb) deb_files ;;
wrt) for f in *-sdcard.img.gz *-sysupgrade.img.gz; do [ -f "$f" ] && echo "$f"; done ;;
esac
}
resolve_gh_user() {
GH_USER=${GH_USER:-$(gh api user -q .login 2>/dev/null || true)}
if [ "${GH_USER,,}" = start9 ]; then
>&2 echo "Error: GitHub user '$GH_USER' is reserved for Start9 signatures"
exit 1
fi
GH_GPG_KEY=$(git -C "$REPO_ROOT" config user.signingkey 2>/dev/null || true)
case "$(git -C "$REPO_ROOT" config gpg.format 2>/dev/null)" in
'' | openpgp) ;;
*) GH_GPG_KEY= ;;
esac
[ -z "$GH_GPG_KEY" ] || gpg --list-secret-keys "$GH_GPG_KEY" >/dev/null 2>&1 || GH_GPG_KEY=
}
require_kind() {
local ok
for ok in "$@"; do
[ "$KIND" = "$ok" ] && return 0
done
>&2 echo "Subcommand '$SUBCOMMAND' does not apply to $PROJECT (kind: $KIND)."
exit 2
}
# Download this project's per-arch debs from a GitHub Actions run into the cwd.
# Download this project's debs for the commit being tagged from the alpha suite.
#
# Selection is by the Git-Hash control field (written by debian/build.sh), which
# dpkg-scanpackages carries into the Packages index — so the build is identified
# and verified before a byte is downloaded. Version alone would not do: every
# master build of a version publishes under the same Version, and the apt pool
# holds exactly one deb per package/arch, so `alpha` always carries the newest
# master build of that version and nothing older.
#
# A mismatch therefore means master moved past the commit being tagged. That is
# a real stop, not a nuisance: releasing then would ship bytes no one soaked.
# Cut the release from an up-to-date master, or fall back to `pull-gha` for a
# repair.
# Verify the alpha suite's signed Release and return a verified Packages file
# for one architecture. Alpha is signed with the CI key precisely so its
# consumers can verify it, and a releaser promoting into stable is one: reading
# the index over plain HTTPS would let anyone able to write the bucket (without
# holding the signing key) get bytes stable-signed by the releaser.
# macOS ships shasum, not coreutils' sha256sum — same as checksum_block, which
# has carried this fallback all along. Prints the bare hash.
sha256_of() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
verify_alpha_release() {
local keyring="$1" tmp="$2"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/dists/${APT_ALPHA_SUITE}/InRelease" -o "$tmp/InRelease"
if ! "$REPO_ROOT/debian/verify-release.sh" "$keyring" "$tmp/InRelease" "$tmp/Release" 2> "$tmp/gpg.err"; then
>&2 echo " ✗ the ${APT_ALPHA_SUITE} InRelease is not signed by the key in ${keyring}"
>&2 sed 's/^/ /' "$tmp/gpg.err"
return 1
fi
# No freshness bound is enforced here, deliberately. A signature proves
# authenticity and not recency, so replaying older signed metadata is
# possible for anyone who can write the bucket — but the defence against
# promoting the wrong build is that the operator is told which commit is
# being tagged (resolve_artifact_commit prints it, and a replay shows up as an
# unexpectedly old hash), not metadata that expires on a timer unrelated to
# this project's release cadence.
}
verify_alpha_packages() {
local tmp="$1" darch="$2" rel_path idx_sha got
rel_path="${APT_COMPONENT}/binary-${darch}/Packages"
idx_sha=$(awk -v p="$rel_path" '
/^SHA256:/ { in_sha = 1; next }
/^[^ ]/ { in_sha = 0 }
in_sha && $3 == p { print $1; exit }
' "$tmp/Release")
if [ -z "$idx_sha" ]; then
>&2 echo " ✗ the signed Release does not list ${rel_path}"
return 1
fi
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/dists/${APT_ALPHA_SUITE}/${rel_path}" -o "$tmp/Packages.${darch}"
got=$(sha256_of "$tmp/Packages.${darch}")
if [ "$got" != "$idx_sha" ]; then
>&2 echo " ✗ ${rel_path} does not match the hash the signed Release commits to"
return 1
fi
}
# Print "<git-hash>\t<sha256>\t<filename>" for this project's deb in a verified
# Packages file. Emitted at the stanza boundary rather than on a particular
# field: the index guarantees no order, and Git-Hash follows Filename in practice.
alpha_stanza() {
local entry
entry=$(awk -v pkg="$PROJECT" -v ver="$VERSION" '
function flush() {
if (p == pkg && index(v, ver) > 0 && f != "") print h "\t" s "\t" f
p = ""; v = ""; h = ""; s = ""; f = ""
}
/^$/ { flush(); next }
/^Package:/ { p = $2 }
/^Version:/ { v = $2 }
/^Git-Hash:/ { h = $2 }
/^SHA256:/ { s = $2 }
/^Filename:/ { f = $2 }
END { flush() }
' "$1")
# First line taken in the shell rather than piped through `head -1`, which
# would close the pipe early and take SIGPIPE under `pipefail`.
echo "${entry%%$'\n'*}"
}
# Verify the alpha suite and collect this project's debs for every architecture.
# Populates ALPHA_COMMIT / ALPHA_SHAS / ALPHA_FILES and leaves the verified
# metadata in ALPHA_TMP, which the caller removes.
#
# One function so every consumer gets the same guarantees: `alpha-commit` used to
# read a single arch and accept a missing Git-Hash, so it could print a commit
# that `pull-alpha` then refused.
alpha_collect() {
local keyring arch darch stanza h
keyring="$REPO_ROOT/apt/start9-alpha.gpg"
if [ ! -f "$keyring" ]; then
>&2 echo "Cannot verify the ${APT_ALPHA_SUITE} suite: ${keyring} is missing."
return 1
fi
ALPHA_TMP=$(mktemp -d)
declare -gA ALPHA_SHAS=() ALPHA_FILES=()
ALPHA_COMMIT=""
verify_alpha_release "$keyring" "$ALPHA_TMP" || return 1
for arch in $DEB_ARCHES; do
darch=$(deb_arch "$arch")
verify_alpha_packages "$ALPHA_TMP" "$darch" || return 1
stanza=$(alpha_stanza "$ALPHA_TMP/Packages.${darch}")
if [ -z "$stanza" ]; then
>&2 echo " ✗ ${darch}: no ${PROJECT} ${VERSION} deb in the ${APT_ALPHA_SUITE} suite"
return 1
fi
h=${stanza%%$'\t'*}
stanza=${stanza#*$'\t'}
ALPHA_SHAS[$arch]=${stanza%%$'\t'*}
ALPHA_FILES[$arch]=${stanza#*$'\t'}
if [ -z "$h" ]; then
>&2 echo " ✗ ${arch}: this deb predates the Git-Hash control field — use 'pull-gha'"
return 1
fi
# Every arch must come from one build: a half-published suite would
# otherwise ship a release assembled from two different commits.
if [ -z "$ALPHA_COMMIT" ]; then
ALPHA_COMMIT=$h
elif [ "$h" != "$ALPHA_COMMIT" ]; then
>&2 echo " ✗ ${APT_ALPHA_SUITE} holds different commits per architecture (${ALPHA_COMMIT} vs ${h})."
>&2 echo " Wait for the build to finish publishing every arch, or use 'pull-gha'."
return 1
fi
done
}
resolve_artifact_commit() {
local build_hash="$1" source="$2" tag_sha behind
tag_sha=$(tag_commit_sha)
[ "$build_hash" != "$tag_sha" ] || return 0
if [ -n "${COMMIT:-}" ]; then
>&2 echo " ✗ ${source} holds a build of ${build_hash}, but COMMIT=${COMMIT} is ${tag_sha}."
>&2 echo " The tag has to point at the commit that produced the artifacts. Drop COMMIT"
>&2 echo " to take that build's, or use 'pull-gha' with the run that built ${tag_sha}."
return 1
fi
if ! (cd "$REPO_ROOT" && git rev-parse --verify --quiet "${build_hash}^{commit}" > /dev/null); then
>&2 echo " ✗ ${source} holds a build of ${build_hash}, which is not in this repository."
>&2 echo " git fetch origin, then re-run."
return 1
fi
if ! (cd "$REPO_ROOT" && git merge-base --is-ancestor "$build_hash" HEAD); then
>&2 echo " ✗ ${source} holds a build of ${build_hash}, which is not an ancestor of HEAD."
>&2 echo " That build did not come from the branch you are releasing. Check out the"
>&2 echo " branch that contains it, or use 'pull-gha'."
return 1
fi
behind=$(cd "$REPO_ROOT" && git rev-list --count "${build_hash}..HEAD")
COMMIT="$build_hash"
echo " Tagging ${build_hash}, the commit ${source} built — HEAD is ${behind} commit(s) further on."
echo " (${PROJECT}'s workflow is path-filtered, so master advances without rebuilding it.)"
echo " To work from that tree: git checkout ${build_hash}"
}
compiled_metadata_matches() {
local current tagged
current=$(cd "$REPO_ROOT" && node scripts/changelog.mjs "$1" "projects/$PROJECT" "$VERSION") || return
tagged=$(cd "$REPO_ROOT" && node scripts/changelog.mjs "$1" "projects/$PROJECT" "$VERSION" --ref "$2") || return
[ "$current" = "$tagged" ]
}
assert_metadata_matches_adopted() {
local adopted file untracked label
adopted=$(tag_commit_sha)
for file in "$REPO_ROOT/projects/$PROJECT/changelog" "$(notes_path "$PROJECT")" "$(pre_update_notes_path "$PROJECT")"; do
label="${file#"$REPO_ROOT/"}"
case "$file" in
*/changelog)
if compiled_metadata_matches render "$adopted"; then continue; fi ;;
"$(notes_path "$PROJECT")")
label="release-note bundle at $label (including locale companions)"
if compiled_metadata_matches notes-bundle "$adopted"; then continue; fi ;;
*)
untracked=$(cd "$REPO_ROOT" && git ls-files --others --exclude-standard -- "$file")
if [ -z "$untracked" ] && (cd "$REPO_ROOT" && git diff --quiet "$adopted" -- "$file"); then continue; fi ;;
esac
if [ -n "${UNCOMMITTED_NOTES:-}" ] && [ "$file" != "$REPO_ROOT/projects/$PROJECT/changelog" ]; then
>&2 echo " ! ${label} differs from ${adopted}; releasing it anyway (UNCOMMITTED_NOTES)"
continue
fi
>&2 echo " ✗ ${label} differs between the working tree and the"
>&2 echo " commit being tagged (${adopted}). Generated release metadata would not match the tag."
>&2 echo
>&2 echo " git checkout ${adopted}"
>&2 echo " ./scripts/manage-release.sh release ${PROJECT}"
return 1
done
if ! compiled_metadata_matches registry-notes "$adopted"; then
if [ -n "${UNCOMMITTED_NOTES:-}" ]; then
>&2 echo " ! release-note bundle (including localized pre-update warnings) differs from ${adopted}; releasing it anyway (UNCOMMITTED_NOTES)"
else
>&2 echo " ✗ release-note bundle (including localized pre-update warnings) differs from the commit being tagged (${adopted})."
return 1
fi
fi
}
# Clear every payload this staging path produces, both halves. Clearing only the
# debs left the reverse partial set possible: binaries from a failed cli download
# sitting beside a fresh marker, or a previous attempt's debs beside new
# binaries. The marker is written by the caller before either half is fetched.
clear_alpha_staging() {
rm -f ./*.deb
[ "$KIND" != cli ] || rm -f ./start-cli_*
}
promote_alpha_debs() {
local want arch darch base got
want=$(tag_commit_sha)
echo "Promoting ${PROJECT} debs from the ${APT_ALPHA_SUITE} suite (commit ${want})..."
for arch in $DEB_ARCHES; do
darch=$(deb_arch "$arch")
base=$(basename "${ALPHA_FILES[$arch]}")
echo " ${arch}: ${base}"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/${ALPHA_FILES[$arch]}" -o "$ALPHA_TMP/$base"
# The pool key is stable across builds, so without this an alpha
# republish between the index read and this download would swap the
# bytes after the commit check had already passed.
got=$(sha256_of "$ALPHA_TMP/$base")
if [ "$got" != "${ALPHA_SHAS[$arch]}" ]; then
>&2 echo " ✗ ${darch}: ${base} does not match the hash the signed index commits to"
>&2 echo " (the suite was republished mid-promotion, or the object was tampered with)"
return 1
fi
done
mv "$ALPHA_TMP"/*.deb .
}
pull_gha_debs() {
local arch
for arch in $DEB_ARCHES; do
echo " ${PROJECT}_${arch}.deb"
gh run download -R "$REPO" "$RUN_ID" -n "${PROJECT}_${arch}.deb" -D "$(pwd)"
done
}
# The pool path of this version's deb in the stable suite. Empty if unpublished.
apt_filename() {
local idx
idx="${APT_BASE_URL}/dists/${APT_SUITE}/${APT_COMPONENT}/binary-$(deb_arch "$1")/Packages"
curl -fsSL "${APT_NO_CACHE[@]}" "$idx" 2>/dev/null | awk -v pkg="$PROJECT" -v ver="$VERSION" '
/^$/ { p=""; v="" }
/^Package:/ { p=$2 }
/^Version:/ { v=$2 }
/^Filename:/ { if (p==pkg && index(v, ver) > 0) print $2 }
' | head -1 || true
}
# Download this project's released debs from the apt repository into the cwd.
pull_apt_debs() {
local arch filename
for arch in $DEB_ARCHES; do
filename=$(apt_filename "$arch")
if [ -n "$filename" ]; then
echo " ${arch}: ${filename}"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/${filename}" -o "$(basename "$filename")"
else
>&2 echo " ! no ${PROJECT} ${arch} deb for ${VERSION} in apt repo"
fi
done
}
# Publish the debs in the cwd to the apt repository and the GitHub release.
publish_debs() {
local files file
mapfile -t files < <(deb_files)
if [ ${#files[@]} -eq 0 ]; then
>&2 echo "No .deb files in $(release_dir)"
return 1
fi
echo "Publishing ${PROJECT} debs to the apt repository..."
"$REPO_ROOT/debian/publish.sh" "${files[@]}"
echo "Uploading ${PROJECT} debs to GitHub release ${TAG}..."
for file in "${files[@]}"; do
gh release upload -R "$REPO" "$TAG" "$file" --clobber
done
}
# The `release` steps for this kind, in order.
release_steps() {
case "$KIND" in
os) echo pre-check pull push-gz tag create-gh-release index sign ;;
# Promote the debs CI published to alpha rather than rebuilding
# trust from a CI run: what testers have been running is what ships.
# (start-cli's per-triple binaries still come from the run — they are
# published only as release assets, so there is no channel to promote
# them from. Both halves are pinned to the tagged commit.)
cli | deb) echo pre-check pull-alpha tag create-gh-release push sign ;;
# create-gh-release before push: everything idempotent runs ahead of
# the one irreversible step (npm publish can't be re-run for a version).
npm) echo pre-check tag create-gh-release push ;;
# CI (start-wrt.yaml `deploy`) uploaded the images to S3 and
# registered + indexed them into the source (beta) registry, where
# beta routers soaked the version — so there's no push here. Pull
# the registered images (signature-verified) to build the release
# notes + sign them, tag, cut the GitHub release, and `index`
# promotes them from source into production.
wrt) echo pre-check pull tag create-gh-release index sign ;;
esac
}
# The `release` steps whose output is already published, keyed by step name.
declare -A DONE=()
# The commit origin's release tag points at. Empty if untagged.
REMOTE_TAG_SHA=""
published_exists() {
[ "$(curl -sI "${APT_NO_CACHE[@]}" -o /dev/null -w '%{http_code}' "$1")" = 200 ]
}
os_img_gz_origin() {
echo "${S3_ORIGIN}/v${VERSION}/$(basename "$1").gz"
}
detect_progress() {
local release assets="" arch triple platform url published=1
DONE=()
REMOTE_TAG_SHA=$(git -C "$REPO_ROOT" ls-remote --tags origin "refs/tags/${TAG}" "refs/tags/${TAG}^{}" | awk 'END { print $1 }')
[ -z "$REMOTE_TAG_SHA" ] || DONE[tag]=1
if release=$(gh release view -R "$REPO" "$TAG" --json assets,isDraft,isPrerelease,body 2>/dev/null); then
assets=$(jq -r '.assets[].name' <<< "$release")
if jq -e --arg summary "$CHANGELOG_SUMMARY" --argjson prerelease "${PRERELEASE:-0}" '
.isDraft == false and
($prerelease != 1 or .isPrerelease == true) and
any(.assets[]; .name == "CHANGELOG.md") and
(.body | contains($summary))
' <<< "$release" >/dev/null; then
DONE[create-gh-release]=1
fi
if grep -qxF signatures.tar.gz <<< "$assets"; then DONE[sign]=1; fi
fi
case "$KIND" in
os)
for platform in $OS_PLATFORMS; do
[[ " $(os_image_exts "$platform") " == *" img "* ]] || continue
url=$(asset_url "$STARTOS_SOURCE_REGISTRY" img "$platform")
if [ -z "$url" ] || ! published_exists "$(os_img_gz_origin "$url")"; then
published=
fi
done
[ -z "$published" ] || DONE[push-gz]=1
if [ "${PRERELEASE:-}" != 1 ] && [ -z "$(missing_registry_assets "$STARTOS_TARGET_REGISTRY")" ]; then
load_registry_index "$STARTOS_TARGET_REGISTRY"
if [ "$(jq -r --arg v "$VERSION" '.versions[$v].gitHash // empty' <<< "$_INDEX_JSON")" = "$(tag_commit_sha)" ]; then
DONE[index]=1
fi
fi
;;
wrt)
[ -n "$(missing_registry_assets "$STARTWRT_TARGET_REGISTRY")" ] || DONE[index]=1
;;
cli | deb)
for arch in $DEB_ARCHES; do
if [ -z "$(apt_filename "$arch")" ] \
|| ! grep -qxF "${PROJECT}_${VERSION}_$(deb_arch "$arch").deb" <<< "${assets:-}"; then
published=
fi
done
if [ "$KIND" = cli ]; then
for triple in $CLI_TRIPLES; do
grep -qxF "start-cli_$(cli_asset_name "$triple")" <<< "${assets:-}" || published=
done
fi
[ -z "$published" ] || DONE[push]=1
;;
npm)
[ -z "$(npm view "${SDK_NPM_PACKAGE}@${VERSION}" version 2>/dev/null || true)" ] || DONE[push]=1
;;
esac
}
# Report a failed "already released" guard. With FORCE=1 it's tolerated (returns
# success) so an idempotent step can be re-run — S3 put -P, gh release --clobber,
# registry re-index, apt re-publish all overwrite in place. Non-idempotent steps
# (npm publish, which can't republish a version) must NOT use this.
release_guard() {
if [ "${FORCE:-}" = 1 ]; then
>&2 echo " ! ${1} (forced)"
return 0
fi
>&2 echo " ✗ ${1}"
return 1
}
cmd_pre_check() {
local errors=0
echo "Pre-checking ${PROJECT} v${VERSION} (tag ${TAG})..."
if check_changelog_version; then
echo " ✓ changelog fragments and version agree"
else
errors=1
fi
if (cd "$REPO_ROOT" && node scripts/changelog.mjs registry-notes "projects/$PROJECT" "$VERSION" > /dev/null); then
echo " ✓ release notes compile"
else
errors=1
fi
case "$KIND" in
os | wrt) check_registry_notes_cli || errors=1 ;;
esac
# releases/latest can resolve to another product's release.
if [ "$KIND" = os ]; then
local docs_tag="${PROJECT}/v${VERSION}" docs_src total good
docs_src="$REPO_ROOT/projects/start-os/docs/src"
if grep -rqF "releases/latest" "$docs_src" 2>/dev/null; then
>&2 echo " ✗ start-os docs still link to releases/latest — pin to https://github.com/${REPO}/releases/tag/${docs_tag}"
errors=1
fi
# `|| true`: zero grep matches must reach the ✗ report below, not trip
# errexit via pipefail on the assignment.
total=$(grep -rohF "${REPO}/releases/tag/" "$docs_src" 2>/dev/null | wc -l | tr -d ' ' || true)
good=$(grep -rohF "${REPO}/releases/tag/${docs_tag}" "$docs_src" 2>/dev/null | wc -l | tr -d ' ' || true)
if [ "$good" -eq 0 ]; then
>&2 echo " ✗ start-os docs link to no ${docs_tag} release — pin to https://github.com/${REPO}/releases/tag/${docs_tag}"
errors=1
elif [ "$total" -ne "$good" ]; then
>&2 echo " ✗ start-os docs pin a stale release link (expected releases/tag/${docs_tag}):"
grep -rnF "${REPO}/releases/tag/" "$docs_src" 2>/dev/null | grep -vF "releases/tag/${docs_tag}" | sed 's/^/ /' >&2
errors=1
else
echo " ✓ start-os docs pin the release link to ${docs_tag}"
fi
# The crate manifest can't hold the OS version, so it carries a label
# (0.4.0.1 -> 0.4.0-rev.1). Nothing reads it; a stale one just misleads.
local expect_label crate_label
case "$VERSION" in
*.*.*.*) expect_label="${VERSION%.*}-rev.${VERSION##*.}" ;;
*) expect_label="$VERSION" ;;
esac
crate_label=$(sed -nE '/^\[package\]/,/^\[/{s/^version *= *"([^"]+)".*/\1/p}' \
"$REPO_ROOT/projects/start-os/Cargo.toml" | head -1)
if [ "$crate_label" = "$expect_label" ]; then
echo " ✓ start-os crate label is ${expect_label}"
else
>&2 echo " ✗ projects/start-os/Cargo.toml version must be ${expect_label} for OS ${VERSION} (found: ${crate_label:-none})"
errors=1
fi
fi
# 1c. The `s9pk init-package` template pins the SDK version authors scaffold
# against; hold it at the version being released. The template ships no
# package-lock.json — `init-package` runs `npm install`, so each scaffold
# generates its own lock; a committed template lock is dead weight that only
# rots out of sync with the pin. See start-sdk AGENTS.md and root AGENTS.md
# "Coupled changes".
if [ "$PROJECT" = start-sdk ]; then
local tmpl tmpl_pin
tmpl="$REPO_ROOT/projects/start-sdk/docs/package-template"
tmpl_pin=$(jq -r '.dependencies["@start9labs/start-sdk"] // ""' "$tmpl/package.json" 2>/dev/null)
if [ "$tmpl_pin" != "$VERSION" ]; then
>&2 echo " ✗ package-template pins @start9labs/start-sdk@${tmpl_pin:-<none>} — bump to ${VERSION} (make -C projects/start-sdk sync-template)"
errors=1
else
echo " ✓ package-template pins @start9labs/start-sdk@${VERSION}"
fi
if [ -e "$tmpl/package-lock.json" ]; then
>&2 echo " ✗ package-template must not commit package-lock.json (init-package regenerates it per scaffold; a committed one only rots out of sync with the SDK pin) — remove it"
errors=1
else
echo " ✓ package-template ships no package-lock.json"
fi
fi
# 2. What of this release is already published. A partial release resumes
# where it stopped; a complete one is refused unless forced.
detect_progress
local step done_steps="" todo=""
for step in $(release_steps); do
case "$step" in pre-check | pull | pull-alpha) continue ;; esac
if [ -n "${DONE[$step]:-}" ]; then
done_steps="${done_steps} ${step}"
else
todo="${todo} ${step}"
fi
done
if [ -z "$done_steps" ]; then
echo " ✓ nothing of ${TAG} is published yet"
elif [ -z "$todo" ]; then
release_guard "${TAG} is already fully released" || errors=1
elif [ -n "${FORCE:-}" ]; then
>&2 echo " ! ${TAG} is partially released (${done_steps# }); FORCE re-runs every step"
else
echo " ✓ ${TAG} is partially released: resuming at${todo} (done:${done_steps})"
fi
if [ "$KIND" = npm ] && [ -n "${DONE[push]:-}" ] && [ -n "${FORCE:-}" ]; then
>&2 echo " ✗ ${SDK_NPM_PACKAGE}@${VERSION} already published to npm (cannot republish)"
errors=1
fi
# A resume works from the commit origin's tag already names.
if [ -n "${DONE[tag]:-}" ] && [ -n "$todo" ] && [ -z "${FORCE:-}" ]; then
if [ -n "${COMMIT:-}" ] && [ "$(tag_commit_sha)" != "$REMOTE_TAG_SHA" ]; then
>&2 echo " ✗ COMMIT=${COMMIT} is not ${REMOTE_TAG_SHA}, the commit ${TAG} already points at on origin"
errors=1
elif ! git -C "$REPO_ROOT" cat-file -e "${REMOTE_TAG_SHA}^{commit}" 2>/dev/null; then
>&2 echo " ✗ ${TAG} points at ${REMOTE_TAG_SHA}, which is not in this repository — git fetch --tags origin"
errors=1
else
COMMIT=$REMOTE_TAG_SHA
assert_metadata_matches_adopted || errors=1
if [ "$KIND" = npm ] && [ -z "${DONE[push]:-}" ] \
&& ! git -C "$REPO_ROOT" diff --quiet "$REMOTE_TAG_SHA" HEAD; then
>&2 echo " ✗ HEAD differs from ${TAG} (${REMOTE_TAG_SHA}); npm publishes the working tree — git checkout ${REMOTE_TAG_SHA}"
errors=1
fi
fi
fi
# 3. Preconditions for the release steps: everything the pipeline needs must
# already be in place, so a release doesn't fail halfway through.
case "$KIND" in
os)
# `release` pulls the images from the source registry and promotes
# them into prod, so every expected asset must already be in source.
local missing
load_registry_index "$STARTOS_SOURCE_REGISTRY"
if ! jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null 2>&1; then
>&2 echo " ✗ OS ${VERSION} not in source registry ${STARTOS_SOURCE_REGISTRY} — promote it there first"
errors=1
else
missing=$(missing_registry_assets "$STARTOS_SOURCE_REGISTRY")
if [ -n "$missing" ]; then
>&2 echo " ✗ source registry is missing OS assets:${missing}"
errors=1
else
echo " ✓ source registry has all ${VERSION} images"
fi
fi
# promoting re-signs registry commitments with the developer key;
# start-cli reads id.key.pem (auto-migrating a legacy developer.key.pem).
if [ -f "$HOME/.startos/id.key.pem" ] || [ -f "$HOME/.startos/developer.key.pem" ]; then
echo " ✓ developer key present"
else
>&2 echo " ✗ ~/.startos/id.key.pem missing (needed to promote to the registry)"
errors=1
fi
;;
npm)
if npm whoami >/dev/null 2>&1; then
echo " ✓ npm authenticated ($(npm whoami 2>/dev/null))"
else
>&2 echo " ✗ not logged in to npm (run: npm login)"
errors=1
fi
;;
wrt)
# `release` pulls the images from the source (beta) registry and
# promotes them into production, so both assets must already be
# registered there (the CI deploy does that; `register` is the
# manual fallback).
local wrt_missing
load_registry_index "$STARTWRT_SOURCE_REGISTRY"
if ! jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null 2>&1; then
>&2 echo " ✗ StartWRT ${VERSION} not in source registry ${STARTWRT_SOURCE_REGISTRY} — run the start-wrt deploy workflow (or 'register') first"
errors=1
else
wrt_missing=$(missing_registry_assets "$STARTWRT_SOURCE_REGISTRY")
if [ -n "$wrt_missing" ]; then
>&2 echo " ✗ source registry is missing StartWRT assets:${wrt_missing}"
errors=1
else
echo " ✓ source registry has both ${VERSION} images"
fi
fi
# promoting re-signs registry commitments with the developer key;
# start-cli reads id.key.pem (auto-migrating a legacy developer.key.pem).
if [ -f "$HOME/.startos/id.key.pem" ] || [ -f "$HOME/.startos/developer.key.pem" ]; then
echo " ✓ developer key present"
else
>&2 echo " ✗ ~/.startos/id.key.pem missing (needed to promote to the registry)"
errors=1
fi
;;
esac
# gh is needed by every release to create the GitHub release (plus the asset
# upload, sign, and apt Release signature for os/cli/deb/wrt).
if gh auth status >/dev/null 2>&1; then
echo " ✓ gh authenticated"
else
>&2 echo " ✗ gh not authenticated (run: gh auth login)"
errors=1
fi
# os/cli/deb/wrt also sign their artifacts with the Start9 org key, and
# render checksum blocks into the release notes (see checksum_block).
if [ "$KIND" != npm ]; then
if gpg --list-secret-keys "$START9_GPG_KEY" >/dev/null 2>&1; then
echo " ✓ Start9 signing key ${START9_GPG_KEY} present"
else
>&2 echo " ✗ Start9 GPG secret key ${START9_GPG_KEY} not in keyring (needed to sign)"
errors=1
fi
if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then
echo " ✓ sha-256 tool available"
else
>&2 echo " ✗ neither sha256sum nor shasum installed (needed for release-notes checksums)"
errors=1
fi
if command -v b3sum >/dev/null 2>&1; then
echo " ✓ b3sum available"
else
>&2 echo " ✗ b3sum not installed (needed for release-notes checksums; brew/cargo install b3sum)"
errors=1
fi
fi
# cli/deb publish debs to the apt repo and os pushes the compressed images
# (push-gz) — all three upload to S3, which needs s3cmd + credentials.
if [ "$KIND" = cli ] || [ "$KIND" = deb ] || [ "$KIND" = os ]; then
if command -v s3cmd >/dev/null 2>&1; then
echo " ✓ s3cmd available"
else
>&2 echo " ✗ s3cmd not installed (needed to upload to S3/apt)"
errors=1
fi
if [ -f "$HOME/.s3cfg" ] || { [ -n "${S3_ACCESS_KEY:-}" ] && [ -n "${S3_SECRET_KEY:-}" ]; }; then
echo " ✓ s3 credentials configured"
else
>&2 echo " ! no ~/.s3cfg and S3_ACCESS_KEY/S3_SECRET_KEY unset — S3 upload may fail"
fi
fi
if [ "$errors" -ne 0 ]; then
>&2 echo "Pre-check failed."
exit 1
fi
echo "Pre-check passed."
}
# Resolve RUN_ID (prompting when unset), assert the run built the commit being
# tagged, and stage the release dir. Sets RUN_ID and RUN_SHA for the caller.
resolve_gha_run() {
if [ -z "${RUN_ID:-}" ]; then
read -rp "RUN_ID (GitHub Actions run for ${PROJECT}): " RUN_ID
fi
RUN_ID=$(parse_run_id "${RUN_ID:-}")
if [ -z "$RUN_ID" ]; then
>&2 echo "RUN_ID is required"
exit 2
fi
# The tag must point at the commit these artifacts were built from.
local tag_sha
RUN_SHA=$(gh run view -R "$REPO" "$RUN_ID" --json headSha -q .headSha)
tag_sha=$(tag_commit_sha)
if [ "$RUN_SHA" != "$tag_sha" ]; then
>&2 echo "Run ${RUN_ID} built commit ${RUN_SHA},"
>&2 echo "but tag ${TAG} would be cut at ${COMMIT:-HEAD} (${tag_sha})."
>&2 echo "Pass the run for that commit, or set COMMIT=${RUN_SHA}."
exit 1
fi
ensure_release_dir
echo "$RUN_SHA" > "$(gha_commit_file)"
}
# The per-triple start-cli binaries. Unlike the debs these are published only as
# GitHub release assets, so there is no channel to promote them from and a
# release still needs the run that built the tagged commit.
pull_gha_cli_binaries() {
local triple name
for triple in $CLI_TRIPLES; do
name=$(cli_asset_name "$triple")
echo " start-cli_${triple} -> start-cli_${name}"
gh run download -R "$REPO" "$RUN_ID" -n "start-cli_${triple}" -D "$(pwd)"
mv start-cli "start-cli_${name}"
done
}
cmd_pull_gha() {
require_kind os cli deb wrt
resolve_gha_run
echo "Downloading ${PROJECT} artifacts from run ${RUN_ID} (commit ${RUN_SHA})..."
case "$KIND" in
os)
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do
echo " ${platform}.${ext}"
gh run download -R "$REPO" "$RUN_ID" -n "${platform}.${ext}" -D "$(pwd)"
done
done
ensure_img_gz
;;
cli)
pull_gha_cli_binaries
pull_gha_debs
;;
deb)
pull_gha_debs
;;
wrt)
echo " ${STARTWRT_BUILD_ARTIFACT} (sdcard + sysupgrade .img.gz)"
gh run download -R "$REPO" "$RUN_ID" -n "$STARTWRT_BUILD_ARTIFACT" -D "$(pwd)"
;;
esac
}
# Stage a deb release from the alpha suite. For start-cli the per-triple
# binaries have no channel to come from, so the run that built the tagged commit
# is still needed for those — both halves are pinned to that commit, so mixing
# the two sources cannot mix builds.
# Print the commit alpha's current build of this project came from, so a tree
# can be put on it without hand-copying a hash out of an error message:
# git checkout "$(./scripts/manage-release.sh alpha-commit start-tunnel)"
cmd_alpha_commit() {
require_kind cli deb
alpha_collect || { rm -rf "${ALPHA_TMP:-}"; exit 1; }
echo "$ALPHA_COMMIT"
rm -rf "$ALPHA_TMP"
}
cmd_pull_alpha() {
require_kind cli deb
ensure_release_dir
# Adopt alpha's commit FIRST. For start-cli the per-triple binaries come from
# a CI run, and resolving that run before adoption validated it against HEAD
# while the debs (and the tag) ended up on alpha's older commit — binaries
# from one commit, debs and tag from another, with the marker agreeing.
alpha_collect || { rm -rf "${ALPHA_TMP:-}"; exit 1; }
resolve_artifact_commit "$ALPHA_COMMIT" "$APT_ALPHA_SUITE" || { rm -rf "$ALPHA_TMP"; exit 1; }
assert_metadata_matches_adopted || { rm -rf "$ALPHA_TMP"; exit 1; }
# Marker and clear before either half is fetched, so a failure anywhere
# leaves an empty staging dir rather than one half of a release beside a
# marker that vouches for both. `push` then fails on the empty dir.
tag_commit_sha > "$(gha_commit_file)"
clear_alpha_staging
if [ "$KIND" = cli ]; then
# Resolved after adoption, so the run is validated against the commit
# actually being tagged and both halves really are pinned to it.
resolve_gha_run
echo "Downloading start-cli binaries from run ${RUN_ID} (commit ${RUN_SHA})..."
pull_gha_cli_binaries
fi
promote_alpha_debs || { rm -rf "$ALPHA_TMP"; exit 1; }
rm -rf "$ALPHA_TMP"
}
# Whether a local file is the asset a registry commits to: <file> <registry> <slot> <platform>.
matches_commitment() {
[ -f "$1" ] && [ "$(b3sum --no-names "$1")" = "$(asset_commitment_b3 "$2" "$3" "$4")" ]
}
pull_published_img_gz() {
local img=$1 url tmp digest
url=$(os_img_gz_origin "$img")
published_exists "$url" || return 0
tmp=$(mktemp "${img}.gz.XXXXXX")
echo " img.gz -> ${img}.gz"
if ! curl -fSL --progress-bar "${APT_NO_CACHE[@]}" "$url" -o "$tmp" \
|| ! digest=$(gzip -dc "$tmp" | b3sum --no-names) \
|| [ "$digest" != "$(b3sum --no-names "$img")" ]; then
rm -f "$tmp"
>&2 echo " ✗ published ${img}.gz does not decompress to the indexed image"
return 1
fi
touch -r "$img" "$tmp"
mv -f "$tmp" "${img}.gz"
}
cmd_pull() {
ensure_release_dir
# Released assets replace any GHA-pulled ones, so the marker no longer applies.
rm -f "$(gha_commit_file)"
echo "Downloading released ${PROJECT} v${VERSION} from its official location..."
case "$KIND" in
os)
# start-cli names its output startos-<ver>_<platform>.<ext>, which
# drops the commit hash the published basename carries. Land each
# asset under its published name instead, so checksums, signatures,
# and the .gz key all agree with what is actually on S3.
local platform ext url published tmp stale expected=()
load_registry_index "$STARTOS_SOURCE_REGISTRY"
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do
url=$(asset_url "$STARTOS_SOURCE_REGISTRY" "$ext" "$platform")
if [ -z "$url" ]; then
>&2 echo " (no '$ext' asset indexed for $platform, skipping)"
continue
fi
published=$(basename "$url")
expected+=("$published")
[ "$ext" != img ] || expected+=("${published}.gz")
if matches_commitment "$published" "$STARTOS_SOURCE_REGISTRY" "$ext" "$platform"; then
echo " ${ext} ${platform} -> ${published} (already here)"
else
echo " ${ext} ${platform} -> ${published}"
tmp=$(mktemp -d "$(pwd)/.get-${platform}-${ext}.XXXXXX")
start-cli --registry="$STARTOS_SOURCE_REGISTRY" registry os asset get "$ext" "$VERSION" "$platform" -d "$tmp"
mv -f "$tmp"/* "$published"
rmdir "$tmp"
fi
if [ "$ext" = img ]; then
pull_published_img_gz "$published" || return 1
fi
done
done
ensure_img_gz
# Everything the notes hash and sign must be named for the S3 object
# it links. Anything else the dir has collected — a pull from before
# assets were renamed to their published basename, or another build
# of the same version — would be hashed under a name that is on no
# URL we publish.
stale=$(comm -23 <(release_files | sort -u) <(printf '%s\n' "${expected[@]}" | sort -u))
if [ -n "$stale" ]; then
>&2 echo " ! not published ${VERSION} assets, but release_files would still hash and sign them — re-run with CLEAN=1 to drop them:"
printf '%s\n' "$stale" | sed 's/^/ /' >&2
fi
;;
cli)
gh release download -R "$REPO" "$TAG" -p 'start-cli_*' -D "$(pwd)" --clobber
pull_apt_debs
;;
deb)
pull_apt_debs
;;
npm)
npm pack "${SDK_NPM_PACKAGE}@${VERSION}"
;;
wrt)
# Pull from the source (beta) registry — at release time the
# version is not yet promoted into production. `registry os asset
# get` verifies the ed25519 registry signature + blake3 commitment
# as it streams. start-cli names its output
# startos-<ver>_<platform>.<ext>, so move each into place under its
# published basename (the basename of the indexed URL) to match
# release_files().
local slot url published tmp
load_registry_index "$STARTWRT_SOURCE_REGISTRY"
for slot in img squashfs; do
url=$(asset_url "$STARTWRT_SOURCE_REGISTRY" "$slot" "$STARTWRT_PLATFORM")
if [ -z "$url" ]; then
>&2 echo " (no '$slot' asset indexed for v$VERSION, skipping)"
continue
fi
published=$(basename "$url")
if matches_commitment "$published" "$STARTWRT_SOURCE_REGISTRY" "$slot" "$STARTWRT_PLATFORM"; then
echo " ${slot} -> ${published} (already here)"
continue
fi
echo " ${slot} -> ${published}"
tmp=$(mktemp -d "$(pwd)/.get-$slot.XXXXXX")
start-cli --registry="$STARTWRT_SOURCE_REGISTRY" registry os asset get "$slot" "$VERSION" "$STARTWRT_PLATFORM" -d "$tmp"
mv -f "$tmp"/* "$published"
rmdir "$tmp"
done
;;
esac
}
cmd_tag() {
if [ "$KIND" = os ]; then resolve_os_commit; fi
assert_metadata_matches_adopted
check_changelog_version
local commit="${COMMIT:-HEAD}" tag_sha pulled_sha
tag_sha=$(tag_commit_sha)
# Refuse to tag a commit other than the one the pulled GHA assets were built from.
if [ -f "$(gha_commit_file)" ]; then
pulled_sha=$(cat "$(gha_commit_file)")
if [ "$pulled_sha" != "$tag_sha" ]; then
>&2 echo "Release dir assets were built from commit ${pulled_sha},"
>&2 echo "but tag ${TAG} would be cut at ${commit} (${tag_sha})."
>&2 echo "Re-run pull-gha with the matching run, or set COMMIT=${pulled_sha}."
exit 1
fi
fi
if [ -n "$(cd "$REPO_ROOT" && git status --porcelain)" ]; then
>&2 echo "Warning: working tree is dirty; tagging ${commit} anyway."
fi
echo "Tagging ${TAG} at ${commit} (${tag_sha})..."
if [ -n "${FORCE:-}" ] || [ "$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/${TAG}^{commit}")" != "$tag_sha" ]; then
git -C "$REPO_ROOT" tag ${FORCE:+-f} "$TAG" "$commit"
fi
git -C "$REPO_ROOT" push origin ${FORCE:+-f} "refs/tags/${TAG}"
}
cmd_create_gh_release() (
require_kind os cli deb npm wrt
if [ "$KIND" != npm ]; then
enter_release_dir
ensure_img_gz
fi
local changelog_dir draft notes_ref="$TAG" prerelease_args=() title_version="$VERSION"
if [ "${PRERELEASE:-}" = 1 ]; then
prerelease_args+=(--prerelease --latest=false)
title_version="${TAG#"${PROJECT}/v"}"
fi
if [ -n "${UNCOMMITTED_NOTES:-}" ]; then notes_ref=; fi
changelog_dir=$(mktemp -d)
trap 'rm -rf "$changelog_dir"' EXIT
(cd "$REPO_ROOT" && node scripts/changelog.mjs render "projects/$PROJECT" "$VERSION" --ref "$TAG") > "$changelog_dir/CHANGELOG.md"
NOTES_REF="$notes_ref" release_body "$(cat "$changelog_dir/CHANGELOG.md")" > "$changelog_dir/notes.md"
echo "Creating GitHub release ${TAG}..."
if draft=$(gh release view -R "$REPO" "$TAG" --json isDraft --jq .isDraft 2>/dev/null); then
gh release edit -R "$REPO" "$TAG" "${prerelease_args[@]}" --title "$(project_display_name "$PROJECT") v${title_version}" --notes-file "$changelog_dir/notes.md"
else
draft=true
gh release create -R "$REPO" "$TAG" --draft --verify-tag "${prerelease_args[@]}" --title "$(project_display_name "$PROJECT") v${title_version}" --notes-file "$changelog_dir/notes.md"
fi
gh release upload -R "$REPO" "$TAG" "$changelog_dir/CHANGELOG.md" --clobber
if [ "$draft" = true ]; then
gh release edit -R "$REPO" "$TAG" --draft=false
fi
)
cmd_push() {
case "$KIND" in
os)
enter_release_dir
echo "Uploading OS images to ${S3_BUCKET}/v${VERSION}/ ..."
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do
for file in *_"$platform"."$ext"; do
[ -f "$file" ] || continue
echo " $file"
s3cmd put -P "$file" "${S3_BUCKET}/v${VERSION}/$file"
done
done
done
;;
cli)
enter_release_dir
local files file
mapfile -t files < <(cli_binaries)
echo "Uploading start-cli binaries to GitHub release ${TAG}..."
for file in "${files[@]}"; do
gh release upload -R "$REPO" "$TAG" "$file" --clobber
done
publish_debs
;;
deb)
enter_release_dir
publish_debs
;;
npm)
echo "Building and publishing ${SDK_NPM_PACKAGE}@${VERSION} to npm..."
make -C "$REPO_ROOT/projects/start-sdk" publish ${OTP:+OTP=$OTP}
;;
wrt)
enter_release_dir
echo "Uploading StartWRT images to ${STARTWRT_S3_BUCKET}/v${VERSION}/ ..."
local file
for file in $(release_files); do
echo " $file"
s3cmd put -P "$file" "${STARTWRT_S3_BUCKET}/v${VERSION}/$file"
done
;;
esac
}
# Put the compressed copy of each raw .img beside the bare image on S3.
#
# This adds an object rather than replacing one: the registry keeps pointing at
# the bare .img (see ensure_img_gz), and only the release-notes download link
# moves to the .gz. That link is the indexed URL + ".gz", so the key has to be
# the indexed basename + ".gz" — the guards below exist because any other name
# yields notes that link to nothing.
#
# The local img is picked by content, not name: one platform can be hotfixed
# and re-indexed at a different commit than the rest of the release, and a
# start-cli download drops the hash entirely, so the git hash a basename embeds
# proves nothing. The registry's signed blake3 commitment is what the .gz must
# decompress to; match against that, then rename to the indexed basename so
# checksums and signatures agree with the object the notes link.
cmd_push_gz() {
require_kind os
enter_release_dir
ensure_img_gz
echo "Uploading compressed OS images to ${S3_BUCKET}/v${VERSION}/ ..."
local platform ext url published expected img f
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do
[ "$ext" = img ] || continue
url=$(asset_url "$STARTOS_SOURCE_REGISTRY" img "$platform")
if [ -z "$url" ]; then
>&2 echo " ✗ no ${platform} img indexed in ${STARTOS_SOURCE_REGISTRY}"
exit 1
fi
published=$(basename "$url")
if [ "$url" != "${S3_CDN}/v${VERSION}/${published}" ]; then
>&2 echo " ✗ ${platform} img is indexed at ${url}, outside ${S3_CDN}/v${VERSION}/ — a .gz uploaded to ${S3_BUCKET} would not be reachable at that URL + .gz"
exit 1
fi
expected=$(asset_commitment_b3 "$STARTOS_SOURCE_REGISTRY" img "$platform")
if [ -z "$expected" ]; then
>&2 echo " ✗ ${published} has no blake3 commitment in ${STARTOS_SOURCE_REGISTRY}"
exit 1
fi
img=
for f in "$published" *.img; do
[ -f "$f" ] || continue
if [ "$(b3sum --no-names "$f")" = "$expected" ]; then
img=$f
break
fi
done
if [ -z "$img" ]; then
>&2 echo " ✗ no img in $(release_dir) matches the blake3 ${STARTOS_SOURCE_REGISTRY} commits to for ${published} — run 'pull' (or 'pull-gha' with the run that built the indexed img) first"
exit 1
fi
if [ "$img" != "$published" ]; then
echo " ${img} -> ${published} (renaming to the indexed basename)"
mv -f "$img" "$published"
[ ! -f "${img}.gz" ] || mv -f "${img}.gz" "${published}.gz"
ensure_img_gz
fi
echo " ${published}.gz"
s3cmd put -P "${published}.gz" "${S3_BUCKET}/v${VERSION}/${published}.gz"
done
done
}
cmd_index() {
require_kind os wrt
case "$KIND" in
os)
_INDEX_REGISTRY=""
resolve_os_commit
local NOTES_REF="$(tag_commit_sha)"
refresh_registry_notes "$STARTOS_SOURCE_REGISTRY"
load_registry_index "$STARTOS_TARGET_REGISTRY"
if [ "${PRERELEASE:-}" = 1 ] \
&& jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null; then
start-cli --registry="$STARTOS_TARGET_REGISTRY" registry os version remove "$VERSION"
fi
echo "Promoting OS ${VERSION}: ${STARTOS_SOURCE_REGISTRY} -> ${STARTOS_TARGET_REGISTRY} ..."
start-cli registry os promote --from "$STARTOS_SOURCE_REGISTRY" --to "$STARTOS_TARGET_REGISTRY" --expected-git-hash "$NOTES_REF" "$VERSION"
;;
wrt)
# Promote the version (+ its img/squashfs assets) from the source
# (beta) registry into production — the same index-copy +
# developer-key re-sign as the OS; the images stay on the StartWRT
# S3 bucket, so nothing is re-uploaded.
refresh_registry_notes "$STARTWRT_SOURCE_REGISTRY"
echo "Promoting StartWRT ${VERSION}: ${STARTWRT_SOURCE_REGISTRY} -> ${STARTWRT_TARGET_REGISTRY} ..."
start-cli registry os promote --from "$STARTWRT_SOURCE_REGISTRY" --to "$STARTWRT_TARGET_REGISTRY" "$VERSION"
;;
esac
}
cmd_register() {
require_kind wrt
local payload notes
payload=$(registry_notes) || return
check_registry_notes_cli || return
notes=$(jq -r '.releaseNotes' <<< "$payload")
# Register + index a CI build into the source (beta) registry, where beta
# routers soak it before `release` promotes it into production. Normally
# the CI deploy job (start-wrt.yaml) does this itself right after the S3
# upload — this is the manual fallback (same commands; keep in sync). Run
# pull-gha first: registering needs the image files locally to compute the
# signed blake3 commitments. The compat range is the set of installed
# versions allowed to upgrade to this one.
enter_release_dir
echo "Registering StartWRT ${VERSION} in ${STARTWRT_SOURCE_REGISTRY}..."
start-cli --registry="$STARTWRT_SOURCE_REGISTRY" registry os version add \
"$VERSION" "v$VERSION" "$notes" "${STARTWRT_COMPAT_FLOOR} <=$VERSION"
# start-cli infers the asset slot from the file extension and only accepts
# iso/img/squashfs. Both images ship gzipped, so present each under a
# hardlink whose extension names its slot: the sdcard image as .img (the
# fresh-install slot) and the sysupgrade image as .squashfs (start-os's
# update-asset slot). The indexed URLs still point at the honestly-named
# .img.gz files on S3 (the registry only requires the URL's bytes to match
# the signed blake3 commitment, which the hardlinks share).
local file index_file
for file in $(release_files); do
case "$file" in
*-sdcard.img.gz) index_file="${file%.img.gz}.img" ;;
*-sysupgrade.img.gz) index_file="${file%.img.gz}.squashfs" ;;
*) index_file="$file" ;;
esac
[ "$index_file" = "$file" ] || ln -f "$file" "$index_file"
echo "Indexing $file for platform ${STARTWRT_PLATFORM}..."
start-cli --registry="$STARTWRT_SOURCE_REGISTRY" registry os asset add \
--platform="$STARTWRT_PLATFORM" --version="$VERSION" \
"$index_file" "$STARTWRT_S3_CDN/v$VERSION/$file"
done
}
cmd_sign() {
require_kind os cli deb wrt
enter_release_dir
ensure_img_gz
resolve_gh_user
local files file
mapfile -t files < <(release_files)
rm -rf signatures
mkdir signatures
for file in "${files[@]}"; do
gpg -u $START9_GPG_KEY --yes --detach-sign --armor -o "signatures/${file}.start9.asc" "$file"
if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then
gpg -u "$GH_GPG_KEY" --yes --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file"
fi
done
gpg --export -a $START9_GPG_KEY > signatures/start9.key.asc
if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then
gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc"
else
>&2 echo 'Warning: could not determine GitHub user or GPG signing key, skipping personal signature'
fi
tar -czf signatures.tar.gz -C signatures .
gh release upload -R "$REPO" "$TAG" signatures.tar.gz --clobber
}
cmd_cosign() {
require_kind os cli deb wrt
enter_release_dir
ensure_img_gz
resolve_gh_user
if [ -z "$GH_USER" ] || [ -z "$GH_GPG_KEY" ]; then
>&2 echo 'Error: could not determine GitHub user or GPG signing key'
>&2 echo "Set GH_USER and/or configure an OpenPGP git user.signingkey"
exit 1
fi
echo "Downloading existing signatures..."
gh release download -R "$REPO" "$TAG" -p "signatures.tar.gz" -D "$(pwd)" --clobber
rm -rf signatures
mkdir signatures
tar -xzf signatures.tar.gz -C signatures
rm -f "signatures/"*".${GH_USER}.asc" "signatures/${GH_USER}.key.asc"
echo "Adding personal signatures as $GH_USER..."
local files file
mapfile -t files < <(release_files)
for file in "${files[@]}"; do
gpg -u "$GH_GPG_KEY" --yes --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file"
done
gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc"
tar -czf signatures.tar.gz -C signatures .
gh release upload -R "$REPO" "$TAG" signatures.tar.gz --clobber
echo "Done. Personal signatures for $GH_USER added to ${TAG}."
}
# Sign each indexed asset with your developer key on the production registry.
cmd_cosign_registry() {
require_kind os wrt
enter_release_dir
local registry slot platform url published file tmp
case "$KIND" in
os) registry=$STARTOS_TARGET_REGISTRY ;;
wrt) registry=$STARTWRT_TARGET_REGISTRY ;;
esac
load_registry_index "$registry"
if ! jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null 2>&1; then
>&2 echo " ✗ ${registry} has no ${VERSION} entry or its index could not be loaded"
return 1
fi
while read -r slot platform; do
url=$(asset_url "$registry" "$slot" "$platform")
if [ -z "$url" ]; then
>&2 echo " (no '$slot' asset indexed for $platform on $registry, skipping)"
continue
fi
published=$(basename "$url")
if ! matches_commitment "$published" "$registry" "$slot" "$platform"; then
>&2 echo " ✗ ${published} is missing or does not match the commitment on ${registry} — run 'pull' first"
return 1
fi
# start-cli infers the slot from the file extension.
file=$published tmp=""
if [ "${published##*.}" != "$slot" ]; then
tmp=$(mktemp -d "$(pwd)/.cosign-${platform}-${slot}.XXXXXX")
file="${tmp}/${published}.${slot}"
ln -s "$(pwd)/${published}" "$file"
fi
echo " ${slot} ${platform} -> ${published}"
start-cli --registry="$registry" registry os asset sign --version "$VERSION" --platform "$platform" "$file"
[ -z "$tmp" ] || rm -rf "$tmp"
done < <(registry_slots)
echo "Done. Developer signatures added to ${VERSION} on ${registry}."
}
release_body() {
echo "<!-- Generated by scripts/manage-release.sh from changelog fragments and optional projects/${PROJECT}/release-notes/${VERSION}.md and .pre-update.md framing — edits made here are overwritten. -->"
echo
composed_notes
echo
local changes
changes=$(printf '%s' "$1" | (cd "$REPO_ROOT" && node scripts/changelog.mjs section "$VERSION"))
echo '<details>'
echo "$CHANGELOG_SUMMARY"
echo
echo "${changes:-No changelog entries.}"
echo
echo '</details>'
echo
generated_sections
}
generated_sections() {
local platform
case "$KIND" in
os)
echo "## Image Downloads"
echo
echo "| Hardware | Image | Download |"
echo "| --- | --- | --- |"
local ext url row hardware image described
load_registry_index "$STARTOS_SOURCE_REGISTRY"
described=""
for row in "${OS_DOWNLOAD_ROWS[@]}"; do
IFS='|' read -r hardware image platform <<< "$row"
described="${described}${platform} "
for ext in $(os_image_exts "$platform"); do
# squashfs is the over-the-air update asset, not a download.
[ "$ext" != squashfs ] || continue
url=$(asset_url "$STARTOS_SOURCE_REGISTRY" "$ext" "$platform")
[ -n "$url" ] || continue
if [ "$ext" = img ]; then
url="${url}.gz \"gzip-compressed — Raspberry Pi Imager and balenaEtcher flash it without unpacking\""
fi
echo "| ${hardware} | ${image} | [${ext^^}](${url}) |"
done
done
for platform in $OS_PLATFORMS; do
case "$described" in
*"${platform} "*) ;;
*) >&2 echo "No OS_DOWNLOAD_ROWS entry for ${platform} — it would be missing from the release notes" ; return 1 ;;
esac
done
echo
local imgs
mapfile -t imgs < <(release_files)
checksum_block "OS Images" "${imgs[@]}"
;;
cli)
local bins debs
mapfile -t bins < <(cli_binaries)
checksum_block "start-cli" "${bins[@]}"
mapfile -t debs < <(deb_files)
checksum_block "start-cli packages" "${debs[@]}"
;;
deb)
local debs
mapfile -t debs < <(deb_files)
checksum_block "${PROJECT} packages" "${debs[@]}"
;;
wrt)
echo "## Image Downloads"
echo
echo "| Hardware | Image | Download |"
echo "| --- | --- | --- |"
local row hardware image slot tip url imgs
load_registry_index "$STARTWRT_SOURCE_REGISTRY"
for row in "${WRT_DOWNLOAD_ROWS[@]}"; do
IFS='|' read -r hardware image slot tip <<< "$row"
url=$(asset_url "$STARTWRT_SOURCE_REGISTRY" "$slot" "$STARTWRT_PLATFORM")
[ -n "$url" ] || continue
echo "| ${hardware} | ${image} | [IMG](${url}${tip:+ \"${tip}\"}) |"
done
echo
mapfile -t imgs < <(release_files)
checksum_block "StartWRT" "${imgs[@]}"
;;
esac
}
checksum_block() {
local title=$1
shift
[ "$#" -gt 0 ] || return 0
echo "## ${title} Checksums"
echo
echo "### SHA-256"
echo '```'
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
# macOS ships shasum but not coreutils' sha256sum; same output format.
shasum -a 256 "$@"
fi
echo '```'
echo
echo "### BLAKE-3"
echo '```'
b3sum "$@"
echo '```'
}
cmd_notes() {
require_kind os cli deb npm wrt
curated_notes
}
cmd_registry_notes() {
require_kind os cli deb npm wrt
registry_notes
}
cmd_body() {
require_kind os cli deb npm wrt
if [ "$KIND" != npm ]; then
enter_release_dir
ensure_img_gz
fi
local changelog
changelog=$(cd "$REPO_ROOT" && node scripts/changelog.mjs render "projects/$PROJECT" "$VERSION")
release_body "$changelog"
}
select_prerelease_tag() {
local name number highest=0
git -C "$REPO_ROOT" fetch --tags origin
while read -r name; do
number="${name#"${PROJECT}/v${VERSION}-rc."}"
[[ "$number" =~ ^[1-9][0-9]*$ ]] || continue
if [ "$number" -gt "$highest" ]; then highest=$number; fi
done < <(git -C "$REPO_ROOT" for-each-ref --sort=-version:refname --format='%(refname:short)' "refs/tags/${PROJECT}/v${VERSION}-rc.*")
TAG="${PROJECT}/v${VERSION}-rc.$((highest + 1))"
}
cmd_pre_release() {
require_kind os
local PRERELEASE=1
local STARTOS_TARGET_REGISTRY="$STARTOS_SOURCE_REGISTRY"
local STARTOS_SOURCE_REGISTRY="$STARTOS_ALPHA_REGISTRY"
local TAG
resolve_os_commit
assert_metadata_matches_adopted
select_prerelease_tag
cmd_release
}
cmd_release() {
if [ "$KIND" = os ]; then resolve_os_commit; fi
local step
for step in $(release_steps); do
if [ -n "${DONE[$step]:-}" ] && [ -z "${FORCE:-}" ]; then
echo "Skipping ${step}: already published."
continue
fi
# Once the debs are out, the files left to sign are the published ones.
if [ "$step" = pull-alpha ] && [ -n "${DONE[push]:-}" ] && [ -z "${FORCE:-}" ]; then
step=pull
fi
"cmd_${step//-/_}"
done
}
usage() {
cat << 'EOF'
Usage: manage-release.sh <subcommand> <project>
Projects:
start-os OS images (iso/squashfs, raspberrypi img) -> S3 + registry
OS index; the raw img also gets a .gz on S3 for the notes
start-cli per-triple binaries -> GitHub release; per-arch .deb -> apt + GitHub
start-tunnel per-arch .deb -> apt repo + GitHub release
start-registry per-arch .deb -> apt repo + GitHub release
start-sdk npm package -> npm + GitHub release
start-wrt flashable images (sdcard + sysupgrade .img.gz) -> S3 +
StartWRT registries (CI's deploy indexes into beta; `release`
promotes beta -> production)
Version is read from the project's manifest (Cargo.toml — for start-wrt the ctrl
crate's — or package.json for start-sdk); the git tag / GitHub release is
<project>/v<version>.
Subcommands:
pre-release start-os only: promote alpha -> beta and publish a GitHub
prerelease tagged start-os/v<VERSION>-rc.N. N increments
on every invocation; the beta listing is replaced.
The image's internal version stays unchanged.
pre-check Verify changelog fragments and their version severity,
and report which release steps are already published
(tag, GitHub release, uploads, registry, signatures).
Fails on a fully released version.
alpha-commit Print the commit alpha's current build of this project came
from. `git checkout "$(... alpha-commit <project>)"` puts a
tree on it. (cli/deb.)
pull-alpha Stage a deb release by promoting the `alpha` apt suite's
current build — what CI published and testers have been
running. The whole chain is verified against the suite
signature (apt/start9-alpha.gpg).
The tag follows the artifact: with COMMIT unset this adopts
the commit alpha built and tags there, which is usually not
HEAD, since each product's workflow is path-filtered. It
says which commit, and prints the matching `git checkout`.
Set COMMIT to assert a different one and it fails instead of
overriding you. This is what `release` uses. For start-cli
it also pulls the per-triple binaries from a run, which have
no channel to promote from. (cli/deb.)
pull-gha Download build artifacts from a GitHub Actions run.
Fails unless the run built the commit being tagged
(COMMIT, default HEAD).
(os/cli/deb/wrt; set RUN_ID or you'll be prompted.)
pull Download the released assets from their official location
(registry / apt repo / GitHub release / npm).
tag Create and push the <project>/v<version> git tag. Fails if
pull-gha'd assets were built from a different commit.
create-gh-release Publish (or update) the GitHub release with notes,
an expandable changelog and full-history attachment.
(all projects.)
push Upload artifacts to their destination (S3 for os, GitHub
release + apt for cli/deb, npm publish for sdk). For os this
normally runs in CI; use it for a manual re-publish.
push-gz os only: compress each raw .img and upload the .gz beside
the bare image on S3 — the registry keeps indexing the bare
image; only the release-notes download link uses the .gz.
Run 'pull' (or 'pull-gha') first.
index Promote the version from the source (beta) registry into
the production registry. (os: pre-release promotes alpha
into beta. wrt: CI indexes into beta.)
register wrt only: register + index a CI build's images into the
source (beta) registry, pointing at their S3/CDN URLs.
Manual fallback — the CI deploy normally does this. Run
pull-gha first; beta routers then soak the version
until `release` promotes it.
sign Sign artifacts with the Start9 org key (+ personal key if
available) and upload signatures.tar.gz. (os/cli/deb/wrt.)
cosign Add your personal GPG signature to an existing release's
signatures.tar.gz. (os/cli/deb/wrt; run 'pull' first.)
cosign-registry Add your start-cli developer-key signature to each asset
indexed on the target (production) registry.
(os/wrt; run 'pull' first.)
notes Print compiled Highlights and release-manager framing,
prefixed by optional release-notes/<version>.pre-update.md
and a full-changelog link. English Markdown. (all projects.)
check-registry-notes-cli
Check the installed CLI supports translated registry notes.
registry-notes Print registry publishing JSON with releaseNotes as a string
or locale map, including localized warnings and changelog
links. (all projects.)
body Print the whole GitHub release body: notes, an expandable
changelog, downloads and checksums. (all projects.)
release Run the full applicable pipeline for the project, skipping
the steps pre-check finds already published — re-running
after a failure resumes at the step that failed.
Environment variables:
VERSION Override the version (default: read from the manifest)
CHANGELOG_REF Link to change fragments at this ref (CI passes the
built commit). Unset links to the GitHub release.
RUN_ID GitHub Actions run id/url for pull-gha
COMMIT Commit to tag (default: HEAD)
FORCE Set to 1 to re-release an already-released version:
run every step even if published, force-move the tag,
and downgrade pre-check's "already released" failure to
a warning (idempotent steps only; npm republish always
fails)
UNCOMMITTED_NOTES Set to 1 to release curated notes that differ from the
tagged commit; commit them to master afterwards
CLEAN Set to 1 to wipe and recreate the release directory
GH_USER Override GitHub username (default: autodetected via gh)
OTP npm one-time password for start-sdk publish
(prompted for at publish time if unset)
Registries are scoped per project (the OS and StartWRT promote source -> target):
STARTOS_ALPHA_REGISTRY registry pre-release pulls/promotes from (default: alpha)
STARTOS_SOURCE_REGISTRY registry release promotes from, and pre-release
promotes into (default: beta)
STARTOS_TARGET_REGISTRY registry the OS release promotes into (default: production)
STARTWRT_SOURCE_REGISTRY registry CI (or `register`) indexes into and the
StartWRT release pulls/promotes from (default: beta)
STARTWRT_TARGET_REGISTRY registry the StartWRT release promotes into
(default: production)
STARTWRT_COMPAT_FLOOR oldest version allowed to upgrade to this StartWRT
release (default: >=0.1.0-beta.1)
EOF
}
SUBCOMMAND="${1:-}"
PROJECT="${2:-}"
if [ -z "$SUBCOMMAND" ] || [ "$SUBCOMMAND" = "-h" ] || [ "$SUBCOMMAND" = "--help" ]; then
usage
exit 0
fi
if ! KIND=$(project_kind "$PROJECT"); then
>&2 echo "Unknown or missing project: '${PROJECT}'"
>&2 echo "Projects: $(node "$SCRIPT_DIR/changelog.mjs" projects)"
exit 2
fi
# Without the `!`, errexit takes a failing derivation at the assignment itself
# and the script exits mute — the state this message exists to describe.
if ! VERSION="${VERSION:-$(derive_version "$PROJECT")}" || [ -z "$VERSION" ]; then
>&2 echo "Could not derive ${PROJECT}'s version from its manifest; pass VERSION=<version> to override."
exit 1
fi
TAG="${PROJECT}/v${VERSION}"
case "$SUBCOMMAND" in
pre-release) cmd_pre_release ;;
pre-check) cmd_pre_check ;;
pull-gha) cmd_pull_gha ;;
pull-alpha) cmd_pull_alpha ;;
alpha-commit) cmd_alpha_commit ;;
pull) cmd_pull ;;
tag) cmd_tag ;;
create-gh-release) cmd_create_gh_release ;;
push) cmd_push ;;
push-gz) cmd_push_gz ;;
index) cmd_index ;;
register) cmd_register ;;
sign) cmd_sign ;;
cosign) cmd_cosign ;;
cosign-registry) cmd_cosign_registry ;;
notes) cmd_notes ;;
registry-notes) cmd_registry_notes ;;
check-registry-notes-cli) check_registry_notes_cli ;;
body) cmd_body ;;
release) cmd_release ;;
*)
>&2 echo "Unknown subcommand: '${SUBCOMMAND}'"
usage
exit 2
;;
esac