From d72ee112130cf04e913ae67ec32490a3e31abcc5 Mon Sep 17 00:00:00 2001 From: mktbsh Date: Sun, 15 Mar 2026 01:11:06 +0900 Subject: [PATCH 1/2] Integrate release-please with gorele --- .github/workflows/release.yml | 83 +++++- .release-please-manifest.json | 3 + .serena/.gitignore | 2 + .serena/memories/done_checklist.md | 6 + .serena/memories/project_overview.md | 6 + .serena/memories/style_and_conventions.md | 7 + .serena/memories/suggested_commands.md | 9 + .serena/project.yml | 135 +++++++++ CHANGELOG.md | 5 + README.md | 14 +- cmd/unispeedtest/main.go | 115 ++++++-- demo.tape | 79 ++++++ docs/README/ja.md | 7 +- ...-03-15-release-please-goreleaser-design.md | 43 +++ internal/cloudflare/client.go | 33 ++- internal/cloudflare/client_test.go | 190 +++++++++++++ internal/cloudflare/measure.go | 259 ++++++++++++++---- internal/cloudflare/types.go | 30 +- internal/reporter/printer.go | 133 ++++++--- internal/reporter/printer_test.go | 66 +++++ release-please-config.json | 10 + version.txt | 1 + 22 files changed, 1099 insertions(+), 137 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/done_checklist.md create mode 100644 .serena/memories/project_overview.md create mode 100644 .serena/memories/style_and_conventions.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/project.yml create mode 100644 CHANGELOG.md create mode 100644 demo.tape create mode 100644 docs/plans/2026-03-15-release-please-goreleaser-design.md create mode 100644 internal/cloudflare/client_test.go create mode 100644 internal/reporter/printer_test.go create mode 100644 release-please-config.json create mode 100644 version.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e091016..099109b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,15 +2,66 @@ name: Release on: push: - tags: - - 'v*' + branches: + - main + workflow_dispatch: permissions: contents: write + issues: write + pull-requests: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: - name: Release + release-please: + name: Release Please + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.detect.outputs.should_release }} + version: ${{ steps.detect.outputs.version }} + tag_name: ${{ steps.detect.outputs.tag_name }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Detect merged release PR + id: detect + shell: bash + run: | + changed_files="$(git show --pretty='' --name-only "$GITHUB_SHA")" + + if printf '%s\n' "$changed_files" | grep -qx 'version.txt' && \ + printf '%s\n' "$changed_files" | grep -qx 'CHANGELOG.md'; then + version="$(tr -d '[:space:]' < version.txt)" + if [ -z "$version" ]; then + echo "version.txt is empty" >&2 + exit 1 + fi + + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag_name=v$version" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_release=false" >> "$GITHUB_OUTPUT" + + goreleaser: + name: GoReleaser + needs: release-please + if: needs.release-please.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -23,11 +74,33 @@ jobs: go-version-file: go.mod cache: true + - name: Verify release commit + run: | + go mod verify + go vet ./... + go test ./... + + - name: Create release tag + shell: bash + run: | + tag="${{ needs.release-please.outputs.tag_name }}" + + if git rev-parse "$tag" >/dev/null 2>&1; then + existing_sha="$(git rev-list -n 1 "$tag")" + if [ "$existing_sha" != "$GITHUB_SHA" ]; then + echo "tag $tag already exists on $existing_sha, expected $GITHUB_SHA" >&2 + exit 1 + fi + else + git tag "$tag" "$GITHUB_SHA" + git push origin "$tag" + fi + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: '~> v2' + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/memories/done_checklist.md b/.serena/memories/done_checklist.md new file mode 100644 index 0000000..a7db002 --- /dev/null +++ b/.serena/memories/done_checklist.md @@ -0,0 +1,6 @@ +# When a task is completed +- Run `go test ./...` at minimum. +- Run `go vet ./...` for static checks. +- If packaging/build behavior changed, run `go build -trimpath -ldflags="-s -w" -o dist/unispeedtest ./cmd/unispeedtest`. +- Preserve the package split between measurement, stats, reporting, and color utilities. +- Be careful not to hide network/HTTP errors in measurement code; quality regressions here can silently skew benchmark results. \ No newline at end of file diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 0000000..949cb0d --- /dev/null +++ b/.serena/memories/project_overview.md @@ -0,0 +1,6 @@ +# universal-speedtest-cli overview +- Purpose: Go-based CLI network benchmark called `unispeedtest`. +- Provider focus: currently Cloudflare speed test endpoints, with intent to be extensible to other providers. +- Entry point: `cmd/unispeedtest/main.go`. +- Main packages: `internal/cloudflare` for HTTP measurement and metadata fetches, `internal/stats` for numeric helpers, `internal/reporter` for human/JSON output, `internal/color` for ANSI color toggles. +- Repo also includes release/install config in `.goreleaser.yml`, `install.sh`, and CI in `.github/workflows/ci.yml`. \ No newline at end of file diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md new file mode 100644 index 0000000..b56c6c6 --- /dev/null +++ b/.serena/memories/style_and_conventions.md @@ -0,0 +1,7 @@ +# Style and conventions +- Language: Go. +- Structure follows small internal packages split by responsibility (`cloudflare`, `stats`, `reporter`, `color`). +- Exported identifiers and functions generally have GoDoc comments. +- Tests use table-driven style with `t.Run` and black-box package names like `stats_test`, `reporter_test`, `cloudflare_test`. +- Naming is mostly descriptive for exported APIs, with some short local abbreviations (`pd`, `w`, `tt`) in implementation/tests. +- Error handling style is mixed: some functions return errors directly (`FetchMeta`, `MakeRequest`, `PrintJSON`), while measurement loops often ignore request failures and continue. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..9240bee --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,9 @@ +# Suggested commands +- Run tests: `go test ./...` +- Run vet: `go vet ./...` +- Build binary: `go build -trimpath -ldflags="-s -w" -o dist/unispeedtest ./cmd/unispeedtest` +- Build with mise task: `mise run build` +- Test with mise task: `mise run test` +- Vet with mise task: `mise run lint` +- Run CLI directly: `go run ./cmd/unispeedtest` +- Install latest module locally: `go install github.com/hsblabs/universal-speedtest-cli/cmd/unispeedtest@latest` \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e418c29 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,135 @@ +# the name by which the project can be referenced within Serena +project_name: "universal-speedtest-cli" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- go + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fec8507 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [0.1.0] - 2026-03-15 + +- Initial release. diff --git a/README.md b/README.md index e177695..54bed91 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ unispeedtest -pretty ## JSON output shape +On partial failures, affected metrics are emitted as `null` and a `warnings` array is added so callers can distinguish missing data from real zero values. + ```json { "download_mbps": 225.14, @@ -71,7 +73,10 @@ unispeedtest -pretty "server_colo": "Tokyo", "network_asn": "AS2516", "network_as_org": "KDDI CORPORATION", - "ip": "203.0.113.10" + "ip": "203.0.113.10", + "warnings": [ + "upload loaded latency unavailable: no samples collected" + ] } ``` @@ -93,6 +98,13 @@ Tips: - Set `NO_COLOR=1` to disable ANSI color output. +## Releasing + +- Follow Conventional Commits in merged PRs (`fix:`, `feat:`, `feat!:` / `BREAKING CHANGE:`). +- Pushes to `main` update or create a release PR via `release-please`. +- Merging that release PR updates `version.txt` and `CHANGELOG.md`. +- The release workflow then creates the corresponding `vX.Y.Z` tag and publishes artifacts with GoReleaser. + ## License MIT diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index afe72d9..29655f4 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io" "os" "os/signal" @@ -13,6 +14,10 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { jsonOut := flag.Bool("json", false, "Output results in JSON format") prettyOut := flag.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") flag.Parse() @@ -34,6 +39,7 @@ func main() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) go func() { <-sigCh fmt.Fprintln(os.Stderr, "\nInterrupted.") @@ -42,55 +48,108 @@ func main() { progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) + var result reporter.Result + var warnings []string + meta, err := cloudflare.FetchMeta() if err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not fetch network metadata: %v\n", err) + warnings = append(warnings, fmt.Sprintf("network metadata unavailable: %v", err)) + } else { + result.ServerColo = stringPtr(meta.Colo.City) + result.NetworkASN = stringPtr(fmt.Sprintf("AS%d", meta.ASN)) + result.NetworkASOrg = stringPtr(meta.ASOrganization) + result.IP = stringPtr(meta.ClientIP) } - unloadedLatencies := cloudflare.MeasureLatency() - unloadedMed := stats.Median(unloadedLatencies) - unloadedJitter := stats.Jitter(unloadedLatencies) + latencyMeasurement := cloudflare.MeasureLatency() + warnings = append(warnings, latencyMeasurement.Warnings...) + if len(latencyMeasurement.Samples) == 0 { + fmt.Fprintln(os.Stderr, "error: unloaded latency measurement produced no successful samples") + return 1 + } + result.UnloadedLatency = float64Ptr(stats.Median(latencyMeasurement.Samples)) + if len(latencyMeasurement.Samples) > 1 { + result.Jitter = float64Ptr(stats.Jitter(latencyMeasurement.Samples)) + } else { + warnings = append(warnings, "jitter unavailable: need at least 2 unloaded latency samples") + } progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) - var progressWriter *os.File + var progressWriter io.Writer if verbose { progressWriter = os.Stdout } - testSizes := []int{101000, 1001000, 10001000, 25001000} - downCounts := []int{10, 8, 6, 4} - downSpeeds, downLatencies := cloudflare.MeasurePhase("download", testSizes, downCounts, progressWriter) - downOverall := stats.Quartile(downSpeeds, 0.90) + downloadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 10}, + {SizeBytes: 1001000, Count: 8}, + {SizeBytes: 10001000, Count: 6}, + {SizeBytes: 25001000, Count: 4}, + } + downloadMeasurement, err := cloudflare.MeasurePhase("download", downloadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(os.Stderr, "error: download measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, downloadMeasurement.Warnings...) + if len(downloadMeasurement.Speeds) == 0 { + fmt.Fprintln(os.Stderr, "error: download measurement produced no successful samples") + return 1 + } + result.DownloadMbps = float64Ptr(stats.Quartile(downloadMeasurement.Speeds, 0.90)) + if len(downloadMeasurement.LoadedLatencies) > 0 { + result.LoadedDownLatency = float64Ptr(stats.Median(downloadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "download loaded latency unavailable: no samples collected") + } progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) - upCounts := []int{8, 6, 4, 4} - upSpeeds, upLatencies := cloudflare.MeasurePhase("upload", testSizes, upCounts, progressWriter) - upOverall := stats.Quartile(upSpeeds, 0.90) + uploadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 8}, + {SizeBytes: 1001000, Count: 6}, + {SizeBytes: 10001000, Count: 4}, + {SizeBytes: 25001000, Count: 4}, + } + uploadMeasurement, err := cloudflare.MeasurePhase("upload", uploadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(os.Stderr, "error: upload measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, uploadMeasurement.Warnings...) + if len(uploadMeasurement.Speeds) == 0 { + fmt.Fprintln(os.Stderr, "error: upload measurement produced no successful samples") + return 1 + } + result.UploadMbps = float64Ptr(stats.Quartile(uploadMeasurement.Speeds, 0.90)) + if len(uploadMeasurement.LoadedLatencies) > 0 { + result.LoadedUpLatency = float64Ptr(stats.Median(uploadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "upload loaded latency unavailable: no samples collected") + } progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) lossPercent, received, total := cloudflare.MeasurePacketLoss() - result := reporter.Result{ - DownloadMbps: downOverall, - UploadMbps: upOverall, - UnloadedLatency: unloadedMed, - LoadedDownLatency: stats.Median(downLatencies), - LoadedUpLatency: stats.Median(upLatencies), - Jitter: unloadedJitter, - PacketLoss: lossPercent, - Received: received, - Total: total, - ServerColo: meta.Colo.City, - NetworkASN: fmt.Sprintf("AS%d", meta.ASN), - NetworkASOrg: meta.ASOrganization, - IP: meta.ClientIP, - } + result.PacketLoss = float64Ptr(lossPercent) + result.Received = received + result.Total = total + result.Warnings = warnings if verbose { reporter.PrintHuman(os.Stdout, result) } else { if err := reporter.PrintJSON(os.Stdout, result, *prettyOut); err != nil { fmt.Fprintf(os.Stderr, "error encoding JSON: %v\n", err) - os.Exit(1) + return 1 } } + + return 0 +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func stringPtr(value string) *string { + return &value } diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..cab5a48 --- /dev/null +++ b/demo.tape @@ -0,0 +1,79 @@ +Output demo.gif + +Set FontSize 32 +Set Width 1200 +Set Height 600 + +Type "unispeedtest" +Enter +Sleep 30s + +# VHS documentation +# +# Output: +# Output .gif Create a GIF output at the given +# Output .mp4 Create an MP4 output at the given +# Output .webm Create a WebM output at the given +# +# Require: +# Require Ensure a program is on the $PATH to proceed +# +# Settings: +# Set FontSize Set the font size of the terminal +# Set FontFamily Set the font family of the terminal +# Set Height Set the height of the terminal +# Set Width Set the width of the terminal +# Set LetterSpacing Set the font letter spacing (tracking) +# Set LineHeight Set the font line height +# Set LoopOffset % Set the starting frame offset for the GIF loop +# Set Theme Set the theme of the terminal +# Set Padding Set the padding of the terminal +# Set Framerate Set the framerate of the recording +# Set PlaybackSpeed Set the playback speed of the recording +# Set MarginFill Set the file or color the margin will be filled with. +# Set Margin Set the size of the margin. Has no effect if MarginFill isn't set. +# Set BorderRadius Set terminal border radius, in pixels. +# Set WindowBar Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) +# Set WindowBarSize Set window bar size, in pixels. Default is 40. +# Set TypingSpeed