diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..94773a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Build: macOS (arm64 + x64, native cross-compile) ────────────────────── + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-apple-darwin,aarch64-apple-darwin + + - uses: Swatinem/rust-cache@v2 + + - name: Build darwin-arm64 + run: cargo build --release -p o8v --target aarch64-apple-darwin + + - name: Build darwin-x64 + run: cargo build --release -p o8v --target x86_64-apple-darwin + + - name: Import Developer ID certificate + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain" + KEYCHAIN_PWD=$(openssl rand -base64 32) + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERTIFICATE_PWD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PWD" \ + "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + - name: Sign macOS binaries + run: | + IDENTITY=$(security find-identity -v -p codesigning \ + | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + codesign --sign "$IDENTITY" --options runtime --timestamp \ + target/aarch64-apple-darwin/release/8v + codesign --sign "$IDENTITY" --options runtime --timestamp \ + target/x86_64-apple-darwin/release/8v + + - name: Decode Apple API key + env: + APPLE_API_KEY_B64: ${{ secrets.APPLE_API_KEY }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + run: | + mkdir -p "$RUNNER_TEMP/apple" + echo "$APPLE_API_KEY_B64" | base64 --decode \ + > "$RUNNER_TEMP/apple/AuthKey_${APPLE_KEY_ID}.p8" + echo "APPLE_API_KEY=$RUNNER_TEMP/apple/AuthKey_${APPLE_KEY_ID}.p8" >> "$GITHUB_ENV" + + - name: Notarize darwin-arm64 + env: + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} + run: | + cd target/aarch64-apple-darwin/release + zip -q 8v-darwin-arm64.zip 8v + xcrun notarytool submit 8v-darwin-arm64.zip \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_KEY_ID" \ + --issuer "$APPLE_ISSUER_ID" \ + --wait + rm 8v-darwin-arm64.zip + + - name: Notarize darwin-x64 + env: + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} + run: | + cd target/x86_64-apple-darwin/release + zip -q 8v-darwin-x64.zip 8v + xcrun notarytool submit 8v-darwin-x64.zip \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_KEY_ID" \ + --issuer "$APPLE_ISSUER_ID" \ + --wait + rm 8v-darwin-x64.zip + + - name: Stage binaries + run: | + mkdir -p dist + cp target/aarch64-apple-darwin/release/8v dist/8v-darwin-arm64 + cp target/x86_64-apple-darwin/release/8v dist/8v-darwin-x64 + + - uses: actions/upload-artifact@v4 + with: + name: binaries-macos + path: dist/ + + # ── Build: Linux (x64 + arm64, musl via cargo-zigbuild) ─────────────────── + build-linux: + name: Build Linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install zig + cargo-zigbuild + run: | + sudo snap install zig --classic --beta || pip3 install ziglang + cargo install cargo-zigbuild + + - name: Build linux-x64 + run: cargo zigbuild --release -p o8v --target x86_64-unknown-linux-musl + + - name: Build linux-arm64 + run: cargo zigbuild --release -p o8v --target aarch64-unknown-linux-musl + + - name: Stage binaries + run: | + mkdir -p dist + cp target/x86_64-unknown-linux-musl/release/8v dist/8v-linux-x64 + cp target/aarch64-unknown-linux-musl/release/8v dist/8v-linux-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: binaries-linux + path: dist/ + + # ── Release: collect binaries, generate checksums, publish ──────────────── + release: + name: Publish release + needs: [build-macos, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: binaries-macos + path: dist/ + + - uses: actions/download-artifact@v4 + with: + name: binaries-linux + path: dist/ + + - name: Generate checksums + run: | + cd dist + sha256sum 8v-* > checksums.txt + cat checksums.txt + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + gh release create "$TAG" \ + --title "$TAG" \ + --generate-notes \ + dist/8v-darwin-arm64 \ + dist/8v-darwin-x64 \ + dist/8v-linux-x64 \ + dist/8v-linux-arm64 \ + dist/checksums.txt diff --git a/.gitignore b/.gitignore index 52893c2..0db8b83 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,11 @@ dist/ **/node_modules/ **/.ruff_cache/ **/.mypy_cache/ +o8v/tests/fixtures/build-go/buildtest # Local state .8v/ .claude/ -.wrangler/ # Logs and crashes *.log diff --git a/o8v/tests/fixtures/build-go/buildtest b/o8v/tests/fixtures/build-go/buildtest deleted file mode 100755 index 55b9d17..0000000 Binary files a/o8v/tests/fixtures/build-go/buildtest and /dev/null differ diff --git a/o8v/tests/unit_release_pipeline.rs b/o8v/tests/unit_release_pipeline.rs index 5fac270..3c15523 100644 --- a/o8v/tests/unit_release_pipeline.rs +++ b/o8v/tests/unit_release_pipeline.rs @@ -23,26 +23,13 @@ fn validate_base_url(url: &str) -> bool { || url.starts_with("http://127.0.0.1") } -/// Verify the version-bump logic lives in bump-version.sh and is referenced -/// from release.sh. release.sh delegates — bump-version.sh is the single -/// source of truth so CI-visible drift is impossible. -fn bump_version_script() -> &'static str { - include_str!("../../scripts/bump-version.sh") -} - -fn release_sh_delegates_to_bump_version() -> bool { - let release_sh = include_str!("../../scripts/release.sh"); - release_sh.contains("bump-version.sh") -} - -/// Extract binary names from release.sh build section. -fn extract_binary_names_from_release_sh() -> Vec { - let release_sh = include_str!("../../scripts/release.sh"); +/// Extract binary names from the release workflow file. +fn extract_binary_names_from_workflow() -> Vec { + let workflow = include_str!("../../.github/workflows/release.yml"); let mut binaries = Vec::new(); - - for line in release_sh.lines() { - if line.contains("cp target") && line.contains("dist/8v-") { - if let Some(start) = line.find("dist/") { + for line in workflow.lines() { + if line.trim_start().starts_with("cp ") && line.contains("dist/8v-") { + if let Some(start) = line.find("dist/8v-") { let rest = &line[start + 5..]; let binary = rest.split_whitespace().next().unwrap_or(""); if !binary.is_empty() { @@ -69,24 +56,31 @@ fn workspace_cargo_toml_has_version_field() { ); assert!( content.contains("version = \""), - "Workspace Cargo.toml missing version field — release.sh sed would silently skip it" + "Workspace Cargo.toml missing version field — bump the version in [workspace.package]" ); } #[test] -fn release_sh_version_bump_targets_workspace_root() { - assert!( - release_sh_delegates_to_bump_version(), - "release.sh must delegate to scripts/bump-version.sh for version bumping" - ); - let bump = bump_version_script(); +fn workflow_targets_workspace_package_version() { + let root = workspace_root(); + let cargo_toml = root.join("Cargo.toml"); + let content = fs::read_to_string(&cargo_toml).expect("read workspace Cargo.toml"); + // The workflow releases whatever version is in [workspace.package]. + // Verify the section and version key are present so a tag push reflects + // the correct version. assert!( - bump.contains("[workspace.package]"), - "bump-version.sh does not target the [workspace.package] section" + content.contains("[workspace.package]"), + "[workspace.package] section missing — workflow release would use wrong version" ); + let in_section = content + .lines() + .skip_while(|l| !l.trim().starts_with("[workspace.package]")) + .skip(1) + .take_while(|l| !l.trim_start().starts_with('[')) + .any(|l| l.trim_start().starts_with("version = \"")); assert!( - bump.contains("^version = "), - "bump-version.sh does not anchor on ^version = (may match dependency versions)" + in_section, + "version field not found under [workspace.package] — CI release would be unversioned" ); } @@ -187,14 +181,17 @@ fn checksum_format_parseable() { #[test] fn binary_names_match_install_platforms() { - let binaries = extract_binary_names_from_release_sh(); - assert!(!binaries.is_empty(), "Failed to extract binary names"); + let binaries = extract_binary_names_from_workflow(); + assert!( + !binaries.is_empty(), + "Failed to extract binary names from workflow" + ); let expected = ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]; for expected_name in expected { assert!( binaries.iter().any(|b| b.contains(expected_name)), - "Binary name {} not found in release.sh", + "Binary name {} not found in release workflow", expected_name ); } @@ -226,42 +223,6 @@ fn version_txt_has_no_whitespace() { ); } -#[test] -fn changelog_sed_preserves_unreleased_and_adds_version() { - let project = TempProject::empty(); - let changelog_path = project.path().join("CHANGELOG.md"); - - let original = r#"# Changelog - -## [Unreleased] - -## [1.0.0] - 2026-01-01 - -### Added - -- Some feature -"#; - project - .write_file("CHANGELOG.md", original.as_bytes()) - .expect("write CHANGELOG"); - - let content = fs::read_to_string(&changelog_path).expect("read CHANGELOG"); - - // Simulate sed: insert new version after [Unreleased] - let updated = content.replace( - "## [Unreleased]", - "## [Unreleased]\n\n## [2.0.0] - 2026-04-07", - ); - project - .write_file("CHANGELOG.md", updated.as_bytes()) - .expect("write updated CHANGELOG"); - - let result = fs::read_to_string(&changelog_path).expect("read updated"); - assert!(result.contains("## [Unreleased]")); - assert!(result.contains("## [2.0.0] - 2026-04-07")); - assert!(result.contains("## [1.0.0] - 2026-01-01")); -} - #[test] fn semver_regex_accepts_valid_versions() { let valid = ["0.1.0", "1.0.0", "10.20.30", "1.2.3"]; diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh deleted file mode 100755 index 6ff2066..0000000 --- a/scripts/bump-version.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Bump version script for 8v. -# Workspace uses [workspace.package] version inheritance — member crates -# declare `version.workspace = true`, so the root Cargo.toml is the single -# source of truth. -# -# Usage: ./scripts/bump-version.sh 0.2.0 - -VERSION="${1:-}" - -if [ -z "$VERSION" ]; then - echo "Usage: ./scripts/bump-version.sh 0.2.0" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$WORKSPACE_ROOT" - -# Bump the workspace-wide version. The regex anchors on the [workspace.package] -# section header to avoid touching dependency version strings that appear in -# [workspace.dependencies]. -# -# Portable sed: mktemp + mv, so this works on both BSD (macOS) and GNU sed. -tmp=$(mktemp) -awk -v ver="$VERSION" ' - /^\[workspace\.package\]/ { in_section = 1; print; next } - /^\[/ && !/^\[workspace\.package\]/ { in_section = 0 } - in_section && /^version = / { print "version = \"" ver "\""; next } - { print } -' Cargo.toml > "$tmp" -mv "$tmp" Cargo.toml - -# Verify the bump actually happened — catch regex drift loudly. -if ! grep -q "^version = \"$VERSION\"$" Cargo.toml; then - echo "✗ Version bump did not apply to root Cargo.toml" >&2 - exit 1 -fi - -# Regenerate Cargo.lock so downstream tools see the new version. -cargo check --workspace > /dev/null 2>&1 - -echo "✓ Bumped [workspace.package] version to $VERSION" diff --git a/scripts/install.sh b/scripts/install.sh index 420f324..a5c6518 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -48,17 +48,47 @@ detect_platform() { esac } +# ============================================================================ +# Validate _8V_BASE_URL (test hook — must be https:// or http://localhost/127) +# ============================================================================ + +validate_base_url() { + URL="$1" + case "$URL" in + https://*) return 0 ;; + http://localhost*) return 0 ;; + http://127.0.0.1*) return 0 ;; + *) + echo "Error: _8V_BASE_URL must be https:// or http://localhost (got: $URL)" >&2 + exit 1 + ;; + esac +} + + # ============================================================================ # Get latest version (follow GitHub /releases/latest redirect) # ============================================================================ get_version() { REPO="$1" + + # _8V_BASE_URL test hook: fetch version from $BASE_URL/latest/version.txt + if [ -n "${_8V_BASE_URL:-}" ]; then + VERSION_URL="${_8V_BASE_URL}/latest/version.txt" + VERSION=$(curl -fsSL --connect-timeout 15 --max-time 30 "$VERSION_URL" 2>/dev/null | sed 's/[[:space:]]//g') + if [ -z "$VERSION" ]; then + echo "Error: failed to fetch version from $VERSION_URL" >&2 + exit 1 + fi + echo "$VERSION" + return 0 + fi + RELEASES_URL="https://github.com/${REPO}/releases/latest" # GitHub redirects /releases/latest → /releases/tag/vX.Y.Z. Parse Location. - REDIRECT=$(curl -fsSI --connect-timeout 15 --max-time 30 "$RELEASES_URL" 2>/dev/null \ - | grep -i '^location:' | tr -d '\r' | awk '{print $2}') + REDIRECT=$(curl -fsSI --connect-timeout 15 --max-time 30 "$RELEASES_URL" 2>/dev/null | grep -i '^location:' | tr -d '\r' | awk '{print $2}') if [ -z "$REDIRECT" ]; then echo "Error: failed to resolve latest release from $RELEASES_URL" >&2 @@ -85,7 +115,14 @@ download_binary() { VERSION="$2" REPO="$3" BINARY_NAME="8v-${PLATFORM}" - BASE="https://github.com/${REPO}/releases/download/v${VERSION}" + + # _8V_BASE_URL test hook overrides the GitHub download base + if [ -n "${_8V_BASE_URL:-}" ]; then + BASE="${_8V_BASE_URL}/v${VERSION}" + else + BASE="https://github.com/${REPO}/releases/download/v${VERSION}" + fi + BINARY_URL="${BASE}/${BINARY_NAME}" CHECKSUMS_URL="${BASE}/checksums.txt" @@ -220,6 +257,11 @@ TEMP_DIR=$(mktemp -d) REPO="${_8V_REPO:-8network/8v}" +# Validate _8V_BASE_URL if set (prevents accidental plain-http production use) +if [ -n "${_8V_BASE_URL:-}" ]; then + validate_base_url "$_8V_BASE_URL" +fi + PLATFORM=$(detect_platform) VERSION=$(get_version "$REPO") diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 25de485..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,420 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Release script for 8v -# Usage: ./scripts/release.sh 0.1.0 -# ./scripts/release.sh 0.1.0 --dry-run - -VERSION="${1:-}" -DRY_RUN="${2:-}" - -# Colors -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Helper functions -success() { - echo -e "${GREEN}✓${NC} $1" -} - -error() { - echo -e "${RED}✗${NC} $1" >&2 -} - -warn() { - echo -e "${YELLOW}!${NC} $1" -} - -step() { - echo "" - echo "▶ $1" -} - -# Validate arguments -if [ -z "$VERSION" ]; then - error "Version argument required" - echo "Usage: ./scripts/release.sh 0.1.0 [--dry-run]" - exit 1 -fi - -if [ -n "$DRY_RUN" ] && [ "$DRY_RUN" != "--dry-run" ]; then - error "Invalid flag: $DRY_RUN (use --dry-run)" - exit 1 -fi - -# Validate version format — must be X.Y.Z (no v-prefix, no prerelease suffix). -if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - error "Invalid version format: '$VERSION'" - echo "Expected: X.Y.Z (e.g. 1.2.3) — no v-prefix, no prerelease suffix" - exit 1 -fi - -# Get workspace root -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$WORKSPACE_ROOT" - -step "Release v$VERSION" -if [ "$DRY_RUN" = "--dry-run" ]; then - echo "(dry-run mode — no commits, tags, or uploads)" -fi - -# ============================================================================ -# 1. VERIFY PREREQUISITES -# ============================================================================ - -step "Checking prerequisites..." - -# Clean git -if ! git diff --quiet || ! git diff --cached --quiet; then - error "Git working tree must be clean" - git status - exit 1 -fi -success "Git working tree is clean" - -# Required tools -for cmd in cargo-zigbuild wrangler codesign xcrun; do - if ! command -v "$cmd" >/dev/null 2>&1; then - # sha256sum may not exist on macOS, that's ok (use shasum) - if [ "$cmd" = "sha256sum" ]; then - if ! command -v shasum >/dev/null 2>&1; then - error "MISSING: shasum or sha256sum required" - exit 1 - fi - else - error "MISSING: $cmd" - if [ "$cmd" = "cargo-zigbuild" ]; then - echo " Install: cargo install cargo-zigbuild" - elif [ "$cmd" = "wrangler" ]; then - echo " Install: npm install -g wrangler" - elif [ "$cmd" = "codesign" ] || [ "$cmd" = "xcrun" ]; then - echo " Install: Xcode command line tools" - elif [ "$cmd" = "zig" ]; then - echo " Install: brew install zig" - fi - exit 1 - fi - fi -done -success "All required tools found" - -# zig is available (used by cargo-zigbuild) -if ! command -v zig >/dev/null 2>&1; then - error "zig must be installed (brew install zig)" - exit 1 -fi -success "zig available" - -# Apple notarization credentials (API key auth) -APPLE_SECRETS="$HOME/.8v/secrets/apple" -NOTARIZE_ENV="$APPLE_SECRETS/notarize.env" -CODESIGN_ENV="$APPLE_SECRETS/codesign.env" - -if [ -f "$NOTARIZE_ENV" ]; then - . "$NOTARIZE_ENV" -else - error "MISSING: $NOTARIZE_ENV" - echo " See docs/design/release.md for setup" - exit 1 -fi - -if [ -z "${APPLE_API_KEY:-}" ] || [ ! -f "${APPLE_API_KEY:-}" ]; then - error "MISSING: Apple API key (.p8 file) at $APPLE_API_KEY" - exit 1 -fi -if [ -z "${APPLE_KEY_ID:-}" ]; then - error "MISSING: APPLE_KEY_ID in $NOTARIZE_ENV" - exit 1 -fi -if [ -z "${APPLE_ISSUER_ID:-}" ]; then - error "MISSING: APPLE_ISSUER_ID in $NOTARIZE_ENV" - exit 1 -fi -success "Apple notarization credentials found (API key: $APPLE_KEY_ID)" - -# Developer ID certificate in keychain -if ! security find-identity -v -p codesigning | grep -q "Developer ID Application"; then - error "MISSING: Developer ID Application certificate in keychain" - exit 1 -fi -success "Developer ID certificate found in keychain" - -# Wrangler authenticated -if ! wrangler r2 bucket list >/dev/null 2>&1; then - error "MISSING: wrangler not authenticated" - echo " Run: wrangler login" - exit 1 -fi -success "wrangler is authenticated" - -# ============================================================================ -# 2. BUMP VERSION (must happen BEFORE build so CARGO_PKG_VERSION bakes in) -# ============================================================================ - -step "Bumping version to $VERSION..." - -# Delegate to scripts/bump-version.sh — single source of truth for workspace -# version bumping (updates [workspace.package] version; members inherit). -"$(dirname "$0")/bump-version.sh" "$VERSION" -success "Version bumped to $VERSION" - -# On dry-run, the bump is local only; we'll restore at the dry-run exit so the -# git tree ends clean. On a real release, the bump is committed at step 11. -BUMP_DONE=1 - -# ============================================================================ -# 3. RUN CHECKS -# ============================================================================ - -step "Running checks..." - -# Build first so we can use local binary for checks -cargo build -p o8v 2>/dev/null -LOCAL_8V="$WORKSPACE_ROOT/target/debug/8v" - -if ! "$LOCAL_8V" check . > /dev/null; then - error "8v check failed" - exit 1 -fi -success "8v check passed" - -if ! "$LOCAL_8V" fmt . --check > /dev/null; then - error "8v fmt --check failed" - exit 1 -fi -success "8v fmt --check passed" - -if ! cargo test --workspace -- --test-threads=1 > /dev/null 2>&1; then - error "cargo test failed" - cargo test --workspace -- --test-threads=1 - exit 1 -fi -success "cargo test passed" - -# ============================================================================ -# 3. BUILD ALL PLATFORMS -# ============================================================================ - -step "Building all platform binaries..." - -mkdir -p dist -rm -f dist/8v-* - -# darwin-arm64 (native) -echo " → darwin-arm64 (native)..." -cargo build --release -p o8v 2>&1 | grep -E "(Compiling|Finished)" || true -cp target/release/8v dist/8v-darwin-arm64 -success "darwin-arm64 built" - -# darwin-x64 (native cross-compile) -echo " → darwin-x64 (cross-compile)..." -rustup target add x86_64-apple-darwin > /dev/null 2>&1 || true -cargo build --release -p o8v --target x86_64-apple-darwin 2>&1 | grep -E "(Compiling|Finished)" || true -cp target/x86_64-apple-darwin/release/8v dist/8v-darwin-x64 -success "darwin-x64 built" - -# linux-x64 (zigbuild, no Docker) -echo " → linux-x64 (zigbuild)..." -cargo zigbuild --release -p o8v --target x86_64-unknown-linux-musl 2>&1 | grep -E "(Compiling|Finished)" || true -cp target/x86_64-unknown-linux-musl/release/8v dist/8v-linux-x64 -success "linux-x64 built" - -# linux-arm64 (zigbuild, no Docker) -echo " → linux-arm64 (zigbuild)..." -cargo zigbuild --release -p o8v --target aarch64-unknown-linux-musl 2>&1 | grep -E "(Compiling|Finished)" || true -cp target/aarch64-unknown-linux-musl/release/8v dist/8v-linux-arm64 -success "linux-arm64 built" - -# ============================================================================ -# 4. SIGN MACOS BINARIES -# ============================================================================ - -step "Signing macOS binaries..." - -# Auto-detect signing identity -IDENTITY=$(security find-identity -v -p codesigning \ - | grep "Developer ID Application" | head -1 \ - | awk -F'"' '{print $2}') - -if [ -z "$IDENTITY" ]; then - error "Could not find Developer ID Application certificate" - exit 1 -fi - -echo " Using identity: $IDENTITY" - -for bin in dist/8v-darwin-arm64 dist/8v-darwin-x64; do - codesign --sign "$IDENTITY" --options runtime --timestamp "$bin" 2>&1 | head -1 || true -done -success "macOS binaries signed" - -# ============================================================================ -# 5. VERIFY SIGNATURES -# ============================================================================ - -step "Verifying signatures..." - -for bin in dist/8v-darwin-arm64 dist/8v-darwin-x64; do - if ! codesign --verify --verbose "$bin" > /dev/null 2>&1; then - error "Signature verification failed for $(basename "$bin")" - exit 1 - fi -done -success "Signatures verified" - -# ============================================================================ -# 6. NOTARIZE MACOS BINARIES -# ============================================================================ - -step "Notarizing macOS binaries (this may take 1-5 minutes)..." - -for bin in dist/8v-darwin-arm64 dist/8v-darwin-x64; do - echo " → $(basename "$bin")..." - ZIP_FILE="${bin}.zip" - - # Create zip for notarization - cd dist - zip -q "$(basename "$ZIP_FILE")" "$(basename "$bin")" - cd "$WORKSPACE_ROOT" - - # Submit for notarization (API key auth) - if ! xcrun notarytool submit "$ZIP_FILE" \ - --key "$APPLE_API_KEY" \ - --key-id "$APPLE_KEY_ID" \ - --issuer "$APPLE_ISSUER_ID" \ - --wait 2>&1; then - error "Notarization rejected for $(basename "$bin")" - echo " Check: xcrun notarytool log ..." - rm -f "$ZIP_FILE" - exit 1 - fi - - rm -f "$ZIP_FILE" -done -success "Notarization complete" - -# ============================================================================ -# 7. VERIFY BINARY SIZES + GENERATE CHECKSUMS -# ============================================================================ - -step "Verifying binary sizes..." - -MAX_SIZE=$((20 * 1024 * 1024)) # 20 MB in bytes - -for f in dist/8v-*; do - SIZE=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0) - SIZE_MB=$((SIZE / 1024 / 1024)) - SIZE_KB=$((SIZE / 1024)) - - if [ "$SIZE" -gt "$MAX_SIZE" ]; then - warn "$(basename "$f") is over 20MB (${SIZE_MB}MB)" - fi - echo " $(basename "$f"): ${SIZE_KB}KB" -done - -step "Generating checksums..." - -cd dist -if command -v sha256sum >/dev/null 2>&1; then - sha256sum 8v-* > checksums.txt -else - shasum -a 256 8v-* > checksums.txt -fi -cat checksums.txt -cd "$WORKSPACE_ROOT" -success "Checksums generated" - -# ============================================================================ -# 8. DRY-RUN: EXIT HERE -# ============================================================================ - -if [ "$DRY_RUN" = "--dry-run" ]; then - # Restore Cargo.toml — the bump happened before build so it's already on disk. - if [ "${BUMP_DONE:-0}" = "1" ]; then - git checkout -- Cargo.toml Cargo.lock 2>/dev/null || true - success "Cargo.toml/Cargo.lock restored (dry-run)" - fi - - step "DRY-RUN COMPLETE" - echo "" - echo "Summary:" - echo " Version: v$VERSION" - echo " Binaries:" - ls -lh dist/8v-* | awk '{print " " $9 " (" $5 ")"}' - echo " Checksums: dist/checksums.txt" - echo "" - echo "Next steps (when ready for real release):" - echo " ./scripts/release.sh $VERSION" - echo "" - exit 0 -fi - -# ============================================================================ -# 11. COMMIT + TAG -# ============================================================================ - -step "Creating release commit and tag..." - -git add -A -git commit -m "Release v$VERSION" -git tag -a "v$VERSION" -m "Release v$VERSION" -success "Commit and tag created" - -# ============================================================================ -# 12. PUSH TO GIT (tag is the source of truth for gh release create) -# ============================================================================ - -step "Pushing to git..." - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -git push origin "$CURRENT_BRANCH" -git push origin "v$VERSION" -success "Pushed to origin" - -# ============================================================================ -# 13. CREATE GITHUB RELEASE (POINT OF NO RETURN) -# ============================================================================ - -step "Creating GitHub release v$VERSION..." - -if ! command -v gh >/dev/null 2>&1; then - error "MISSING: gh CLI required (brew install gh)" - exit 1 -fi - -gh release create "v$VERSION" \ - --title "v$VERSION" \ - --notes "Release v$VERSION" \ - dist/8v-darwin-arm64 \ - dist/8v-darwin-x64 \ - dist/8v-linux-arm64 \ - dist/8v-linux-x64 \ - dist/checksums.txt - -success "GitHub release v$VERSION created" - -step "Verifying release..." - -# Resolve /releases/latest redirect — must point at the new tag. -LATEST=$(curl -fsSI "https://github.com/8network/8v/releases/latest" \ - | grep -i '^location:' | tr -d '\r' | awk '{print $2}') -EXPECTED_SUFFIX="/tag/v${VERSION}" -case "$LATEST" in - *"$EXPECTED_SUFFIX") success "/releases/latest → $LATEST" ;; - *) error "/releases/latest points at '$LATEST', expected suffix '$EXPECTED_SUFFIX'"; exit 1 ;; -esac - -# ============================================================================ -# DONE -# ============================================================================ - -step "✓ Release v$VERSION complete!" -echo "" -echo "Release details:" -echo " Version: v$VERSION" -echo " Tag: $(git describe --tags)" -echo " Release: https://github.com/8network/8v/releases/tag/v${VERSION}" -echo "" diff --git a/scripts/test-release.sh b/scripts/test-release.sh deleted file mode 100755 index 979bc62..0000000 --- a/scripts/test-release.sh +++ /dev/null @@ -1,418 +0,0 @@ -#!/bin/sh -# Release pipeline validation tests. -# -# Tests the invariants the release pipeline depends on — fast, no credentials, -# no cross-compilation, no wrangler. What it covers: -# -# 1. Version bump — workspace-root [workspace.package] version only; -# member crates must inherit via `version.workspace = true` -# 2. Checksum format — sha256sum/shasum output parseable by install.sh -# 3. Binary naming — names match exactly what install.sh requests -# 4. version.txt format — clean string, no whitespace (the tr bug class) -# 5. Changelog update — [Unreleased] marker replaced correctly -# 6. Semver validation — release.sh must reject bad version strings -# -# Usage: -# sh scripts/test-release.sh - -set -eu - -WORKSPACE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$WORKSPACE_ROOT" - -PASS=0 -FAIL=0 - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -ok() { - PASS=$((PASS + 1)) - echo "ok: $*" -} - -fail() { - FAIL=$((FAIL + 1)) - echo "FAIL: $*" >&2 -} - -assert_eq() { - # assert_eq label expected actual - if [ "$2" = "$3" ]; then - ok "$1" - else - fail "$1: expected '$2', got '$3'" - fi -} - -assert_contains() { - # assert_contains label needle haystack - if echo "$3" | grep -q "$2"; then - ok "$1" - else - fail "$1: '$2' not found in output" - fi -} - -assert_not_contains() { - if echo "$3" | grep -q "$2"; then - fail "$1: '$2' must not appear in output" - else - ok "$1" - fi -} - -# ── Test 1: Version bump (workspace inheritance) ───────────────────────────── -# -# The workspace uses [workspace.package] version inheritance — the root -# Cargo.toml is the single source of truth, and member crates declare -# `version.workspace = true`. bump-version.sh must: -# (a) update the [workspace.package] version line in root Cargo.toml -# (b) NOT touch member crates' Cargo.toml (inheritance handles them) -# (c) NOT match version strings outside [workspace.package] (e.g. in -# [workspace.dependencies] or [dependencies] sections) - -echo "" -echo "── 1. Version bump ──" - -# Invariant: every workspace member must inherit, not pin its own version. -# A drift here means bump-version.sh won't reach that crate. -MEMBER_CRATES=$(awk ' - /^\[workspace\]/ { in_members = 0; in_ws = 1; next } - in_ws && /^members = \[/ { in_members = 1; next } - in_members && /^\]/ { in_members = 0; next } - in_members { gsub(/[",[:space:]]/, ""); if (length($0)) print $0 } -' Cargo.toml) - -for crate in $MEMBER_CRATES; do - cargo_file="$crate/Cargo.toml" - if [ ! -f "$cargo_file" ]; then - fail "workspace member listed but missing: $cargo_file" - continue - fi - if grep -q '^version\.workspace = true' "$cargo_file"; then - ok "member inherits version: $crate" - elif grep -q '^version = ' "$cargo_file"; then - fail "$crate pins its own version — must use 'version.workspace = true'" - else - fail "$crate has no version field and no workspace inheritance" - fi -done - -# Exercise bump-version.sh against a temp copy of Cargo.toml and verify -# only [workspace.package] version changes; dependency versions stay intact. -TMPDIR_BUMP=$(mktemp -d) -TARGET_VERSION="9.8.7" - -cat > "$TMPDIR_BUMP/Cargo.toml" << 'EOF' -[workspace] -members = ["a"] -resolver = "2" - -[workspace.package] -version = "0.1.0" -edition = "2021" - -[workspace.dependencies] -serde = { version = "1.0.0", features = ["derive"] } -EOF - -# Apply the same awk section-scoped bump that bump-version.sh uses. -tmp=$(mktemp) -awk -v ver="$TARGET_VERSION" ' - /^\[workspace\.package\]/ { in_section = 1; print; next } - /^\[/ && !/^\[workspace\.package\]/ { in_section = 0 } - in_section && /^version = / { print "version = \"" ver "\""; next } - { print } -' "$TMPDIR_BUMP/Cargo.toml" > "$tmp" -mv "$tmp" "$TMPDIR_BUMP/Cargo.toml" - -if grep -q "^version = \"$TARGET_VERSION\"$" "$TMPDIR_BUMP/Cargo.toml"; then - ok "workspace.package version bumped to $TARGET_VERSION" -else - fail "workspace.package version bump did not apply" -fi - -if grep -q 'serde = { version = "1.0.0"' "$TMPDIR_BUMP/Cargo.toml"; then - ok "workspace.dependencies version untouched" -else - fail "workspace.dependencies version was incorrectly modified" -fi - -rm -rf "$TMPDIR_BUMP" - -# ── Test 2: Checksum format ──────────────────────────────────────────────────── -# -# release.sh writes: sha256sum 8v-* > checksums.txt -# install.sh reads: grep "$BINARY_NAME\$" checksums.txt | awk '{print $1}' -# -# Verify: the output format of sha256sum/shasum is parseable by install.sh's -# grep+awk, and that the binary name anchor ($) works correctly. - -echo "" -echo "── 2. Checksum format ──" - -TMPDIR_CKSUM=$(mktemp -d) - -# Create fake binaries matching the exact names install.sh expects -for name in 8v-darwin-arm64 8v-darwin-x64 8v-linux-x64 8v-linux-arm64; do - echo "fake binary $name" > "$TMPDIR_CKSUM/$name" -done - -# Generate checksums using the same logic as release.sh -cd "$TMPDIR_CKSUM" -if command -v sha256sum >/dev/null 2>&1; then - sha256sum 8v-* > checksums.txt -else - shasum -a 256 8v-* > checksums.txt -fi -cd "$WORKSPACE_ROOT" - -# Verify install.sh's grep+awk can extract each checksum -for name in 8v-darwin-arm64 8v-darwin-x64 8v-linux-x64 8v-linux-arm64; do - EXTRACTED=$(grep "${name}\$" "$TMPDIR_CKSUM/checksums.txt" | awk '{print $1}') - if [ -n "$EXTRACTED" ] && echo "$EXTRACTED" | grep -qE '^[a-f0-9]{64}$'; then - ok "checksum parseable for $name: ${EXTRACTED:0:16}..." - else - fail "could not extract valid SHA256 for $name from checksums.txt" - fi -done - -# Verify that a binary name that is a PREFIX of another does NOT match incorrectly. -# e.g. grepping for "8v-darwin-arm" must not match "8v-darwin-arm64". -WRONG=$(grep "8v-darwin-arm\$" "$TMPDIR_CKSUM/checksums.txt" | awk '{print $1}' || true) -if [ -z "$WRONG" ]; then - ok "checksum anchor: prefix 'arm' does not match 'arm64'" -else - fail "checksum anchor broken: 'arm' matched when looking for 'arm64'" -fi - -rm -rf "$TMPDIR_CKSUM" - -# ── Test 3: Binary naming ────────────────────────────────────────────────────── -# -# install.sh requests: "8v-$PLATFORM" where PLATFORM is one of the four values -# from detect_platform(). release.sh produces files named the same way. -# Verify the names are in sync. - -echo "" -echo "── 3. Binary naming consistency ──" - -# Names install.sh can request -INSTALL_PLATFORMS="darwin-arm64 darwin-x64 linux-x64 linux-arm64" - -# Names release.sh produces (extract from the cp lines) -RELEASE_NAMES=$(grep "^cp target" "$WORKSPACE_ROOT/scripts/release.sh" \ - | grep "dist/8v-" \ - | sed 's/.*dist\/8v-//' \ - | tr -d '"') - -for platform in $INSTALL_PLATFORMS; do - if echo "$RELEASE_NAMES" | grep -q "^$platform$"; then - ok "binary name in sync: 8v-$platform" - else - fail "platform '$platform' in install.sh has no matching binary in release.sh" - fi -done - -# ── Test 4: version.txt format ──────────────────────────────────────────────── -# -# install.sh reads: curl ... | tr -d '\n\r' -# The version must be a clean semver string — no whitespace, no v-prefix. - -echo "" -echo "── 4. version.txt format ──" - -# Simulate what release.sh writes (step 13: echo "$VERSION" > version.txt) -TMPDIR_VER=$(mktemp -d) -TEST_VERSION="1.2.3" -printf "%s\n" "$TEST_VERSION" > "$TMPDIR_VER/version.txt" - -# Simulate what install.sh reads -READ_VERSION=$(cat "$TMPDIR_VER/version.txt" | tr -d '\n\r') - -assert_eq "version.txt readable by install.sh" "$TEST_VERSION" "$READ_VERSION" -assert_not_contains "version.txt has no v-prefix" "^v" "$READ_VERSION" -assert_not_contains "version.txt has no spaces" " " "$READ_VERSION" - -rm -rf "$TMPDIR_VER" - -# ── Test 5: Changelog update ─────────────────────────────────────────────────── -# -# release.sh uses: -# sed -i '' "s/## \[Unreleased\]/## [Unreleased]\n\n## [$VERSION] - $DATE/" CHANGELOG.md -# -# Verify: the Unreleased header is preserved and the new version header is added. - -echo "" -echo "── 5. Changelog update ──" - -TMPDIR_CL=$(mktemp -d) -DATE=$(date +%Y-%m-%d) -CL_VERSION="2.0.0" - -cat > "$TMPDIR_CL/CHANGELOG.md" << 'EOF' -# Changelog - -## [Unreleased] - -### Added -- Something new - -## [1.0.0] - 2026-01-01 - -### Added -- Initial release -EOF - -# Apply the sed from release.sh (BSD sed on macOS requires the replacement on separate lines) -tmp=$(mktemp) -sed "s/## \[Unreleased\]/## [Unreleased]\n\n## [$CL_VERSION] - $DATE/" \ - "$TMPDIR_CL/CHANGELOG.md" > "$tmp" -mv "$tmp" "$TMPDIR_CL/CHANGELOG.md" - -CONTENT=$(cat "$TMPDIR_CL/CHANGELOG.md") - -assert_contains "changelog: [Unreleased] preserved" "\[Unreleased\]" "$CONTENT" -assert_contains "changelog: new version header added" "\[$CL_VERSION\]" "$CONTENT" -assert_contains "changelog: date added" "$DATE" "$CONTENT" -assert_contains "changelog: old version still present" "\[1.0.0\]" "$CONTENT" - -rm -rf "$TMPDIR_CL" - -# ── Test 6: Semver format validation ───────────────────────────────────────── -# -# release.sh validates version format — must be X.Y.Z. -# Test the same regex used in release.sh. - -echo "" -echo "── 6. Semver format validation ──" - -SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+$' - -for v in "0.1.0" "1.0.0" "10.20.30" "1.2.3"; do - if echo "$v" | grep -qE "$SEMVER_REGEX"; then - ok "valid semver accepted: $v" - else - fail "valid semver rejected: $v" - fi -done - -for bad in "v1.0.0" "1.0" "latest" "" "1.0.0-beta" "1.0.0.0"; do - if echo "$bad" | grep -qE "$SEMVER_REGEX"; then - fail "invalid version must be rejected by release.sh: '$bad'" - else - ok "invalid version correctly rejected: '$bad'" - fi -done - -# ── Test 7: _8V_BASE_URL validation ────────────────────────────────────────── -# -# install.sh now validates _8V_BASE_URL: -# - https:// → allowed (production) -# - http://localhost → allowed (test server) -# - http://127.0.0.1 → allowed (test server) -# - anything else → rejected -# -# Test using the validate_base_url function extracted from install.sh. - -echo "" -echo "── 7. _8V_BASE_URL validation ──" - -check_url_allowed() { - # Mimics install.sh's validate_base_url logic - case "$1" in - https://*) echo "allowed" ;; - http://localhost*) echo "allowed" ;; - http://127.0.0.1*) echo "allowed" ;; - *) echo "rejected" ;; - esac -} - -for url in \ - "https://releases.8vast.io" \ - "https://example.com" \ - "http://localhost:8080" \ - "http://127.0.0.1:9000"; do - result=$(check_url_allowed "$url") - if [ "$result" = "allowed" ]; then - ok "_8V_BASE_URL allowed: $url" - else - fail "_8V_BASE_URL must be allowed: $url" - fi -done - -for url in \ - "http://evil.example.com" \ - "http://192.168.1.1" \ - "ftp://releases.8vast.io" \ - ""; do - result=$(check_url_allowed "$url") - if [ "$result" = "rejected" ]; then - ok "_8V_BASE_URL correctly rejected: '$url'" - else - fail "_8V_BASE_URL must be rejected: '$url'" - fi -done - -# ── Test 8: release.sh delegates to bump-version.sh ────────────────────────── -# -# Single-source-of-truth check: release.sh must use scripts/bump-version.sh -# (or inline the exact same awk), not maintain its own drift-prone logic. - -echo "" -echo "── 8. release.sh uses bump-version.sh ──" - -if grep -q 'scripts/bump-version.sh' "$WORKSPACE_ROOT/scripts/release.sh"; then - ok "release.sh delegates version bump to bump-version.sh" -else - fail "release.sh does not call scripts/bump-version.sh — duplicate logic risks drift" -fi - -# ── Test 9: Version bump sed precision ─────────────────────────────────────── -# -# The sed pattern `^version = ` is anchored to the start of the line. -# Inline dependency specs like `serde = { version = "1.0" }` are NOT at -# the start of a line and must NOT be modified by the version bump sed. - -echo "" -echo "── 9. Version bump sed precision ──" - -TMPDIR_SED=$(mktemp -d) -cat > "$TMPDIR_SED/Cargo.toml" << 'EOF' -[package] -name = "test-crate" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = { version = "1.0.0", features = ["derive"] } -tokio = "1.0" -EOF - -tmp=$(mktemp) -sed "s/^version = \".*\"/version = \"9.9.9\"/" "$TMPDIR_SED/Cargo.toml" > "$tmp" -mv "$tmp" "$TMPDIR_SED/Cargo.toml" - -if grep -q '^version = "9.9.9"' "$TMPDIR_SED/Cargo.toml"; then - ok "sed precision: [package] version bumped" -else - fail "sed precision: [package] version NOT bumped" -fi - -if grep -q 'serde = { version = "1.0.0"' "$TMPDIR_SED/Cargo.toml"; then - ok "sed precision: inline dependency version not touched" -else - fail "sed precision: inline dependency version was incorrectly modified" -fi - -rm -rf "$TMPDIR_SED" - -# ── Summary ─────────────────────────────────────────────────────────────────── - -echo "" -echo "────────────────────────────────" -echo "Results: $PASS passed, $FAIL failed" - -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi