Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ vendor/
# OS
.DS_Store
Thumbs.db

# Local agent/project metadata
.serena/
docs/plans/
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.0"
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## [0.1.0] - 2026-03-15

- Initial release.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
]
}
```

Expand All @@ -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
115 changes: 87 additions & 28 deletions cmd/unispeedtest/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"flag"
"fmt"
"io"
"os"
"os/signal"

Expand All @@ -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()
Expand All @@ -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.")
Expand All @@ -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
}
7 changes: 6 additions & 1 deletion docs/README/ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ unispeedtest -pretty

## JSON 出力形式

一部の測定だけ失敗した場合、該当メトリクスは `null` で出力され、`warnings` 配列に理由が入ります。これにより、本当の `0` と欠損値を区別できます。

```json
{
"download_mbps": 225.14,
Expand All @@ -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"
]
}
```

Expand Down
Loading
Loading