#!/usr/bin/env bash # # manage-release.sh — drive a monorepo product through its release steps. # # Usage: ./scripts/manage-release.sh # # See usage() for the subcommands. The 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 /v (the slash namespaces each # product's tags; releases before July 2026 used _v). # 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="Full changelog" # 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: . 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 " " 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 -- '' <<< "$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): . 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 "\t\t" 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:-} — 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: . 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-_., 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-_., 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 "" echo composed_notes echo local changes changes=$(printf '%s' "$1" | (cd "$REPO_ROOT" && node scripts/changelog.mjs section "$VERSION")) echo '
' echo "$CHANGELOG_SUMMARY" echo echo "${changes:-No changelog entries.}" echo echo '
' 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 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 /v. Subcommands: pre-release start-os only: promote alpha -> beta and publish a GitHub prerelease tagged start-os/v-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 )"` 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 /v 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/.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= 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