diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..02cb5ee
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,80 @@
+name: Release
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/release.yml"
+ - "netmon.sh"
+ - "tests/**"
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ name: Verify release contract
+ runs-on: macos-15
+ timeout-minutes: 10
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Check Bash syntax
+ run: /bin/bash -n netmon.sh tests/netmon_test.sh
+
+ - name: Run deterministic behavior tests
+ run: /bin/bash tests/netmon_test.sh
+
+ - name: Verify declared version
+ run: |
+ declared="$(/bin/bash netmon.sh --version)"
+ test "$declared" = "MacNetStatsTerm 1.0.0"
+
+ publish:
+ name: Publish tagged script
+ if: startsWith(github.ref, 'refs/tags/')
+ needs: verify
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+
+ steps:
+ - name: Check out tagged source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Verify tag matches script version
+ run: |
+ grep --quiet --extended-regexp '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$GITHUB_REF_NAME"
+ version="$(bash netmon.sh --version)"
+ test "$version" = "MacNetStatsTerm ${GITHUB_REF_NAME#v}"
+
+ - name: Build single-file distribution
+ run: |
+ mkdir dist
+ install -m 0755 netmon.sh dist/macnetstats
+ (
+ cd dist
+ sha256sum macnetstats > macnetstats.sha256
+ )
+ sha256sum --check dist/macnetstats.sha256
+ test "$(dist/macnetstats --version)" = "MacNetStatsTerm ${GITHUB_REF_NAME#v}"
+
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ dist/macnetstats \
+ dist/macnetstats.sha256 \
+ --verify-tag \
+ --title "MacNetStatsTerm ${GITHUB_REF_NAME#v}" \
+ --generate-notes
diff --git a/README.md b/README.md
index 5ee19bb..48d9a2d 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,28 @@ No package manager or third-party runtime is required. The script is macOS-speci
## Install and run
+### Install the v1 release
+
+Download the exact reviewed script and its checksum from the
+[v1.0.0 release](https://github.com/okturan/MacNetStatsTerm/releases/tag/v1.0.0):
+
+```bash
+mkdir -p "$HOME/.local/bin"
+curl --fail --location --remote-name \
+ https://github.com/okturan/MacNetStatsTerm/releases/download/v1.0.0/macnetstats
+curl --fail --location --remote-name \
+ https://github.com/okturan/MacNetStatsTerm/releases/download/v1.0.0/macnetstats.sha256
+shasum -a 256 -c macnetstats.sha256
+install -m 0755 macnetstats "$HOME/.local/bin/macnetstats"
+"$HOME/.local/bin/macnetstats" --version
+```
+
+The checksum detects download corruption and binds the asset to the release
+notes; it is published through the same GitHub release channel and is not an
+independent code-signing identity.
+
+### Run from source
+
```bash
git clone https://github.com/okturan/MacNetStatsTerm.git
cd MacNetStatsTerm
@@ -35,7 +57,7 @@ cd MacNetStatsTerm
The repository tracks `netmon.sh` as executable. Press Control+C to stop; the original terminal screen and cursor are restored.
-### Install for the current user
+### Install the source checkout for the current user
After cloning, install the tested script under a user-owned executable directory:
@@ -51,7 +73,9 @@ Add `$HOME/.local/bin` to `PATH` if you want to run it as `macnetstats`. Remove
rm "$HOME/.local/bin/macnetstats"
```
-There is no packaged release or Homebrew formula yet. The default branch and its green macOS CI are the current distribution source; a versioned release remains a separate maintainer decision.
+There is no Homebrew formula. The v1.0.0 GitHub Release is the supported
+single-file distribution; the default branch and its green macOS CI remain the
+source of truth for later development.
### Select an interface explicitly
@@ -103,7 +127,10 @@ Normal `INT`, `TERM`, and shell-exit paths restore the cursor and leave the alte
## Verification
-The deterministic checks source the script without starting its infinite UI loop. They cover rate scaling, counter deltas and resets, interface detection and fallback, dependency failures, and the non-interactive execution guard.
+The deterministic checks source the script without starting its infinite UI
+loop. They cover rate scaling, counter deltas and resets, interface detection
+and fallback, dependency failures, the non-interactive execution guard, and
+the release version/help/invalid-option contract.
```bash
bash -n netmon.sh tests/netmon_test.sh
diff --git a/netmon.sh b/netmon.sh
index 162fcfb..67df4cd 100755
--- a/netmon.sh
+++ b/netmon.sh
@@ -1,6 +1,7 @@
#!/bin/bash
# Constants
+readonly MACNETSTATS_VERSION="1.0.0"
readonly KB_TO_MB_THRESHOLD=1024
readonly REFRESH_INTERVAL=1
@@ -16,6 +17,26 @@ print_error() {
printf 'MacNetStatsTerm: %s\n' "$*" >&2
}
+print_version() {
+ printf 'MacNetStatsTerm %s\n' "$MACNETSTATS_VERSION"
+}
+
+print_usage() {
+ cat <<'USAGE'
+Usage: macnetstats [--help | --version]
+
+Interactive macOS terminal dashboard for the active network interface.
+
+Options:
+ -h, --help Show this help and exit.
+ -V, --version Show the installed version and exit.
+
+Environment:
+ NETMON_INTERFACE Monitor this interface instead of auto-detecting.
+ NETMON_FALLBACK_INTERFACE Fallback interface when detection fails (default: en0).
+USAGE
+}
+
is_non_negative_integer() {
case "${1:-}" in
'' | *[!0-9]*) return 1 ;;
@@ -211,6 +232,30 @@ main() {
local download_rate
local upload_rate
+ if [ "$#" -gt 0 ]; then
+ if [ "$#" -ne 1 ]; then
+ print_error "expected at most one option"
+ print_usage >&2
+ return 2
+ fi
+
+ case "$1" in
+ -h | --help)
+ print_usage
+ return 0
+ ;;
+ -V | --version)
+ print_version
+ return 0
+ ;;
+ *)
+ print_error "unknown option: $1"
+ print_usage >&2
+ return 2
+ ;;
+ esac
+ fi
+
if [ ! -t 1 ]; then
print_error "an interactive terminal is required"
return 1
diff --git a/tests/netmon_test.sh b/tests/netmon_test.sh
index aa4401c..392298f 100755
--- a/tests/netmon_test.sh
+++ b/tests/netmon_test.sh
@@ -145,6 +145,29 @@ test_direct_execution_rejects_noninteractive_output() {
assert_contains "$output" "interactive terminal is required" "non-interactive error"
}
+test_version_is_available_without_a_terminal() {
+ local actual
+ actual=$(bash "$ROOT_DIR/netmon.sh" --version)
+ assert_equal "MacNetStatsTerm 1.0.0" "$actual" "version output"
+}
+
+test_help_is_available_without_a_terminal() {
+ local output
+ output=$(bash "$ROOT_DIR/netmon.sh" --help)
+ assert_contains "$output" "Usage: macnetstats" "help usage" || return 1
+ assert_contains "$output" "NETMON_INTERFACE" "help environment"
+}
+
+test_unknown_option_fails_before_terminal_check() {
+ local output
+ local status
+ output=$(bash "$ROOT_DIR/netmon.sh" --unknown 2>&1)
+ status=$?
+
+ assert_equal "2" "$status" "unknown option status" || return 1
+ assert_contains "$output" "unknown option: --unknown" "unknown option error"
+}
+
test_cleanup_restores_terminal_state() {
local expected="cnorm sgr0 rmcup"
TPUT_CALLS=""
@@ -163,7 +186,7 @@ test_cleanup_restores_terminal_state() {
assert_equal "0" "$SCREEN_ACTIVE" "terminal cleanup state"
}
-printf '1..12\n'
+printf '1..15\n'
run_test "formats KB/s using the sample interval" test_rate_formats_kilobytes
run_test "scales 1024 KB/s to MB/s" test_rate_scales_at_one_megabyte
run_test "calculates independent receive/transmit deltas" test_transfer_pair_uses_independent_deltas
@@ -175,6 +198,9 @@ run_test "honors an explicit interface override" test_explicit_interface_bypasse
run_test "accepts available dependencies" test_dependency_check_accepts_present_commands
run_test "reports all missing dependencies" test_dependency_check_reports_missing_commands
run_test "rejects direct non-interactive execution" test_direct_execution_rejects_noninteractive_output
+run_test "prints the version without requiring a terminal" test_version_is_available_without_a_terminal
+run_test "prints help without requiring a terminal" test_help_is_available_without_a_terminal
+run_test "rejects an unknown option before terminal setup" test_unknown_option_fails_before_terminal_check
run_test "restores terminal state on cleanup" test_cleanup_restores_terminal_state
if [ "$TESTS_FAILED" -ne 0 ]; then