diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..624ca48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run unit tests + run: | + mkdir -p .cache + GOCACHE=$(pwd)/.cache go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97a248c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run unit tests + run: | + mkdir -p .cache + GOCACHE=$(pwd)/.cache go test ./... + + build: + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build ${{ matrix.goos }}-${{ matrix.goarch }} binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p bin + go build -ldflags "-s -w -X github.com/strandnerd/tunn/version.Version=${GITHUB_REF_NAME}" -o bin/tunn-${{ matrix.goos }}-${{ matrix.goarch }} . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: tunn-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/tunn-${{ matrix.goos }}-${{ matrix.goarch }} + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + + - name: Publish release + uses: softprops/action-gh-release@v1 + with: + files: dist/** + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cli/options.go b/cli/options.go index 1491e3e..55121ce 100644 --- a/cli/options.go +++ b/cli/options.go @@ -12,6 +12,7 @@ const ( CommandStart Command = iota CommandStatus CommandStop + CommandVersion ) // Options captures parsed CLI arguments. @@ -23,10 +24,12 @@ type Options struct { } var ( - errStatusWithDetach = errors.New("status command cannot be used with --detach") - errStatusWithArgs = errors.New("status command does not accept tunnel names") - errStopWithDetach = errors.New("stop command cannot be used with --detach") - errStopWithArgs = errors.New("stop command does not accept tunnel names") + errStatusWithDetach = errors.New("status command cannot be used with --detach") + errStatusWithArgs = errors.New("status command does not accept tunnel names") + errStopWithDetach = errors.New("stop command cannot be used with --detach") + errStopWithArgs = errors.New("stop command does not accept tunnel names") + errVersionWithDetach = errors.New("version command cannot be used with --detach") + errVersionWithArgs = errors.New("version command does not accept additional arguments") ) // Parse inspects the provided arguments and produces structured options. @@ -43,6 +46,9 @@ func Parse(args []string) (*Options, error) { if opts.Command == CommandStop { return nil, errStopWithDetach } + if opts.Command == CommandVersion { + return nil, errVersionWithDetach + } opts.Detach = true case "--internal-daemon": opts.InternalDaemon = true @@ -68,8 +74,19 @@ func Parse(args []string) (*Options, error) { return nil, errStopWithArgs } opts.Command = CommandStop + case "version": + if opts.Command != CommandStart { + return nil, fmt.Errorf("duplicate command") + } + if opts.Detach { + return nil, errVersionWithDetach + } + if len(opts.TunnelNames) > 0 { + return nil, errVersionWithArgs + } + opts.Command = CommandVersion case "-h", "--help": - return nil, fmt.Errorf("usage: tunn [--detach|-d] [tunnel ...]\n tunn status") + return nil, fmt.Errorf("usage: tunn [--detach|-d] [tunnel ...]\n tunn status\n tunn version") default: if len(arg) > 0 && arg[0] == '-' { return nil, fmt.Errorf("unknown flag: %s", arg) @@ -80,6 +97,9 @@ func Parse(args []string) (*Options, error) { if opts.Command == CommandStop { return nil, errStopWithArgs } + if opts.Command == CommandVersion { + return nil, errVersionWithArgs + } opts.TunnelNames = append(opts.TunnelNames, arg) } } diff --git a/cli/options_test.go b/cli/options_test.go index 3f4f3e9..e027d0a 100644 --- a/cli/options_test.go +++ b/cli/options_test.go @@ -34,6 +34,11 @@ func TestParse(t *testing.T) { input: []string{"status"}, want: Options{Command: CommandStatus}, }, + { + name: "version", + input: []string{"version"}, + want: Options{Command: CommandVersion}, + }, { name: "status with detach", input: []string{"status", "--detach"}, @@ -64,6 +69,16 @@ func TestParse(t *testing.T) { input: []string{"stop", "db"}, wantError: errStopWithArgs.Error(), }, + { + name: "version with detach", + input: []string{"version", "--detach"}, + wantError: errVersionWithDetach.Error(), + }, + { + name: "version with args", + input: []string{"version", "extra"}, + wantError: errVersionWithArgs.Error(), + }, { name: "unknown flag", input: []string{"--unknown"}, diff --git a/main.go b/main.go index 2a3923d..c002120 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/strandnerd/tunn/output" "github.com/strandnerd/tunn/status" "github.com/strandnerd/tunn/tunnel" + "github.com/strandnerd/tunn/version" ) const daemonPreviewDuration = 2 * time.Second @@ -56,6 +57,9 @@ func run() error { return runDaemonCommand(paths, opts.TunnelNames) } return runStartCommand(paths, opts) + case cli.CommandVersion: + fmt.Println(version.String()) + return nil default: return fmt.Errorf("unknown command") } diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..d3973a5 --- /dev/null +++ b/version/version.go @@ -0,0 +1,9 @@ +package version + +// Version mirrors the build's semantic version; overridden via ldflags at release time. +var Version = "dev" + +// String returns the current version string. +func String() string { + return Version +}