diff --git a/Makefile b/Makefile index 4fd2f1f..4d31a2f 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ lint-docker: lint-sh: @echo "Linting shell scripts..." \ && shellcheck scripts/*.sh scripts/*/*.sh tests/deb/*.sh images/*/bin/* \ - && shellcheck -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*/*.bats \ + && shellcheck -e SC2154 $$(find tests/bats -type f \( -name '*.bash' -o -name '*.bats' \)) \ && echo "OK" # Check shell script formatting diff --git a/tests/bats/scripts/generate-checksums.bats b/tests/bats/scripts/generate-checksums.bats new file mode 100644 index 0000000..722f280 --- /dev/null +++ b/tests/bats/scripts/generate-checksums.bats @@ -0,0 +1,131 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Tests for scripts/generate-checksums.sh — release artifact +# checksum generation. The script derives REPO_ROOT from its own +# path and writes to ${REPO_ROOT}/artifacts/release/, so each test +# stages a fake repo (symlinking the script in) to keep output +# confined to BATS_TEST_TMPDIR. + +load ../helpers/common + +setup() { + common_setup + FAKE_REPO="${BATS_TEST_TMPDIR}/repo" + mkdir -p "${FAKE_REPO}/scripts" + ln -s "${REPO_ROOT}/scripts/generate-checksums.sh" \ + "${FAKE_REPO}/scripts/generate-checksums.sh" + SCRIPT="${FAKE_REPO}/scripts/generate-checksums.sh" + OUT_DIR="${FAKE_REPO}/artifacts/release" + export SCRIPT OUT_DIR +} + +# Drop empty fixture artifacts into a directory. Each arg is a +# filename; the contents are stable across the suite so checksums +# are deterministic. +_make_artifacts() { + local dir="$1" + shift + mkdir -p "${dir}" + local name + for name in "$@"; do + mkdir -p "$(dirname "${dir}/${name}")" + printf 'fixture-%s' "${name}" > "${dir}/${name}" + done +} + +# ── happy path ─────────────────────────────────────────────────────── + +@test "writes checksums.txt and release-body.md in GNU coreutils format" { + local dist="${FAKE_REPO}/dist" + _make_artifacts "${dist}" "ci-tools_1.0.0_linux-x64.tar.gz" "ci-tools_1.0.0_amd64.deb" + run "${SCRIPT}" "${dist}" + assert_success + assert_file_exist "${OUT_DIR}/checksums.txt" + assert_file_exist "${OUT_DIR}/release-body.md" + # Each line must be "<64 hex> " — GNU coreutils format + # (matches what `sha256sum -c` expects). grep -E gives per-line + # anchoring, which bash's =~ does not. + run grep -Eq "^[a-f0-9]{64} ci-tools_1\.0\.0_amd64\.deb$" \ + "${OUT_DIR}/checksums.txt" + assert_success + run grep -Eq "^[a-f0-9]{64} ci-tools_1\.0\.0_linux-x64\.tar\.gz$" \ + "${OUT_DIR}/checksums.txt" + assert_success +} + +@test "checksums.txt records every artifact found in the dist dir" { + # Note: the script's final `| sort` sorts lines by the sha256 + # prefix (the first column on each line), not by filename, so we + # assert only the *set* of records — pinning the buggy hash-order + # would lock in a quirk that shouldn't be load-bearing. + local dist="${FAKE_REPO}/dist" + _make_artifacts "${dist}" \ + "ci-tools_1.0.0_osx-x64.tar.gz" \ + "ci-tools_1.0.0_amd64.deb" \ + "ci-tools_1.0.0_linux-x64.tar.gz" + run "${SCRIPT}" "${dist}" + assert_success + run awk '{print $2}' "${OUT_DIR}/checksums.txt" + assert_line "ci-tools_1.0.0_osx-x64.tar.gz" + assert_line "ci-tools_1.0.0_amd64.deb" + assert_line "ci-tools_1.0.0_linux-x64.tar.gz" + assert_equal "${#lines[@]}" 3 +} + +@test "checksums.txt records basenames only, even for nested artifacts" { + local dist="${FAKE_REPO}/dist" + _make_artifacts "${dist}" "deb/ci-tools_1.0.0_amd64.deb" + run "${SCRIPT}" "${dist}" + assert_success + run cat "${OUT_DIR}/checksums.txt" + assert_output --partial " ci-tools_1.0.0_amd64.deb" + refute_output --partial "deb/ci-tools" +} + +@test "release-body.md wraps the checksum table in a markdown code block" { + local dist="${FAKE_REPO}/dist" + _make_artifacts "${dist}" "ci-tools_1.0.0_amd64.deb" + run "${SCRIPT}" "${dist}" + assert_success + run cat "${OUT_DIR}/release-body.md" + assert_output --partial "## SHA256 Checksums" + assert_output --partial '```' + assert_output --partial "ci-tools_1.0.0_amd64.deb" +} + +@test "defaults to artifacts/release when no dist-dir is given" { + _make_artifacts "${OUT_DIR}" "ci-tools_1.0.0_amd64.deb" + run "${SCRIPT}" + assert_success + assert_file_exist "${OUT_DIR}/checksums.txt" + run cat "${OUT_DIR}/checksums.txt" + assert_output --partial "ci-tools_1.0.0_amd64.deb" +} + +# ── argument errors ────────────────────────────────────────────────── + +@test "exits 0 and prints usage for --help" { + run "${SCRIPT}" --help + assert_success + assert_output --partial "Usage:" +} + +@test "exits 2 (distinct from runtime errors) when called with too many args" { + run "${SCRIPT}" one two + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "exits 1 when the dist-dir does not exist" { + run "${SCRIPT}" "${FAKE_REPO}/no-such-dir" + assert_failure 1 + assert_output --partial "Directory not found" +} + +@test "exits nonzero when the dist-dir contains no release artifacts" { + local dist="${FAKE_REPO}/dist" + mkdir -p "${dist}" + run "${SCRIPT}" "${dist}" + assert_failure +} diff --git a/tests/bats/scripts/lib/resolve.bats b/tests/bats/scripts/lib/resolve.bats new file mode 100644 index 0000000..35ad300 --- /dev/null +++ b/tests/bats/scripts/lib/resolve.bats @@ -0,0 +1,148 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Unit tests for scripts/lib/resolve.sh helpers that do not touch +# the network. The upstream-fetching wrappers (latest_gh_tag, +# fetch_gh_asset, fetch_gh_digests, latest_npm_version, +# latest_luarocks_version) are integration-only and excluded. + +load ../../helpers/common + +# 64 hex chars, lowercase — the canonical valid SHA256 shape. +VALID_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +setup() { + common_setup + LIB="${REPO_ROOT}/scripts/lib/resolve.sh" + export LIB +} + +# ── validate_sha256 ────────────────────────────────────────────────── + +@test "validate_sha256 accepts a 64-char lowercase hex string" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "${VALID_SHA}" "shfmt" + assert_success + assert_output "" +} + +@test "validate_sha256 rejects an empty hash and reports (empty)" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "" "shfmt" + assert_failure 1 + assert_output --partial "invalid SHA256 for shfmt" + assert_output --partial "(empty)" +} + +@test "validate_sha256 rejects uppercase hex" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" "shfmt" + assert_failure 1 + assert_output --partial "invalid SHA256 for shfmt" +} + +@test "validate_sha256 rejects a too-short hash" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "deadbeef" "shfmt" + assert_failure 1 + assert_output --partial "invalid SHA256 for shfmt" +} + +@test "validate_sha256 rejects a too-long hash" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "${VALID_SHA}a" "shfmt" + assert_failure 1 + assert_output --partial "invalid SHA256 for shfmt" +} + +@test "validate_sha256 rejects non-hex characters" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "shfmt" + assert_failure 1 + assert_output --partial "invalid SHA256 for shfmt" +} + +@test "validate_sha256 includes the tool name in the error message" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_sha256 "nope" "markdownlint-cli2" + assert_failure 1 + assert_output --partial "invalid SHA256 for markdownlint-cli2" +} + +# ── resolve_local ──────────────────────────────────────────────────── + +@test "resolve_local returns the pinned override when provided" { + # shellcheck disable=SC1090 + source "${LIB}" + run resolve_local "1.0.0" "2.0.0" + assert_success + assert_output "2.0.0" +} + +@test "resolve_local returns the current value when no pin is given" { + # shellcheck disable=SC1090 + source "${LIB}" + run resolve_local "1.0.0" "" + assert_success + assert_output "1.0.0" +} + +@test "resolve_local returns the current value when called with one arg" { + # shellcheck disable=SC1090 + source "${LIB}" + run resolve_local "1.0.0" + assert_success + assert_output "1.0.0" +} + +@test "resolve_local defaults to 'local' when current is empty and no pin" { + # shellcheck disable=SC1090 + source "${LIB}" + run resolve_local "" "" + assert_success + assert_output "local" +} + +@test "resolve_local prefers a pinned override even when current is empty" { + # shellcheck disable=SC1090 + source "${LIB}" + run resolve_local "" "3.0.0" + assert_success + assert_output "3.0.0" +} + +# ── pick_gh_digest ─────────────────────────────────────────────────── + +@test "pick_gh_digest extracts the matching asset's hex digest" { + # shellcheck disable=SC1090 + source "${LIB}" + local digests + digests="shfmt_v3.13.0_linux_amd64=${VALID_SHA} +shfmt_v3.13.0_linux_arm64=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + run pick_gh_digest "${digests}" "shfmt_v3.13.0_linux_amd64" + assert_success + assert_output "${VALID_SHA}" +} + +@test "pick_gh_digest errors when the asset is missing from the digest list" { + # shellcheck disable=SC1090 + source "${LIB}" + run pick_gh_digest "other_asset=${VALID_SHA}" "shfmt_v3.13.0_linux_amd64" + assert_failure 1 + assert_output --partial "no digest found for asset shfmt_v3.13.0_linux_amd64" +} + +@test "pick_gh_digest errors when the matched digest is malformed" { + # shellcheck disable=SC1090 + source "${LIB}" + run pick_gh_digest "asset=not-a-real-hash" "asset" + assert_failure 1 + assert_output --partial "invalid digest for asset" +} diff --git a/tests/bats/scripts/lib/validate-lockfile.bats b/tests/bats/scripts/lib/validate-lockfile.bats new file mode 100644 index 0000000..be6f396 --- /dev/null +++ b/tests/bats/scripts/lib/validate-lockfile.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Tests for scripts/lib/validate-lockfile.sh — the gate that keeps +# Dockerfile ARGs and versions.lock keys in sync. Each test builds a +# minimal fake repo under BATS_TEST_TMPDIR with a Dockerfile + +# versions.lock pair and runs the script against it. +# +# REPO_ROOT inside the script is derived from its own path, so we +# symlink the real scripts/lib into the fake repo. That keeps the +# source dependency on resolve.sh (for die()) working without +# copying files. + +load ../../helpers/common + +setup() { + common_setup + FAKE_REPO="${BATS_TEST_TMPDIR}/repo" + mkdir -p "${FAKE_REPO}/scripts" + ln -s "${REPO_ROOT}/scripts/lib" "${FAKE_REPO}/scripts/lib" + SCRIPT="${FAKE_REPO}/scripts/lib/validate-lockfile.sh" + IMAGE_DIR="${FAKE_REPO}/images/test-image" + mkdir -p "${IMAGE_DIR}" + export SCRIPT IMAGE_DIR +} + +# Each positional arg becomes one line in the Dockerfile / lockfile. +_make_dockerfile() { printf '%s\n' "$@" > "${IMAGE_DIR}/Dockerfile"; } +_make_lockfile() { printf '%s\n' "$@" > "${IMAGE_DIR}/versions.lock"; } + +# ── happy path ─────────────────────────────────────────────────────── + +@test "exits 0 when Dockerfile ARGs and lockfile keys match exactly" { + _make_dockerfile "FROM scratch" "ARG FOO" "ARG BAR" + _make_lockfile "FOO=1.0.0" "BAR=2.0.0" + run "${SCRIPT}" test-image + assert_success + refute_output --partial "missing" +} + +# ── mismatch reporting ─────────────────────────────────────────────── + +@test "exits 1 and names the ARG when an ARG is missing from versions.lock" { + _make_dockerfile "FROM scratch" "ARG FOO" "ARG BAR" + _make_lockfile "FOO=1.0.0" + run "${SCRIPT}" test-image + assert_failure 1 + assert_output --partial "ARGs in Dockerfile missing from versions.lock" + assert_output --partial "BAR" +} + +@test "exits 1 and names the key when a lockfile key has no matching ARG" { + _make_dockerfile "FROM scratch" "ARG FOO" + _make_lockfile "FOO=1.0.0" "EXTRA=2.0.0" + run "${SCRIPT}" test-image + assert_failure 1 + assert_output --partial "Keys in versions.lock missing from Dockerfile" + assert_output --partial "EXTRA" +} + +@test "exits 1 and reports mismatches in both directions" { + _make_dockerfile "FROM scratch" "ARG A" "ARG B" + _make_lockfile "A=1" "C=2" + run "${SCRIPT}" test-image + assert_failure 1 + assert_output --partial "ARGs in Dockerfile missing from versions.lock" + assert_output --partial "B" + assert_output --partial "Keys in versions.lock missing from Dockerfile" + assert_output --partial "C" +} + +# ── filtering rules ────────────────────────────────────────────────── + +@test "TARGETARCH is excluded from the comparison (supplied by buildx)" { + _make_dockerfile "FROM scratch" "ARG TARGETARCH" "ARG FOO" + _make_lockfile "FOO=1.0.0" + run "${SCRIPT}" test-image + assert_success +} + +@test "ARGs with default values are excluded from the comparison" { + # The 'bare ARG' regex anchors at end-of-line, so 'ARG NAME=default' + # never enters the comparison set. Locks down the invariant — easy + # to lose if the sed gets 'simplified'. + _make_dockerfile "FROM scratch" "ARG WITH_DEFAULT=already-set" "ARG FOO" + _make_lockfile "FOO=1.0.0" + run "${SCRIPT}" test-image + assert_success +} + +# ── input errors ───────────────────────────────────────────────────── + +@test "exits 1 with usage message when the image arg is missing" { + run "${SCRIPT}" + assert_failure 1 + assert_output --partial "usage: validate-lockfile.sh " +} + +@test "exits 1 when the Dockerfile is missing" { + _make_lockfile "FOO=1.0.0" + run "${SCRIPT}" test-image + assert_failure 1 + assert_output --partial "Dockerfile not found" +} + +@test "exits 1 when the versions.lock is missing" { + _make_dockerfile "FROM scratch" "ARG FOO" + run "${SCRIPT}" test-image + assert_failure 1 + assert_output --partial "lockfile not found" +} diff --git a/tests/bats/scripts/lib/verify.bats b/tests/bats/scripts/lib/verify.bats new file mode 100644 index 0000000..64c4303 --- /dev/null +++ b/tests/bats/scripts/lib/verify.bats @@ -0,0 +1,100 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Unit tests for scripts/lib/verify.sh — check() and verify_exit(). +# Sourcing verify.sh also pulls in version.sh (used by +# normalize_version inside check()). + +load ../../helpers/common + +setup() { + common_setup + LIB="${REPO_ROOT}/scripts/lib/verify.sh" + export LIB +} + +# ── check ──────────────────────────────────────────────────────────── + +@test "check prints OK when the reported version matches the expected one" { + # shellcheck disable=SC1090 + source "${LIB}" + run check "shfmt" "v3.13.0" echo "v3.13.0" + assert_success + assert_output --partial "OK" + assert_output --partial "shfmt" + assert_output --partial "v3.13.0" +} + +@test "check tolerates a v-prefix difference via normalize_version" { + # Expected "v3.13.0" must match output "3.13.0" (and vice versa). + # shellcheck disable=SC1090 + source "${LIB}" + run check "shfmt" "v3.13.0" echo "3.13.0" + assert_success + assert_output --partial "OK" +} + +@test "check prints FAIL and flips VERIFY_FAILED when the version mismatches" { + # shellcheck disable=SC1090 + source "${LIB}" + # Call directly (not via run) so the side effect on VERIFY_FAILED + # propagates into the test scope; tee the message to a tmpfile so + # we can assert on it afterwards. + local out="${BATS_TEST_TMPDIR}/check.out" + check "shfmt" "v3.13.0" echo "v3.12.0" > "${out}" + assert_equal "${VERIFY_FAILED}" 1 + run cat "${out}" + assert_output --partial "FAIL" + assert_output --partial "shfmt" + assert_output --partial "expected v3.13.0" + assert_output --partial "got v3.12.0" +} + +@test "check prints FAIL when the command exits nonzero (tool missing)" { + # shellcheck disable=SC1090 + source "${LIB}" + local out="${BATS_TEST_TMPDIR}/check.out" + check "ghost" "1.0.0" false > "${out}" + assert_equal "${VERIFY_FAILED}" 1 + run cat "${out}" + assert_output --partial "FAIL" + assert_output --partial "ghost" + assert_output --partial "not found" +} + +@test "check compares only the first line of the command output" { + # shellcheck disable=SC1090 + source "${LIB}" + run check "multiline" "v1.0.0" printf '1.0.0\nextra junk\n' + assert_success + assert_output --partial "OK" + refute_output --partial "extra junk" +} + +@test "check skips the version check when expected is empty" { + # shellcheck disable=SC1090 + source "${LIB}" + run check "noversion" "" echo "anything goes" + assert_success + assert_output --partial "OK" + assert_output --partial "anything goes" +} + +# ── verify_exit ────────────────────────────────────────────────────── + +@test "verify_exit prints OK and exits 0 when no checks failed" { + # shellcheck disable=SC1090 + source "${LIB}" + run verify_exit + assert_success + assert_output "OK" +} + +@test "verify_exit prints FAIL and exits 1 when a check failed" { + # shellcheck disable=SC1090 + source "${LIB}" + VERIFY_FAILED=1 + run verify_exit + assert_failure 1 + assert_output "FAIL" +} diff --git a/tests/bats/scripts/lib/version.bats b/tests/bats/scripts/lib/version.bats new file mode 100644 index 0000000..1dfc137 --- /dev/null +++ b/tests/bats/scripts/lib/version.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Unit tests for scripts/lib/version.sh. Each test sources the +# library and invokes a single function so side effects stay +# isolated to the bats subshell. + +load ../../helpers/common + +setup() { + common_setup + LIB="${REPO_ROOT}/scripts/lib/version.sh" + export LIB +} + +# ── normalize_version ──────────────────────────────────────────────── + +@test "normalize_version strips a leading v prefix" { + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "v3.12.0" + assert_success + assert_output "3.12.0" +} + +@test "normalize_version strips a trailing -N rockspec suffix" { + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "1.2.0-1" + assert_success + assert_output "1.2.0" +} + +@test "normalize_version leaves a plain MAJOR.MINOR.PATCH unchanged" { + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "0.20.0" + assert_success + assert_output "0.20.0" +} + +@test "normalize_version strips both a v prefix and a -N suffix" { + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "v1.2.0-1" + assert_success + assert_output "1.2.0" +} + +@test "normalize_version trims only the shortest trailing -suffix" { + # Documented limitation: 1.0.0-beta-2 collapses to 1.0.0-beta, + # not 1.0.0. Safe for the current tool set (only luarocks uses -N). + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "1.0.0-beta-2" + assert_success + assert_output "1.0.0-beta" +} + +@test "normalize_version returns empty for an empty input" { + # shellcheck disable=SC1090 + source "${LIB}" + run normalize_version "" + assert_success + assert_output "" +} + +# ── validate_strict_version ────────────────────────────────────────── + +@test "validate_strict_version accepts MAJOR.MINOR.PATCH and echoes the bare version" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version "1.2.3" + assert_success + assert_output "1.2.3" +} + +@test "validate_strict_version strips a leading v prefix" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version "v1.2.3" + assert_success + assert_output "1.2.3" +} + +@test "validate_strict_version rejects a pre-release suffix" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version "1.0.0-rc1" + assert_failure 1 + assert_output --partial "Invalid strict version" +} + +@test "validate_strict_version rejects a two-segment version" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version "1.2" + assert_failure 1 + assert_output --partial "Invalid strict version" +} + +@test "validate_strict_version rejects an empty argument" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version "" + assert_failure 1 + assert_output --partial "Version argument required" +} + +@test "validate_strict_version rejects a missing argument" { + # shellcheck disable=SC1090 + source "${LIB}" + run validate_strict_version + assert_failure 1 + assert_output --partial "Version argument required" +} diff --git a/tests/bats/scripts/validate-version.bats b/tests/bats/scripts/validate-version.bats new file mode 100644 index 0000000..67649be --- /dev/null +++ b/tests/bats/scripts/validate-version.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Tests for scripts/validate-version.sh — the regex gate that +# guards versions used in release filenames and shell commands. +# Unlike validate-version-strict.sh (covered transitively by +# version.bats), this script accepts pre-release suffixes and +# does NOT strip a leading "v". + +load ../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/scripts/validate-version.sh" + export SCRIPT +} + +# ── happy path ─────────────────────────────────────────────────────── + +@test "accepts plain MAJOR.MINOR.PATCH and echoes it back" { + run "${SCRIPT}" "1.0.0" + assert_success + assert_output "1.0.0" +} + +@test "accepts a simple pre-release identifier" { + run "${SCRIPT}" "1.0.0-alpha" + assert_success + assert_output "1.0.0-alpha" +} + +@test "accepts a dot-segmented pre-release identifier" { + run "${SCRIPT}" "1.0.0-beta.1" + assert_success + assert_output "1.0.0-beta.1" +} + +# ── reject paths ───────────────────────────────────────────────────── + +@test "rejects a leading 'v' prefix (contract differs from strict variant)" { + # validate-version-strict.sh strips a leading v; this one rejects. + # Pinning the contract keeps filenames consistent across the + # release pipeline. + run "${SCRIPT}" "v1.0.0" + assert_failure 1 + assert_output --partial "Invalid version format" +} + +@test "rejects a two-segment version" { + run "${SCRIPT}" "1.2" + assert_failure 1 + assert_output --partial "Invalid version format" +} + +@test "rejects a trailing dash with no pre-release identifier" { + run "${SCRIPT}" "1.0.0-" + assert_failure 1 + assert_output --partial "Invalid version format" +} + +@test "rejects non-alphanumeric characters in the pre-release identifier" { + run "${SCRIPT}" "1.0.0-rc_1" + assert_failure 1 + assert_output --partial "Invalid version format" +} + +# ── argument errors ────────────────────────────────────────────────── + +@test "exits 1 with usage when called without arguments" { + run "${SCRIPT}" + assert_failure 1 + assert_output --partial "Usage:" +}