From cc31f6cb6797c27cb280e6854d7e30ec38e0e9da Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 11 Sep 2026 09:13:36 +0000 Subject: [PATCH] build: add reproducible release bundles --- cmd/rvbox-server/main.go | 10 ++++ cmd/rvbox/main.go | 7 +++ cmd/rvbox/main_test.go | 8 +++ cmd/rvc/main.go | 7 +++ cmd/rvc/main_test.go | 8 +++ docs/operations-runbook.md | 24 ++++++++ scripts/release | 116 +++++++++++++++++++++++++++++++++++++ 7 files changed, 180 insertions(+) create mode 100755 scripts/release diff --git a/cmd/rvbox-server/main.go b/cmd/rvbox-server/main.go index c76b4cb..4a9a8ab 100644 --- a/cmd/rvbox-server/main.go +++ b/cmd/rvbox-server/main.go @@ -26,12 +26,22 @@ import ( "google.golang.org/grpc" ) +// buildVersion is set by scripts/release for published artifacts. Development +// and test builds intentionally retain the explicit non-release value. +var buildVersion = "dev" + func main() { var configPath string var checkConfig bool + var version bool flag.StringVar(&configPath, "config", "", "absolute server TOML configuration path") flag.BoolVar(&checkConfig, "check-config", false, "validate server configuration and exit") + flag.BoolVar(&version, "version", false, "print build version and exit") flag.Parse() + if version { + fmt.Fprintln(os.Stdout, buildVersion) + return + } if configPath == "" { log.Print("rvbox-server: --config is required") os.Exit(2) diff --git a/cmd/rvbox/main.go b/cmd/rvbox/main.go index b6602b8..13770b0 100644 --- a/cmd/rvbox/main.go +++ b/cmd/rvbox/main.go @@ -26,6 +26,9 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// buildVersion is set by scripts/release for published artifacts. +var buildVersion = "dev" + func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, "rvbox:", err) @@ -39,6 +42,10 @@ var nativeTestContextFailures map[clientwindows.ExecutionContext]bool // --service with an explicit config path; tray/helper modes cannot silently // turn an ordinary process invocation into a privileged service. func run(args []string, output, diagnostics io.Writer) error { + if len(args) == 1 && args[0] == "--version" { + _, err := fmt.Fprintln(output, buildVersion) + return err + } if len(args) == 0 { return errors.New("an internal mode is required (use --help)") } diff --git a/cmd/rvbox/main_test.go b/cmd/rvbox/main_test.go index 1aa6b24..ab86c92 100644 --- a/cmd/rvbox/main_test.go +++ b/cmd/rvbox/main_test.go @@ -23,6 +23,14 @@ func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) { } } +func TestClientVersionDoesNotRequireConfiguration_HP_WINCLI_03(t *testing.T) { + t.Parallel() + var output, diagnostics bytes.Buffer + if err := run([]string{"--version"}, &output, &diagnostics); err != nil || output.String() != "dev\n" { + t.Fatalf("version = %q, %v", output.String(), err) + } +} + func TestClientHTTPClientAcceptsMatchingSelfSignedLeaf(t *testing.T) { t.Parallel() server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/cmd/rvc/main.go b/cmd/rvc/main.go index 38c052f..1af870a 100644 --- a/cmd/rvc/main.go +++ b/cmd/rvc/main.go @@ -25,6 +25,9 @@ import ( const defaultControlSocket = "/run/rvbox/server.sock" +// buildVersion is set by scripts/release for published artifacts. +var buildVersion = "dev" + func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, "rvc:", err) @@ -33,6 +36,10 @@ func main() { } func run(args []string, output, diagnostics io.Writer) error { + if len(args) == 1 && args[0] == "--version" { + _, err := fmt.Fprintln(output, buildVersion) + return err + } if len(args) == 0 { return errors.New("a command is required (stat or run)") } diff --git a/cmd/rvc/main_test.go b/cmd/rvc/main_test.go index e88fb58..aa4fd44 100644 --- a/cmd/rvc/main_test.go +++ b/cmd/rvc/main_test.go @@ -50,6 +50,14 @@ func TestGlobalRequestIDIsInjectedOnlyForMutations_HP_CTL_12(t *testing.T) { } } +func TestVersionDoesNotRequireControlSocket_HP_CTL_16(t *testing.T) { + t.Parallel() + var output, diagnostics bytes.Buffer + if err := run([]string{"--version"}, &output, &diagnostics); err != nil || output.String() != "dev\n" { + t.Fatalf("version = %q, %v", output.String(), err) + } +} + func TestRenderCommandStatShowsExpiryRetentionAndWindowsIdentity_HP_CTL_13(t *testing.T) { t.Parallel() expiry := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index b79d30b..540abc0 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -69,6 +69,30 @@ false and records an incident; do not delete segments to force readiness. and full data directory, then start the known-good version. Preserve logs and the failed copy for diagnosis. +## Release bundle + +Build a release candidate only from a clean, committed worktree. The +containerized release wrapper embeds the supplied version in all three binaries, +creates an immutable `dist/rvbox-VERSION` directory, and writes `SHA256SUMS` +plus `manifest.json` only after the Windows executable has optionally been +signed: + +```sh +scripts/release build --version 1.0.0-rc.1 +sha256sum -c dist/rvbox-1.0.0-rc.1/SHA256SUMS +dist/rvbox-1.0.0-rc.1/rvbox-server-linux-amd64 --version +dist/rvbox-1.0.0-rc.1/rvc-linux-amd64 --version +``` + +For a Windows-signed release, provide an executable host-side signing hook via +`--sign-windows-hook /absolute/path/to/hook`. The wrapper invokes it with the +Windows executable path and version, then records `windows_signed: true` in the +manifest. Without that hook the manifest deliberately declares the artifact +unsigned; this is suitable for CI/test evidence but not a signed public +release. The wrapper never overwrites a final bundle, so correcting a failed +candidate requires choosing a new version/output or deliberately removing that +exact ignored `dist/` directory after preserving any evidence. + ## Common incidents - **No client / stale session:** verify nginx has WebSocket `101` entries for diff --git a/scripts/release b/scripts/release new file mode 100755 index 0000000..0eac5e5 --- /dev/null +++ b/scripts/release @@ -0,0 +1,116 @@ +#!/bin/sh +# Build an inspectable, reproducible RVBox Linux-server/Windows-client bundle. +# Publishing and certificate custody remain outside this repository; an optional +# local signing hook can sign the Windows executable before checksums are made. +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +compose_file=$repo_root/deploy/compose.yaml + +usage() { + cat <<'EOF' +usage: scripts/release build --version VERSION [--output DIR] [--sign-windows-hook FILE] + +Builds a fresh immutable bundle containing: + rvbox-server-linux-amd64 + rvc-linux-amd64 + rvbox-windows-amd64.exe + SHA256SUMS + manifest.json + +The default output is dist/rvbox-VERSION. VERSION must be a safe release label +([0-9A-Za-z][0-9A-Za-z._+-]{0,63}); an existing final output is never replaced. +Artifacts are built inside the pinned Docker toolchain with that exact version +embedded in `--version`. The optional signing hook is a regular executable run +on the host as: HOOK WINDOWS_EXE VERSION. It receives RVBOX_ARTIFACT and +RVBOX_VERSION as environment variables and must sign the supplied executable +in place. SHA256SUMS and manifest.json are generated only after it succeeds. + +No signing hook means the manifest explicitly marks the Windows artifact as +unsigned. This is deliberate for CI/test builds; do not publish it as signed. +EOF +} + +fail() { printf '%s\n' "release: $*" >&2; exit 2; } + +command=${1:-} +[ "$#" -gt 0 ] && shift +[ "$command" = build ] || { usage >&2; fail "expected build"; } + +version= +output= +sign_hook= +while [ "$#" -gt 0 ]; do + case $1 in + --version) [ "$#" -ge 2 ] || fail "--version needs a value"; version=$2; shift 2 ;; + --output) [ "$#" -ge 2 ] || fail "--output needs a directory"; output=$2; shift 2 ;; + --sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown argument $1" ;; + esac +done + +case $version in + ''|[!0-9A-Za-z]*|*[!0-9A-Za-z._+-]*|?????????????????????????????????????????????????????????????????*) + fail "--version must match [0-9A-Za-z][0-9A-Za-z._+-]{0,63}" + ;; +esac +if [ -z "$output" ]; then output=$repo_root/dist/rvbox-$version; fi +case $output in + /*) ;; + *) output=$repo_root/$output ;; +esac +parent=$(dirname -- "$output") +[ ! -e "$output" ] || fail "refusing to replace existing output $output" +[ -d "$parent" ] || mkdir -p "$parent" +[ ! -L "$parent" ] || fail "refusing symlink output parent $parent" +if [ -n "$sign_hook" ]; then + [ -f "$sign_hook" ] && [ ! -L "$sign_hook" ] && [ -x "$sign_hook" ] || fail "signing hook must be an executable regular file" +fi + +docker version >/dev/null 2>&1 || fail "Docker is unavailable" +docker compose -f "$compose_file" version >/dev/null 2>&1 || fail "Docker Compose is unavailable" + +partial=$parent/.rvbox-$version.partial-$$ +mkdir "$partial" || fail "could not create private release staging directory" +cleanup() { rm -rf -- "$partial"; } +trap cleanup EXIT HUP INT TERM + +commit=$(git -C "$repo_root" rev-parse HEAD) +dirty=$(git -C "$repo_root" status --porcelain) +[ -z "$dirty" ] || fail "refusing release build from a dirty worktree" + +docker compose -f "$compose_file" run --rm toolchain sh -ec ' + set -eu + version=$1 + out=$2 + flags="-s -w -X main.buildVersion=$version" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-amd64" ./cmd/rvbox-server + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-amd64" ./cmd/rvc + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox +' sh "$version" "$partial" + +signed=false +if [ -n "$sign_hook" ]; then + RVBOX_ARTIFACT=$partial/rvbox-windows-amd64.exe RVBOX_VERSION=$version "$sign_hook" "$partial/rvbox-windows-amd64.exe" "$version" + signed=true +fi + +for artifact in rvbox-server-linux-amd64 rvc-linux-amd64 rvbox-windows-amd64.exe; do + [ -f "$partial/$artifact" ] && [ ! -L "$partial/$artifact" ] || fail "builder did not create regular $artifact" + done +(cd "$partial" && sha256sum rvbox-server-linux-amd64 rvc-linux-amd64 rvbox-windows-amd64.exe) >"$partial/SHA256SUMS" +{ + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$version" + printf ' "git_commit": "%s",\n' "$commit" + printf ' "windows_signed": %s,\n' "$signed" + printf ' "artifacts": "SHA256SUMS"\n' + printf '}\n' +} >"$partial/manifest.json" +chmod 0755 "$partial/rvbox-server-linux-amd64" "$partial/rvc-linux-amd64" "$partial/rvbox-windows-amd64.exe" +chmod 0644 "$partial/SHA256SUMS" "$partial/manifest.json" +mv -- "$partial" "$output" +trap - EXIT HUP INT TERM +printf 'release_bundle=%s\n' "$output"