diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 29250bf..c6ce1db 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -89,6 +89,7 @@ homebrew_casks: owner: GreyhavenHQ name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + skip_upload: auto homepage: "https://github.com/GreyhavenHQ/greywall" description: "Sandboxed command execution with network isolation" license: "Apache-2.0" diff --git a/Makefile b/Makefile index 0634cac..94a8cac 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ BINARY_UNIX=$(BINARY_NAME)_unix TUN2SOCKS_VERSION=v2.5.2 TUN2SOCKS_BIN_DIR=internal/sandbox/bin -.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor download-tun2socks help +.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor release-beta download-tun2socks help all: build @@ -102,6 +102,10 @@ release-minor: @echo "Creating minor release..." ./scripts/release.sh minor +release-beta: + @echo "Creating beta release..." + ./scripts/release.sh beta + help: @echo "Available targets:" @echo " all - build (default)" @@ -122,4 +126,5 @@ help: @echo " lint - Lint code" @echo " release - Create patch release (v0.0.X)" @echo " release-minor - Create minor release (v0.X.0)" + @echo " release-beta - Create beta release (v0.0.X-beta.N)" @echo " help - Show this help" diff --git a/README.md b/README.md index 0bd04fd..02cfa88 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,19 @@ brew install greywall This also installs [greyproxy](https://github.com/GreyhavenHQ/greyproxy) as a dependency. -**Linux / Mac:** +**Linux / Mac (build from source):** ```bash -curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.sh | sh +git clone https://github.com/GreyhavenHQ/greywall +cd greywall +make setup && make build +cp greywall ~/.local/bin/greywall +``` + +Then install greyproxy (the network proxy): + +```bash +greywall setup ```
@@ -44,14 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.s ```bash go install github.com/GreyhavenHQ/greywall/cmd/greywall@latest -``` - -**Build from source:** - -```bash -git clone https://github.com/GreyhavenHQ/greywall -cd greywall -make setup && make build +greywall setup ```
diff --git a/cmd/greywall/main.go b/cmd/greywall/main.go index a513c13..a836cca 100644 --- a/cmd/greywall/main.go +++ b/cmd/greywall/main.go @@ -4,6 +4,7 @@ package main import ( "encoding/json" "fmt" + "io" "net/url" "os" "os/exec" @@ -125,6 +126,7 @@ Configuration file format: rootCmd.AddCommand(newProfilesCmd()) rootCmd.AddCommand(newCheckCmd()) rootCmd.AddCommand(newSetupCmd()) + rootCmd.AddCommand(newUpdateCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -589,13 +591,15 @@ func newSetupCmd() *cobra.Command { return &cobra.Command{ Use: "setup", Short: "Install and start greyproxy (network proxy for sandboxed commands)", - Long: `Downloads and installs greyproxy from GitHub releases. + Long: `Builds and installs greyproxy from source. greyproxy provides SOCKS5 proxying and DNS resolution for sandboxed commands. +Requires git and go on PATH. + The installer will: - 1. Download the latest greyproxy release for your platform - 2. Install the binary to ~/.local/bin/greyproxy - 3. Register and start a systemd user service`, + 1. Clone the greyproxy repository at the latest release tag + 2. Build the binary with go build + 3. Install to ~/.local/bin/greyproxy and register as a service`, Args: cobra.NoArgs, RunE: runSetup, } @@ -605,23 +609,25 @@ func runSetup(_ *cobra.Command, _ []string) error { status := proxy.Detect() if status.Installed && status.Running { - latest, err := proxy.CheckLatestVersion() + latest, err := proxy.CheckLatestTag(false) if err != nil { fmt.Fprintf(os.Stderr, "Warning: could not check for updates: %v\n", err) fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version) fmt.Printf("Run 'greywall check' for full status.\n") return nil } - if proxy.IsOlderVersion(status.Version, latest) { - fmt.Printf("greyproxy update available: v%s -> v%s\n", status.Version, latest) + latestVer := strings.TrimPrefix(latest, "v") + if proxy.IsOlderVersion(status.Version, latestVer) { + fmt.Printf("greyproxy update available: v%s -> %s\n", status.Version, latest) if proxy.IsBrewManaged(status.Path) { fmt.Printf("greyproxy is managed by Homebrew. To update, run:\n") fmt.Printf(" brew upgrade greyproxy\n") return nil } - fmt.Printf("Upgrading...\n") - return proxy.Install(proxy.InstallOptions{ + fmt.Printf("Upgrading from source...\n") + return proxy.InstallFromSource(proxy.SourceBuildOptions{ Output: os.Stderr, + Tag: latest, }) } fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version) @@ -644,11 +650,167 @@ func runSetup(_ *cobra.Command, _ []string) error { return nil } - return proxy.Install(proxy.InstallOptions{ + return proxy.InstallFromSource(proxy.SourceBuildOptions{ Output: os.Stderr, }) } +// newUpdateCmd creates the update subcommand for updating greywall and greyproxy. +func newUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update greywall and greyproxy to the latest release (builds from source)", + Long: `Updates both greywall and greyproxy by cloning their repos and building from source. + +Requires git, go, and make on PATH. + +Examples: + greywall update # update to latest stable + greywall update --beta # update to latest beta`, + Args: cobra.NoArgs, + RunE: runUpdate, + } + cmd.Flags().Bool("beta", false, "Update to the latest beta (pre-release) version") + return cmd +} + +func runUpdate(cmd *cobra.Command, _ []string) error { + beta, _ := cmd.Flags().GetBool("beta") + + // 1. Fetch latest greyproxy tag + greyproxyTag, err := proxy.CheckLatestTag(beta) + if err != nil { + return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err) + } + + // 2. Fetch latest greywall tag + greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta) + if err != nil { + return fmt.Errorf("failed to fetch latest greywall tag: %w", err) + } + + channel := "stable" + if beta { + channel = "beta" + } + fmt.Printf("Updating to latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag) + + // 3. Update greyproxy + fmt.Println("==> Updating greyproxy...") + if err := proxy.InstallFromSource(proxy.SourceBuildOptions{ + Output: os.Stderr, + Tag: greyproxyTag, + }); err != nil { + return fmt.Errorf("failed to update greyproxy: %w", err) + } + + // 4. Update greywall (skip if brew-managed) + selfPath, _ := os.Executable() + if proxy.IsBrewManaged(selfPath) { + fmt.Printf("\ngreywall is managed by Homebrew. To update, run:\n") + if beta { + fmt.Printf(" greywall update --beta (Homebrew tracks stable only — use this command for beta)\n") + } else { + fmt.Printf(" brew upgrade greywall\n") + } + return nil + } + + fmt.Println("\n==> Updating greywall...") + if err := updateSelf(greywallTag, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update greywall: %v\n", err) + fmt.Fprintf(os.Stderr, "To update manually:\n") + fmt.Fprintf(os.Stderr, " git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag) + fmt.Fprintf(os.Stderr, " cd greywall && make build-ci && cp greywall ~/.local/bin/\n") + } + + return nil +} + +// updateSelf builds greywall from source at the given tag and replaces the running binary. +func updateSelf(tag string, output io.Writer) error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required: install git and try again") + } + if _, err := exec.LookPath("make"); err != nil { + return fmt.Errorf("make is required: install make and try again") + } + + // Resolve symlinks to get the real executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "greywall-build-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + _, _ = fmt.Fprintf(output, "Cloning greywall %s...\n", tag) + cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch", tag, "https://github.com/GreyhavenHQ/greywall.git", tmpDir) //nolint:gosec // URL and tag are controlled + cloneCmd.Stdout = output + cloneCmd.Stderr = output + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + + _, _ = fmt.Fprintf(output, "Building greywall...\n") + buildCmd := exec.Command("make", "build-ci") //nolint:gosec + buildCmd.Dir = tmpDir + buildCmd.Stdout = output + buildCmd.Stderr = output + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + newBinary := filepath.Join(tmpDir, "greywall") + if _, err := os.Stat(newBinary); err != nil { + return fmt.Errorf("built binary not found: %w", err) + } + + _, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath) + if err := os.Rename(newBinary, execPath); err != nil { + // Rename across filesystems fails; copy instead + if err2 := copyFileTo(newBinary, execPath); err2 != nil { + return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2) + } + } + + _, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag) + return nil +} + +// copyFileTo copies src to dst atomically (write to temp, then rename). +func copyFileTo(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // src is a path we control + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + tmpDst := dst + ".new" + out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec + if err != nil { + return err + } + defer func() { _ = os.Remove(tmpDst) }() + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Rename(tmpDst, dst) +} + // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/proxy/install.go b/internal/proxy/install.go index f42843f..da708eb 100644 --- a/internal/proxy/install.go +++ b/internal/proxy/install.go @@ -1,8 +1,6 @@ package proxy import ( - "archive/tar" - "compress/gzip" "context" "encoding/json" "fmt" @@ -11,16 +9,16 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strconv" "strings" "time" ) const ( - githubOwner = "greyhavenhq" - githubRepo = "greyproxy" - apiTimeout = 15 * time.Second + githubOwner = "greyhavenhq" + githubRepo = "greyproxy" + greyproxyRepoURL = "https://github.com/greyhavenhq/greyproxy.git" + apiTimeout = 15 * time.Second ) // release represents a GitHub release. @@ -35,11 +33,6 @@ type asset struct { BrowserDownloadURL string `json:"browser_download_url"` } -// InstallOptions controls the greyproxy installation behavior. -type InstallOptions struct { - Output io.Writer // progress output (typically os.Stderr) -} - // CheckLatestVersion fetches the latest greyproxy release tag from GitHub // and returns the version string (without the "v" prefix). func CheckLatestVersion() (string, error) { @@ -82,61 +75,88 @@ func IsOlderVersion(current, latest string) bool { return false } -// Install downloads the latest greyproxy release and runs "greyproxy install". -// Set GREYWALL_NO_GREYPROXY_INSTALL=1 to skip installation entirely. -func Install(opts InstallOptions) error { +// fetchLatestRelease queries the GitHub API for the latest greyproxy release. +func fetchLatestRelease() (*release, error) { + return fetchReleaseFor(githubOwner, githubRepo, "latest") +} + +// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". +func runGreyproxyInstall(binaryPath string) error { + cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// SourceBuildOptions controls the source-build installation behavior. +type SourceBuildOptions struct { + Output io.Writer // progress output (typically os.Stderr) + Tag string // specific tag to build; if empty, uses latest + Beta bool // if Tag is empty and Beta is true, fetches latest pre-release tag +} + +// InstallFromSource clones the greyproxy repo at the given tag, builds it, +// and runs "greyproxy install --force" to register the service. +// Requires git and go on PATH. +func InstallFromSource(opts SourceBuildOptions) error { if os.Getenv("GREYWALL_NO_GREYPROXY_INSTALL") == "1" { return nil } - if opts.Output == nil { opts.Output = os.Stderr } - // 1. Fetch latest release - _, _ = fmt.Fprintf(opts.Output, "Fetching latest greyproxy release...\n") - rel, err := fetchLatestRelease() - if err != nil { - return fmt.Errorf("failed to fetch latest release: %w", err) + tag := opts.Tag + if tag == "" { + var err error + tag, err = CheckLatestTag(opts.Beta) + if err != nil { + return fmt.Errorf("failed to fetch latest tag: %w", err) + } } - ver := strings.TrimPrefix(rel.TagName, "v") - _, _ = fmt.Fprintf(opts.Output, "Latest version: %s\n", ver) + _, _ = fmt.Fprintf(opts.Output, "Building greyproxy %s from source...\n", tag) - // 2. Find the correct asset for this platform - assetURL, assetName, err := resolveAssetURL(rel) - if err != nil { - return err + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to build from source: install git and try again") } - _, _ = fmt.Fprintf(opts.Output, "Downloading %s...\n", assetName) - - // 3. Download to temp file - archivePath, err := downloadAsset(assetURL) - if err != nil { - return fmt.Errorf("download failed: %w", err) + if _, err := exec.LookPath("go"); err != nil { + return fmt.Errorf("go is required to build from source: install Go from https://go.dev/dl/ and try again") } - defer func() { _ = os.Remove(archivePath) }() - // 4. Extract - _, _ = fmt.Fprintf(opts.Output, "Extracting...\n") - extractDir, err := extractTarGz(archivePath) + tmpDir, err := os.MkdirTemp("", "greyproxy-build-*") if err != nil { - return fmt.Errorf("extraction failed: %w", err) + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + _, _ = fmt.Fprintf(opts.Output, "Cloning greyproxy...\n") + cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch", tag, greyproxyRepoURL, tmpDir) //nolint:gosec // URL and tag are from hardcoded constants and GitHub API + cloneCmd.Stdout = opts.Output + cloneCmd.Stderr = opts.Output + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) } - defer func() { _ = os.RemoveAll(extractDir) }() - // 5. Find the greyproxy binary in extracted content - binaryPath := filepath.Join(extractDir, "greyproxy") - if _, err := os.Stat(binaryPath); err != nil { - return fmt.Errorf("greyproxy binary not found in archive") + _, _ = fmt.Fprintf(opts.Output, "Building...\n") + ver := strings.TrimPrefix(tag, "v") + buildCmd := exec.Command("go", "build", //nolint:gosec // arguments are controlled constants and a sanitized version string + "-ldflags", fmt.Sprintf("-s -w -X main.version=%s", ver), + "-o", "greyproxy", + "./cmd/greyproxy", + ) + buildCmd.Dir = tmpDir + buildCmd.Stdout = opts.Output + buildCmd.Stderr = opts.Output + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) } - // 6. Shell out to "greyproxy install" + binaryPath := filepath.Join(tmpDir, "greyproxy") _, _ = fmt.Fprintf(opts.Output, "\n") if err := runGreyproxyInstall(binaryPath); err != nil { return fmt.Errorf("greyproxy install failed: %w", err) } - // 7. Verify _, _ = fmt.Fprintf(opts.Output, "\nVerifying installation...\n") status := Detect() if status.Installed { @@ -152,10 +172,28 @@ func Install(opts InstallOptions) error { return nil } -// fetchLatestRelease queries the GitHub API for the latest greyproxy release. -func fetchLatestRelease() (*release, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", githubOwner, githubRepo) +// CheckLatestTag returns the latest greyproxy release tag (with "v" prefix). +// If beta is true, returns the latest pre-release tag. +func CheckLatestTag(beta bool) (string, error) { + return CheckLatestTagFor(githubOwner, githubRepo, beta) +} +// CheckLatestTagFor returns the latest release tag for any GitHub repo. +// If beta is true, returns the latest pre-release tag; otherwise returns the latest stable tag. +func CheckLatestTagFor(owner, repo string, beta bool) (string, error) { + if !beta { + rel, err := fetchReleaseFor(owner, repo, "latest") + if err != nil { + return "", err + } + return rel.TagName, nil + } + return fetchLatestPreReleaseTagFor(owner, repo) +} + +// fetchReleaseFor fetches a specific GitHub release endpoint (e.g. "latest" or a tag name). +func fetchReleaseFor(owner, repo, endpoint string) (*release, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/%s", owner, repo, endpoint) client := &http.Client{Timeout: apiTimeout} ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) @@ -168,7 +206,7 @@ func fetchLatestRelease() (*release, error) { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "greywall-setup") - resp, err := client.Do(req) //nolint:gosec // apiURL is built from hardcoded constants + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs if err != nil { return nil, fmt.Errorf("GitHub API request failed: %w", err) } @@ -185,120 +223,43 @@ func fetchLatestRelease() (*release, error) { return &rel, nil } -// resolveAssetURL finds the correct asset download URL for the current OS/arch. -func resolveAssetURL(rel *release) (downloadURL, name string, err error) { - ver := strings.TrimPrefix(rel.TagName, "v") - osName := runtime.GOOS - archName := runtime.GOARCH - - expected := fmt.Sprintf("greyproxy_%s_%s_%s.tar.gz", ver, osName, archName) - - for _, a := range rel.Assets { - if a.Name == expected { - return a.BrowserDownloadURL, a.Name, nil - } - } - return "", "", fmt.Errorf("no release asset found for %s/%s (expected: %s)", osName, archName, expected) -} - -// downloadAsset downloads a URL to a temp file, returning its path. -func downloadAsset(downloadURL string) (string, error) { - client := &http.Client{Timeout: 5 * time.Minute} +// fetchLatestPreReleaseTagFor returns the most recent pre-release tag for the given repo. +func fetchLatestPreReleaseTagFor(owner, repo string) (string, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=20", owner, repo) + client := &http.Client{Timeout: apiTimeout} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", err } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "greywall-setup") - resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs if err != nil { - return "", err + return "", fmt.Errorf("GitHub API request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("download returned status %d", resp.StatusCode) - } - - tmpFile, err := os.CreateTemp("", "greyproxy-*.tar.gz") - if err != nil { - return "", err + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } - if _, err := io.Copy(tmpFile, resp.Body); err != nil { - _ = tmpFile.Close() - _ = os.Remove(tmpFile.Name()) //nolint:gosec // tmpFile.Name() is from os.CreateTemp, not user input - return "", err - } - _ = tmpFile.Close() - - return tmpFile.Name(), nil -} - -// extractTarGz extracts a .tar.gz archive to a temp directory, returning the dir path. -func extractTarGz(archivePath string) (string, error) { - f, err := os.Open(archivePath) //nolint:gosec // archivePath is a temp file we created - if err != nil { - return "", err + var releases []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` } - defer func() { _ = f.Close() }() - - gz, err := gzip.NewReader(f) - if err != nil { - return "", fmt.Errorf("failed to create gzip reader: %w", err) + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("failed to parse releases response: %w", err) } - defer func() { _ = gz.Close() }() - tmpDir, err := os.MkdirTemp("", "greyproxy-extract-*") - if err != nil { - return "", err - } - - tr := tar.NewReader(gz) - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - _ = os.RemoveAll(tmpDir) - return "", fmt.Errorf("tar read error: %w", err) - } - - // Sanitize: only extract regular files with safe names - name := filepath.Base(header.Name) - if name == "." || name == ".." || strings.Contains(header.Name, "..") { - continue - } - - target := filepath.Join(tmpDir, name) //nolint:gosec // name is sanitized via filepath.Base and path traversal check above - - switch header.Typeflag { - case tar.TypeReg: - out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) //nolint:gosec // mode from tar header of trusted archive - if err != nil { - _ = os.RemoveAll(tmpDir) - return "", err - } - if _, err := io.Copy(out, io.LimitReader(tr, 256<<20)); err != nil { // 256 MB limit per file - _ = out.Close() - _ = os.RemoveAll(tmpDir) - return "", err - } - _ = out.Close() + for _, r := range releases { + if r.PreRelease { + return r.TagName, nil } } - - return tmpDir, nil -} - -// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". -func runGreyproxyInstall(binaryPath string) error { - cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return "", fmt.Errorf("no pre-release found for %s/%s", owner, repo) } diff --git a/scripts/release.sh b/scripts/release.sh index 11b98ec..56d215c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Usage: ./scripts/release.sh [patch|minor] +# Usage: ./scripts/release.sh [patch|minor|beta] # Default: patch BUMP_TYPE="${1:-patch}" @@ -17,8 +17,8 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } # Validate bump type -if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" ]]; then - error "Invalid bump type: $BUMP_TYPE. Use 'patch' or 'minor' (or no argument for minor)." +if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "beta" ]]; then + error "Invalid bump type: $BUMP_TYPE. Use 'patch', 'minor', or 'beta'." fi info "Bump type: $BUMP_TYPE" @@ -95,30 +95,56 @@ info "✓ All preflight checks passed" if [[ -z "$LAST_TAG" ]]; then # No existing tags, start at v0.1.0 NEW_VERSION="v0.1.0" + if [[ "$BUMP_TYPE" == "beta" ]]; then + NEW_VERSION="v0.1.0-beta.1" + fi info "No existing tags found. Starting at $NEW_VERSION" else - # Parse current version (strip 'v' prefix) - VERSION="${LAST_TAG#v}" - IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" - - # Validate parsed version - if [[ -z "$MAJOR" || -z "$MINOR" || -z "$PATCH" ]]; then - error "Failed to parse version from tag: $LAST_TAG" + # For beta: find the last stable tag (ignore -beta.* tags) as the base + if [[ "$BUMP_TYPE" == "beta" ]]; then + LAST_STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-' | sort -V | tail -1 || true) + if [[ -z "$LAST_STABLE_TAG" ]]; then + LAST_STABLE_TAG="v0.0.0" + fi + VERSION="${LAST_STABLE_TAG#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + PATCH=$((PATCH + 1)) + BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}" + # Find highest existing beta number for this base version + LATEST_BETA=$(git tag -l "v${BASE_VERSION}-beta.*" | sort -V | tail -1 || true) + if [[ -z "$LATEST_BETA" ]]; then + BETA_NUM=1 + else + BETA_NUM=$(echo "$LATEST_BETA" | sed 's/.*-beta\.\([0-9]*\)$/\1/') + BETA_NUM=$((BETA_NUM + 1)) + fi + NEW_VERSION="v${BASE_VERSION}-beta.${BETA_NUM}" + info "Beta tag: $LAST_STABLE_TAG → $NEW_VERSION" + else + # Parse current version (strip 'v' prefix and any pre-release suffix) + VERSION="${LAST_TAG#v}" + VERSION="${VERSION%%-*}" # strip pre-release suffix if present + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Validate parsed version + if [[ -z "$MAJOR" || -z "$MINOR" || -z "$PATCH" ]]; then + error "Failed to parse version from tag: $LAST_TAG" + fi + + # Increment based on bump type + case "$BUMP_TYPE" in + patch) + PATCH=$((PATCH + 1)) + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + esac + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + info "Version bump: $LAST_TAG → $NEW_VERSION" fi - - # Increment based on bump type - case "$BUMP_TYPE" in - patch) - PATCH=$((PATCH + 1)) - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - esac - - NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" - info "Version bump: $LAST_TAG → $NEW_VERSION" fi # ============================================================================= @@ -150,4 +176,4 @@ git push origin "$NEW_VERSION" echo "" info "✓ Released $NEW_VERSION" info "GitHub Actions will now build and publish the release." -info "Watch progress at: https://github.com/Monadical-SAS/greywall/actions" +info "Watch progress at: https://github.com/GreyhavenHQ/greywall/actions"