refactor: delegate release workflow to fish script

Amolith created

Replace the inline release logic in Taskfile.yaml with a dedicated
release.fish script following the same staged pattern used by lune,
yatd, and crush. The script handles tag computation, cross-compilation,
UPX packing, and upload via the shared release(1) fish function.

Taskfile changes:
- Release task now delegates to ./release.fish
- Add release:build convenience task for --from build
- Clean task also removes dist/

Change summary

Taskfile.yaml |  52 +--------
release.fish  | 282 +++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 290 insertions(+), 44 deletions(-)

Detailed changes

Taskfile.yaml 🔗

@@ -27,29 +27,24 @@ tasks:
       - task: reuse
 
   fmt:
-    desc: Format all Go source code
     cmds:
       - go install mvdan.cc/gofumpt@latest
       - gofumpt -l -w .
 
   lint:
-    desc: Lint Go source code
     cmds:
       - golangci-lint run
 
   staticcheck:
-    desc: Perform static analysis
     cmds:
       - go install honnef.co/go/tools/cmd/staticcheck@latest
       - staticcheck ./...
 
   test:
-    desc: Run tests
     cmds:
       - go test -v ./...
 
   vuln:
-    desc: Check for vulnerabilities
     cmds:
       - go install golang.org/x/vuln/cmd/govulncheck@latest
       - govulncheck ./...
@@ -60,19 +55,16 @@ tasks:
       - reuse lint
 
   build:
-    desc: Build git-format
     cmds:
       - go build -o git-format -ldflags "-s -w -X main.version={{.VERSION}}"
     generates:
       - git-format
 
   install:
-    desc: Install git-format
     cmds:
       - go install -ldflags "-s -w -X main.version={{.VERSION}}"
 
   run:
-    desc: Run git-format
     cmds:
       - go run -ldflags "-s -w -X main.version={{.VERSION}}" . {{.CLI_ARGS}}
 
@@ -87,7 +79,7 @@ tasks:
   clean:
     desc: Remove build artifacts
     cmds:
-      - rm -rf git-format
+      - rm -rf git-format dist/
 
   clean-all:
     desc: Remove build artifacts and config.toml
@@ -95,39 +87,11 @@ tasks:
       - rm -rf git-format config.toml
 
   release:
-    desc: Interactive release workflow
-    vars:
-      BUMP:
-        sh: |
-          opts="major minor patch prerelease"
-          if git describe --tags --abbrev=0 2>/dev/null | grep -qE '\-[a-zA-Z]+\.[0-9]+$'; then
-            opts="$opts graduate"
-          fi
-          gum choose $opts
-      NEXT:
-        sh: |
-          case "{{.BUMP}}" in
-            graduate) svu next ;;
-            prerelease)
-              suffix=$(gum input --placeholder "Suffix (beta, rc, etc.)")
-              svu prerelease --pre-release "$suffix" ;;
-            *) svu {{.BUMP}} ;;
-          esac
-      LAST_STABLE:
-        sh: git tag -l | grep -v '\-' | sort -V | tail -n 1
-    prompt: "Release {{.NEXT}}?"
-    preconditions:
-      - sh: '[ $(git symbolic-ref --short HEAD) = "main" ] || [ $(git symbolic-ref --short HEAD) = "dev" ]'
-        msg: Not on main or dev branch
-      - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
-        msg: "Git is dirty"
+    desc: Interactive release workflow (tag, build, pack, upload)
     cmds:
-      - |
-        if [ "{{.BUMP}}" = "graduate" ]; then
-          llm-tag -b {{.LAST_STABLE}} {{.NEXT}}
-        else
-          llm-tag {{.NEXT}}
-        fi
-      - git push soft {{.NEXT}}
-      - go list -m git.secluded.site/git-format@{{.NEXT}} > /dev/null
-      - echo "Released {{.NEXT}} and notified module proxy"
+      - ./release.fish
+
+  release:build:
+    desc: Cross-compile and pack for all release targets (no tag/push)
+    cmds:
+      - ./release.fish --from build

release.fish 🔗

@@ -0,0 +1,282 @@
+#!/usr/bin/env fish
+
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+function __release_git_format_usage
+    echo "Usage: release.fish [--from <stage>] [--only <stage>] [--help]
+
+Stages (run in order):
+    tag       Compute next version, create and push annotated 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 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 __rgf_stages tag build pack upload
+set -g __rgf_from ""
+set -g __rgf_only ""
+
+# --- Parse flags ---
+
+set -l i 1
+while test $i -le (count $argv)
+    switch $argv[$i]
+        case --from
+            set i (math $i + 1)
+            set -g __rgf_from $argv[$i]
+        case --only
+            set i (math $i + 1)
+            set -g __rgf_only $argv[$i]
+        case --help -h
+            __release_git_format_usage
+            exit 0
+        case '*'
+            echo "Error: unknown flag '$argv[$i]'" >&2
+            __release_git_format_usage >&2
+            exit 1
+    end
+    set i (math $i + 1)
+end
+
+if test -n "$__rgf_from" -a -n "$__rgf_only"
+    echo "Error: --from and --only are mutually exclusive" >&2
+    exit 1
+end
+
+if test -n "$__rgf_from"; and not contains -- "$__rgf_from" $__rgf_stages
+    echo "Error: unknown stage '$__rgf_from' (valid: $__rgf_stages)" >&2
+    exit 1
+end
+
+if test -n "$__rgf_only"; and not contains -- "$__rgf_only" $__rgf_stages
+    echo "Error: unknown stage '$__rgf_only' (valid: $__rgf_stages)" >&2
+    exit 1
+end
+
+# Returns 0 if the given stage should execute, 1 otherwise.
+function __should_run -a stage
+    if test -n "$__rgf_only"
+        test "$stage" = "$__rgf_only"
+        return
+    end
+
+    if test -z "$__rgf_from"
+        return 0
+    end
+
+    set -l reached 0
+    for s in $__rgf_stages
+        if test "$s" = "$__rgf_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 ---
+
+    set -l branch (git symbolic-ref --short HEAD)
+    if test "$branch" != main -a "$branch" != dev
+        echo "Error: not on main or dev branch (on $branch)" >&2
+        exit 1
+    end
+
+    if test (git status --porcelain=2 | wc -l) -ne 0
+        echo "Error: git working tree is dirty" >&2
+        exit 1
+    end
+
+    # --- Fetch tags ---
+
+    git fetch soft --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 ---
+
+    git tag -a $tag; or begin
+        echo "Error: tagging failed" >&2
+        exit 1
+    end
+
+    git push soft $tag; or begin
+        echo "Error: tag push failed — deleting local tag" >&2
+        git tag -d $tag 2>/dev/null
+        exit 1
+    end
+
+    echo "Released $tag"
+
+    # --- Notify Go module proxy ---
+
+    go list -m git.secluded.site/git-format@$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 main.version=$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/git-format-$tag-$os-$arch$ext" -ldflags "$ldflags"; 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/git-format-$tag-$suffix"
+        if test -f "$bin"
+            echo "Packing $suffix..."
+            upx -q "$bin"
+        end
+    end
+end
+
+# --- Upload ---
+
+if __should_run upload
+    fish -c "release upload git-format $tag --latest dist/*"
+end