Add release process

Amolith created

Change summary

mise.toml    |  14 -
release.fish | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 302 insertions(+), 10 deletions(-)

Detailed changes

mise.toml 🔗

@@ -23,16 +23,6 @@ run = "go test -v ./..."
 [tasks.fmt]
 run = "gofumpt -w ."
 
-[tasks."fmt:check"]
-run = """
-output=$(gofumpt -d .)
-if [ -n "$output" ]; then
-  echo "$output"
-  echo "Files unformatted; execute 'mise run fmt'"
-  exit 1
-fi
-"""
-
 [tasks.fix]
 run = "jj --config 'fix.tools.gofumpt.command=[\"gofumpt\"]' --config 'fix.tools.gofumpt.patterns=[\"glob:**/*.go\"]' fix"
 
@@ -58,3 +48,7 @@ fi
 
 [tasks.check]
 depends = ["fmt", "vet", "lint", "vuln", "build", "test:quiet"]
+
+[tasks.release]
+description = "Interactive release workflow (tag, build, pack, upload)"
+run = "fish release.fish"

release.fish 🔗

@@ -0,0 +1,298 @@
+#!/usr/bin/env fish
+
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+function __release_sb_mcp_usage
+    echo "Usage: release.fish [--from <stage>] [--only <stage>] [--help]
+
+Stages (run in order):
+    tag       Compute next version, create and push lightweight tag
+    build     Cross-compile for all release targets
+    pack      Compress Linux binaries with UPX
+    upload    Upload artifacts via release(1)
+
+Flags:
+    --from <stage>   Start at this stage and continue through the rest
+    --only <stage>   Run just this one stage
+    --help, -h       Show this help
+
+With no flags, all stages run in order (tag → build → pack → upload).
+The tag stage also notifies the Go module proxy after pushing.
+
+Examples:
+    ./release.fish                 Full release
+    ./release.fish --from build    Rebuild, pack, and upload (skip tagging)
+    ./release.fish --only build    Just cross-compile
+    ./release.fish --only upload   Re-upload existing dist/ artifacts"
+end
+
+set -l binary sb-mcp
+set -l module_path git.secluded.site/sb-mcp
+set -l version_pkg git.secluded.site/sb-mcp/internal/server.Version
+set -l remote soft
+
+set -l targets \
+    linux/amd64 \
+    linux/arm64 \
+    darwin/amd64 \
+    darwin/arm64 \
+    windows/amd64 \
+    freebsd/amd64
+
+# UPX 5.x dropped macOS support; windows/amd64 is PE32 only and
+# freebsd/amd64 is ELF32 only, so only Linux targets are packed.
+set -l pack_targets \
+    linux-amd64 \
+    linux-arm64
+
+# Global so __should_run can see them. Prefixed to avoid collisions.
+set -g __rl_stages tag build pack upload
+set -g __rl_from ""
+set -g __rl_only ""
+
+# --- Parse flags ---
+
+set -l i 1
+while test $i -le (count $argv)
+    switch $argv[$i]
+        case --from
+            set i (math $i + 1)
+            if test $i -gt (count $argv)
+                echo "Error: --from requires a value" >&2
+                __release_sb_mcp_usage >&2
+                exit 1
+            end
+            set -g __rl_from $argv[$i]
+        case --only
+            set i (math $i + 1)
+            if test $i -gt (count $argv)
+                echo "Error: --only requires a value" >&2
+                __release_sb_mcp_usage >&2
+                exit 1
+            end
+            set -g __rl_only $argv[$i]
+        case --help -h
+            __release_sb_mcp_usage
+            exit 0
+        case '*'
+            echo "Error: unknown flag '$argv[$i]'" >&2
+            __release_sb_mcp_usage >&2
+            exit 1
+    end
+    set i (math $i + 1)
+end
+
+if test -n "$__rl_from" -a -n "$__rl_only"
+    echo "Error: --from and --only are mutually exclusive" >&2
+    exit 1
+end
+
+if test -n "$__rl_from"; and not contains -- "$__rl_from" $__rl_stages
+    echo "Error: unknown stage '$__rl_from' (valid: $__rl_stages)" >&2
+    exit 1
+end
+
+if test -n "$__rl_only"; and not contains -- "$__rl_only" $__rl_stages
+    echo "Error: unknown stage '$__rl_only' (valid: $__rl_stages)" >&2
+    exit 1
+end
+
+# Returns 0 if the given stage should execute, 1 otherwise.
+function __should_run -a stage
+    if test -n "$__rl_only"
+        test "$stage" = "$__rl_only"
+        return
+    end
+
+    if test -z "$__rl_from"
+        return 0
+    end
+
+    set -l reached 0
+    for s in $__rl_stages
+        if test "$s" = "$__rl_from"
+            set reached 1
+        end
+        if test $reached -eq 1 -a "$s" = "$stage"
+            return 0
+        end
+    end
+    return 1
+end
+
+# --- Resolve tag ---
+#
+# When skipping the tag stage we still need a tag for filenames and
+# ldflags, so fall back to git describe.
+
+set -l tag
+
+if __should_run tag
+    # --- Guards ---
+
+    # In jj the working copy is always a draft commit; "clean" means
+    # the working-copy change has no file diffs.
+    if test -n "$(jj diff --summary)"
+        echo "Error: working copy has uncommitted changes" >&2
+        exit 1
+    end
+
+    # --- Fetch tags ---
+
+    git fetch $remote --tags
+
+    # --- Compute next version ---
+
+    set -l current (git describe --tags --abbrev=0 2>/dev/null; or echo v0.0.0)
+    set -l bump (gum choose major minor patch prerelease)
+
+    # Detect whether the current tag is already a prerelease
+    # (e.g. v1.2.3-rc.4).
+    set -l current_is_prerelease no
+    if string match -rq -- '-[a-zA-Z]+\.[0-9]+$' $current
+        set current_is_prerelease yes
+    end
+
+    # Ask whether to create a prerelease, unless the user already chose
+    # "prerelease".
+    set -l is_prerelease no
+    if test "$bump" = prerelease
+        set is_prerelease yes
+    else
+        if gum confirm "Create pre-release?"
+            set is_prerelease yes
+        end
+    end
+
+    # Determine the prerelease suffix (e.g. "beta", "rc").
+    set -l prerelease_suffix ""
+    if test "$bump" = prerelease -a "$current_is_prerelease" = yes
+        # Reuse the suffix from the current prerelease tag.
+        set prerelease_suffix (string replace -r '.*-([a-zA-Z]+)\.[0-9]+$' '$1' $current)
+    else if test "$is_prerelease" = yes
+        set prerelease_suffix (gum input --placeholder "Enter pre-release suffix (e.g. beta, rc)")
+        if test -z "$prerelease_suffix"
+            echo "Error: pre-release suffix is required" >&2
+            exit 1
+        end
+    end
+
+    # Compute the base version (without prerelease suffix).
+    set -l base_next
+    if test "$bump" = prerelease -a "$current_is_prerelease" = yes
+        # Strip the prerelease suffix to get the base
+        # (e.g. v1.2.3-rc.4 → v1.2.3).
+        set base_next (string replace -r -- '-[a-zA-Z]+\.[0-9]+$' '' $current)
+    else
+        set base_next (svu $bump)
+    end
+
+    # Compute the suffix version number.
+    set -l suffix_ver ""
+    if test "$is_prerelease" = yes -a -n "$prerelease_suffix"
+        if test "$bump" = prerelease -a "$current_is_prerelease" = yes
+            # Increment the current prerelease number.
+            set -l current_num (string replace -r '.*-[a-zA-Z]+\.([0-9]+)$' '$1' $current)
+            set suffix_ver (math $current_num + 1)
+        else
+            # Find existing tags with this suffix and increment past the
+            # highest.
+            set -l highest (git tag -l "$base_next-$prerelease_suffix.*" \
+                | string replace -r ".*-$prerelease_suffix\." '' \
+                | sort -n | tail -1)
+            if test -n "$highest"
+                set suffix_ver (math $highest + 1)
+            else
+                set suffix_ver 0
+            end
+        end
+    end
+
+    # Assemble the final tag.
+    if test "$is_prerelease" = yes -a -n "$prerelease_suffix"
+        set tag "$base_next-$prerelease_suffix.$suffix_ver"
+    else
+        set tag "$base_next"
+    end
+
+    # --- Confirm ---
+
+    read -P "Release $tag? [y/N] " confirm
+    if test "$confirm" != y -a "$confirm" != Y
+        echo Aborted.
+        exit 0
+    end
+
+    # --- Tag and push ---
+    #
+    # jj tag set creates a lightweight tag pointing at the given
+    # revision. We tag @- (the parent of the working copy) which is
+    # the commit main points to. Then push via git since jj git push
+    # does not push tags.
+
+    jj tag set $tag -r @-; or begin
+        echo "Error: tagging failed" >&2
+        exit 1
+    end
+
+    git push $remote $tag; or begin
+        echo "Error: tag push failed — deleting local tag" >&2
+        jj tag delete $tag 2>/dev/null
+        exit 1
+    end
+
+    echo "Released $tag"
+
+    # --- Notify Go module proxy ---
+
+    go list -m $module_path@$tag >/dev/null; or begin
+        echo "Warning: module proxy notification failed" >&2
+    end
+else
+    set tag (git describe --tags --always 2>/dev/null; or echo dev)
+end
+
+# --- Build ---
+
+if __should_run build
+    set -l ldflags "-s -w -X $version_pkg=$tag"
+
+    rm -rf dist
+    mkdir -p dist
+
+    for target in $targets
+        set -l os (echo $target | cut -d/ -f1)
+        set -l arch (echo $target | cut -d/ -f2)
+        set -l ext ""
+        if test "$os" = windows
+            set ext .exe
+        end
+
+        echo "Building $os/$arch..."
+        env CGO_ENABLED=0 GOOS=$os GOARCH=$arch \
+            go build -o "dist/$binary-$tag-$os-$arch$ext" -ldflags "$ldflags" ./cmd/$binary/; or begin
+            echo "Error: build failed for $os/$arch" >&2
+            exit 1
+        end
+    end
+end
+
+# --- Pack ---
+
+if __should_run pack
+    for suffix in $pack_targets
+        set -l bin "dist/$binary-$tag-$suffix"
+        if test -f "$bin"
+            echo "Packing $suffix..."
+            upx -q "$bin"
+        end
+    end
+end
+
+# --- Upload ---
+
+if __should_run upload
+    fish -c "release upload $binary $tag --latest dist/*"
+end