From 7448f35fe72a30fd06158385c6e3255395657276 Mon Sep 17 00:00:00 2001 From: Eric_Lee Date: Tue, 3 Nov 2026 23:55:33 +0800 Subject: [PATCH 1/2] build: publish tclient releases and remove duplicate example --- .github/workflows/release.yml | 105 +++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 49 ++++++++++++++- .gitignore | 6 +- .goreleaser.yaml | 63 ++++++++++++++++++++ README.md | 20 +++++-- _example/telnet_client/go.mod | 13 ---- _example/telnet_client/go.sum | 6 -- _example/telnet_client/main.go | 14 ----- docs/cli.md | 42 +++++++++++-- docs/releasing.md | 93 +++++++++++++++++++++++++++++ internal/cli/options.go | 5 ++ internal/cli/run.go | 9 ++- internal/cli/version.go | 47 +++++++++++++++ internal/cli/version_test.go | 65 ++++++++++++++++++++ 14 files changed, 488 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml delete mode 100644 _example/telnet_client/go.mod delete mode 100644 _example/telnet_client/go.sum delete mode 100644 _example/telnet_client/main.go create mode 100644 docs/releasing.md create mode 100644 internal/cli/version.go create mode 100644 internal/cli/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b1f84c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Version tag to release from master (for example v0.1.0 or v0.1.0-rc.1)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + test: + uses: ./.github/workflows/test.yml + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Validate the release target + shell: bash + run: | + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix." >&2 + exit 1 + fi + if [[ "$RELEASE_TAG" == *-* ]]; then + IFS='.' read -ra identifiers <<< "${RELEASE_TAG#*-}" + for identifier in "${identifiers[@]}"; do + if [[ "$identifier" =~ ^0[0-9]+$ ]]; then + echo "Numeric prerelease identifiers must not have leading zeroes." >&2 + exit 1 + fi + done + fi + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/master ]]; then + echo "Run manual releases from master." >&2 + exit 1 + fi + git merge-base --is-ancestor HEAD origin/master + if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)" + elif [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then + echo "The pushed release tag must already exist." >&2 + exit 1 + fi + published=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq ".[] | select(.tag_name == \"$RELEASE_TAG\") | .draft") + if [[ "$published" == false ]]; then + echo "This version is already published. Create a new version instead of overwriting it." >&2 + exit 1 + fi + - name: Create the annotated tag for a manual release + if: github.event_name == 'workflow_dispatch' + shell: bash + run: | + if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + tag_object=$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/tags" \ + -f tag="$RELEASE_TAG" -f message="$RELEASE_TAG" \ + -f object="$(git rev-parse HEAD)" -f type=commit --jq .sha) + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" -f sha="$tag_object" --silent + git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + fi + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + - name: Build archives and upload a draft release + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: v2.19.0 + args: release --clean + env: + GITHUB_TOKEN: ${{ github.token }} + GORELEASER_CURRENT_TAG: ${{ env.RELEASE_TAG }} + - name: Attest the release archives + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: ./dist/checksums.txt + - name: Publish only after every archive and attestation succeeds + shell: bash + run: | + if [[ "$RELEASE_TAG" == *-* ]]; then + gh release edit "$RELEASE_TAG" --draft=false --verify-tag --latest=false + else + gh release edit "$RELEASE_TAG" --draft=false --verify-tag + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 099fb37..61cc2d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,9 @@ name: Test on: push: + branches: [master] pull_request: + workflow_call: permissions: contents: read @@ -16,8 +18,10 @@ jobs: go-version: ["1.20.x", "stable"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go-version }} - run: go test -race -timeout=3m ./... @@ -27,5 +31,44 @@ jobs: run: | go build -o "$RUNNER_TEMP/tclient" ./cmd/tclient python3 scripts/test_cli_pty.py "$RUNNER_TEMP/tclient" - - run: cd _example/telnet_client && go test ./... - run: cd _example/monitor && go test ./... + + release-snapshot: + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + - name: Check release configuration + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: v2.19.0 + args: check + - name: Build release archives without publishing + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: v2.19.0 + args: release --snapshot --clean + - name: Verify checksums and the Linux CLI archive + shell: bash + run: | + (cd dist && sha256sum --check checksums.txt) + mkdir -p "$RUNNER_TEMP/tclient-release-smoke" + tar -xzf dist/tclient_*_linux_amd64.tar.gz -C "$RUNNER_TEMP/tclient-release-smoke" + "$RUNNER_TEMP/tclient-release-smoke/tclient" --version + "$RUNNER_TEMP/tclient-release-smoke/tclient" --help + - name: Upload reviewable release archives + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: tclient-snapshot-${{ github.sha }} + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index 241aa67..74dcb50 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ # Dependency directories (remove the comment below to include it) # vendor/ -.idea/ \ No newline at end of file +.idea/ + +# CLI builds and GoReleaser output +/tclient +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..0bb9c02 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,63 @@ +version: 2 + +project_name: tclient + +builds: + - id: tclient + main: ./cmd/tclient + binary: tclient + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - >- + -s -w + -X github.com/LeeEirc/tclientlib/internal/cli.version={{ .Version }} + -X github.com/LeeEirc/tclientlib/internal/cli.commit={{ .FullCommit }} + -X github.com/LeeEirc/tclientlib/internal/cli.date={{ .Date }} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: tclient + ids: + - tclient + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - docs/cli.md + +checksum: + name_template: checksums.txt + algorithm: sha256 + +changelog: + sort: asc + +release: + draft: true + prerelease: auto + replace_existing_draft: true + footer: | + ## Downloads + + Archives contain the `tclient` binary and CLI documentation. Verify the + download against `checksums.txt`, then run `tclient --version` to inspect + its version and build information. GitHub build attestations are available + for the archives. + + Interactive terminals are supported on Unix. Windows supports redirected + input and output; Windows TTY input is not supported. diff --git a/README.md b/README.md index b885138..eb0b043 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ use explicit methods. ## Command-line client -Install the interactive client with Go 1.20 or later: +Download a versioned binary from [GitHub Releases](https://github.com/LeeEirc/tclientlib/releases). +Archives are available for Linux, macOS, and Windows on amd64 and arm64, together +with SHA-256 checksums and GitHub build attestations. See the +[installation and verification instructions](docs/cli.md#install-a-release). + +Alternatively, install the interactive client with Go 1.20 or later: ```bash go install github.com/LeeEirc/tclientlib/cmd/tclient@latest @@ -18,6 +23,7 @@ Or build the current checkout: ```bash go build ./cmd/tclient +./tclient --version ./tclient 192.0.2.10 23 ./tclient --auto-login --user admin --ask-pass 192.0.2.10 23 ./tclient --charset gbk --line-ending crnul 192.0.2.10 23 @@ -271,10 +277,6 @@ Manual login does not run the automatic-login redaction phase. A trace can therefore contain manually typed passwords as well as commands and output. The CLI warns when tracing is enabled and creates the file with mode `0600`. -`_example/telnet_client` now delegates to the same CLI, with the same flags. -Replace its old `-ip`/`-port` flags with positional `host [port]`, and replace -`-username`/`-password` with `--auto-login --user ... --ask-pass`. - For failures involving another Telnet client, or for observing traffic without changing application code, use the transparent monitor: @@ -290,6 +292,14 @@ The monitor and `Trace` observe the TCP byte stream. User-space `Read` boundarie are not the original TCP packet boundaries. Use `tcpdump` or Wireshark when the actual network packet layout, retransmission, or timing is relevant. +## Releases + +The Go library and `cmd/tclient` share one semantic-version tag. The release +workflow runs the test matrix, builds all six platform archives, uploads a draft, +and publishes it only after build attestations succeed. Maintainers can start a +release from GitHub Actions or push a version tag; see the +[release guide](docs/releasing.md). + ## Scope and privilege switching `tclientlib` owns Telnet framing, option negotiation, initial account login, diff --git a/_example/telnet_client/go.mod b/_example/telnet_client/go.mod deleted file mode 100644 index 825a816..0000000 --- a/_example/telnet_client/go.mod +++ /dev/null @@ -1,13 +0,0 @@ -module github.com/LeeEirc/tclientlib/_example/telnet_client - -go 1.20 - -require github.com/LeeEirc/tclientlib v0.0.0 - -require ( - golang.org/x/sys v0.30.0 // indirect - golang.org/x/term v0.29.0 // indirect - golang.org/x/text v0.22.0 // indirect -) - -replace github.com/LeeEirc/tclientlib => ../../ diff --git a/_example/telnet_client/go.sum b/_example/telnet_client/go.sum deleted file mode 100644 index 4acb987..0000000 --- a/_example/telnet_client/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/_example/telnet_client/main.go b/_example/telnet_client/main.go deleted file mode 100644 index 6acadf4..0000000 --- a/_example/telnet_client/main.go +++ /dev/null @@ -1,14 +0,0 @@ -// This entry point is retained for existing example checkouts. The supported -// client lives in cmd/tclient; both entry points use the same implementation -// and flags (see docs/cli.md). -package main - -import ( - "os" - - "github.com/LeeEirc/tclientlib/internal/cli" -) - -func main() { - os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) -} diff --git a/docs/cli.md b/docs/cli.md index d61a59a..1f99903 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,6 +4,36 @@ library. It is an interactive client and a way to reproduce a connection outside Koko. It is not a complete GNU/NetKit Telnet replacement. +## Install a release + +Download the archive matching your system from +[GitHub Releases](https://github.com/LeeEirc/tclientlib/releases). The archive +name is `tclient_VERSION_OS_ARCH`: Linux and macOS use `.tar.gz`, and Windows +uses `.zip`. `darwin` denotes macOS; `amd64` is x86-64, and `arm64` includes +Apple Silicon and other ARM64 systems. Windows supports redirected sessions; +its interactive TTY mode is not supported. + +Download `checksums.txt` from the same release. On Linux, verify downloaded +archives with: + +```bash +sha256sum --check --ignore-missing checksums.txt +``` + +On macOS, use `shasum -a 256 ARCHIVE`; on Windows, use +`Get-FileHash ARCHIVE -Algorithm SHA256`, and compare the hash with the matching +line in `checksums.txt`. GitHub CLI can also verify the build's repository +identity and archive digest: + +```bash +gh attestation verify ARCHIVE --repo LeeEirc/tclientlib +``` + +Extract the archive and place `tclient` (or `tclient.exe`) on your PATH. Run +`tclient --version` to see the version, commit, build date, Go version, and +target platform. Source builds without injected release metadata use the +available Go module/VCS information and report an unknown build date. + ## Build and connect ```bash @@ -184,10 +214,10 @@ Relevant specifications: [RFC 854](https://www.rfc-editor.org/rfc/rfc854), [RFC 1571](https://www.rfc-editor.org/rfc/rfc1571), and [RFC 1572](https://www.rfc-editor.org/rfc/rfc1572). -## Old example entry point +## Migrating old example commands -`_example/telnet_client` delegates to the same implementation as `cmd/tclient`. -It no longer maintains its own terminal, signal, password, or trace logic. -Its flags have changed: use positional `host [port]` instead of `-ip` and -`-port`; use `--auto-login --user ... --ask-pass` instead of `-username` and -`-password`. +The duplicate `_example/telnet_client` entry point has been removed. Use the +installed `tclient` command or `go run ./cmd/tclient` from the repository root. +Replace `-ip` and `-port` with positional `host [port]`, and replace `-username` +and `-password` with `--auto-login --user ... --ask-pass`. The transparent +`_example/monitor` remains a separate debugging example. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..d3adb89 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,93 @@ +# Releasing tclientlib and tclient + +The Go module and `cmd/tclient` use the same `vMAJOR.MINOR.PATCH` tag. A +prerelease adds a suffix such as `v0.1.0-rc.1`. Separate CLI tags would break the +normal Go module version selection used by `go install .../cmd/tclient@latest`. + +Release commits must already be merged into `master`. PR checks include native +Linux and Windows tests on Go 1.20.x and stable, the Linux PTY/TCP suite, and a +GoReleaser snapshot build of all six archives. Snapshots are review artifacts, +not GitHub Releases; they have read-only repository permissions and expire +after seven days. + +## Start a release + +In GitHub, open **Actions → Release → Run workflow**, select `master`, and +enter the version tag. For example, use `v0.1.0` for a stable release or +`v0.1.0-rc.1` for a release candidate. + +The workflow tests the exact commit selected when it starts. After the tests +pass, it creates an annotated tag if that tag does not already exist. An +existing tag must point to the same commit. It then builds and publishes from +that commit, even if `master` advances while the workflow is running. A tag +created with the workflow's `GITHUB_TOKEN` does not start another workflow; +the same run continues the release. + +Alternatively, push an annotated tag from a reviewed checkout: + +```bash +git switch master +git pull --ff-only +git tag -a v0.1.0 -m "v0.1.0" +git push origin v0.1.0 +``` + +Choose one entry point for a version. Both run the same tests and release job. +The tag-push path uses the existing tag and never moves it. + +## What gets published + +GoReleaser OSS is pinned to `v2.19.0`. Actions are pinned to full commit SHAs. +Only the release job receives repository write and OIDC/attestation permissions; +it uses GitHub's short-lived `GITHUB_TOKEN`, without a personal token or signing +key. Builds disable CGO, use `-trimpath`, and embed the version, full commit, +build time, Go compiler version, and target platform. + +| Platform | Architectures | Archive | +| --- | --- | --- | +| Linux | amd64, arm64 | `tclient_VERSION_linux_ARCH.tar.gz` | +| macOS | amd64, arm64 | `tclient_VERSION_darwin_ARCH.tar.gz` | +| Windows | amd64, arm64 | `tclient_VERSION_windows_ARCH.zip` | + +Each archive contains the binary, README, and CLI guide. The release also +contains `checksums.txt` with SHA-256 hashes. Windows binaries support +redirected sessions; interactive terminal support currently targets Unix. +Cross-compilation does not replace platform runtime testing: native CI runs +on Linux and Windows amd64, and the PTY suite runs on Linux. + +Publication is ordered: + +1. Run the test matrix for the release commit. +2. Build all archives and upload them to a draft release with generated notes. +3. Create GitHub build attestations for the archive checksums. +4. Publish the completed draft. Prerelease tags remain prereleases and are not + marked as the latest stable release. + +This order also works when repository release immutability is enabled. It +avoids publishing a partial set of assets before all uploads and attestations +have succeeded. Do not publish the intermediate draft manually. + +## Verify or retry + +Follow the [CLI installation guide](cli.md#install-a-release) to verify +checksums and attestations. `tclient --version` reports the compiled metadata. +The release uses the current stable Go compiler and records it in the binary; +it does not claim byte-identical results across different toolchain versions or +build times. + +If building, uploading, or attesting fails, fix the failure and rerun the +original workflow run. Any existing draft for that tag can be replaced, but +the tag must still identify the tested commit. A successful published release +is never overwritten by this workflow. Publish a new version for changes to +already released code or assets. + +For local packaging checks, install GoReleaser `v2.19.0` and run: + +```bash +goreleaser check +goreleaser release --snapshot --clean +``` + +Snapshot mode builds and packages without publishing. Output is written to the +ignored `dist/` directory. The repository's `.goreleaser.yaml` remains the +single definition of the release targets and archive layout. diff --git a/internal/cli/options.go b/internal/cli/options.go index 227f495..4ef9b4c 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -19,6 +19,7 @@ import ( ) type options struct { + version bool address string timeout time.Duration drainTimeout time.Duration @@ -37,6 +38,7 @@ func parseOptions(args []string, output io.Writer) (options, error) { var o options f := flag.NewFlagSet("tclient", flag.ContinueOnError) f.SetOutput(output) + f.BoolVar(&o.version, "version", false, "print version and build information and exit") f.DurationVar(&o.timeout, "timeout", 15*time.Second, "connection and automatic-login timeout") f.DurationVar(&o.drainTimeout, "drain-timeout", 5*time.Second, "maximum wait for remote output after stdin EOF") f.BoolVar(&o.closeWrite, "close-write", false, "half-close TCP on stdin EOF (disables further Telnet replies)") @@ -61,6 +63,9 @@ func parseOptions(args []string, output io.Writer) (options, error) { if err := f.Parse(args); err != nil { return o, err } + if o.version { + return o, nil + } if f.NArg() < 1 || f.NArg() > 2 { return o, fmt.Errorf("expected host [port]; use --help for usage") } diff --git a/internal/cli/run.go b/internal/cli/run.go index 8e834ca..8c7b088 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -20,7 +20,7 @@ import ( // Run runs tclient and returns an exit code after restoring the local terminal. // Codes are 0 for normal completion, 1 for connection or I/O errors, 2 for // invalid arguments, and 3 for automatic-login failures. It does not exit the -// process, so example commands can share the same implementation. +// process. func Run(args []string, stdin *os.File, stdout, stderr io.Writer) int { o, err := parseOptions(args, stderr) if errors.Is(err, flag.ErrHelp) { @@ -30,6 +30,13 @@ func Run(args []string, stdin *os.File, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "tclient:", err) return 2 } + if o.version { + if _, err := fmt.Fprintln(stdout, versionString()); err != nil { + fmt.Fprintln(stderr, "tclient: write version:", err) + return 1 + } + return 0 + } isTTY, err := fileIsTerminal(stdin) if err != nil { fmt.Fprintln(stderr, "tclient: inspect stdin:", err) diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..6cd4436 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,47 @@ +package cli + +import ( + "fmt" + "runtime" + "runtime/debug" + "strings" +) + +// Release builds set these with -ldflags -X. Ordinary go build/go install +// commands can still report the module version and VCS revision below. +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +func versionString() string { + info, _ := debug.ReadBuildInfo() + return formatVersion(version, commit, date, info) +} + +func formatVersion(version, commit, date string, info *debug.BuildInfo) string { + if info != nil { + if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" { + version = strings.TrimPrefix(info.Main.Version, "v") + } + if commit == "unknown" { + var dirty bool + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + commit = setting.Value + case "vcs.modified": + dirty = setting.Value == "true" + } + } + if dirty && commit != "unknown" { + commit += "-dirty" + } + } + } + // VCS time describes a commit, not the build. Keep an unknown build date + // when no date was supplied by the release process. + return fmt.Sprintf("tclient %s (commit %s, built %s, %s %s/%s)", + version, commit, date, runtime.Version(), runtime.GOOS, runtime.GOARCH) +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..6c5b40a --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "bytes" + "io" + "runtime/debug" + "strings" + "testing" +) + +func TestVersionDoesNotRequireHostOrInput(t *testing.T) { + var output, diagnostics bytes.Buffer + if code := Run([]string{"--version"}, nil, &output, &diagnostics); code != 0 { + t.Fatalf("exit %d: %s", code, diagnostics.String()) + } + if got := output.String(); !strings.HasPrefix(got, "tclient ") || strings.Count(got, "\n") != 1 { + t.Fatalf("unexpected version output: %q", got) + } + if diagnostics.Len() != 0 { + t.Fatalf("version wrote to stderr: %q", diagnostics.String()) + } +} + +func TestVersionStillRejectsInvalidFlags(t *testing.T) { + for _, args := range [][]string{ + {"--version", "--unknown"}, + {"--version=maybe"}, + {"--version=false"}, + } { + var output, diagnostics bytes.Buffer + if code := Run(args, nil, &output, &diagnostics); code != 2 { + t.Errorf("%q: exit %d, want 2; %s", args, code, diagnostics.String()) + } + if output.Len() != 0 { + t.Errorf("%q: wrote version despite invalid flags: %q", args, output.String()) + } + } +} + +func TestHelpListsVersionWithoutInput(t *testing.T) { + var output bytes.Buffer + if code := Run([]string{"--help"}, nil, io.Discard, &output); code != 0 { + t.Fatalf("help exit %d: %s", code, output.String()) + } + if !strings.Contains(output.String(), "-version") { + t.Fatalf("version missing from help: %s", output.String()) + } +} + +func TestVersionMetadataPrecedence(t *testing.T) { + info := &debug.BuildInfo{ + Main: debug.Module{Version: "v0.1.0"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "source-commit"}, + {Key: "vcs.modified", Value: "true"}, + {Key: "vcs.time", Value: "2026-01-01T00:00:00Z"}, + }, + } + if got := formatVersion("dev", "unknown", "unknown", info); !strings.HasPrefix(got, "tclient 0.1.0 (commit source-commit-dirty, built unknown,") { + t.Fatalf("go install/source fallback: %s", got) + } + if got := formatVersion("0.2.0", "release-commit", "2026-11-03T00:00:00Z", info); !strings.HasPrefix(got, "tclient 0.2.0 (commit release-commit, built 2026-11-03T00:00:00Z,") { + t.Fatalf("release metadata lost precedence: %s", got) + } +} From 8c7c3e78907ab64a33f34c775783f641c317c7c9 Mon Sep 17 00:00:00 2001 From: Eric_Lee Date: Wed, 4 Nov 2026 00:06:35 +0800 Subject: [PATCH 2/2] docs: document v0.1.0 breaking changes before release --- .goreleaser.yaml | 15 ++++++- CHANGELOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ docs/releasing.md | 6 ++- 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0bb9c02..87ca18b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -38,7 +38,8 @@ archives: - zip files: - README.md - - docs/cli.md + - CHANGELOG.md + - docs/*.md checksum: name_template: checksums.txt @@ -51,10 +52,20 @@ release: draft: true prerelease: auto replace_existing_draft: true + header: | + ## Upgrade notes + + Read the [changelog and migration guide](https://github.com/LeeEirc/tclientlib/blob/{{ .Tag }}/CHANGELOG.md) + for release highlights and compatibility changes. + + **Upgrading from v0.0.x:** v0.1.0 changes the library configuration API and + requires Go 1.20 or later. `WindowChange` now takes **height before width**; + old calls still compile, so update them explicitly. The migration guide + also covers prompt matching, login line endings, and removed APIs. footer: | ## Downloads - Archives contain the `tclient` binary and CLI documentation. Verify the + Archives contain the `tclient` binary, changelog, and CLI documentation. Verify the download against `checksums.txt`, then run `tclient --version` to inspect its version and build information. GitHub build attestations are available for the archives. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb14f80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +The Go library and `tclient` command share the same version. Entries describe +user-visible changes; the GitHub Release also lists the commits since the +previous tag. + +## v0.1.0 + +This is the first release with downloadable `tclient` binaries. It also includes +the library redesign since `v0.0.2`, with the incompatible changes below. + +### Added and improved + +- A supported client at `cmd/tclient`, with manual sessions, opt-in automatic + login, charset conversion, local commands, window resizing, and redirected + input/output. `--version` reports the version, commit, build time, compiler, + and target platform. +- Archives for Linux, macOS, and Windows on amd64 and arm64, with SHA-256 + checksums and GitHub build attestations. Unix supports interactive terminals; + Windows currently supports redirected sessions only. +- A stateful login matcher with bounded buffering, complete-line prompt + matching, password-change detection, and preservation of data received after + the shell prompt. Partial failure messages and banner text no longer count + as successful login prompts. +- Telnet stream handling for fragmented commands, escaped IAC bytes, directional + BINARY/NVT decoding, SGA, terminal type/speed, NAWS, and environment negotiation. + Only the configured user is exposed through environment negotiation. +- `WrapStream` for application-stream adapters, per-client tracing, explicit + `SendCommand` controls, and a `TerminalMode` negotiation snapshot. +- Regression coverage for protocol/login boundaries, concurrency, native + Linux/Windows builds, and interactive Linux PTY/TCP sessions. + +### Upgrade from v0.0.x + +**Review every `WindowChange` call: the argument order changed from +`WindowChange(width, height)` to `WindowChange(height, width)`.** Both arguments +are still integers, so old calls compile but send the wrong dimensions. The +new order matches `ssh.Session.WindowChange`. + +The minimum supported Go version is now **Go 1.20**, up from Go 1.15. + +#### Configuration and public API + +| v0.0.x | v0.1.0 migration | +| --- | --- | +| `Config` | Use `ClientConfig` in `Dial` and `NewClientConn`. | +| `Config.Username` | Use `ClientConfig.User`. | +| `TTYOptions: &TerminalOptions{Wide, High, TermType}` | Set `Width`, `Height`, and `TerminalType` directly on `ClientConfig`. | +| `Config.SetDefaults()` | Constructors copy and validate the configuration and apply defaults; pass a non-nil `&ClientConfig{}` for default settings. | +| `UsernameRegex`, `PasswordRegex` | Use `UsernamePrompt`, `PasswordPrompt`. | +| `BuiltinFailureRegex` | Use `FailurePrompt`. | +| `BuiltinSuccessRegex`, `LoginSuccessRegex` | Replace with `ShellPrompt` matching the final interactive prompt, not a success banner. | +| Exported `Default*Pattern` globals | Leave the corresponding prompt field nil to use its built-in matcher, or supply your own expression. | +| `ErrFailedLogin` | Use `ErrLoginFailed`; test wrapped errors with `errors.Is`. | +| Global `SetMode`, `DebugMode`, `NoPrintMode` | Set `ClientConfig.Trace` to an `io.Writer`; nil disables tracing. | +| Raw protocol exports such as `OptionPacket`, `ReadOptionPacket`, `ConvertSubOptions`, `CodeTOASCII`, protocol constants, and `AuthStatus` | These are no longer public. Use `Read`/`Write` for application data, `SendCommand` with `Command*` values for standalone controls, and `WindowChange` for NAWS. | + +For example: + +```go +config := &tclientlib.ClientConfig{ + User: "admin", + Password: password, + TerminalType: "xterm", + Width: 120, + Height: 40, +} +client, err := tclientlib.Dial("tcp", "192.0.2.10:23", config) +// Handle err before using client. +// On resize: client.WindowChange(height, width). +``` + +#### Login and stream behavior + +- Automatic login starts when **either** `User` or `Password` is non-empty; + older releases required both. Leave both empty for manual interaction. +- Login now waits for a final shell prompt. Custom expressions replace the + matcher for that event and must match a complete normalized logical line. + A banner containing "success" or "Last login" is not a shell prompt. Review + device-specific expressions before upgrading. +- Automatic username, password, and continue responses use **CR LF** by default. + Set `LineEnding: tclientlib.LineEndingCRNUL` to retain the older credential + terminator, or choose `LineEndingCR`/`LineEndingLF` when the device requires it. +- Positive `Timeout` values are respected, including values below 15 seconds; + non-positive values default to 15 seconds. The timeout bounds the connection + attempt and initial login separately. Login failures can be identified with + `ErrLoginTimeout`, `ErrLoginFailed`, and `ErrPasswordChangeRequired`. +- `Write` is an application-data API and now escapes IAC bytes. Stop writing raw + Telnet frames through it; use the explicit control methods instead. Caller + supplied line endings remain unchanged by `Write`. +- In non-BINARY receive mode, `Read` converts CR NUL to CR, while retaining CR LF + and unrelated NUL bytes. BINARY mode preserves those bytes. Negotiation state + advances as the application reads; the library does not start a background + reader or change the local terminal. + +See [the API and login guide](README.md#api) for current examples and +[the login stream design](docs/login-stream.md) for matching details. + +### Removed example client + +`_example/telnet_client` and its separate module have been removed. Use +`cmd/tclient` or a release binary instead; see the +[CLI migration notes](docs/cli.md#migrating-old-example-commands) for the +updated options. The independent `_example/monitor` TCP monitor remains. diff --git a/README.md b/README.md index eb0b043..239459d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ constructors return a ready-to-use client (with optional prompt-based login), application reads and writes never expose protocol framing, and protocol controls use explicit methods. +Upgrading from `v0.0.x`? Read the [changelog and migration guide](CHANGELOG.md), +including the changed `WindowChange(height, width)` argument order and +configuration API. + ## Command-line client Download a versioned binary from [GitHub Releases](https://github.com/LeeEirc/tclientlib/releases). diff --git a/docs/releasing.md b/docs/releasing.md index d3adb89..eb7ce12 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -12,6 +12,10 @@ after seven days. ## Start a release +Update [CHANGELOG.md](../CHANGELOG.md) with the new version's user-visible +changes and migration steps before merging the release commit. Generated +commit lists supplement these notes; they do not replace API upgrade guidance. + In GitHub, open **Actions → Release → Run workflow**, select `master`, and enter the version tag. For example, use `v0.1.0` for a stable release or `v0.1.0-rc.1` for a release candidate. @@ -49,7 +53,7 @@ build time, Go compiler version, and target platform. | macOS | amd64, arm64 | `tclient_VERSION_darwin_ARCH.tar.gz` | | Windows | amd64, arm64 | `tclient_VERSION_windows_ARCH.zip` | -Each archive contains the binary, README, and CLI guide. The release also +Each archive contains the binary, README, changelog, and documentation guides. The release also contains `checksums.txt` with SHA-256 hashes. Windows binaries support redirected sessions; interactive terminal support currently targets Unix. Cross-compilation does not replace platform runtime testing: native CI runs