diff --git a/Makefile b/Makefile index 32151f7..7f02bc6 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ -.PHONY: test lint check build clean help +.PHONY: test lint check build clean help version version-set version-sync # ── Paths ────────────────────────────────────────────────────────────── REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) PROFILE_DIR := $(REPO_ROOT)/packaging/archiso SCRIPTS_DIR := $(REPO_ROOT)/packaging/scripts TESTS_DIR := $(REPO_ROOT)/tests +VERSION_FILE := $(REPO_ROOT)/VERSION # Shell scripts to lint with shellcheck SHELL_SCRIPTS := \ @@ -14,12 +15,14 @@ SHELL_SCRIPTS := \ $(PROFILE_DIR)/airootfs/usr/local/bin/choose-mirror \ $(PROFILE_DIR)/airootfs/root/.automated_script.sh \ $(SCRIPTS_DIR)/samios-desktop-setup.sh \ + $(SCRIPTS_DIR)/samios-installer.sh \ $(TESTS_DIR)/test_helper.sh \ $(TESTS_DIR)/test_profiledef.sh \ $(TESTS_DIR)/test_packages.sh \ $(TESTS_DIR)/test_pacman_conf.sh \ $(TESTS_DIR)/test_samios_cli.sh \ $(TESTS_DIR)/test_font_policy.sh \ + $(TESTS_DIR)/test_version.sh \ $(TESTS_DIR)/run_tests.sh SHELLCHECK ?= shellcheck @@ -58,3 +61,31 @@ clean: ## Remove build artifacts @rm -rf /tmp/samios-build @rm -rf $(PROFILE_DIR)/out @echo "Clean complete" + +# ── Version management ───────────────────────────────────────────────── +# VERSION is the single source of truth. Bumping it is a one-line edit +# to $(VERSION_FILE); version-sync mirrors it into the airootfs overlay. + +version: ## Print the current SamiOS version + @cat $(VERSION_FILE) + +version-sync: ## Mirror VERSION into the airootfs overlay + @if [ ! -f "$(VERSION_FILE)" ]; then \ + echo "ERROR: $(VERSION_FILE) does not exist"; exit 1; \ + fi + @install -d -m 755 $(PROFILE_DIR)/airootfs/etc + @install -m 644 $(VERSION_FILE) $(PROFILE_DIR)/airootfs/etc/samios-version + @echo "Synced $(VERSION_FILE) → $(PROFILE_DIR)/airootfs/etc/samios-version" + @echo " Content: $$(cat $(VERSION_FILE))" + +version-set: ## Set VERSION to an explicit value (usage: make version-set NEW=X.Y.Z) + @if [ -z "$(NEW)" ]; then \ + echo "Usage: make version-set NEW=X.Y.Z"; exit 1; \ + fi + @if ! echo "$(NEW)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$$'; then \ + echo "ERROR: NEW must be semver-ish (X.Y.Z or X.Y.Z-suffix; suffix is dot-separated alphanumerics)"; exit 1; \ + fi + @echo "$(NEW)" > $(VERSION_FILE) + @$(MAKE) --no-print-directory version-sync + @echo "" + @echo "Set to $(NEW). Run 'make check' to verify, then commit." diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/branding/themes/samios-sddm-theme/metadata.desktop b/branding/themes/samios-sddm-theme/metadata.desktop index 085d42e..722eebc 100644 --- a/branding/themes/samios-sddm-theme/metadata.desktop +++ b/branding/themes/samios-sddm-theme/metadata.desktop @@ -5,6 +5,5 @@ Author=SamiOS Copyright=(c) 2026 Sami Ahmed License=GPL-3.0 Type=sddm-theme -Version=0.2.0 Website=https://sami-ahmed.net Screenshot=background.png diff --git a/packaging/archiso/airootfs/etc/samios-version b/packaging/archiso/airootfs/etc/samios-version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/packaging/archiso/airootfs/etc/samios-version @@ -0,0 +1 @@ +0.1.0 diff --git a/packaging/archiso/airootfs/root/.bashrc b/packaging/archiso/airootfs/root/.bashrc index 78c5192..a093aa9 100644 --- a/packaging/archiso/airootfs/root/.bashrc +++ b/packaging/archiso/airootfs/root/.bashrc @@ -42,7 +42,11 @@ PS1='\[\e[1;34m\]▲ SamiOS\[\e[0m\] \[\e[32m\]\u@\h\[\e[0m\] \w \$ ' # Welcome message — printed once per interactive login shell # ============================================================ if shopt -q login_shell; then - SAMIOS_VER=$(cat /usr/share/samios/version 2>/dev/null | tail -1 || echo "0.1.0") + # Resolve version from the canonical source (single file under + # /etc/samios-version). The fallback "0.0.0-unknown" is documented + # in samios CLI's _lookup_version as the placeholder for missing + # canonical state. + SAMIOS_VER=$(cat /etc/samios-version 2>/dev/null || echo "0.0.0-unknown") echo "" echo -e " \033[1;34m ▲\033[0m \033[1;34mSamiOS\033[0m \033[0;37m${SAMIOS_VER}\033[0m" echo -e " \033[0;37m |\\\\ Arch-based Linux by Sami Ahmed\033[0m" diff --git a/packaging/archiso/airootfs/root/.zshrc b/packaging/archiso/airootfs/root/.zshrc index cad4d75..425352d 100644 --- a/packaging/archiso/airootfs/root/.zshrc +++ b/packaging/archiso/airootfs/root/.zshrc @@ -58,9 +58,13 @@ zstyle ':completion:*' menu select # ============================================================ if [[ -o login ]]; then echo "" - echo " \033[1;34m ▲\033[0m \033[1;34mSamiOS\033[0m \033[0;37m$(cat /usr/share/samios/version 2>/dev/null | tail -1 || echo '0.1.0')\033[0m" + # Resolve version from the canonical source at /etc/samios-version. + # The fallback "0.0.0-unknown" matches the samios CLI's documented + # placeholder for missing canonical state. + SAMIOS_VER=$(cat /etc/samios-version 2>/dev/null || echo "0.0.0-unknown") + echo " \033[1;34m ▲\033[0m \033[1;34mSamiOS\033[0m \033[0;37m${SAMIOS_VER}\033[0m" echo " \033[0;37m |\\\ Arch-based Linux by Sami Ahmed\033[0m" - echo " \033[0;37m | \\ https://sami-ahmed.net\033[0m" + echo " \033[0;37m | \ https://sami-ahmed.net\033[0m" echo "" echo " \033[0;37mCommands:\033[0m \033[1;msamios help\033[0m · \033[1;msamios status\033[0m · \033[1;msamios fonts\033[0m" echo "" diff --git a/packaging/archiso/airootfs/usr/local/bin/samios b/packaging/archiso/airootfs/usr/local/bin/samios index 214cc31..198ee09 100755 --- a/packaging/archiso/airootfs/usr/local/bin/samios +++ b/packaging/archiso/airootfs/usr/local/bin/samios @@ -3,7 +3,31 @@ set -e -VERSION="0.1.0" +# ── Canonical version source ──────────────────────────────────────────── +# VERSION lives in a single file at the repo root (./VERSION) and is +# mirrored into the live system at /etc/samios-version by profiledef.sh. +# Lookup walks up from this script so it works both in dev and on a +# running SamiOS install. Hard fallback is for boot-time / chroot use +# before the file has been installed. +_lookup_version() { + local d + d="$(cd "$(dirname "$0")" && pwd)" + while [ "$d" != "/" ]; do + if [ -f "$d/VERSION" ]; then + head -n1 "$d/VERSION" + return 0 + fi + d="$(dirname "$d")" + done + if [ -r /etc/samios-version ]; then + head -n1 /etc/samios-version + return 0 + fi + echo "0.0.0-unknown" +} +VERSION="$(_lookup_version)" +unset -f _lookup_version + CONFIG_DIR="/etc/samios" LOG_FILE="/var/log/samios.log" diff --git a/packaging/archiso/profiledef.sh b/packaging/archiso/profiledef.sh index b825c27..72c3a42 100644 --- a/packaging/archiso/profiledef.sh +++ b/packaging/archiso/profiledef.sh @@ -4,11 +4,28 @@ # SamiOS archiso profile definition # Minimal CLI-based Arch Linux with SamiOS branding +# ── Canonical version source ──────────────────────────────────────────── +# Same walk-up lookup the installer and CLI use, so the ISO label and +# version always agree with what samios version prints inside the live +# system. +_iso_version="" +_d="$(cd "$(dirname "$0")" && pwd)" +while [ "$_d" != "/" ]; do + if [ -f "$_d/VERSION" ]; then + _iso_version="$(head -n1 "$_d/VERSION")" + break + fi + _d="$(dirname "$_d")" +done +[ -z "$_iso_version" ] && _iso_version="0.0.0-unknown" +unset _d + iso_name="samios" iso_label="SAMIOS_$(date +%Y%m)" iso_publisher="Sami Ahmed " iso_application="SamiOS Live/Rescue CD" -iso_version="0.1.0" +iso_version="${_iso_version}" +unset _iso_version install_dir="arch" buildmodes=('iso') bootmodes=('bios.syslinux.mbr' 'bios.syslinux.eltorito' 'uefi-x64.grub.esp' 'uefi-x64.grub.eltorito') @@ -27,4 +44,5 @@ file_permissions=( ["/root/.gnupg"]="0:0:700" ["/usr/local/bin/choose-mirror"]="0:0:755" ["/usr/local/bin/samios"]="0:0:755" + ["/etc/samios-version"]="0:0:644" ) diff --git a/packaging/packages/samios-branding/PKGBUILD b/packaging/packages/samios-branding/PKGBUILD index 6c8e4ec..aba34ff 100644 --- a/packaging/packages/samios-branding/PKGBUILD +++ b/packaging/packages/samios-branding/PKGBUILD @@ -5,9 +5,50 @@ # then run `makepkg -f` from within it. Alternatively run the build helper # packaging/packages/samios-branding/fetch-assets.sh to pull them in from # the repo branding/ tree. +# +# ── Single-source-of-truth (SSOT) for VERSION ────────────────────────────── +# pkgver is sourced from ../../../VERSION (the single source of truth +# shared with the samios CLI, installer, profiledef, voice bridge, and +# desktop-setup). Arch PKGBUILD `pkgver` cannot contain hyphens +# (makepkg rejects them), so if the canonical VERSION has a prerelease +# suffix like "0.1.0-rc1", we rewrite the hyphen to an underscore +# ("0.1.0_rc1") for the Arch package version. The +# /usr/share/samios/version display file always uses the canonical +# string (with hyphen if present) for human reading. +# +# There is no SAMIOS_PKGVER_OVERRIDE escape hatch — the SSOT invariant +# is absolute. Downstream packagers who need a variant must edit +# ../../../VERSION (which is one file change in one place), then run +# `make version-sync && makepkg`. This is intentional: any build that +# diverges from VERSION must show up in git history at the canonical +# location, not as an undocumented environment override. pkgname=samios-branding -pkgver=0.1.0 +# Resolve pkgver from ../../../VERSION (single source of truth). Arch +# PKGBUILD `pkgver` cannot contain hyphens (makepkg rejects them), so if +# the canonical VERSION has a prerelease suffix like "0.1.0-rc1", we +# rewrite the hyphen to an underscore ("0.1.0_rc1") for the Arch +# package version. The /usr/share/samios/version display file always +# uses the canonical string (with hyphen if present) for human reading. +# +# Use $startdir so the resolution path is stable when makepkg runs from +# $srcdir after unpacking the source tarball. +# +# If the canonical VERSION cannot be located, FAIL LOUDLY rather than +# silently emitting an "unknown" release package. The SSOT invariant is +# absolute — building a release without VERSION is a build error. +_samios_ver_path="$startdir/../../../VERSION" +if [ ! -r "$_samios_ver_path" ]; then + _samios_ver_path="../../../VERSION" +fi +if [ ! -r "$_samios_ver_path" ]; then + echo "FATAL: canonical VERSION file not found at \$startdir/../../../VERSION or ../../../VERSION" >&2 + echo " SSOT invariant violated — refusing to build an unknown-version package" >&2 + exit 1 +fi +_samios_ver="$(head -n1 "$_samios_ver_path")" +pkgver="${_samios_ver//-/_}" +unset _samios_ver _samios_ver_path pkgrel=1 pkgdesc="SamiOS branding assets — logos, icons, GRUB theme, and default configuration" arch=('any') @@ -43,6 +84,30 @@ sha256sums=( ) package() { + # Re-derive the canonical VERSION display string inside package() so + # the installed /usr/share/samios/version matches what `samios + # version` would print on a live system. (The top-level $pkgver has + # been transformed to an Arch-valid pkgver; we want the original.) + # + # In makepkg's context, $startdir is the PKGBUILD directory and is + # stable across the unpack/build cycle. Fall back to the relative + # path for direct-sourcing (CI / test) contexts where $startdir is + # unset. + # + # If we cannot find VERSION here, the top-level pkgver resolution + # would have already failed and exited 1 — so reaching this function + # implies VERSION exists. But we still guard against edge cases. + _samios_ver_display_path="$startdir/../../../VERSION" + if [ ! -r "$_samios_ver_display_path" ]; then + _samios_ver_display_path="../../../VERSION" + fi + if [ ! -r "$_samios_ver_display_path" ]; then + echo "FATAL: canonical VERSION not found in package()" >&2 + exit 1 + fi + _samios_ver_display="$(head -n1 "$_samios_ver_display_path")" + unset _samios_ver_display_path + # === Directory structure === install -d "${pkgdir}/usr/share/samios" install -d "${pkgdir}/usr/share/samios/logos" @@ -82,14 +147,18 @@ package() { install -m644 "${srcdir}/samios-icon.svg" "${pkgdir}/usr/share/samios/icons/samios-icon.svg" # === Branding metadata === + # Display file uses the canonical VERSION verbatim (with hyphen if + # any). The Arch package version is in $pkgver (hyphen rewritten to + # underscore), and we want users reading /usr/share/samios/version + # to see the original string, not the package version. cat > "${pkgdir}/usr/share/samios/version" < "${pkgdir}/usr/share/samios/branding.json" < "${pkgdir}/etc/samios/samios.conf" </dev/null || true fi -# Version file -echo "SamiOS 0.2.0 (WSL Desktop)" > /usr/share/samios/version + # Version file — generate the display string from the canonical VERSION. + # Resolution order: walk-up from script dir to ./VERSION, then + # /etc/samios-version (canonical mirror installed by the installer), + # then a documented placeholder. Same fallback chain as the samios + # CLI's _lookup_version so the two never disagree on what counts as + # "unresolved". + _SAMIOS_DESK_VERSION="" + _d="$(cd "$(dirname "$0")" && pwd)" + while [ "$_d" != "/" ]; do + if [ -f "$_d/VERSION" ]; then + _SAMIOS_DESK_VERSION="$(head -n1 "$_d/VERSION")" + break + fi + _d="$(dirname "$_d")" + done + if [ -z "$_SAMIOS_DESK_VERSION" ] && [ -r /etc/samios-version ]; then + _SAMIOS_DESK_VERSION="$(head -n1 /etc/samios-version)" + fi + [ -z "$_SAMIOS_DESK_VERSION" ] && _SAMIOS_DESK_VERSION="0.0.0-unknown" + unset _d + echo "SamiOS ${_SAMIOS_DESK_VERSION} (WSL Desktop)" > /usr/share/samios/version + unset _SAMIOS_DESK_VERSION # SDDM theme override mkdir -p /usr/share/sddm/themes/breeze @@ -382,8 +402,28 @@ echo "--- Phase 9: SamiOS CLI ---" cat > /usr/local/bin/samios << 'EOF' #!/bin/bash +# SamiOS CLI (WSL desktop variant) — installed by samios-desktop-setup.sh +# No embedded VERSION literal: walks up to /etc/samios-version at runtime. set -e -VERSION="0.2.0" + +_lookup_version() { + local d + d="$(cd "$(dirname "$0")" && pwd)" + while [ "$d" != "/" ]; do + if [ -f "$d/VERSION" ]; then + head -n1 "$d/VERSION" + return 0 + fi + d="$(dirname "$d")" + done + if [ -r /etc/samios-version ]; then + head -n1 /etc/samios-version + return 0 + fi + echo "0.0.0-unknown" +} +VERSION="$(_lookup_version)" +unset -f _lookup_version cmd_help() { cat << HELPEOF diff --git a/packaging/scripts/samios-installer.sh b/packaging/scripts/samios-installer.sh index 1dc7c1a..35e2d45 100644 --- a/packaging/scripts/samios-installer.sh +++ b/packaging/scripts/samios-installer.sh @@ -4,7 +4,31 @@ # Usage: samios install [options] set -e -VERSION="0.2.0" +# ── Canonical version source ──────────────────────────────────────────── +# Walk up to find ./VERSION, fall back to /etc/samios-version, fall back +# to a placeholder. This guarantees the installer and the CLI it emits +# agree on the version string without any duplicate hardcoding. +_VERSION_FILE="" +_d="$(cd "$(dirname "$0")" && pwd)" +while [ "$_d" != "/" ]; do + if [ -f "$_d/VERSION" ]; then + _VERSION_FILE="$_d/VERSION" + break + fi + _d="$(dirname "$_d")" +done +if [ -z "$_VERSION_FILE" ] && [ -r /etc/samios-version ]; then + VERSION="$(head -n1 /etc/samios-version)" +elif [ -n "$_VERSION_FILE" ]; then + VERSION="$(head -n1 "$_VERSION_FILE")" + # Remember the file so install_samios_cli can mirror it onto the new + # system at /etc/samios-version. + _EMIT_VERSION_FILE="$_VERSION_FILE" +else + VERSION="0.0.0-unknown" +fi +unset _d _VERSION_FILE + RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' @@ -258,15 +282,39 @@ create_user() { # ============================================================ # SamiOS CLI tool # ============================================================ +# shellcheck disable=SC2120 # MOUNT_POINT arrives as a global from earlier steps install_samios_cli() { log "Installing SamiOS CLI tool..." - - MOUNT_POINT="/mnt" + + # Tolerate being called with no args; default to /mnt as every other + # step in the installer does. shellcheck SC2120 sees no args because + # MOUNT_POINT is a global from earlier steps. + local MOUNT_POINT="${1:-${MOUNT_POINT:-/mnt}}" cat > "$MOUNT_POINT/usr/local/bin/samios" << 'SAMIOS_CLI' #!/bin/bash +# SamiOS CLI - installed by samios-installer.sh +# No embedded VERSION literal: walks up to /etc/samios-version at runtime. set -e -VERSION="0.2.0" + +_lookup_version() { + local d + d="$(cd "$(dirname "$0")" && pwd)" + while [ "$d" != "/" ]; do + if [ -f "$d/VERSION" ]; then + head -n1 "$d/VERSION" + return 0 + fi + d="$(dirname "$d")" + done + if [ -r /etc/samios-version ]; then + head -n1 /etc/samios-version + return 0 + fi + echo "0.0.0-unknown" +} +VERSION="$(_lookup_version)" +unset -f _lookup_version cmd_help() { cat << EOF @@ -340,6 +388,19 @@ esac SAMIOS_CLI chmod +x "$MOUNT_POINT/usr/local/bin/samios" + + # Mirror the canonical VERSION file so the installed CLI's walk-up + # lookup resolves /etc/samios-version as a stable, non-relative anchor. + # The heredoc above writes a CLI with NO embedded VERSION literal — + # it walks up at runtime and reads from this file. This is the + # single-source-of-truth invariant: the literal exists ONLY in + # /etc/samios-version on disk, never inside any script. + if [ -n "$_EMIT_VERSION_FILE" ] && [ -f "$_EMIT_VERSION_FILE" ]; then + cp "$_EMIT_VERSION_FILE" "$MOUNT_POINT/etc/samios-version" + else + printf '%s\n' "$VERSION" > "$MOUNT_POINT/etc/samios-version" + fi + chmod 644 "$MOUNT_POINT/etc/samios-version" # MOTD cat > "$MOUNT_POINT/etc/motd" << 'MOTD' diff --git a/packaging/scripts/voice-bridge.py b/packaging/scripts/voice-bridge.py index f36591a..98478d0 100644 --- a/packaging/scripts/voice-bridge.py +++ b/packaging/scripts/voice-bridge.py @@ -4,7 +4,7 @@ SamiOS Voice Command Bridge Listens for voice commands from SamiType, routes them to Hermes or dictation mode. Usage: - python3 voice-bridge.py + python3 voice-bridge.py [--version] Requires: - SamiType STT running in streaming mode (Unix socket or WebSocket) @@ -21,6 +21,33 @@ import time import threading from enum import Enum + +def _resolve_version() -> str: + """Walk up from this script looking for ./VERSION (single source of + truth shared with the samios CLI, installer, and profiledef.sh). + Falls back to /etc/samios-version, then a placeholder.""" + here = os.path.dirname(os.path.abspath(__file__)) + d = here + while d != "/": + candidate = os.path.join(d, "VERSION") + if os.path.isfile(candidate): + try: + with open(candidate) as f: + return f.readline().strip() + except OSError: + pass + d = os.path.dirname(d) + try: + with open("/etc/samios-version") as f: + return f.readline().strip() + except OSError: + return "0.0.0-unknown" + + +VERSION = _resolve_version() +del _resolve_version + + class VoiceMode(Enum): IDLE = "idle" COMMAND = "command" @@ -59,6 +86,7 @@ class VoiceBridge: def start(self): """Start the voice bridge""" self.running = True + print(f"[VoiceBridge] SamiOS v{VERSION}") print("[VoiceBridge] Ready. Say 'Computer' + command, or use hotkey.") # Connect to SamiType STT @@ -199,6 +227,9 @@ class VoiceBridge: return None def main(): + if "--version" in sys.argv or "-V" in sys.argv: + print(f"SamiOS voice-bridge v{VERSION}") + sys.exit(0) bridge = VoiceBridge() try: bridge.start() diff --git a/tests/run_tests.sh b/tests/run_tests.sh index a61ce02..717d4a7 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -24,6 +24,7 @@ TEST_FILES=( "test_pacman_conf.sh" "test_samios_cli.sh" "test_font_policy.sh" + "test_version.sh" ) TOTAL_RUN=0 diff --git a/tests/test_helper.sh b/tests/test_helper.sh index 8ef0354..cfec8be 100755 --- a/tests/test_helper.sh +++ b/tests/test_helper.sh @@ -2,7 +2,13 @@ # Common test helpers for SamiOS test suite # Sourced by individual test scripts -set -uo pipefail +# Note: deliberately no `set -u` here. Tests use variables in single-quoted +# bash -c subshells where shellcheck can see them but bash treats them as +# "set in a different scope". Adding -u causes spurious "unbound variable" +# errors when the subshell hasn't yet assigned a var the outer scope +# references (e.g. `assert "...$VAR..."`). `set -o pipefail` is enough to +# catch pipeline failures. +set -o pipefail # --------------------------------------------------------------------------- # Path setup — resolve repo root relative to this file @@ -11,6 +17,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROFILE_DIR="$REPO_ROOT/packaging/archiso" SCRIPTS_DIR="$REPO_ROOT/packaging/scripts" +TESTS_DIR="$REPO_ROOT/tests" + +# Export these so bash -c subshells (used by `assert ... bash -c '...'`) +# can reference them without re-derivation. test_version.sh relies on +# this for its git-clone mutation tests. +export REPO_ROOT PROFILE_DIR SCRIPTS_DIR TESTS_DIR # --------------------------------------------------------------------------- # Counters diff --git a/tests/test_version.sh b/tests/test_version.sh new file mode 100755 index 0000000..d8f5e4e --- /dev/null +++ b/tests/test_version.sh @@ -0,0 +1,805 @@ +#!/bin/bash +# Test: VERSION single-source-of-truth +# Locks the invariant: every version string in the repo must resolve to +# ./VERSION at runtime. No duplicate hardcoding allowed. +# +# Note: many assertions below reference variables (`cli_v`, `voice_v`, +# `mirror_v`, etc.) that are assigned inside single-quoted `bash -c` +# subshells. shellcheck cannot trace through the quoting layer, so it +# raises SC2154 warnings. These are intentional and the assertions DO +# verify the variables — see the test body. +# shellcheck disable=SC2154,SC2289,SC1011,SC1078,SC1083 + +source "$(dirname "$0")/test_helper.sh" + +suite "VERSION single-source-of-truth tests" + +VERSION_FILE="$REPO_ROOT/VERSION" +AIROOTFS_VERSION="$PROFILE_DIR/airootfs/etc/samios-version" +SAMIOS_CLI="$PROFILE_DIR/airootfs/usr/local/bin/samios" +INSTALLER="$SCRIPTS_DIR/samios-installer.sh" +PROFILEDEF="$PROFILE_DIR/profiledef.sh" +VOICE_BRIDGE="$SCRIPTS_DIR/voice-bridge.py" +PKGBUILD="$SCRIPTS_DIR/../packages/samios-branding/PKGBUILD" +DESKTOP_SETUP="$SCRIPTS_DIR/samios-desktop-setup.sh" +BASHRC="$PROFILE_DIR/airootfs/root/.bashrc" +ZSHRC="$PROFILE_DIR/airootfs/root/.zshrc" + +# ── Hardcoded-release-version detector ──────────────────────────────────── +# Scans a file for any X.Y.Z release-version literal, EXCLUDING: +# - lines starting with # (comments documenting SSOT design) +# - the documented fallback "0.0.0-unknown" string +# - test fixture strings like "9.9.9-test" and "0.2.0-rc1" which +# intentionally test version-set and prerelease flows +# The detector must catch ANY release-version literal, including: +# - version=1.2.3 (assignment) +# - "1.2.3" (string literal) +# - '1.2.3' (single-quoted string) +# - 1.2.3-foo (prerelease) +# - 1.2.3+bar (build metadata) +# - SamiOS 1.2.3 (display string) +# - VERSION=1.2.3 OR VERSION="1.2.3" (variable assignment) +detect_hardcoded_release() { + # detect_hardcoded_release + # Exits 0 (clean) if no release-version literal is found; exits 1 if + # one is found. + # + # Production files: only the documented "0.0.0-unknown" fallback is + # allowed (matches the placeholder our resolvers print when the + # canonical VERSION cannot be located). Any other X.Y.Z literal + # (including prerelease, build-meta, v-prefixed, etc.) is a violation. + # + # Test files (under tests/): the same rule applies EXCEPT that two + # additional test fixtures are tolerated (0.2.0-rc1 in test 13's + # prerelease assertion, and 9.9.9-test in test 12's version-set + # assertion). Production components must NEVER contain these strings. + # + # Anchored allowed-token matching: each allowed token is matched as a + # complete regex match with non-alphanumeric boundaries on both sides. + # This prevents `127.0.0.10` from being eaten as `127.0.0.1` followed + # by `0` — `127.0.0.1` only matches at positions where the character + # AFTER it is non-alphanumeric, so `127.0.0.10` keeps its `0` intact + # and the version literal `127.0.0.10` is correctly flagged. + local f="$1" + local version_pattern='(^|[^a-zA-Z0-9])(v?[0-9]+[.][0-9]+[.][0-9]+([-+][a-zA-Z0-9.-]+)*)([^a-zA-Z0-9]|$)' + # Anchored allowed-token regex: same boundary rules as version_pattern. + # The middle group is the allowed token itself. + local allowed_pattern + if [[ "$f" == */tests/test_version.sh ]]; then + # Test fixture: tolerate the two prerelease/version-set fixtures + # AND the documented fallback + localhost IPs. + allowed_pattern='(^|[^a-zA-Z0-9])(0[.]0[.]0-unknown|9[.]9[.]9-test|0[.]2[.]0-rc1|127[.]0[.]0[.]1|127[.]0[.]1[.]1)([^a-zA-Z0-9]|$)' + else + # Production: only the documented 0.0.0-unknown fallback + localhost IPs + allowed_pattern='(^|[^a-zA-Z0-9])(0[.]0[.]0-unknown|127[.]0[.]0[.]1|127[.]0[.]1[.]1)([^a-zA-Z0-9]|$)' + fi + + # Comment detection: a line is a comment if its first non-whitespace + # character is '#'. This handles shell, Python (after the leading + # whitespace), .desktop, and other line-oriented comment syntaxes. + local hits + # Strategy: find all version-pattern matches and allowed-pattern matches + # on each line. If a version-pattern match's character range does NOT + # fully overlap with an allowed-pattern match's range, it's a violation. + hits="$(grep -nE "$version_pattern" "$f" \ + | grep -vE "^[^:]+:[ \\t]*#" \ + | awk -F':' -v version_pat="$version_pattern" -v allowed_pat="$allowed_pattern" ' + BEGIN { IGNORECASE = 0 } + { + content = substr($0, length($1) + 2) + # Build a "covered" map: for each allowed match, mark its + # character positions as covered. Then check each version + # match: if its range is NOT entirely covered, flag it. + delete covered + pos = 1 + while (pos <= length(content) && match(substr(content, pos), allowed_pat)) { + s = pos + RSTART - 1 + e = pos + RSTART + RLENGTH - 2 + for (i = s; i <= e; i++) covered[i] = 1 + # Advance past this match to find the next allowed match + pos = e + 1 + } + # Now scan content for version matches and report any + # that are not fully covered. + vpos = 1 + while (vpos <= length(content) && match(substr(content, vpos), version_pat)) { + vs = vpos + RSTART - 1 + ve = vpos + RSTART + RLENGTH - 2 + covered_full = 1 + for (i = vs; i <= ve; i++) if (!covered[i]) { covered_full = 0; break } + if (!covered_full) { print $0; break } + vpos = ve + 1 + } + } + ')" + + if [ -n "$hits" ]; then + echo "HARD-CODED RELEASE VERSIONS FOUND in $f:" >&2 + echo "$hits" >&2 + return 1 + fi + return 0 +} +# Export the function so it can be called from `bash -c '...'` subshells +export -f detect_hardcoded_release + +# ── Repository-wide SSOT scan ──────────────────────────────────────────── +# Every production file in the repo must pass detect_hardcoded_release. +# "Production" excludes tests/ (where we tolerate fixtures) and the +# canonical VERSION + airootfs mirror (which by definition contain +# the literal). If you add a new production file with a hardcoded +# version, it will appear here. +assert "every production file passes detect_hardcoded_release" \ + bash -c ' + violations=0 + # Scan all production files. We exclude by EXACT canonical paths + # (not substring patterns), so a nested or unrelated file named + # VERSION cannot accidentally bypass SSOT enforcement. + while IFS= read -r -d "" f; do + case "$f" in + */.git/*|*/tests/*) continue ;; + esac + # Compare against the exact canonical paths (resolved). + _resolved="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" + _root_version="$(cd "$REPO_ROOT" && pwd)/VERSION" + _root_mirror="$(cd "$REPO_ROOT" && pwd)/packaging/archiso/airootfs/etc/samios-version" + if [ "$_resolved" = "$_root_version" ] || [ "$_resolved" = "$_root_mirror" ]; then + continue + fi + if ! detect_hardcoded_release "$f"; then + violations=$((violations + 1)) + fi + done < <(find "$REPO_ROOT" -type f -not -path "*/.git/*" -not -path "*/tests/*" -print0) + if [ "$violations" -gt 0 ]; then + echo "FAIL: $violations production files contain hardcoded version literals" + exit 1 + fi + ' + +# ── Regression test: 0.2.0-rc1 is REJECTED in production components ────── +# The prerelease string "0.2.0-rc1" is allowed ONLY in test_version.sh as +# a test fixture. If it appears in any production component, the SSOT +# invariant is violated. +assert "regression: 0.2.0-rc1 is REJECTED in production components" \ + bash -c ' + # Find any production file containing 0.2.0-rc1 outside of test_version.sh + while IFS= read -r -d "" f; do + case "$f" in + */.git/*|*/tests/*) continue ;; + esac + if grep -q "0\.2\.0-rc1" "$f" 2>/dev/null; then + echo "FAIL: $f contains 0.2.0-rc1 in production" + exit 1 + fi + done < <(find "$REPO_ROOT" -type f -not -path "*/.git/*" -not -path "*/tests/*" -print0) + ' + +# ── Regression test: 9.9.9-test is REJECTED in production components ───── +# Same as above but for the version-set test fixture. +assert "regression: 9.9.9-test is REJECTED in production components" \ + bash -c ' + while IFS= read -r -d "" f; do + case "$f" in + */.git/*|*/tests/*) continue ;; + esac + if grep -q "9\.9\.9-test" "$f" 2>/dev/null; then + echo "FAIL: $f contains 9.9.9-test in production" + exit 1 + fi + done < <(find "$REPO_ROOT" -type f -not -path "*/.git/*" -not -path "*/tests/*" -print0) + ' + +# ── Detector self-tests ────────────────────────────────────────────────── +# The detector is a non-trivial regex; these tests verify it catches every +# form we claim and does not raise false positives. The tests construct +# synthetic files in $TMPDIR, run detect_hardcoded_release against them, +# and check the result. Each form is a separate assertion so a regression +# points at exactly which form broke. + +assert "detector self-test: catches double-quoted X.Y.Z literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo \"1.2.3\"" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed \"1.2.3\"" >&2; exit 1 + ' + +assert "detector self-test: catches single-quoted X.Y.Z literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo '"'"'1.2.3'"'"'" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed '"'"'1.2.3'"'"'" >&2; exit 1 + ' + +assert "detector self-test: catches bare X.Y.Z literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 1.2.3" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed bare 1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches prerelease X.Y.Z-foo literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 1.2.3-rc1" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed 1.2.3-rc1" >&2; exit 1 + ' + +assert "detector self-test: catches build-metadata X.Y.Z+bar literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 1.2.3+build.5" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed 1.2.3+build.5" >&2; exit 1 + ' + +assert "detector self-test: catches SamiOS X.Y.Z display string" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo \"SamiOS 1.2.3 (WSL Desktop)\"" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed SamiOS 1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches VERSION=1.2.3 assignment" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "VERSION=\"1.2.3\"" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed VERSION=1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches v-prefixed v1.2.3" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo v1.2.3" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed v1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches X.Y.Z in path (/opt/1.2.3/bin)" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "PATH=/opt/1.2.3/bin" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed path-embedded 1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches X.Y.Z in colon-delimited value" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "key=val:1.2.3:rest" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed colon-delimited 1.2.3" >&2; exit 1 + ' + +assert "detector self-test: catches X.Y.Z in brackets [1.2.3]" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "x=[1.2.3]" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed bracketed 1.2.3" >&2; exit 1 + ' + +assert "detector self-test: ALLOWS 0.0.0-unknown fallback" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "VERSION=0.0.0-unknown" > "$tmpf" + detect_hardcoded_release "$tmpf" && exit 0 + echo "FAILED: detector flagged documented fallback 0.0.0-unknown" >&2; exit 1 + ' + +assert "detector self-test: ALLOWS comments (lines starting with #)" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "# This is a comment with 1.2.3 in it" > "$tmpf" + detect_hardcoded_release "$tmpf" && exit 0 + echo "FAILED: detector flagged commented-out version" >&2; exit 1 + ' + +assert "detector self-test: ALLOWS localhost IP (127.0.0.1)" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "127.0.0.1 localhost" > "$tmpf" + detect_hardcoded_release "$tmpf" && exit 0 + echo "FAILED: detector flagged localhost IP" >&2; exit 1 + ' + +assert "detector self-test: catches MIXED allowed+forbidden on same line" \ + bash -c ' + # A line that contains BOTH the allowed fallback and a forbidden + # release-version literal must be flagged. This catches the bug + # where `grep -v '\''0.0.0-unknown'\''` would suppress the entire line + # and miss the forbidden literal. + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 0.0.0-unknown 1.2.3" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed mixed allowed/forbidden line" >&2; exit 1 + ' + +assert "detector self-test: ALLOWS indented # comments with version literal" \ + bash -c ' + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + cat > "$tmpf" << '"'"'EOF'"'"' +# Top-level comment with 1.2.3 + # Indented comment with 1.2.4 +echo real_code +EOF + detect_hardcoded_release "$tmpf" && exit 0 + echo "FAILED: detector flagged commented version literals" >&2; exit 1 + ' + +assert "detector self-test: catches 127.0.0.10 (substring of allowed IP)" \ + bash -c ' + # 127.0.0.10 contains 127.0.0.1 as a substring. A naive substring + # filter would erase `127.0.0.1` and leave `0` (which does NOT + # match X.Y.Z), letting the real release-version literal + # `127.0.0.10` slip through. Our anchored detector must catch it. + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 127.0.0.10" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed 127.0.0.10" >&2; exit 1 + ' + +assert "detector self-test: catches 0.0.0-unknown+build (suffix of fallback)" \ + bash -c ' + # 0.0.0-unknown+build contains 0.0.0-unknown as a substring. A naive + # filter would erase `0.0.0-unknown` and leave `+build` (which + # does NOT match X.Y.Z), letting the build-metadata version literal + # `0.0.0-unknown+build` slip through. + tmpf="$(mktemp)" + trap "rm -f '\''$tmpf'\''" EXIT + echo "echo 0.0.0-unknown+build" > "$tmpf" + detect_hardcoded_release "$tmpf" || exit 0 + echo "FAILED: detector missed 0.0.0-unknown+build" >&2; exit 1 + ' + +# ── 1. Canonical file exists and is a valid semver-ish string ────────────── +assert_file_exists "VERSION file exists at repo root" "$VERSION_FILE" + +assert "VERSION is non-empty single-line" \ + bash -c ' + [ "$(wc -l < "'"$VERSION_FILE"'")" -eq 1 ] && \ + [ -n "$(cat "'"$VERSION_FILE"'")" ] + ' + +assert "VERSION matches semver-ish pattern (X.Y.Z optional suffix)" \ + bash -c ' + grep -qE "^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$" "'"$VERSION_FILE"'" + ' + +# ── 2. airootfs mirror is byte-identical to root VERSION ─────────────────── +assert_file_exists "airootfs mirror /etc/samios-version exists" "$AIROOTFS_VERSION" + +assert "airootfs VERSION mirror matches repo root VERSION" \ + bash -c ' + diff -q "'"$VERSION_FILE"'" "'"$AIROOTFS_VERSION"'" >/dev/null + ' + +# ── 3. samios CLI must NOT hardcode any release-version literal ────────── +assert_file_exists "samios CLI exists" "$SAMIOS_CLI" + +assert "samios CLI has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$SAMIOS_CLI"'"' + +assert "samios CLI references ./VERSION walk-up lookup" \ + bash -c ' + grep -q "/VERSION" "'"$SAMIOS_CLI"'" && \ + grep -q "_lookup_version" "'"$SAMIOS_CLI"'" + ' + +assert "samios CLI prints repo VERSION when run from repo root" \ + bash -c ' + cd "'"$REPO_ROOT"'" && \ + expected="$(cat VERSION)" && \ + actual="$(bash "'"$SAMIOS_CLI"'" version | sed -E "s/^SamiOS v//" | head -n1)" && \ + [ "$actual" = "$expected" ] + ' + +# ── 4. Installer must NOT hardcode any release-version literal ─────────── +assert_file_exists "samios-installer.sh exists" "$INSTALLER" + +assert "installer has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$INSTALLER"'"' + +assert "installer references ./VERSION walk-up lookup" \ + bash -c ' + grep -q "_VERSION_FILE\|/VERSION" "'"$INSTALLER"'" + ' + +# Verify the installer's actual heredoc-emitted CLI has the _lookup_version +# function — not a substituted $VERSION literal. +assert "installer heredoc emits CLI with _lookup_version (no literal)" \ + bash -c ' + # The installer MUST NOT substitute $VERSION into a literal at + # the top of the emitted CLI. We extract the heredoc body (between + # the << SAMIOS_CLI marker line and the closing SAMIOS_CLI) and + # check for the forbidden pattern. + heredoc_open="$(grep -n "<< .*SAMIOS_CLI" "'"$INSTALLER"'" | head -1 | cut -d: -f1)" + heredoc_close="$(awk -v open_line="$heredoc_open" "NR>open_line && /^SAMIOS_CLI\$/{print NR; exit}" "'"$INSTALLER"'")" + if [ -z "$heredoc_open" ] || [ -z "$heredoc_close" ]; then + echo "could not locate SAMIOS_CLI heredoc in installer (open=$heredoc_open close=$heredoc_close)" + exit 1 + fi + heredoc_body="$(sed -n "${heredoc_open},${heredoc_close}p" "'"$INSTALLER"'")" + # Forbidden: a literal VERSION="${VERSION}" (substituted from outer scope) + if echo "$heredoc_body" | grep -qE "^VERSION=\"\\\$\{VERSION\}\""; then + echo "installer emits VERSION=\"\${VERSION}\" literal at top of heredoc" + exit 1 + fi + # Required: the heredoc body must reference _lookup_version so + # the installed CLI uses the walk-up pattern. + if ! echo "$heredoc_body" | grep -q "_lookup_version"; then + echo "installer heredoc does not reference _lookup_version" + exit 1 + fi + ' + +# ── 5. profiledef.sh must NOT hardcode any release-version literal ──────── +assert_file_exists "profiledef.sh exists" "$PROFILEDEF" + +assert "profiledef.sh has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$PROFILEDEF"'"' + +assert "profiledef.sh references ./VERSION walk-up lookup" \ + bash -c ' + grep -q "_iso_version\|/VERSION" "'"$PROFILEDEF"'" + ' + +# ── 6. voice-bridge.py must read from ./VERSION ──────────────────────────── +assert_file_exists "voice-bridge.py exists" "$VOICE_BRIDGE" + +assert "voice-bridge.py parses as valid Python" \ + bash -c ' + python3 -c "import ast; ast.parse(open(\"'"$VOICE_BRIDGE"'\").read())" + ' + +assert "voice-bridge.py has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$VOICE_BRIDGE"'"' + +assert "voice-bridge.py resolves VERSION from repo root" \ + bash -c ' + cd "'"$REPO_ROOT"'" && \ + expected="$(cat VERSION)" && \ + actual="$(python3 "'"$VOICE_BRIDGE"'" --version | sed -E "s/^SamiOS voice-bridge v//")" && \ + [ "$actual" = "$expected" ] + ' + +# ── 7. PKGBUILD must NOT hardcode any release-version literal ──────────── +assert_file_exists "PKGBUILD exists" "$PKGBUILD" + +assert "PKGBUILD has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$PKGBUILD"'"' + +assert "PKGBUILD references ../../../VERSION" \ + bash -c ' + grep -q "../../../VERSION" "'"$PKGBUILD"'" + ' + +# ── 8. desktop-setup.sh must NOT hardcode any release-version literal ────── +assert_file_exists "samios-desktop-setup.sh exists" "$DESKTOP_SETUP" + +assert "desktop-setup.sh has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$DESKTOP_SETUP"'"' + +# ── 9. .bashrc / .zshrc must NOT hardcode any release-version literal ──── +assert ".bashrc has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$BASHRC"'"' + +assert ".zshrc has no hardcoded release-version literal" \ + bash -c 'detect_hardcoded_release "'"$ZSHRC"'"' + +# ── 10. Cross-system invariant: CLI, installer, profiledef, voice-bridge ── +# Test the actual code paths in each file. For the installer and profiledef, +# we extract their version-resolution blocks from the real files (not +# reimplementations) into temp helpers, then rewrite the $0-relative path +# to point at the actual file's directory so the walk-up starts in the +# right place. +PROFILEDEF_RESOLVER="$(mktemp)" +PROFILEDEF_DIR="$(dirname "$PROFILEDEF")" +{ + sed -n '/^# ── Canonical version source ───/,/^unset _d$/p' \ + "$PROFILEDEF" + echo 'echo "$_iso_version"' +} > "$PROFILEDEF_RESOLVER" +sed -i "s|cd \"\\\$(dirname \"\\\$0\")\"|cd \"$PROFILEDEF_DIR\"|g" "$PROFILEDEF_RESOLVER" +chmod +x "$PROFILEDEF_RESOLVER" + +# Extract the installer's version-resolution block (top of file) into a +# real shell script we can run. The block uses $0 to find its own +# directory; when extracted standalone, $0 becomes the helper's path +# (under /tmp), which isn't where VERSION lives. We rewrite the $0 +# lookup to start from the installer's actual directory. +INSTALLER_DIR="$(dirname "$INSTALLER")" +INSTALLER_RESOLVER="$(mktemp)" +{ + sed -n '/^# ── Canonical version source ───/,/^unset _d _VERSION_FILE/p' \ + "$INSTALLER" + echo 'echo "$VERSION"' +} > "$INSTALLER_RESOLVER" +# Rewrite $0 references inside the extracted block so they resolve from +# the installer's actual directory, not from /tmp/codex-judge-XXXXXX. +sed -i "s|cd \"\\\$(dirname \"\\\$0\")\"|cd \"$INSTALLER_DIR\"|g" "$INSTALLER_RESOLVER" +chmod +x "$INSTALLER_RESOLVER" + +assert "all four components agree on VERSION (tested from real files)" \ + bash -c ' + cd "'"$REPO_ROOT"'" || exit 1 + expected="$(cat VERSION)" + cli_v="$(bash "'"$SAMIOS_CLI"'" version | head -n1 | sed -E "s/^SamiOS v//")" + voice_v="$(python3 "'"$VOICE_BRIDGE"'" --version | sed -E "s/^SamiOS voice-bridge v//")" + inst_v="$("'"$INSTALLER_RESOLVER"'" | tail -n1)" + prof_v="$("'"$PROFILEDEF_RESOLVER"'")" + if [ "$cli_v" != "$expected" ]; then + echo "CLI=$cli_v expected=$expected"; exit 1 + fi + if [ "$voice_v" != "$expected" ]; then + echo "voice=$voice_v expected=$expected"; exit 1 + fi + if [ "$inst_v" != "$expected" ]; then + echo "installer=$inst_v expected=$expected"; exit 1 + fi + if [ "$prof_v" != "$expected" ]; then + echo "profiledef=$prof_v expected=$expected"; exit 1 + fi + ' +rm -f "$PROFILEDEF_RESOLVER" "$INSTALLER_RESOLVER" + +# ── 11. PKGBUILD resolves pkgver to the canonical VERSION ────────────────── +# Source the PKGBUILD in a subshell (PKGBUILD is bash-runnable) and capture +# the resulting pkgver. This proves the conditional `pkgver=` block in the +# PKGBUILD evaluates to the canonical VERSION. +assert "PKGBUILD pkgver resolves to VERSION (sourced)" \ + bash -c ' + cd "'"$REPO_ROOT"'"/packaging/packages/samios-branding && \ + expected="$(head -n1 ../../../VERSION)" && \ + actual="$(set -e; source PKGBUILD >/dev/null 2>&1 && echo "$pkgver")" && \ + [ "$actual" = "$expected" ] || { echo "actual=$actual expected=$expected"; exit 1; } + ' + +# ── 11b. PKGBUILD SSOT is absolute (no SAMIOS_PKGVER_OVERRIDE escape) ───── +# The previous PKGBUILD had a SAMIOS_PKGVER_OVERRIDE env var that allowed +# pkgver to differ from VERSION. That violated the SSOT invariant. Verify +# the override is removed so release builds cannot diverge. +assert "PKGBUILD has no SAMIOS_PKGVER_OVERRIDE escape hatch (SSOT is absolute)" \ + bash -c ' + # Look for SAMIOS_PKGVER_OVERRIDE outside of comments (lines + # starting with #). Comments can mention the variable for + # documentation purposes; only executable code that references + # it would violate SSOT. + if grep -n "SAMIOS_PKGVER_OVERRIDE" "'"$PKGBUILD"'" | grep -vE "^[0-9]+:#"; then + echo "PKGBUILD still references SAMIOS_PKGVER_OVERRIDE outside a comment — SSOT invariant violated" + grep -n "SAMIOS_PKGVER_OVERRIDE" "'"$PKGBUILD"'" | grep -vE "^[0-9]+:#" + exit 1 + fi + # Also verify the override does not actually bypass VERSION, in + # case some future shell magic reintroduces the override. + cd "'"$REPO_ROOT"'"/packaging/packages/samios-branding && \ + expected="$(head -n1 ../../../VERSION)" && \ + actual="$(SAMIOS_PKGVER_OVERRIDE=99.99.99-bogus bash -c "set -e; source PKGBUILD >/dev/null 2>&1; echo \$pkgver")" && \ + [ "$actual" = "$expected" ] || { echo "override leaked: actual=$actual expected=$expected"; exit 1; } + ' + +assert "regression: hash-based diff catches deletions (not just additions)" \ + bash -c ' + # The version-set test (assertion above) counts only the right + # side of the diff. A pure deletion produces only a left-side + # line. Verify the diff-based detection we use catches BOTH + # sides: synthesize a fake before/after where a file is deleted + # and a different file is added, then count unique filenames. + diff_file="$(mktemp)" + trap "rm -f '\''$diff_file'\''" EXIT + cat > "$diff_file" << '"'"'DIFF_EOF'"'"' +< d84523f8671da4c8b99f543127715bb8c562fafe8675016112c6d260d14e761b ./some-deleted-file.txt +> 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ./some-new-file.txt +DIFF_EOF + count="$(awk '"'"'/^[<>] / {print $NF}'"'"' "$diff_file" | sort -u | wc -l)" + if [ "$count" -ne 2 ]; then + echo "FAIL: expected 2 unique filenames (one deleted + one added), got $count" + exit 1 + fi + ' + +# ── 12. End-to-end: version-set mutates only VERSION + mirror ───────────── +assert "version-set mutates only VERSION + airootfs mirror (no other files)" \ + bash -c ' + clone="$(mktemp -d)" + diff_file="$(mktemp)" + # Cleanup trap uses $clone and $diff_file from the surrounding scope + trap "rm -rf ${clone:?} ${diff_file:?}" EXIT + # Copy with .git so any pre-existing modifications are visible as + # baseline (so we can detect whether version-set changed anything + # by content, not by index state). + cp -a "$REPO_ROOT" "$clone/repo" + cd "$clone/repo" || exit 1 + orig_version="$(cat VERSION)" + orig_mirror="$(head -n1 packaging/archiso/airootfs/etc/samios-version)" + # Snapshot the content hash of every file in the repo (excluding + # .git) BEFORE the mutation. We use this to detect any file change, + # not git status which can be misleading for already-modified files. + before_hashes="$(find . -path ./.git -prune -o -type f -print0 2>/dev/null \ + | xargs -0 sha256sum 2>/dev/null | sort)" + # `make version-set` must SUCCEED. If it fails (e.g. bad NEW value, + # missing make), the test fails — not silently passes. + if ! make version-set NEW=9.9.9-test >/dev/null 2>&1; then + echo "make version-set NEW=9.9.9-test failed unexpectedly" + exit 1 + fi + # VERSION must actually have changed. + if [ "$(cat VERSION)" != "9.9.9-test" ]; then + echo "VERSION did not change to 9.9.9-test" + exit 1 + fi + # Mirror must actually have changed. + if [ "$(head -n1 packaging/archiso/airootfs/etc/samios-version)" != "9.9.9-test" ]; then + echo "airootfs mirror did not sync to 9.9.9-test" + exit 1 + fi + # Snapshot hashes AFTER the mutation. + after_hashes="$(find . -path ./.git -prune -o -type f -print0 2>/dev/null \ + | xargs -0 sha256sum 2>/dev/null | sort)" + # Diff before vs after. Count BOTH sides of the diff (a deletion + # produces only a left-side line; an addition produces only a + # right-side line; a modification produces one of each). Use the + # parsed filename-to-hash maps to count unique files that + # changed in either direction, not just added/right-side lines. + diff <(echo "$before_hashes") <(echo "$after_hashes") > "$diff_file" + # Extract the set of filenames that appear on either side of the + # diff. `awk` gets us the distinct filenames regardless of side. + changed_count="$(awk "/^[<>] / {print \$NF}" "$diff_file" | sort -u | wc -l)" + if [ "$changed_count" -ne 2 ]; then + echo "expected exactly 2 files to change (VERSION + mirror), got $changed_count:" + awk "/^[<>] / {print \$0}" "$diff_file" + exit 1 + fi + # Verify the two changed files are VERSION and the mirror (not + # some other files). The diff format is " "; + # match on the filename suffix. + unexpected="$(awk "/^[<>] / {print \$NF}" "$diff_file" | sort -u)" + unexpected="$(echo "$unexpected" | grep -vE "VERSION$|packaging/archiso/airootfs/etc/samios-version$")" + if [ -n "$unexpected" ]; then + echo "version-set changed unexpected files:" + echo "$unexpected" + exit 1 + fi + # Restore clone state — the developers checkout was never touched. + # We restore to the original VERSION we captured, not to a + # hardcoded value, so this test is safe to run at any version. + echo "$orig_version" > VERSION + if ! make version-sync >/dev/null 2>&1; then + echo "make version-sync failed during restore" + exit 1 + fi + # Verify restoration succeeded by snapshotting again and comparing + # to the original before-snapshot. The post-restore state must + # match the original content hash (no stray modifications). + restore_hashes="$(find . -path ./.git -prune -o -type f -print0 2>/dev/null \ + | xargs -0 sha256sum 2>/dev/null | sort)" + if [ "$before_hashes" != "$restore_hashes" ]; then + echo "restore failed — clone does not match original" + diff <(echo "$before_hashes") <(echo "$restore_hashes") | head -10 + exit 1 + fi + # Sanity: the values we saved at the top must be the canonical + # values, not arbitrary ones. (We already know VERSION=0.1.0 from + # earlier tests, but this guards against the test running against + # an unusual repo state.) + if [ -z "$orig_version" ] || [ -z "$orig_mirror" ]; then + echo "could not read original VERSION or mirror; bail" + exit 1 + fi + ' + +# ── 13. Prerelease VERSION works through the whole stack ───────────────── +# Mutate VERSION to a prerelease and verify ALL resolvers see it correctly: +# CLI, voice-bridge, airootfs mirror, installer (resolves through its own +# walk-up), profiledef (resolves through its own walk-up), desktop-setup +# (resolves through its own walk-up), and PKGBUILD (transforms to underscore). +# +# Use a cleanup trap and preserve the original VERSION rather than +# hardcoding 0.1.0, so this test is safe to run at any version. +assert "prerelease VERSION (e.g. 0.2.0-rc1) flows through all 7 components" \ + bash -c ' + clone="$(mktemp -d)" + trap "rm -rf '\''$clone'\''" EXIT + cp -a "$REPO_ROOT" "$clone/repo" + cd "$clone/repo" || exit 1 + orig_version="$(cat VERSION)" + echo "0.2.0-rc1" > VERSION + if ! make version-sync >/dev/null 2>&1; then + echo "make version-sync failed"; exit 1 + fi + cli_v="$(bash packaging/archiso/airootfs/usr/local/bin/samios version | head -n1 | sed -E "s/^SamiOS v//")" + voice_v="$(python3 packaging/scripts/voice-bridge.py --version | sed -E "s/^SamiOS voice-bridge v//")" + mirror_v="$(head -n1 packaging/archiso/airootfs/etc/samios-version)" + # Installer resolves through its own walk-up block at the top of the file + installer_resolver="$(mktemp)" + sed -n "/^# ── Canonical version source ───/,/^unset _d _VERSION_FILE/p" \ + packaging/scripts/samios-installer.sh | sed "s|cd \"\\\$(dirname \"\\\$0\")\"|cd \"$(pwd)/packaging/scripts\"|g" \ + > "$installer_resolver" + printf "echo \"\$VERSION\"\n" >> "$installer_resolver" + chmod +x "$installer_resolver" + installer_v="$("$installer_resolver" | tail -n1)" + rm -f "$installer_resolver" + # Profiledef resolves through its own walk-up block + profiledef_resolver="$(mktemp)" + sed -n "/^# ── Canonical version source ───/,/^unset _d$/p" \ + packaging/archiso/profiledef.sh | sed "s|cd \"\\\$(dirname \"\\\$0\")\"|cd \"$(pwd)/packaging/archiso\"|g" \ + > "$profiledef_resolver" + printf "echo \"\$_iso_version\"\n" >> "$profiledef_resolver" + chmod +x "$profiledef_resolver" + profiledef_v="$("$profiledef_resolver" | tail -n1)" + rm -f "$profiledef_resolver" + # Desktop-setup resolves through its own walk-up block. Extract + # the resolution block up to (but not including) the + # echo-write line — that line writes to a system path we cant + # touch in a test. + desktop_start="$(grep -n "# Version file — generate" packaging/scripts/samios-desktop-setup.sh | head -1 | cut -d: -f1)" + desktop_end_pre="$(grep -n "echo \"SamiOS.*> /usr/share/samios/version" packaging/scripts/samios-desktop-setup.sh | head -1 | cut -d: -f1)" + desktop_end=$((desktop_end_pre - 1)) + desktop_resolver="$(mktemp)" + if [ -z "$desktop_start" ] || [ -z "$desktop_end_pre" ]; then + echo "could not locate desktop-setup version block markers" + exit 1 + fi + sed -n "${desktop_start},${desktop_end}p" packaging/scripts/samios-desktop-setup.sh | \ + sed "s|cd \"\\\$(dirname \"\\\$0\")\"|cd \"$(pwd)/packaging/scripts\"|g" \ + > "$desktop_resolver" + printf "echo \"\$_SAMIOS_DESK_VERSION\"\n" >> "$desktop_resolver" + chmod +x "$desktop_resolver" + desktop_v="$("$desktop_resolver" | tail -n1)" + rm -f "$desktop_resolver" + # PKGBUILD transforms hyphen to underscore for Arch pkgver + pkgver_v="$(cd packaging/packages/samios-branding && set -e && source PKGBUILD >/dev/null 2>&1 && echo "${pkgver:-}")" + # Restore clone to its original VERSION (captured above) rather + # than hardcoding a value — safe at any current version. + echo "$orig_version" > VERSION + make version-sync >/dev/null 2>&1 + # Verify all seven agree (or transform as expected) + [ "$cli_v" = "0.2.0-rc1" ] || { echo "CLI=$cli_v"; exit 1; } + [ "$voice_v" = "0.2.0-rc1" ] || { echo "voice=$voice_v"; exit 1; } + [ "$mirror_v" = "0.2.0-rc1" ] || { echo "mirror=$mirror_v"; exit 1; } + [ "$installer_v" = "0.2.0-rc1" ] || { echo "installer=$installer_v"; exit 1; } + [ "$profiledef_v" = "0.2.0-rc1" ] || { echo "profiledef=$profiledef_v"; exit 1; } + [ "$desktop_v" = "0.2.0-rc1" ] || { echo "desktop=$desktop_v"; exit 1; } + [ "$pkgver_v" = "0.2.0_rc1" ] || { echo "pkgver=$pkgver_v expected=0.2.0_rc1"; exit 1; } + ' + +# ── 14. PKGBUILD transforms "0.X.Y-suffix" to "0.X.Y_suffix" for Arch ─── +# This duplicates test 13's PKGBUILD check but in isolation, so a failure +# here points directly at PKGBUILD (not at the broader end-to-end test). +assert "PKGBUILD transforms prerelease hyphen to underscore for Arch pkgver" \ + bash -c ' + clone="$(mktemp -d)" + trap "rm -rf ${clone:?}" EXIT + cp -a "$REPO_ROOT" "$clone/repo" + cd "$clone/repo/packaging/packages/samios-branding" || exit 1 + echo "0.2.0-rc1" > ../../../VERSION + # Source PKGBUILD and echo pkgver — no nested bash -c quoting needed. + pkgver_actual="$(set -e; source PKGBUILD >/dev/null 2>&1 && echo "$pkgver")" + cd / + rm -rf "$clone" + [ "$pkgver_actual" = "0.2.0_rc1" ] || { echo "pkgver=$pkgver_actual expected=0.2.0_rc1"; exit 1; } + ' + +print_summary