Publish releases from two scripts, one per remote

A release meant running the RELEASING.md checklist by hand: tag, push
twice, create the GitHub release, then remember the Gitea push. The two
scripts do the machine-checkable part of it, with the shared flow in
scripts/lib/ so they cannot drift apart: clean worktree, not behind the
remote, typecheck, tests and the install.sh syntax check, then the tag at
HEAD, the branch push and the release object.

Only the release object differs. GitHub creates it with gh, and publishing
it is what fires the image build. Gitea gets the same notes through its
API, with the token from GITEA_TOKEN or the one tea already stored, since
nothing there builds images.

Both accept --version (defaulting to package.json), --branch, --yes,
--dry-run and --skip-checks, and a rerun finishes a release that failed
half way instead of starting over: an existing tag is reused, a pushed ref
is skipped and an existing release is left alone. A tag sitting on another
commit is refused, since publishing would ship that commit's code.
This commit is contained in:
2026-10-22 20:10:59 +01:00
parent 70d6f9b3d8
commit e8e4df6f56
9 changed files with 1382 additions and 8 deletions
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env bash
# Shared helpers for the publish scripts.
#
# . "$(dirname "$0")/lib/publish-common.sh"
#
# Never run this file: it only defines functions and constants, so the
# scripts own their own `set -Eeuo pipefail` and a test can source it
# without changing the caller's shell options.
# MARK:preamble
if [ -t 1 ]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
else
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_GREEN=""
C_YELLOW=""
C_BLUE=""
fi
DRY_RUN="${DRY_RUN:-0}"
ASSUME_YES="${ASSUME_YES:-0}"
RELEASE_NOTES_MAX_COMMITS="${RELEASE_NOTES_MAX_COMMITS:-100}"
step() { printf '\n%s==>%s %s\n' "$C_BLUE$C_BOLD" "$C_RESET" "$*"; }
ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
detail() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
fail() {
printf '%sfail%s %s\n' "$C_RED" "$C_RESET" "$*" >&2
exit 1
}
# Runs a command, or prints it when the script was asked for a dry run.
# Only use this for commands that can be spelled as a single command.
run() {
if [ "$DRY_RUN" = "1" ]; then
printf ' %s[dry run]%s %s\n' "$C_DIM" "$C_RESET" "$*"
return 0
fi
"$@"
}
is_dry_run() { [ "$DRY_RUN" = "1" ]; }
# END:preamble
# MARK:require-cmd
require_cmd() {
local name="$1"
local hint="${2:-}"
if ! command -v "$name" >/dev/null 2>&1; then
if [ -n "$hint" ]; then
fail "Missing '$name'. $hint"
fi
fail "Missing '$name'."
fi
}
# END:require-cmd
# Moves into the top of the checkout and remembers it as ROOT.
require_repo_root() {
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || fail "Not inside a git checkout."
cd "$ROOT"
export ROOT
}
# MARK:git-state
require_clean_worktree() {
local dirty
dirty="$(git status --porcelain --untracked-files=no)"
if [ -n "$dirty" ]; then
printf '%s\n' "$dirty" >&2
fail "The working tree has uncommitted changes. Commit or stash them before publishing."
fi
}
require_on_branch() {
local branch="$1"
local current
current="$(git rev-parse --abbrev-ref HEAD)"
if [ "$current" != "$branch" ]; then
fail "On '$current', not '$branch'. Check out $branch before publishing (or pass --branch)."
fi
}
require_remote() {
local remote="$1"
if ! git remote get-url "$remote" >/dev/null 2>&1; then
fail "No '$remote' git remote. Add it with: git remote add $remote <url>"
fi
}
# Fails when the remote branch has commits HEAD does not have. HEAD being
# ahead is fine: the script pushes.
require_not_behind() {
local remote="$1"
local branch="$2"
local counts ahead behind
# A remote with no branch yet is not behind: the first push creates it.
if ! git ls-remote --exit-code --heads "$remote" "refs/heads/$branch" >/dev/null 2>&1; then
detail "$remote has no $branch yet; the first push creates it."
return 0
fi
git fetch --quiet "$remote" "$branch" || fail "Could not fetch $remote/$branch."
if ! git rev-parse -q --verify "refs/remotes/$remote/$branch" >/dev/null; then
return 0
fi
counts="$(git rev-list --left-right --count "HEAD...$remote/$branch")"
ahead="$(printf '%s' "$counts" | cut -f1)"
behind="$(printf '%s' "$counts" | cut -f2)"
if [ "$behind" != "0" ]; then
fail "$remote/$branch has $behind commit(s) HEAD does not have. Pull before publishing."
fi
if [ "$ahead" != "0" ]; then
detail "HEAD is $ahead commit(s) ahead of $remote/$branch; they are pushed with the release."
fi
}
# Prints the tag already on the remote for a version, or nothing.
remote_tag_exists() {
local remote="$1"
local tag="$2"
local found
found="$(git ls-remote --tags "$remote" "refs/tags/$tag" 2>/dev/null || true)"
[ -n "$found" ] && printf 'yes'
}
local_tag_exists() {
git rev-parse -q --verify "refs/tags/$1" >/dev/null
}
# Creates the annotated tag when it is missing, and reuses an existing one.
ensure_tag() {
local tag="$1"
local message="${2:-$1}"
if local_tag_exists "$tag"; then
ok "Tag $tag already exists locally; reusing it."
return 0
fi
run git tag -a "$tag" -m "$message"
}
# END:git-state
# MARK:remote-url
# Splits a git remote URL into REMOTE_HOST, REMOTE_OWNER and REMOTE_REPO.
# Understands git@host:owner/repo.git, ssh://git@host[:port]/owner/repo.git
# and https://host[:port]/owner/repo.git.
parse_remote_url() {
local url="$1"
local rest path
case "$url" in
*://*)
rest="${url#*://}"
rest="${rest#*@}"
REMOTE_HOST="${rest%%/*}"
path="${rest#*/}"
;;
*@*:*)
rest="${url#*@}"
REMOTE_HOST="${rest%%:*}"
path="${rest#*:}"
;;
*)
fail "Could not read a host out of the remote URL '$url'."
;;
esac
path="${path#/}"
path="${path%.git}"
REMOTE_OWNER="${path%%/*}"
REMOTE_REPO="${path##*/}"
if [ -z "$REMOTE_HOST" ] || [ -z "$REMOTE_OWNER" ] || [ -z "$REMOTE_REPO" ]; then
fail "Could not read owner and repository out of the remote URL '$url'."
fi
}
# END:remote-url
# MARK:version
VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$'
# Turns `0.1.2` or `v0.1.2` into `v0.1.2`.
normalize_version() {
local raw="$1"
printf 'v%s' "${raw#v}"
}
# Refuses anything that is not a release version, so a typo never becomes a
# tag that the release workflow cannot read a major.minor from.
validate_version() {
local raw="$1"
local bare="${raw#v}"
if ! printf '%s' "$bare" | grep -Eq "$VERSION_PATTERN"; then
fail "Version '$raw' is not a release version. Expected vMAJOR.MINOR.PATCH, e.g. v0.1.2."
fi
}
# The version in package.json, read the same way the release workflow reads it.
source_version() {
sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' "${ROOT:?}/package.json" | head -n1
}
# The newest release tag before `to`, or nothing when there is none.
previous_tag() {
local to="$1"
git -C "${ROOT:?}" describe --tags --abbrev=0 --match 'v[0-9]*' "${to}^" 2>/dev/null || true
}
# END:version
# MARK:notes
# Builds a release body from the commit log, so the Gitea mirror gets notes
# too (only GitHub can generate them itself).
release_notes() {
local from=""
local to=""
local compare_url=""
while [ $# -gt 0 ]; do
case "$1" in
--from) from="$2"; shift 2 ;;
--to) to="$2"; shift 2 ;;
--compare-url) compare_url="$2"; shift 2 ;;
*) fail "release_notes: unknown argument '$1'." ;;
esac
done
if [ -z "$to" ]; then
fail "release_notes: --to is required."
fi
local range="$to"
if [ -n "$from" ]; then
range="$from..$to"
fi
local commits total
commits="$(git -C "${ROOT:?}" log --no-merges --pretty=format:'- %s (%h)' --max-count="$RELEASE_NOTES_MAX_COMMITS" "$range")"
total="$(git -C "$ROOT" log --no-merges --oneline "$range" | wc -l | tr -d ' ')"
printf '## Changes\n\n'
if [ -z "$commits" ]; then
printf 'No commits listed.\n'
else
printf '%s\n' "$commits"
if [ "$total" -gt "$RELEASE_NOTES_MAX_COMMITS" ]; then
printf '\n_Showing the newest %s of %s commits._\n' "$RELEASE_NOTES_MAX_COMMITS" "$total"
fi
fi
if [ -n "$compare_url" ]; then
printf '\n**Full changelog**: %s\n' "$compare_url"
fi
}
# END:notes
# MARK:json-escape
# Escapes a string for a JSON body. jq is used when it is installed, since it
# handles every control character; the bash fallback covers the ones release
# notes actually contain.
json_escape() {
local text="$1"
if command -v jq >/dev/null 2>&1; then
# `-s` produces one JSON string; the quotes it wraps it in are the
# characters the caller's template adds, so they come back off.
local escaped
escaped="$(printf '%s' "$text" | jq -R -s '.')"
escaped="${escaped:1:${#escaped}-2}"
printf '%s' "$escaped"
return 0
fi
local out=""
local char
local i=0
local length=${#text}
while [ "$i" -lt "$length" ]; do
char="${text:$i:1}"
case "$char" in
'\') out+='\\' ;;
'"') out+='\"' ;;
$'\n') out+='\n' ;;
$'\r') out+='\r' ;;
$'\t') out+='\t' ;;
$'\b') out+='\b' ;;
$'\f') out+='\f' ;;
*) out+="$char" ;;
esac
i=$((i + 1))
done
printf '%s' "$out"
}
# END:json-escape
# MARK:confirm
confirm() {
local prompt="$1"
local answer=""
if [ "$ASSUME_YES" = "1" ]; then
return 0
fi
if [ ! -t 0 ]; then
fail "Not a terminal, so nothing was confirmed. Rerun with --yes to publish without a prompt."
fi
read -r -p "$prompt [y/N] " answer || true
case "$answer" in
y | Y | yes | YES) return 0 ;;
*) fail "Cancelled." ;;
esac
}
# END:confirm
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# The release flow both publish scripts share: argument parsing, preflight
# checks, tag preparation and the push.
#
# . "$(dirname "$0")/lib/publish-common.sh"
# . "$(dirname "$0")/lib/publish-release.sh"
#
# What differs between the two remotes is only the last step: the GitHub
# release object (which the release workflow builds the images from) and the
# Gitea one. Everything before that lives here, so the two scripts cannot
# drift apart.
# Defaults, overridden by the flags in parse_publish_args.
BRANCH="main"
VERSION_RAW=""
TAG=""
SKIP_CHECKS=0
HELP=0
REMAINING_ARGS=()
NOTES_FILE=""
TAG_CREATED=0
parse_publish_args() {
while [ $# -gt 0 ]; do
case "$1" in
-h | --help) HELP=1; shift ;;
--yes | -y) ASSUME_YES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--branch) BRANCH="${2:?--branch needs a value}"; shift 2 ;;
--version) VERSION_RAW="${2:?--version needs a value}"; shift 2 ;;
--skip-checks) SKIP_CHECKS=1; shift ;;
*)
REMAINING_ARGS+=("$1")
shift
;;
esac
done
DRY_RUN="${DRY_RUN:-0}"
ASSUME_YES="${ASSUME_YES:-0}"
}
# Fails when the platform script was handed a flag it does not know.
reject_unknown_args() {
local script="$1"
if [ "${#REMAINING_ARGS[@]}" -eq 0 ]; then
return 0
fi
printf 'Unknown argument: %s\n\n' "${REMAINING_ARGS[*]}" >&2
"$script" --help >&2
exit 2
}
# MARK:preflight
preflight() {
local remote="$1"
require_repo_root
require_cmd git
require_cmd "bun" "Install Bun: https://bun.sh"
step "Checking the checkout"
require_clean_worktree
ok "The working tree has nothing uncommitted."
require_on_branch "$BRANCH"
ok "On $BRANCH."
require_remote "$remote"
ok "Remote '$remote' is $(git remote get-url "$remote")."
require_not_behind "$remote" "$BRANCH"
ok "$remote/$BRANCH has nothing HEAD is missing."
}
# END:preflight
# MARK:resolve-version
# Sets TAG (vX.Y.Z) from --version, else from package.json.
resolve_version() {
step "Resolving the version"
if [ -z "$VERSION_RAW" ]; then
VERSION_RAW="$(source_version)"
if [ -z "$VERSION_RAW" ]; then
fail "No version in package.json and none passed with --version."
fi
detail "Read from package.json: $VERSION_RAW"
fi
validate_version "$VERSION_RAW"
TAG="$(normalize_version "$VERSION_RAW")"
ok "Releasing $TAG."
if [ "$TAG" != "v$(source_version)" ]; then
warn "package.json says $(source_version) but the release is $TAG. Bump package.json's version with the release so a dev build reports the same number (RELEASING.md, step 5)."
fi
}
# END:resolve-version
# MARK:checks
# The release gate from RELEASING.md that a machine can check.
run_release_checks() {
if [ "$SKIP_CHECKS" = "1" ]; then
warn "Skipping typecheck, tests and the installer syntax check (--skip-checks)."
return 0
fi
step "Running the release checks"
run bun run typecheck
ok "Type-check passed."
run bun run test
ok "Tests passed."
run bash -n install.sh
ok "install.sh parses."
}
# END:checks
# MARK:tag
# Creates the tag at HEAD, or verifies the existing one is at HEAD.
prepare_tag() {
step "Preparing the tag $TAG"
if local_tag_exists "$TAG"; then
local tagged
tagged="$(git rev-list -n1 "$TAG")"
if [ "$tagged" != "$(git rev-parse HEAD)" ]; then
fail "Tag $TAG already exists on ${tagged:0:12}, not on HEAD ($(git rev-parse --short HEAD)). Publishing would ship the code that tag was made from. Move it with: git tag -f -a $TAG -m '$TAG'"
fi
ok "Tag $TAG already points at HEAD; reusing it."
return 0
fi
run git tag -a "$TAG" -m "$TAG"
if [ "$DRY_RUN" != "1" ]; then
TAG_CREATED=1
fi
ok "Tagged HEAD as $TAG."
}
# END:tag
# MARK:notes-file
# Writes release_notes into NOTES_FILE, which the caller cleans up.
write_notes_file() {
local compare_url="$1"
local from=""
# A dry run makes no tag, so the notes are read up to HEAD instead.
local to="$TAG"
if ! local_tag_exists "$to"; then
to="HEAD"
fi
from="$(previous_tag "$to")"
if [ -n "$from" ]; then
detail "Changes since $from."
else
detail "No earlier tag, so every commit is listed."
fi
NOTES_FILE="$(mktemp -t cobble-release-notes.XXXXXX)"
release_notes --from "$from" --to "$to" --compare-url "$compare_url" >"$NOTES_FILE"
ok "Wrote the release notes to $NOTES_FILE."
}
cleanup_notes() {
if [ -n "$NOTES_FILE" ] && [ -f "$NOTES_FILE" ]; then
rm -f "$NOTES_FILE"
fi
}
# END:notes-file
# MARK:push
# Pushes the branch, then the tag. A tag the remote already has is left alone,
# so a rerun after a failed release object finishes the job instead of
# failing on the push.
push_branch_and_tag() {
local remote="$1"
step "Pushing to $remote"
run git push "$remote" "$BRANCH"
ok "Pushed $BRANCH."
if [ -n "$(remote_tag_exists "$remote" "$TAG")" ]; then
ok "$remote already has $TAG; not pushing it again."
return 0
fi
run git push "$remote" "refs/tags/$TAG"
ok "Pushed $TAG."
}
# END:push
# MARK:reuse-release
# A release object that already exists is left as it is, so rerunning the
# script never fails on it and never overwrites a body someone edited.
release_already_exists() {
local description="$1"
warn "$description for $TAG already exists; leaving it untouched."
warn "Delete it first if it needs to be recreated."
}
# END:reuse-release
print_release_summary() {
local remote="$1"
local branch="$2"
local release_url="$3"
step "Published $TAG"
printf ' %sremote%s %s\n' "$C_DIM" "$C_RESET" "$remote ($(git remote get-url "$remote"))"
printf ' %sbranch%s %s\n' "$C_DIM" "$C_RESET" "$branch"
printf ' %stag%s %s\n' "$C_DIM" "$C_RESET" "$TAG"
if [ -n "$release_url" ]; then
printf ' %srelease%s %s\n' "$C_DIM" "$C_RESET" "$release_url"
fi
if [ "$DRY_RUN" = "1" ]; then
printf '\n%sDry run: nothing was tagged, pushed or published.%s\n' "$C_YELLOW" "$C_RESET"
fi
}