Artifact dca4c00590c1357e0ad823dcbb9c1e6151dda0fe4da526deb0d6d3a99cb75325:
- Executable file
scripts/release.sh
— part of check-in
[8414300b13]
at
2026-05-04 23:30:41
on branch trunk
— chore: add auto-release workflow + scripts/release.sh (#141)
Mechanizes the multi-step release flow that today is hand-driven via: 1. tag leaf submodule, push 2. open chore/bump-leaf PR, merge 3. tag root, push (fires release.yml + bones dispatch)
scripts/release.sh detects which submodules have changes since their last <name>/v* tag, computes next versions (patch by default; --bump= minor|major), tags and pushes them, rewrites the matching require pin in root go.mod, commits straight to main as github-actions[bot], then tags the next root v* and pushes — firing release.yml.
Local invocation prompts before each push so the operator can intervene. Workflow invocation (auto-release.yml) passes --ci to suppress prompts and uses the bot identity. --dry-run reports the plan without touching anything.
Direct-to-main bot commits is the right tradeoff here: the change is deterministic robot work (one-line go.mod edit derived from the just- tagged version) and avoiding a PR round-trip lets a release ship in a single workflow run rather than coordinating across two. If branch protection blocks the github-actions bot, configure RELEASE_PUSH_TOKEN as a fine-grained PAT with contents:write.
Side observation: dry-run flags that bridge/ has unreleased changes since bridge/v0.0.2 (libfossil bump + README), so the next auto-release run will tag bridge/v0.0.3 alongside whatever else changes. (user: [email protected], size: 8283) [annotate] [blame] [check-ins using]
#!/usr/bin/env bash # # scripts/release.sh — drive a coordinated EdgeSync release. # # What it does, in order: # 1. Walk each Go submodule (leaf, bridge). For each, find its latest # <name>/v* tag and detect whether <name>/ has changes since. If so, # compute the next patch version, create the tag, and push it. # 2. If any submodule was tagged, rewrite the root go.mod's `require` # pin for that submodule to the new version, commit, and push to main. # 3. Detect whether the root module has changes since its latest v* tag # (any new submodule pin counts). If so, tag the next root patch and # push it. Pushing the root tag fires .github/workflows/release.yml, # which runs goreleaser and dispatches the bones bump-upstream. # # Usage: # scripts/release.sh # do the bumps # scripts/release.sh --dry-run # report what it would do # scripts/release.sh --bump=minor # use minor instead of patch (or major) # scripts/release.sh --remote=origin # remote to push to (default origin) # # Local invocation uses your git identity and prompts before any push that # touches main or pushes a tag. Workflow invocation (auto-release.yml) # passes --ci to suppress prompts and switches to the github-actions bot # identity. set -euo pipefail DRY_RUN=false CI_MODE=false ALLOW_NON_MAIN=false BUMP_KIND=patch REMOTE=origin SUBMODULES=("leaf" "bridge") ROOT_TAG_PREFIX="v" for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; --ci) CI_MODE=true ;; --allow-non-main) ALLOW_NON_MAIN=true ;; --bump=patch|--bump=minor|--bump=major) BUMP_KIND="${arg#--bump=}" ;; --remote=*) REMOTE="${arg#--remote=}" ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; *) echo "release.sh: unknown arg: $arg" >&2; exit 2 ;; esac done log() { printf '\033[1;36m[release]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[release]\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31m[release]\033[0m %s\n' "$*" >&2; } run() { if $DRY_RUN; then printf '\033[2m[dry-run]\033[0m %s\n' "$*" else eval "$@" fi } confirm() { $CI_MODE && return 0 $DRY_RUN && return 0 printf '\033[1;33m[release]\033[0m %s [y/N] ' "$1" read -r reply [[ "$reply" =~ ^[Yy]$ ]] } # ---------- helpers ---------- # latest_tag_version <prefix> → echoes "0.0.9" for prefix="leaf/v" if # leaf/v0.0.9 is the latest, "" if no matching tag. latest_tag_version() { local prefix="$1" git tag --list "${prefix}*" --sort=-v:refname \ | grep -E "^${prefix//./\\.}[0-9]+\\.[0-9]+\\.[0-9]+$" \ | head -n1 \ | sed "s|^${prefix}||" \ || true } # bump_version <semver> <patch|minor|major> → echoes the next semver. bump_version() { local v="$1" kind="$2" local major minor patch IFS='.' read -r major minor patch <<<"$v" case "$kind" in patch) patch=$((patch + 1)) ;; minor) minor=$((minor + 1)); patch=0 ;; major) major=$((major + 1)); minor=0; patch=0 ;; *) err "unknown bump kind: $kind"; return 1 ;; esac printf '%s.%s.%s\n' "$major" "$minor" "$patch" } # has_path_changes <ref> <path> → exits 0 if <path> has any diff between # <ref> and HEAD. Empty diff returns 1. has_path_changes() { local ref="$1" path="$2" ! git diff --quiet "$ref" HEAD -- "$path" } # update_gomod_pin <module-path> <new-version> → in-place edit of go.mod. # Touches only the require directive, not replace. update_gomod_pin() { local module="$1" version="$2" GOMOD_MODULE="$module" GOMOD_VERSION="$version" python3 - <<'PY' import os, pathlib, re, sys module = os.environ["GOMOD_MODULE"] version = os.environ["GOMOD_VERSION"] p = pathlib.Path("go.mod") src = p.read_text() pat = re.compile(rf"^(\s*{re.escape(module)}\s+)v[0-9.]+(\s*(?://.*)?)$", re.MULTILINE) new, n = pat.subn(rf"\g<1>v{version}\g<2>", src) if n == 0: sys.exit(f"release.sh: no require line for {module} in go.mod") if n > 1: sys.exit(f"release.sh: multiple require lines for {module} in go.mod (got {n}, expected 1)") p.write_text(new) PY } # ---------- preflight ---------- cd "$(git rev-parse --show-toplevel)" if [[ "$(git symbolic-ref --short HEAD)" != "main" ]]; then if $ALLOW_NON_MAIN; then warn "running from $(git symbolic-ref --short HEAD) (--allow-non-main set; intended for testing only)" else err "must run from main; you're on $(git symbolic-ref --short HEAD)" exit 1 fi fi if ! $CI_MODE && ! git diff --quiet HEAD -- go.mod; then err "go.mod has uncommitted changes — refusing to start" exit 1 fi log "fetching tags from $REMOTE..." run "git fetch --tags $REMOTE" if ! git diff --quiet "$REMOTE/main" HEAD; then err "local main is not in sync with $REMOTE/main — pull or push first" exit 1 fi # ---------- step 1: tag submodules ---------- declare -a NEW_SUBMODULE_TAGS=() declare -A NEW_SUBMODULE_VERSIONS=() for sm in "${SUBMODULES[@]}"; do cur=$(latest_tag_version "${sm}/v") if [[ -z "$cur" ]]; then warn "no ${sm}/v* tag exists; skipping (cut a baseline tag manually)" continue fi if ! has_path_changes "${sm}/v${cur}" "${sm}/"; then log "${sm}: no changes since ${sm}/v${cur}; skipping" continue fi next=$(bump_version "$cur" "$BUMP_KIND") log "${sm}: ${cur} → ${next} (changes since ${sm}/v${cur})" if ! confirm "tag ${sm}/v${next} at HEAD and push?"; then warn "${sm}: skipped by user" continue fi run "git tag '${sm}/v${next}' HEAD -m '${sm} v${next}: auto-release'" run "git push '$REMOTE' '${sm}/v${next}'" NEW_SUBMODULE_TAGS+=("${sm}/v${next}") NEW_SUBMODULE_VERSIONS["$sm"]="$next" done # ---------- step 2: bump root pins for newly-tagged submodules ---------- PIN_BUMP_NEEDED=false for sm in "${!NEW_SUBMODULE_VERSIONS[@]}"; do module_path="github.com/danmestas/EdgeSync/${sm}" new_version="${NEW_SUBMODULE_VERSIONS[$sm]}" log "rewriting go.mod: ${module_path} → v${new_version}" if ! $DRY_RUN; then update_gomod_pin "$module_path" "$new_version" fi PIN_BUMP_NEEDED=true done if $PIN_BUMP_NEEDED; then if $DRY_RUN || git diff --quiet HEAD -- go.mod; then if ! $DRY_RUN; then warn "expected go.mod diff after pin update but found none — skipping commit" PIN_BUMP_NEEDED=false fi fi fi if $PIN_BUMP_NEEDED; then msg="chore: bump submodule pins ($(IFS=,; echo "${NEW_SUBMODULE_TAGS[*]}"))" log "committing: $msg" if $CI_MODE; then run "git config user.name 'github-actions[bot]'" run "git config user.email '41898282+github-actions[bot]@users.noreply.github.com'" fi if confirm "commit pin bump and push to $REMOTE/main?"; then run "git add go.mod" run "git commit -m '${msg}'" run "git push '$REMOTE' main" else warn "user declined pin bump push — aborting before root tag" exit 1 fi fi # ---------- step 3: tag root ---------- root_cur=$(latest_tag_version "${ROOT_TAG_PREFIX}") if [[ -z "$root_cur" ]]; then err "no v* tag exists on root; cut a baseline tag manually" exit 1 fi root_changes=false if $PIN_BUMP_NEEDED; then # The pin commit itself is a root-relevant change. root_changes=true elif git diff --name-only "${ROOT_TAG_PREFIX}${root_cur}" HEAD -- \ ':!leaf/' ':!bridge/' ':!docs/' ':!**.md' ':!LICENSE' ':!wrangler.jsonc' \ | grep -q .; then # Detect non-submodule, non-doc changes (e.g. hub/ commits) since last v*. root_changes=true fi if ! $root_changes; then log "root: no relevant changes since ${ROOT_TAG_PREFIX}${root_cur}; nothing to tag" exit 0 fi root_next=$(bump_version "$root_cur" "$BUMP_KIND") log "root: ${root_cur} → ${root_next}" if ! confirm "tag ${ROOT_TAG_PREFIX}${root_next} at HEAD and push (fires release.yml + bones dispatch)?"; then warn "user declined root tag — submodules tagged + pin bumped but root release skipped" exit 1 fi # Build the annotation body listing the submodule bumps for context. annotation="${ROOT_TAG_PREFIX}${root_next}: auto-release" if [[ ${#NEW_SUBMODULE_TAGS[@]} -gt 0 ]]; then annotation="${annotation} (includes $(IFS=,; echo "${NEW_SUBMODULE_TAGS[*]}"))" fi run "git tag '${ROOT_TAG_PREFIX}${root_next}' HEAD -m '${annotation}'" run "git push '$REMOTE' '${ROOT_TAG_PREFIX}${root_next}'" log "done. release.yml is now firing on ${ROOT_TAG_PREFIX}${root_next}."