diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac0463a..7aab24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,35 @@ permissions: contents: read jobs: + minimum-rust: + name: Rust 1.85 source build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-15 + target: aarch64-apple-darwin + - runner: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.85.0 + targets: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --locked --release -p bitbygit --target ${{ matrix.target }} + rust: name: Rust runs-on: ubuntu-latest @@ -38,6 +67,10 @@ jobs: "${tool_dir}/actionlint" bash scripts/test-release-workflow.sh + - name: Validate Linux installation documentation + shell: bash + run: bash scripts/test-installation-docs.sh + - name: Format run: cargo fmt --all --check @@ -49,3 +82,30 @@ jobs: - name: Build run: cargo build --locked --workspace + + installation-docs-macos: + name: macOS installation docs + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Validate installation documentation + shell: bash + run: bash scripts/test-installation-docs.sh + + installation-docs-windows: + name: Windows installation docs + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Validate installation documentation + shell: pwsh + run: ./scripts/test-installation-docs.ps1 diff --git a/README.md b/README.md index 32bf706..4e2eab9 100644 --- a/README.md +++ b/README.md @@ -42,23 +42,37 @@ User policy is defined in `config.toml`; see [`docs/configuration.md`](docs/configuration.md) for path resolution, the strict schema, and safe fallback behavior. +## Installation + +See [`docs/installation.md`](docs/installation.md) for supported release +archives, checksum verification, PATH setup, and source build instructions. +Package-manager distribution is not currently available. + ## Development Prerequisites: - Rust 1.85 or newer. - `git` available on `PATH`. -- `gh` is optional for future GitHub workflows. +- `gh` is optional and required only for GitHub-specific workflows. Run local checks: ```sh +bash scripts/test-release-workflow.sh +bash scripts/test-installation-docs.sh cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -cargo build --workspace +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace +cargo build --locked --workspace ``` +The Bash installation validator runs on Linux and macOS without PowerShell. +Windows contributors can run the platform-specific installation validation with +`pwsh -NoProfile -File scripts/test-installation-docs.ps1`; CI runs that command +on a Windows runner. The validator uses temporary process-scoped PATH state and +never changes the persistent user PATH. + Or run: ```sh diff --git a/crates/bitbygit-core/src/config.rs b/crates/bitbygit-core/src/config.rs index 8665b56..6f9ec84 100644 --- a/crates/bitbygit-core/src/config.rs +++ b/crates/bitbygit-core/src/config.rs @@ -37,13 +37,13 @@ impl AppConfig { } self.policy.validate()?; - if let Some(branch) = &self.pull_requests.default_base_branch - && !valid_branch_name(branch) - { - return Err(invalid_value( - "pull-requests.default-base-branch", - "must be a valid local branch name", - )); + if let Some(branch) = &self.pull_requests.default_base_branch { + if !valid_branch_name(branch) { + return Err(invalid_value( + "pull-requests.default-base-branch", + "must be a valid local branch name", + )); + } } Ok(()) } diff --git a/crates/bitbygit-core/src/policy.rs b/crates/bitbygit-core/src/policy.rs index 27d5245..faee385 100644 --- a/crates/bitbygit-core/src/policy.rs +++ b/crates/bitbygit-core/src/policy.rs @@ -73,16 +73,17 @@ impl EffectivePolicy { confirmation_label(requirement) )]; - if let Some(branch) = branch - && self.is_protected_branch(branch) - && let Some(minimum) = protected_branch_requirement(operation) - { - requirement = requirement.max(minimum); - reasons.push(format!( - "protected branch {branch}: {} requires at least {} confirmation", - operation.action_label(), - confirmation_label(minimum) - )); + if let Some(branch) = branch { + if self.is_protected_branch(branch) { + if let Some(minimum) = protected_branch_requirement(operation) { + requirement = requirement.max(minimum); + reasons.push(format!( + "protected branch {branch}: {} requires at least {} confirmation", + operation.action_label(), + confirmation_label(minimum) + )); + } + } } PolicyEvaluation { diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..72b5315 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,485 @@ +# Installation + +`bitbygit` can be installed from a GitHub Release archive or built from a +published source tag. No tagged release has been published yet. Use these +instructions once the version you want appears on the +[Releases page](https://github.com/cosentinode/bitbygit/releases). + +## Runtime prerequisites + +- `git` must be installed and available on `PATH`. +- [GitHub CLI (`gh`)](https://cli.github.com/) is optional. It is required only + for GitHub-specific operations such as opening a pull request; run + `gh auth login` before using those operations. +- Rust and a native build toolchain are not required when using a release + archive. See [Build from source](#build-from-source) for source prerequisites. + +## Supported release targets + +| Platform | Target | Archive | +| --- | --- | --- | +| Linux x86-64 | `x86_64-unknown-linux-gnu` | `bitbygit--x86_64-unknown-linux-gnu.tar.gz` | +| macOS Intel | `x86_64-apple-darwin` | `bitbygit--x86_64-apple-darwin.tar.gz` | +| macOS Apple silicon | `aarch64-apple-darwin` | `bitbygit--aarch64-apple-darwin.tar.gz` | +| Windows x86-64 | `x86_64-pc-windows-msvc` | `bitbygit--x86_64-pc-windows-msvc.zip` | + +The Linux artifact uses GNU libc and is checked not to require a GLIBC symbol +newer than 2.35. The release workflow does not produce artifacts for other +architectures or operating systems. + +Each tagged release also includes `SHA256SUMS`. Verify the downloaded archive +before extracting or running it. The examples below use `0.1.0`; set `VERSION` +or `$Version` to an available release version without the leading `v`. + +## Linux x86-64 archive + +Run these commands in Bash. They require `curl`, `tar`, `awk`, and GNU +coreutils (`sha256sum`, `mkdir`, and `install`) and stop before extraction or +installation if any download or checksum check fails: + +```bash +VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && +TARGET=x86_64-unknown-linux-gnu +ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" +PACKAGE="bitbygit-${VERSION}-${TARGET}" +BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" +WORK_DIR="$(mktemp -d)" +cleanup() { + status=$? + trap - EXIT + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi + exit "${status}" +} +trap cleanup EXIT +cd "${WORK_DIR}" + +curl -fLO "${BASE_URL}/${ARCHIVE}" +curl -fLO "${BASE_URL}/SHA256SUMS" +awk -v archive="${ARCHIVE}" '$2 == archive && NF == 2 { print; found=1 } END { exit !found }' SHA256SUMS | + sha256sum --check - +tar -xzf "${ARCHIVE}" + +mkdir -p "${HOME}/.local/bin" +install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 + exit 1 +fi +BITBYGIT_INSTALL +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version +else + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 + false +fi +} +``` + +Add `export PATH="$HOME/.local/bin:$PATH"` to your shell startup file to keep +the command available in new shells. + +## macOS archive + +Run these commands in Bash. They select the Intel or Apple silicon artifact +automatically and require `curl`, `awk`, `shasum`, and `tar`, which are included with +macOS. The shell stops before extraction or installation if any download or +checksum check fails: + +```bash +VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && +case "$(uname -m)" in + x86_64) TARGET=x86_64-apple-darwin ;; + arm64) TARGET=aarch64-apple-darwin ;; + *) echo "Unsupported macOS architecture: $(uname -m)" >&2; exit 1 ;; +esac +ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" +PACKAGE="bitbygit-${VERSION}-${TARGET}" +BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" +WORK_DIR="$(mktemp -d)" +cleanup() { + status=$? + trap - EXIT + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi + exit "${status}" +} +trap cleanup EXIT +cd "${WORK_DIR}" + +curl -fLO "${BASE_URL}/${ARCHIVE}" +curl -fLO "${BASE_URL}/SHA256SUMS" +awk -v archive="${ARCHIVE}" '$2 == archive && NF == 2 { print; found=1 } END { exit !found }' SHA256SUMS | + shasum -a 256 --check - +tar -xzf "${ARCHIVE}" + +mkdir -p "${HOME}/.local/bin" +install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 + exit 1 +fi +BITBYGIT_INSTALL +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version +else + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 + false +fi +} +``` + +Add `export PATH="$HOME/.local/bin:$PATH"` to `~/.zprofile` (or the startup file +for your shell) to keep the command available in new shells. + +## Windows x86-64 archive + +Run these commands in PowerShell: + +```powershell +& { +$ErrorActionPreference = "Stop" + +$Version = "0.1.0" +$Target = "x86_64-pc-windows-msvc" +$Archive = "bitbygit-$Version-$Target.zip" +$Package = "bitbygit-$Version-$Target" +$BaseUrl = "https://github.com/cosentinode/bitbygit/releases/download/v$Version" +$WorkDir = (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()))).FullName + +try { +$ArchivePath = Join-Path $WorkDir $Archive +$ChecksumsPath = Join-Path $WorkDir "SHA256SUMS" +Invoke-WebRequest -Uri "$BaseUrl/$Archive" -OutFile $ArchivePath +Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile $ChecksumsPath +$ExpectedLine = Get-Content $ChecksumsPath | Where-Object { $_.EndsWith(" $Archive") } +if (-not $ExpectedLine) { throw "No checksum found for $Archive" } +$Expected = ($ExpectedLine -split '\s+')[0] +$Actual = (Get-FileHash -Algorithm SHA256 $ArchivePath).Hash +if ($Actual -ne $Expected) { throw "Checksum verification failed for $Archive" } +Expand-Archive -Path $ArchivePath -DestinationPath $WorkDir + +$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item (Join-Path $WorkDir "$Package\bitbygit.exe") $InstallDir +$InstalledBinary = Join-Path $InstallDir "bitbygit.exe" +$Output = & $InstalledBinary --version +if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $Output" +} +$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim() + if (-not [string]::IsNullOrWhiteSpace($Extension)) { + [void] $PathExtensions.Add($Extension) + } +} +$MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") +$MachineCommand = $MachinePath -split ";" | ForEach-Object { + $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) + if (-not [string]::IsNullOrWhiteSpace($Entry)) { + foreach ($Extension in $PathExtensions) { + Join-Path $Entry "bitbygit$Extension" + } + } +} | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +if ($MachineCommand) { + throw "Machine PATH already contains $MachineCommand. User PATH cannot override it in new shells. Remove or update that machine-level installation, then rerun; until then invoke $InstalledBinary explicitly." +} +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +$UserPathEntries = @($UserPath -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" +$ProcessPathEntries = @($env:Path -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$PreviousProcessPath = $env:Path +try { + $env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" + $ResolvedCommand = Get-Command bitbygit -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $ResolvedPath = (Resolve-Path -LiteralPath $ResolvedCommand.Source).Path + $InstalledPath = (Resolve-Path -LiteralPath $InstalledBinary).Path + if (-not [string]::Equals($ResolvedPath, $InstalledPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Expected $InstalledPath on PATH, got $ResolvedPath" + } + $PathOutput = & $ResolvedPath --version + if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" + } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +} catch { + $env:Path = $PreviousProcessPath + throw +} +& $ResolvedPath --version +} finally { + try { + Remove-Item -LiteralPath $WorkDir -Recurse -Force + } catch { + [Console]::Error.WriteLine("Warning: failed to remove temporary directory ${WorkDir}: $($_.Exception.Message)") + } +} +} +``` + +Windows places machine `PATH` entries before user entries in new processes. The +block therefore refuses to update user `PATH` when a machine entry already +contains a `bitbygit` application with any non-empty executable extension +enabled by `PATHEXT`, matched case-insensitively; otherwise an older +machine-level installation could silently win after restarting PowerShell. +Remove or update that machine-level installation and rerun the block. The +selected file is still available through the unambiguous explicit invocation: + +```powershell +& "$env:LOCALAPPDATA\Programs\bitbygit\bitbygit.exe" --version +``` + +When no machine-level conflict exists, new shells use the updated user `PATH`. + +## Build from source + +Source builds require `git`, [Rust](https://www.rust-lang.org/tools/install) 1.85 +or newer, Cargo, and the native tools used by the selected Rust target: + +- Linux GNU targets require a C compiler, linker, and libc development headers, + commonly installed through the distribution's `build-essential` or + equivalent package. The installation commands also use GNU coreutils + (`mkdir` and `install`). +- macOS requires the Xcode Command Line Tools (`xcode-select --install`). +- `x86_64-pc-windows-msvc` requires Visual Studio 2022 Build Tools with the + **Desktop development with C++** workload, including MSVC and a Windows SDK. + +The following commands build the selected published release tag, not the moving +development branch. Because no tag has been published yet, `v0.1.0` will become +usable only if that release appears on the Releases page. + +On Linux or macOS, run: + +```bash +VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && +TAG="refs/tags/v${VERSION}" +WORK_DIR="$(mktemp -d)" +SOURCE_DIR="${WORK_DIR}/bitbygit" +TARGET_DIR="${WORK_DIR}/cargo-target" +cleanup() { + status=$? + trap - EXIT + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi + exit "${status}" +} +trap cleanup EXIT + +mkdir "${SOURCE_DIR}" +git -C "${SOURCE_DIR}" init +git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}" +tag_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${TAG}^{commit}")" +git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}" +[[ "$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD)" == "${tag_commit}" ]] +rustc_version="$(rustc -vV)" +host_target= +while IFS= read -r rustc_line; do + case "${rustc_line}" in + "host: "*) host_target="${rustc_line#host: }"; break ;; + esac +done <<< "${rustc_version}" +[[ -n "${host_target}" ]] +cargo build --manifest-path "${SOURCE_DIR}/Cargo.toml" --locked --release -p bitbygit \ + --target-dir "${TARGET_DIR}" --target "${host_target}" +built_binary="${TARGET_DIR}/${host_target}/release/bitbygit" +built_output="$("${built_binary}" --version)" +if [[ "${built_output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${built_output}" >&2 + exit 1 +fi + +mkdir -p "${HOME}/.local/bin" +install -m 0755 "${built_binary}" "${HOME}/.local/bin/bitbygit" +installed_output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${installed_output}" >&2 + exit 1 +fi +BITBYGIT_INSTALL +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version +else + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 + false +fi +} +``` + +On Windows, run in PowerShell: + +```powershell +& { +$ErrorActionPreference = "Stop" + +$Version = "0.1.0" +$Tag = "refs/tags/v$Version" +$WorkDir = (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()))).FullName +$SourceDir = Join-Path $WorkDir "bitbygit" +$TargetDir = Join-Path $WorkDir "cargo-target" + +try { +New-Item -ItemType Directory -Path $SourceDir | Out-Null +git -C $SourceDir init +if ($LASTEXITCODE -ne 0) { throw "git init failed" } +git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}" +if ($LASTEXITCODE -ne 0) { throw "git fetch failed" } +$TagCommit = git -C $SourceDir rev-parse --verify "${Tag}^{commit}" +if ($LASTEXITCODE -ne 0) { throw "release tag verification failed" } +git -C $SourceDir checkout --detach $TagCommit +if ($LASTEXITCODE -ne 0) { throw "release tag checkout failed" } +$HeadCommit = git -C $SourceDir rev-parse --verify HEAD +if ($LASTEXITCODE -ne 0 -or $HeadCommit -ne $TagCommit) { throw "release tag checkout verification failed" } +$RustcVersion = rustc -vV +if ($LASTEXITCODE -ne 0) { throw "rustc version detection failed" } +$HostLine = $RustcVersion | Where-Object { $_.StartsWith("host: ") } | Select-Object -First 1 +if (-not $HostLine) { throw "rustc host target detection failed" } +$HostTarget = $HostLine.Substring(6) +cargo build --manifest-path (Join-Path $SourceDir "Cargo.toml") --locked --release -p bitbygit --target-dir $TargetDir --target $HostTarget +if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } +$BuiltBinary = Join-Path $TargetDir "$HostTarget\release\bitbygit.exe" +$BuiltOutput = & $BuiltBinary --version +if ($LASTEXITCODE -ne 0 -or $BuiltOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $BuiltOutput" +} + +$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item $BuiltBinary $InstallDir +$InstalledBinary = Join-Path $InstallDir "bitbygit.exe" +$InstalledOutput = & $InstalledBinary --version +if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $InstalledOutput" +} +$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim() + if (-not [string]::IsNullOrWhiteSpace($Extension)) { + [void] $PathExtensions.Add($Extension) + } +} +$MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") +$MachineCommand = $MachinePath -split ";" | ForEach-Object { + $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) + if (-not [string]::IsNullOrWhiteSpace($Entry)) { + foreach ($Extension in $PathExtensions) { + Join-Path $Entry "bitbygit$Extension" + } + } +} | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +if ($MachineCommand) { + throw "Machine PATH already contains $MachineCommand. User PATH cannot override it in new shells. Remove or update that machine-level installation, then rerun; until then invoke $InstalledBinary explicitly." +} +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +$UserPathEntries = @($UserPath -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" +$ProcessPathEntries = @($env:Path -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$PreviousProcessPath = $env:Path +try { + $env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" + $ResolvedCommand = Get-Command bitbygit -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $ResolvedPath = (Resolve-Path -LiteralPath $ResolvedCommand.Source).Path + $InstalledPath = (Resolve-Path -LiteralPath $InstalledBinary).Path + if (-not [string]::Equals($ResolvedPath, $InstalledPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Expected $InstalledPath on PATH, got $ResolvedPath" + } + $PathOutput = & $ResolvedPath --version + if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" + } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +} catch { + $env:Path = $PreviousProcessPath + throw +} +& $ResolvedPath --version +} finally { + try { + Remove-Item -LiteralPath $WorkDir -Recurse -Force + } catch { + [Console]::Error.WriteLine("Warning: failed to remove temporary directory ${WorkDir}: $($_.Exception.Message)") + } +} +} +``` + +## Package managers + +Package-manager distribution is deferred. `bitbygit` is not currently +published to crates.io, Homebrew, WinGet, Scoop, or Linux package repositories. +Use an archive or source tag after a release is published. + +## Verify the installation + +Every installation block above first runs the installed file directly, then +updates `PATH`, resolves an application while ignoring same-named aliases and +functions, and verifies that it is the installed file before reporting +`bitbygit `. diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 40dd776..8697e52 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh set -eu +bash scripts/test-release-workflow.sh +bash scripts/test-installation-docs.sh cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 new file mode 100644 index 0000000..7f576b0 --- /dev/null +++ b/scripts/test-installation-docs.ps1 @@ -0,0 +1,621 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$DocsPath = Join-Path $Root "docs/installation.md" +$CargoPath = Join-Path $Root "Cargo.toml" +$Temp = Join-Path ([System.IO.Path]::GetTempPath()) "bitbygit-install-$([guid]::NewGuid())" + +function Fail([string] $Message) { + throw "installation docs validation failed: $Message" +} + +function Get-Block([string] $Heading, [string] $Language) { + $Pattern = '(?ms)^## {0}\r?\n.*?^```{1}\r?\n(.*?)^```' -f [regex]::Escape($Heading), [regex]::Escape($Language) + $Match = [regex]::Match($script:DocsText, $Pattern) + if (-not $Match.Success) { Fail "missing $Heading $Language block" } + return $Match.Groups[1].Value +} + +function ConvertTo-IsolatedPathBlock([string] $Block) { + $GetUserPath = 'GetEnvironmentVariable("Path", "User")' + $GetMachinePath = 'GetEnvironmentVariable("Path", "Machine")' + $SetUserPath = '[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")' + if (-not $Block.Contains($GetUserPath) -or -not $Block.Contains($GetMachinePath) -or + -not $Block.Contains($SetUserPath)) { + Fail "Windows block does not contain the expected PATH operations" + } + + $Isolated = $Block.Replace($GetUserPath, 'GetEnvironmentVariable("BITBYGIT_TEST_USER_PATH", "Process")') + $Isolated = $Isolated.Replace($GetMachinePath, 'GetEnvironmentVariable("BITBYGIT_TEST_MACHINE_PATH", "Process")') + $Isolated = $Isolated.Replace($SetUserPath, '$script:CapturedUserPath = $NewUserPath') + if ($Isolated.Contains('"User"') -or $Isolated.Contains('"Machine"')) { + Fail "Windows block still accesses persistent PATH state" + } + return $Isolated +} + +function ConvertTo-SelectedVersionBlock([string] $Block, [string] $Heading) { + $DocumentedAssignment = '$Version = "{0}"' -f $script:WorkspaceVersion + if (-not $Block.Contains($DocumentedAssignment)) { + Fail "missing selected version in $Heading powershell block" + } + + $SelectedAssignment = '$Version = "{0}"' -f $script:SelectedVersion + return $Block.Replace($DocumentedAssignment, $SelectedAssignment) +} + +$DocsText = Get-Content -Raw $DocsPath +$CargoText = Get-Content -Raw $CargoPath +$VersionMatch = [regex]::Match($CargoText, '(?ms)^\[workspace\.package\]\r?\n.*?^version = "([^"]+)"') +if (-not $VersionMatch.Success) { Fail "workspace version was not found" } +$WorkspaceVersion = $VersionMatch.Groups[1].Value +$SelectedVersion = "2.3.4" +if ($SelectedVersion -eq $WorkspaceVersion -or $SelectedVersion -eq "0.1.0") { + Fail "selected validator version must differ from the documented example" +} +if (-not $DocsText.Contains("`$Version = `"$WorkspaceVersion`"")) { + Fail "PowerShell examples do not use workspace version $WorkspaceVersion" +} +$UserPathOrder = '$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";"' +if ([regex]::Matches($DocsText, [regex]::Escape($UserPathOrder)).Count -ne 2) { + Fail "Windows examples do not prioritize the install directory in user PATH" +} +$ProcessPathOrder = '$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";"' +if ([regex]::Matches($DocsText, [regex]::Escape($ProcessPathOrder)).Count -ne 2) { + Fail "Windows examples do not prioritize the install directory in process PATH" +} +$PathEntryFilter = '-not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir' +if ([regex]::Matches($DocsText, [regex]::Escape($PathEntryFilter)).Count -ne 4) { + Fail "Windows examples do not remove empty and duplicate install directory PATH entries" +} +$MachinePathLookup = 'GetEnvironmentVariable("Path", "Machine")' +if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('User PATH cannot override it in new shells')).Count -ne 2) { + Fail "Windows examples do not reject machine-level PATH shadowing" +} +$PathExtLookup = '$env:PATHEXT -split ";"' +$CaseInsensitivePathExtensions = '$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)' +$AddPathExtension = '[void] $PathExtensions.Add($Extension)' +if ([regex]::Matches($DocsText, [regex]::Escape($PathExtLookup)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape($CaseInsensitivePathExtensions)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape($AddPathExtension)).Count -ne 2) { + Fail "Windows examples do not inspect every PATHEXT application candidate case-insensitively" +} +if ([regex]::Matches($DocsText, '(?m)^& \$ResolvedPath --version\r?$').Count -ne 2) { + Fail "Windows examples do not finish with PATH-resolved version output" +} +$ApplicationResolution = 'Get-Command bitbygit -CommandType Application -ErrorAction Stop' +if ([regex]::Matches($DocsText, [regex]::Escape($ApplicationResolution)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('[System.StringComparison]::OrdinalIgnoreCase')).Count -ne 2) { + Fail "Windows examples do not bypass aliases and functions or verify resolved path identity" +} +$RandomTempName = '[System.IO.Path]::GetRandomFileName()' +$CleanupWarning = '[Console]::Error.WriteLine("Warning: failed to remove temporary directory' +if ([regex]::Matches($DocsText, [regex]::Escape($RandomTempName)).Count -ne 2 -or + [regex]::Matches($DocsText, 'finally \{').Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('Remove-Item -LiteralPath $WorkDir -Recurse -Force')).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape($CleanupWarning)).Count -ne 2) { + Fail "Windows examples do not securely stage and clean up temporary work" +} + +foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^```')) { + $Tokens = $null + $Errors = $null + [System.Management.Automation.Language.Parser]::ParseInput( + $Match.Groups[1].Value, + [ref] $Tokens, + [ref] $Errors + ) | Out-Null + if ($Errors.Count -ne 0) { Fail "PowerShell snippet has syntax errors: $($Errors -join '; ')" } +} + +$InstallBlock = Get-Block "Windows x86-64 archive" "powershell" +$InstallBlock = ConvertTo-SelectedVersionBlock $InstallBlock "Windows x86-64 archive" +$InstallBlock = ConvertTo-IsolatedPathBlock $InstallBlock +if (-not $InstallBlock.Contains('$Package = "bitbygit-$Version-$Target"')) { + Fail "Windows package directory does not match the release layout" +} +if (-not $InstallBlock.Contains('& $InstalledBinary --version')) { + Fail "Windows installation does not validate the installed path" +} +$SourceBlock = Get-Block "Build from source" "powershell" +$SourceBlock = ConvertTo-SelectedVersionBlock $SourceBlock "Build from source" +$SourceBlock = ConvertTo-IsolatedPathBlock $SourceBlock +if (-not $SourceBlock.Contains('git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"') -or + -not $SourceBlock.Contains('git -C $SourceDir checkout --detach $TagCommit') -or + -not $SourceBlock.Contains('$HeadCommit -ne $TagCommit')) { + Fail "PowerShell source build is not pinned to the selected tag" +} + +New-Item -ItemType Directory -Path $Temp | Out-Null +$OriginalLocalAppData = $env:LOCALAPPDATA +$OriginalProcessPath = $env:Path +$OriginalTemp = $env:TEMP +$OriginalTmp = $env:TMP +$OriginalPathExt = $env:PATHEXT +$OriginalCargoTargetDir = $env:CARGO_TARGET_DIR +$OriginalCargoBuildTarget = $env:CARGO_BUILD_TARGET + +try { + cargo build --locked --release -p bitbygit + if ($LASTEXITCODE -ne 0) { Fail "cargo build failed" } + + $WorkspaceBinary = Join-Path $Root "target/release/bitbygit.exe" + $WorkspaceOutput = & $WorkspaceBinary --version + if ($LASTEXITCODE -ne 0 -or $WorkspaceOutput -ne "bitbygit $WorkspaceVersion") { + Fail "workspace binary reported the wrong version" + } + + $FixtureSource = Join-Path $Temp "selected-version-fixture.rs" + $FixtureBinary = Join-Path $Temp "selected-version-bitbygit.exe" + $FixtureSourceText = 'fn main() {{ println!("bitbygit {0}"); }}' -f $SelectedVersion + Set-Content -Path $FixtureSource -Value $FixtureSourceText + rustc --crate-name selected_version_fixture $FixtureSource -o $FixtureBinary + if ($LASTEXITCODE -ne 0) { Fail "selected-version fixture build failed" } + $CustomPathExtensions = @(".VbS", ".BiTbYgIt-Test") + $env:PATHEXT = (@(".CoM", ".EXE", ".BaT", ".CMD") + $CustomPathExtensions + @(".vbs", "", " ")) -join ";" + + $Target = "x86_64-pc-windows-msvc" + $Archive = "bitbygit-$SelectedVersion-$Target.zip" + $Package = "bitbygit-$SelectedVersion-$Target" + $Assets = Join-Path $Temp "assets" + $PackageDir = Join-Path $Assets $Package + $MachinePathDir = Join-Path $Temp "machine-bin" + New-Item -ItemType Directory -Path $PackageDir | Out-Null + New-Item -ItemType Directory -Path $MachinePathDir | Out-Null + Copy-Item $FixtureBinary (Join-Path $PackageDir "bitbygit.exe") + Compress-Archive -Path $PackageDir -DestinationPath (Join-Path $Assets $Archive) + $Digest = (Get-FileHash -Algorithm SHA256 (Join-Path $Assets $Archive)).Hash + Set-Content -Path (Join-Path $Assets "SHA256SUMS") -Value "$Digest $Archive" + + $SuccessDir = Join-Path $Temp "success" + $SuccessTemp = Join-Path $SuccessDir "temp" + $ProtectedPackage = Join-Path $SuccessDir "protected-package" + New-Item -ItemType Directory -Path $SuccessDir, $SuccessTemp, $ProtectedPackage | Out-Null + $ProtectedFile = Join-Path $SuccessDir "protected-file" + Set-Content -Path $ProtectedFile -Value "protected" + Set-Content -Path (Join-Path $ProtectedPackage "marker") -Value "protected" + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir $Archive) -Target $ProtectedFile | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir "SHA256SUMS") -Target $ProtectedFile | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir $Package) -Target $ProtectedPackage | Out-Null + $SuccessScript = Join-Path $SuccessDir "install.ps1" + Set-Content -Path $SuccessScript -Value $InstallBlock + $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" + $env:TEMP = $SuccessTemp + $env:TMP = $SuccessTemp + $env:Path = $null + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + $CapturedUserPath = $null + $env:MOCK_DOWNLOAD_DIR = $Assets + $ErrorActionPreference = "Continue" + $ShadowInvoked = $false + + function bitbygit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + function Invoke-ShadowedBitByGit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + Set-Alias -Name bitbygit -Value Invoke-ShadowedBitByGit + + function Invoke-WebRequest { + param($Uri, $OutFile) + Copy-Item (Join-Path $env:MOCK_DOWNLOAD_DIR ([System.IO.Path]::GetFileName([uri] $Uri))) $OutFile + } + + Push-Location $SuccessDir + try { + $StartingLocation = (Get-Location).Path + foreach ($Attempt in 1..2) { + $ShadowInvoked = $false + . $SuccessScript + if ($ShadowInvoked) { Fail "archive block invoked a shadowing alias or function" } + if ($Attempt -eq 1) { Remove-Item Alias:bitbygit } + if ((Get-Location).Path -ne $StartingLocation) { + Fail "archive block changed the caller working directory" + } + if ((Get-ChildItem -Force $SuccessTemp).Count -ne 0) { + Fail "archive block left temporary installation files behind" + } + } + } finally { + Pop-Location + } + Remove-Item Function:bitbygit + Remove-Item Function:Invoke-ShadowedBitByGit + + if ((Get-Item -Force (Join-Path $SuccessDir $Archive)).LinkType -ne "SymbolicLink" -or + (Get-Item -Force (Join-Path $SuccessDir "SHA256SUMS")).LinkType -ne "SymbolicLink" -or + (Get-Item -Force (Join-Path $SuccessDir $Package)).LinkType -ne "SymbolicLink") { + Fail "archive block replaced a pre-existing working-directory symlink" + } + if ((Get-Content $ProtectedFile) -ne "protected" -or + (Get-Content (Join-Path $ProtectedPackage "marker")) -ne "protected") { + Fail "archive block modified a pre-existing working-directory symlink target" + } + + $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" + $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" + if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } + if ($env:Path -ne $InstallDir) { Fail "archive block malformed an empty process PATH" } + if ($CapturedUserPath -ne $InstallDir) { Fail "archive block malformed an empty user PATH" } + if (-not (Test-Path $InstalledBinary)) { Fail "archive block did not install bitbygit.exe" } + $ResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($ResolvedBinary -ne $InstalledBinary) { Fail "archive block did not resolve the installed command through PATH" } + $Output = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $SelectedVersion") { + Fail "archive block installed the wrong version" + } + $env:Path = "$($env:BITBYGIT_TEST_MACHINE_PATH);$CapturedUserPath" + $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($NewShellResolvedBinary -ne $InstalledBinary) { Fail "archive new shell PATH did not resolve the installed command" } + $NewShellOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $NewShellOutput -ne "bitbygit $SelectedVersion") { + Fail "archive new shell PATH resolved the wrong version" + } + + $MachineConflictDir = Join-Path $Temp "machine-conflict" + $MachineOldBinaryDir = Join-Path $MachineConflictDir "machine-bin" + New-Item -ItemType Directory -Path $MachineOldBinaryDir -Force | Out-Null + Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit.exe") + $MachineConflictScript = Join-Path $MachineConflictDir "install.ps1" + Set-Content -Path $MachineConflictScript -Value $InstallBlock + $MachineConflictTemp = Join-Path $MachineConflictDir "temp" + New-Item -ItemType Directory -Path $MachineConflictTemp | Out-Null + $env:LOCALAPPDATA = Join-Path $MachineConflictDir "local-app-data" + $env:TEMP = $MachineConflictTemp + $env:TMP = $MachineConflictTemp + $env:Path = "$MachineOldBinaryDir;$OriginalProcessPath" + $env:BITBYGIT_TEST_MACHINE_PATH = $MachineOldBinaryDir + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + $CapturedUserPath = $null + $env:MOCK_DOWNLOAD_DIR = $Assets + $MachineConflictStartingPath = $env:Path + $MachineConflictStartingPathExt = $env:PATHEXT + $OldMachineOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $OldMachineOutput -ne "bitbygit $WorkspaceVersion") { + Fail "older machine-level bitbygit.exe fixture reported the wrong version" + } + foreach ($Extension in (@(".exe", ".com", ".bat", ".cmd") + $CustomPathExtensions)) { + Remove-Item (Join-Path $MachineOldBinaryDir "bitbygit.*") -Force + Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit$Extension") + $MachineConflictFailed = $false + Push-Location $MachineConflictDir + try { + try { + . $MachineConflictScript + } catch { + $MachineConflictFailed = $_.Exception.Message.Contains("User PATH cannot override it in new shells") + } + } finally { + Pop-Location + } + if (-not $MachineConflictFailed) { Fail "archive block did not reject machine-level $Extension PATH shadowing" } + if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } + if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } + if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } + if ($env:PATHEXT -ne $MachineConflictStartingPathExt) { Fail "machine-level conflict changed PATHEXT" } + $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" + $ConflictOutput = & $ConflictInstalledBinary --version + if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { + Fail "machine-level conflict did not leave the selected binary available by explicit path" + } + } + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + Push-Location $MachineConflictDir + try { + $StartingLocation = (Get-Location).Path + . $MachineConflictScript + if ((Get-Location).Path -ne $StartingLocation) { Fail "retried archive block changed the caller working directory" } + } finally { + Pop-Location + } + if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "retried archive block left temporary installation files behind" } + $ConflictRetryOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $ConflictRetryOutput -ne "bitbygit $SelectedVersion") { + Fail "archive block could not retry after removing a machine-level conflict" + } + + $FailureAssets = Join-Path $Temp "failure-assets" + $FailureDir = Join-Path $Temp "failure" + $FailureTemp = Join-Path $FailureDir "temp" + New-Item -ItemType Directory -Path $FailureAssets, $FailureDir, $FailureTemp | Out-Null + Copy-Item (Join-Path $Assets $Archive) $FailureAssets + Set-Content -Path (Join-Path $FailureAssets "SHA256SUMS") -Value "$('0' * 64) $Archive" + $FailureScript = Join-Path $FailureDir "install.ps1" + Set-Content -Path $FailureScript -Value $InstallBlock + $env:LOCALAPPDATA = Join-Path $FailureDir "local-app-data" + $env:TEMP = $FailureTemp + $env:TMP = $FailureTemp + $env:Path = $OriginalProcessPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + $env:MOCK_DOWNLOAD_DIR = $FailureAssets + $env:MOCK_SIDE_EFFECT = Join-Path $FailureDir "expanded" + $ErrorActionPreference = "Continue" + + function Expand-Archive { + Set-Content -Path $env:MOCK_SIDE_EFFECT -Value "expanded" + throw "archive extraction unexpectedly reached" + } + + $ChecksumFailed = $false + Push-Location $FailureDir + try { + try { + . $FailureScript + } catch { + $ChecksumFailed = $true + } + } finally { + Pop-Location + } + if (-not $ChecksumFailed) { Fail "archive block accepted an invalid checksum" } + if (Test-Path $env:MOCK_SIDE_EFFECT) { Fail "archive block extracted after checksum failure" } + if ((Get-ChildItem -Force $FailureTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } + if ($ErrorActionPreference -ne "Continue") { Fail "failed archive block changed the caller error preference" } + if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } + + $CleanupFailureBlock = $InstallBlock.Replace( + ' Remove-Item -LiteralPath $WorkDir -Recurse -Force', + ' throw "forced cleanup failure"' + ) + if ($CleanupFailureBlock -eq $InstallBlock) { Fail "could not force an archive cleanup failure" } + $CleanupFailureScript = Join-Path $FailureDir "install-cleanup-failure.ps1" + Set-Content -Path $CleanupFailureScript -Value $CleanupFailureBlock + $CleanupErrorWriter = [System.IO.StringWriter]::new() + $OriginalErrorWriter = [Console]::Error + $CleanupPrimaryMessage = $null + [Console]::SetError($CleanupErrorWriter) + Push-Location $FailureDir + try { + try { + . $CleanupFailureScript + } catch { + $CleanupPrimaryMessage = $_.Exception.Message + } + } finally { + Pop-Location + [Console]::SetError($OriginalErrorWriter) + } + if ([string]::IsNullOrEmpty($CleanupPrimaryMessage) -or + -not $CleanupPrimaryMessage.Contains("Checksum verification failed")) { + Fail "archive cleanup failure masked the checksum failure: $CleanupPrimaryMessage" + } + if (-not $CleanupErrorWriter.ToString().Contains("forced cleanup failure")) { + Fail "archive block did not report the cleanup failure" + } + + function git { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) + + $ExpectedTag = "refs/tags/v$SelectedVersion" + if ($Arguments.Count -eq 3 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "init") { + } elseif ($Arguments.Count -eq 7 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "fetch" -and $Arguments[3] -eq "--depth" -and + $Arguments[4] -eq "1" -and + $Arguments[5] -eq "https://github.com/cosentinode/bitbygit.git" -and + $Arguments[6] -eq "${ExpectedTag}:${ExpectedTag}") { + $script:FetchedExactTag = $true + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "rev-parse" -and $Arguments[3] -eq "--verify" -and + $Arguments[4] -eq "${ExpectedTag}^{commit}" -and $script:FetchedExactTag) { + return "tag-commit" + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "checkout" -and $Arguments[3] -eq "--detach" -and + $Arguments[4] -eq "tag-commit" -and $script:FetchedExactTag) { + $script:CheckedOutExactTag = $true + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "rev-parse" -and $Arguments[3] -eq "--verify" -and + $Arguments[4] -eq "HEAD" -and $script:CheckedOutExactTag) { + return "tag-commit" + } else { + throw "unexpected git arguments: $Arguments" + } + $global:LASTEXITCODE = 0 + } + + function cargo { + param( + [Alias("p")] [string] $Package, + [Parameter(ValueFromRemainingArguments = $true)] [object[]] $Arguments + ) + + if ($env:MOCK_CARGO_FAILURE -eq "1") { + $global:LASTEXITCODE = 1 + return + } + + $ManifestIndex = [array]::IndexOf($Arguments, "--manifest-path") + $TargetDirIndex = [array]::IndexOf($Arguments, "--target-dir") + $TargetIndex = [array]::IndexOf($Arguments, "--target") + if ($Arguments[0] -ne "build" -or $ManifestIndex -lt 0 -or $TargetDirIndex -lt 0 -or + $TargetIndex -lt 0 -or + -not $Arguments.Contains("--locked") -or -not $Arguments.Contains("--release") -or + $Package -ne "bitbygit") { + throw "unexpected cargo arguments: $Arguments" + } + $ManifestPath = [string] $Arguments[$ManifestIndex + 1] + $TargetDir = [string] $Arguments[$TargetDirIndex + 1] + $Target = [string] $Arguments[$TargetIndex + 1] + $ExpectedTargetDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ManifestPath)) "cargo-target" + if ($TargetDir -ne $ExpectedTargetDir -or $TargetDir -eq $env:CARGO_TARGET_DIR -or + $Target -ne $script:HostTarget) { + throw "cargo output was not isolated for the native target: $Arguments" + } + $BuildDir = Join-Path $TargetDir "$Target\release" + New-Item -ItemType Directory -Path $BuildDir | Out-Null + Copy-Item $FixtureBinary (Join-Path $BuildDir "bitbygit.exe") + $script:CargoUsedIsolatedTarget = $true + $global:LASTEXITCODE = 0 + } + + $SourceSuccessDir = Join-Path $Temp "source-success" + $SourceSuccessTemp = Join-Path $SourceSuccessDir "temp" + $ProtectedSource = Join-Path $SourceSuccessDir "protected-source" + New-Item -ItemType Directory -Path $SourceSuccessDir, $SourceSuccessTemp, $ProtectedSource | Out-Null + Set-Content -Path (Join-Path $ProtectedSource "marker") -Value "protected" + New-Item -ItemType SymbolicLink -Path (Join-Path $SourceSuccessDir "bitbygit") -Target $ProtectedSource | Out-Null + $SourceScript = Join-Path $SourceSuccessDir "install-from-source.ps1" + Set-Content -Path $SourceScript -Value $SourceBlock + $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" + $env:TEMP = $SourceSuccessTemp + $env:TMP = $SourceSuccessTemp + $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" + $OldBinaryDir = Join-Path $SourceSuccessDir "old-bin" + New-Item -ItemType Directory -Path $OldBinaryDir | Out-Null + Copy-Item $WorkspaceBinary (Join-Path $OldBinaryDir "bitbygit.exe") + $SourceUserPath = "$OldBinaryDir;$SourceInstallDir;$SourceInstallDir" + $SourceStartingPath = "$MachinePathDir;$SourceUserPath" + $SourceExpectedPath = "$SourceInstallDir;$MachinePathDir;$OldBinaryDir" + $SourceExpectedUserPath = "$SourceInstallDir;$OldBinaryDir" + $env:Path = $SourceStartingPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + $env:BITBYGIT_TEST_USER_PATH = $SourceUserPath + $env:CARGO_TARGET_DIR = Join-Path $SourceSuccessDir "configured-target" + $env:CARGO_BUILD_TARGET = "configured-non-host-target" + $HostLine = rustc -vV | Where-Object { $_.StartsWith("host: ") } | Select-Object -First 1 + if (-not $HostLine) { Fail "could not determine validator host target" } + $HostTarget = $HostLine.Substring(6) + $OldResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source + if ($OldResolvedBinary -ne (Join-Path $OldBinaryDir "bitbygit.exe")) { Fail "older bitbygit.exe fixture was not first on PATH" } + $OldOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $OldOutput -ne "bitbygit $WorkspaceVersion") { + Fail "older bitbygit.exe fixture reported the wrong version" + } + $CapturedUserPath = $null + $FetchedExactTag = $false + $CheckedOutExactTag = $false + $env:MOCK_CARGO_FAILURE = "0" + $ErrorActionPreference = "Continue" + $ShadowInvoked = $false + + function bitbygit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + function Invoke-ShadowedBitByGit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + Set-Alias -Name bitbygit -Value Invoke-ShadowedBitByGit + Push-Location $SourceSuccessDir + try { + $StartingLocation = (Get-Location).Path + foreach ($Attempt in 1..2) { + $FetchedExactTag = $false + $CheckedOutExactTag = $false + $CargoUsedIsolatedTarget = $false + $ShadowInvoked = $false + . $SourceScript + if ($ShadowInvoked) { Fail "source block invoked a shadowing alias or function" } + if ($Attempt -eq 1) { Remove-Item Alias:bitbygit } + if ((Get-Location).Path -ne $StartingLocation) { + Fail "source block changed the caller working directory" + } + if ((Get-ChildItem -Force $SourceSuccessTemp).Count -ne 0) { + Fail "source block left temporary build files behind" + } + if (-not $CargoUsedIsolatedTarget) { Fail "source block did not use isolated native Cargo output" } + } + } finally { + Pop-Location + } + Remove-Item Function:bitbygit + Remove-Item Function:Invoke-ShadowedBitByGit + if ((Get-Item -Force (Join-Path $SourceSuccessDir "bitbygit")).LinkType -ne "SymbolicLink" -or + (Get-Content (Join-Path $ProtectedSource "marker")) -ne "protected") { + Fail "source block modified a pre-existing working-directory source symlink" + } + $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" + if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } + if ($env:Path -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the process PATH entry" } + if (($env:Path -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the process PATH entry" } + if ($CapturedUserPath -ne $SourceExpectedUserPath) { Fail "source block did not prioritize and deduplicate the user PATH entry" } + if (($CapturedUserPath -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the user PATH entry" } + if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } + if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } + $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source + if ($SourceResolvedBinary -ne $SourceInstalledBinary) { Fail "source block did not resolve the installed command through PATH" } + $SourceOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $SelectedVersion") { + Fail "source block installed the wrong version" + } + $env:Path = "$($env:BITBYGIT_TEST_MACHINE_PATH);$CapturedUserPath" + $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source + if ($NewShellResolvedBinary -ne $SourceInstalledBinary) { Fail "new shell PATH resolved the older bitbygit.exe" } + $NewShellOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $NewShellOutput -ne "bitbygit $SelectedVersion") { + Fail "new shell PATH resolved the wrong bitbygit version" + } + + $SourceFailureDir = Join-Path $Temp "source-failure" + $SourceFailureTemp = Join-Path $SourceFailureDir "temp" + New-Item -ItemType Directory -Path $SourceFailureDir, $SourceFailureTemp | Out-Null + $SourceFailureScript = Join-Path $SourceFailureDir "install-from-source.ps1" + Set-Content -Path $SourceFailureScript -Value $SourceBlock + $env:LOCALAPPDATA = Join-Path $SourceFailureDir "local-app-data" + $env:TEMP = $SourceFailureTemp + $env:TMP = $SourceFailureTemp + $env:Path = $OriginalProcessPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + $FetchedExactTag = $false + $CheckedOutExactTag = $false + $env:MOCK_CARGO_FAILURE = "1" + $ErrorActionPreference = "Continue" + $SourceBuildFailed = $false + Push-Location $SourceFailureDir + try { + $StartingLocation = (Get-Location).Path + try { + . $SourceFailureScript + } catch { + $SourceBuildFailed = $true + } + if ((Get-Location).Path -ne $StartingLocation) { + Fail "failed source block changed the caller working directory" + } + } finally { + Pop-Location + } + if (-not $SourceBuildFailed) { Fail "source block continued after a failed build" } + if ((Get-ChildItem -Force $SourceFailureTemp).Count -ne 0) { Fail "failed source block left temporary build files behind" } + if ($ErrorActionPreference -ne "Continue") { Fail "failed source block changed the caller error preference" } + if ($env:Path -ne $OriginalProcessPath) { Fail "failed source block changed the process PATH" } + + $env:MOCK_CARGO_FAILURE = "0" + $CapturedUserPath = $null + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + Push-Location $SourceFailureDir + try { + $StartingLocation = (Get-Location).Path + . $SourceFailureScript + if ((Get-Location).Path -ne $StartingLocation) { Fail "retried source block changed the caller working directory" } + } finally { + Pop-Location + } + if ((Get-ChildItem -Force $SourceFailureTemp).Count -ne 0) { Fail "retried source block left temporary build files behind" } + $SourceRetryOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $SourceRetryOutput -ne "bitbygit $SelectedVersion") { + Fail "source block could not retry after a failed build" + } + + $global:LASTEXITCODE = 0 + Write-Output "installation docs validation passed on Windows" +} finally { + $env:LOCALAPPDATA = $OriginalLocalAppData + $env:Path = $OriginalProcessPath + $env:TEMP = $OriginalTemp + $env:TMP = $OriginalTmp + $env:PATHEXT = $OriginalPathExt + $env:CARGO_TARGET_DIR = $OriginalCargoTargetDir + $env:CARGO_BUILD_TARGET = $OriginalCargoBuildTarget + Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue +} diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh new file mode 100755 index 0000000..ab47f2d --- /dev/null +++ b/scripts/test-installation-docs.sh @@ -0,0 +1,529 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +docs="${root}/docs/installation.md" +workflow="${root}/.github/workflows/release.yml" +ci_workflow="${root}/.github/workflows/ci.yml" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +fail() { + echo "installation docs validation failed: $*" >&2 + exit 1 +} + +extract_block() { + local heading="$1" + local language="$2" + HEADING="${heading}" LANGUAGE="${language}" perl -0777 -ne ' + $heading = quotemeta($ENV{HEADING}); + $language = quotemeta($ENV{LANGUAGE}); + if (/^## $heading\n.*?^```$language\n(.*?)^```/ms) { + print $1; + } + ' "${docs}" +} + +docs_text="$(<"${docs}")" +workflow_text="$(<"${workflow}")" +ci_workflow_text="$(<"${ci_workflow}")" +workspace_version="$(perl -ne ' + $workspace = 1 if /^\[workspace\.package\]$/; + if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } +' "${root}/Cargo.toml")" +[[ -n "${workspace_version}" ]] || fail "workspace version was not found" +validator_host_target="$(rustc -vV | perl -ne 'print $1 if /^host: (.+)$/')" +[[ -n "${validator_host_target}" ]] || fail "rustc host target was not found" +selected_version=2.3.4 +[[ "${selected_version}" != "${workspace_version}" && "${selected_version}" != "0.1.0" ]] || fail "selected validator version must differ from the documented example" +[[ "${docs_text}" == *"VERSION=${workspace_version}"* ]] || fail "Bash examples do not use workspace version ${workspace_version}" +[[ "${docs_text}" == *"\$Version = \"${workspace_version}\""* ]] || fail "PowerShell examples do not use workspace version ${workspace_version}" + +extract_selected_block() { + local block + block="$(extract_block "$1" "$2")" + [[ "${block}" == *"VERSION=${workspace_version}"* ]] || fail "missing selected version in $1 $2 block" + printf '%s' "${block/VERSION=${workspace_version}/VERSION=${selected_version}}" +} + +extract_selected_path_block() { + extract_selected_block "$1" bash | perl -0777 -ne ' + if (/^BITBYGIT_INSTALL\n(.*)\z/ms) { print $1 } + ' +} + +release_artifacts=() +while IFS= read -r artifact; do + release_artifacts+=("${artifact}") +done < <(perl -0777 -ne ' + while (/^\s+- runner:.*?^\s+target:\s+(\S+)\s*\n^\s+archive_extension:\s+(\S+)/msg) { + print "bitbygit--$1.$2\n"; + } +' "${workflow}") +[[ "${#release_artifacts[@]}" -eq 4 ]] || fail "expected four release artifacts" +for artifact in "${release_artifacts[@]}"; do + [[ "${docs_text}" == *"${artifact}"* ]] || fail "missing release artifact ${artifact}" +done +[[ "${ci_workflow_text}" == *"toolchain: 1.85.0"* ]] || fail "CI does not pin the documented minimum Rust version" +for target in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-msvc; do + [[ "${ci_workflow_text}" == *"target: ${target}"* ]] || fail "Rust 1.85 CI does not build ${target}" +done +[[ "${workflow_text}" == *'package="bitbygit-${version}-${{ matrix.target }}"'* ]] || fail "release package directory contract changed" +[[ "${docs_text}" == *'PACKAGE="bitbygit-${VERSION}-${TARGET}"'* ]] || fail "Bash package directory does not match release layout" +[[ "${docs_text}" == *'$Package = "bitbygit-$Version-$Target"'* ]] || fail "PowerShell package directory does not match release layout" +[[ "${docs_text}" == *'"${PACKAGE}/bitbygit"'* ]] || fail "Unix binary path does not match release layout" +[[ "${docs_text}" == *'"$Package\bitbygit.exe"'* ]] || fail "Windows binary path does not match release layout" + +perl -0777 -ne 'while (/^```bash\n(.*?)^```/msg) { print "$1\n" }' "${docs}" > "${tmp}/snippets.bash" +bash -n "${tmp}/snippets.bash" + +mkdir -p "${tmp}/assets" "${tmp}/mock-bin" +for target in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin; do + package="bitbygit-${selected_version}-${target}" + mkdir -p "${tmp}/package/${package}" + printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${selected_version}" > "${tmp}/package/${package}/bitbygit" + chmod +x "${tmp}/package/${package}/bitbygit" + tar -C "${tmp}/package" -czf "${tmp}/assets/${package}.tar.gz" "${package}" + rm -rf "${tmp}/package/${package}" +done +( + cd "${tmp}/assets" + for archive in bitbygit-*.tar.gz; do + if command -v sha256sum >/dev/null; then + digest="$(sha256sum "${archive}")" + else + digest="$(shasum -a 256 "${archive}")" + fi + printf '%s %s\n' "${digest%% *}" "${archive}" + done > SHA256SUMS +) + +cat > "${tmp}/mock-bin/curl" <<'MOCK' +#!/usr/bin/env bash +for source in "$@"; do :; done +cp "${MOCK_DOWNLOAD_DIR}/${source##*/}" . +MOCK +cat > "${tmp}/mock-bin/uname" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "-m" ]]; then + printf '%s\n' "${MOCK_UNAME_MACHINE}" +else + /usr/bin/uname "$@" +fi +MOCK +cat > "${tmp}/mock-bin/bitbygit" <<'MOCK' +#!/usr/bin/env sh +printf 'bitbygit 9.9.9\n' +MOCK +chmod +x "${tmp}/mock-bin/"* + +check_unix_success() { + local heading="$1" + local target="$2" + local machine="$3" + local case_dir="${tmp}/success-${target}" + local archive="bitbygit-${selected_version}-${target}.tar.gz" + local package="bitbygit-${selected_version}-${target}" + mkdir -p "${case_dir}/home" "${case_dir}/temp" "${case_dir}/protected-package" + printf 'protected\n' > "${case_dir}/protected-file" + printf 'protected\n' > "${case_dir}/protected-package/marker" + ln -s protected-file "${case_dir}/${archive}" + ln -s protected-file "${case_dir}/SHA256SUMS" + ln -s protected-package "${case_dir}/${package}" + extract_selected_block "${heading}" bash > "${case_dir}/snippet.bash" + extract_selected_path_block "${heading}" > "${case_dir}/path-snippet.bash" + [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" + + ( + cd "${case_dir}" + set +e + set +u + set +o pipefail + export HOME="${case_dir}/home" + export TMPDIR="${case_dir}/temp" + export MOCK_DOWNLOAD_DIR="${tmp}/assets" + export MOCK_UNAME_MACHINE="${machine}" + base_path="${PATH}" + newline_entry="${case_dir}/line"$'\n'"break" + expected_path="${HOME}/.local/bin::${tmp}/mock-bin::${newline_entry}:${base_path}:" + export PATH=":${tmp}/mock-bin::${newline_entry}:${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" + starting_dir="${PWD}" + + bitbygit() { + : > "${case_dir}/function-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + source "${case_dir}/snippet.bash" || fail "${heading} failed a valid ${target} installation" + [[ ! -e "${case_dir}/function-shadow-used" ]] || fail "${heading} invoked a shadowing function" + unset -f bitbygit + shadow_bitbygit() { + : > "${case_dir}/alias-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + shopt -s expand_aliases + alias bitbygit=shadow_bitbygit + source "${case_dir}/snippet.bash" || fail "${heading} failed when retried" + [[ ! -e "${case_dir}/alias-shadow-used" ]] || fail "${heading} invoked a shadowing alias" + unalias bitbygit + unset -f shadow_bitbygit + [[ "${PWD}" == "${starting_dir}" ]] || fail "${heading} changed the caller working directory" + [[ "$-" != *e* && "$-" != *u* ]] || fail "${heading} changed caller shell options" + if shopt -qo pipefail; then + fail "${heading} enabled pipefail in the caller shell" + fi + [[ "${PATH}" == "${expected_path}" ]] || fail "${heading} did not preserve empty PATH entries while deduplicating the install directory" + path_entry_count=0 + remaining_path="${PATH}" + while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" + [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + done + [[ "${remaining_path}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + [[ "${path_entry_count}" -eq 1 ]] || fail "${heading} duplicated the install directory on PATH" + [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" + [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} installed the wrong version" + resolved_binary="$(type -P bitbygit)" + [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed application through PATH" + [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" + default_path="$(command -p getconf PATH)" + unset PATH + source "${case_dir}/path-snippet.bash" || fail "${heading} failed with an originally unset PATH" + [[ "${PATH}" == "${HOME}/.local/bin:${default_path}" ]] || fail "${heading} did not retain default command lookup for an originally unset PATH" + [[ -n "$(type -P ls)" ]] || fail "${heading} lost standard command lookup for an originally unset PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "${heading} left temporary installation files behind" + fi + ) + [[ -L "${case_dir}/${archive}" && "$(readlink "${case_dir}/${archive}")" == protected-file ]] || fail "${heading} replaced the pre-existing archive symlink" + [[ -L "${case_dir}/SHA256SUMS" && "$(readlink "${case_dir}/SHA256SUMS")" == protected-file ]] || fail "${heading} replaced the pre-existing checksum symlink" + [[ -L "${case_dir}/${package}" && "$(readlink "${case_dir}/${package}")" == protected-package ]] || fail "${heading} replaced the pre-existing package symlink" + [[ "$(<"${case_dir}/protected-file")" == protected ]] || fail "${heading} modified a symlink target" + [[ "$(<"${case_dir}/protected-package/marker")" == protected ]] || fail "${heading} extracted through a package symlink" +} + +host_os="${INSTALLATION_DOCS_TEST_OS:-$(uname -s)}" +case "${host_os}" in + Linux) + check_unix_success "Linux x86-64 archive" x86_64-unknown-linux-gnu x86_64 + failure_heading="Linux x86-64 archive" + failure_target=x86_64-unknown-linux-gnu + failure_machine=x86_64 + ;; + Darwin) + check_unix_success "macOS archive" x86_64-apple-darwin x86_64 + check_unix_success "macOS archive" aarch64-apple-darwin arm64 + failure_heading="macOS archive" + failure_target=x86_64-apple-darwin + failure_machine=x86_64 + ;; + *) fail "unsupported validation host ${host_os}" ;; +esac + +mkdir -p "${tmp}/failure-assets" "${tmp}/failure-bin" +cp "${tmp}/assets/bitbygit-${selected_version}-${failure_target}.tar.gz" "${tmp}/failure-assets/" +printf '%064d bitbygit-%s-%s.tar.gz\n' 0 "${selected_version}" "${failure_target}" > "${tmp}/failure-assets/SHA256SUMS" +cp "${tmp}/mock-bin/curl" "${tmp}/failure-bin/curl" +cp "${tmp}/mock-bin/uname" "${tmp}/failure-bin/uname" +for command in tar install; do + cat > "${tmp}/failure-bin/${command}" <<'MOCK' +#!/usr/bin/env bash +: > "${MOCK_SIDE_EFFECTS}" +MOCK +done +chmod +x "${tmp}/failure-bin/"* + +failure_dir="${tmp}/failure-${failure_target}" +mkdir -p "${failure_dir}/home" "${failure_dir}/temp" +extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" +( + cd "${failure_dir}" + set +e + set +u + set +o pipefail + original_path="${tmp}/failure-bin:${PATH}" + export HOME="${failure_dir}/home" + export TMPDIR="${failure_dir}/temp" + export MOCK_DOWNLOAD_DIR="${tmp}/failure-assets" + export MOCK_SIDE_EFFECTS="${failure_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${original_path}" + if source "${failure_dir}/snippet.bash"; then + fail "${failure_heading} continued after checksum failure" + fi + [[ "$-" != *e* && "$-" != *u* ]] || fail "failed ${failure_heading} changed caller shell options" + if shopt -qo pipefail; then + fail "failed ${failure_heading} enabled pipefail in the caller shell" + fi + [[ "${PATH}" == "${original_path}" ]] || fail "failed ${failure_heading} changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "failed ${failure_heading} left temporary installation files behind" + fi +) +[[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" + +collision_dir="${tmp}/checksum-collision-${failure_target}" +collision_assets="${tmp}/checksum-collision-assets" +mkdir -p "${collision_dir}/home" "${collision_dir}/temp" "${collision_dir}/mock-bin" "${collision_assets}" +collision_archive="bitbygit-${selected_version}-${failure_target}.tar.gz" +cp "${tmp}/assets/${collision_archive}" "${collision_assets}/${collision_archive}" +printf 'malicious sidecar\n' > "${collision_assets}/${collision_archive}.sig" +if command -v sha256sum >/dev/null; then + collision_digest="$(sha256sum "${collision_assets}/${collision_archive}.sig")" +else + collision_digest="$(shasum -a 256 "${collision_assets}/${collision_archive}.sig")" +fi +printf '%s %s.sig\n' "${collision_digest%% *}" "${collision_archive}" > "${collision_assets}/SHA256SUMS" +cp "${tmp}/failure-bin/uname" "${tmp}/failure-bin/tar" "${tmp}/failure-bin/install" "${collision_dir}/mock-bin/" +cat > "${collision_dir}/mock-bin/curl" <<'MOCK' +#!/usr/bin/env bash +for source in "$@"; do :; done +name="${source##*/}" +cp "${MOCK_DOWNLOAD_DIR}/${name}" . +if [[ -f "${MOCK_DOWNLOAD_DIR}/${name}.sig" ]]; then + cp "${MOCK_DOWNLOAD_DIR}/${name}.sig" . +fi +MOCK +chmod +x "${collision_dir}/mock-bin/"* +extract_selected_block "${failure_heading}" bash > "${collision_dir}/snippet.bash" +( + cd "${collision_dir}" + original_path="${collision_dir}/mock-bin:${PATH}" + export HOME="${collision_dir}/home" + export TMPDIR="${collision_dir}/temp" + export MOCK_DOWNLOAD_DIR="${collision_assets}" + export MOCK_SIDE_EFFECTS="${collision_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${original_path}" + if source "${collision_dir}/snippet.bash"; then + fail "${failure_heading} accepted a checksum for a similarly named file" + fi + [[ "${PATH}" == "${original_path}" ]] || fail "checksum collision changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "checksum collision left temporary installation files behind" + fi +) +[[ ! -e "${collision_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed without an exact checksum entry" + +cleanup_failure_dir="${tmp}/cleanup-failure-${failure_target}" +mkdir -p "${cleanup_failure_dir}/home" "${cleanup_failure_dir}/temp" "${cleanup_failure_dir}/mock-bin" +cp "${tmp}/failure-bin/"* "${cleanup_failure_dir}/mock-bin/" +cat > "${cleanup_failure_dir}/mock-bin/rm" <<'MOCK' +#!/usr/bin/env sh +exit 73 +MOCK +chmod +x "${cleanup_failure_dir}/mock-bin/rm" +extract_selected_block "${failure_heading}" bash > "${cleanup_failure_dir}/snippet.bash" +( + cd "${cleanup_failure_dir}" + export HOME="${cleanup_failure_dir}/home" + export TMPDIR="${cleanup_failure_dir}/temp" + export MOCK_DOWNLOAD_DIR="${tmp}/failure-assets" + export MOCK_SIDE_EFFECTS="${cleanup_failure_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${cleanup_failure_dir}/mock-bin:${PATH}" + if source "${cleanup_failure_dir}/snippet.bash" > "${cleanup_failure_dir}/output" 2>&1; then + fail "${failure_heading} accepted an invalid checksum when cleanup failed" + else + cleanup_status=$? + fi + [[ "${cleanup_status}" -eq 1 ]] || fail "${failure_heading} cleanup failure masked status 1 with ${cleanup_status}" + cleanup_output="$(<"${cleanup_failure_dir}/output")" + [[ "${cleanup_output}" == *"Warning: failed to remove temporary directory"* ]] || fail "${failure_heading} did not report cleanup failure" +) +[[ ! -e "${cleanup_failure_dir}/side-effects" ]] || fail "${failure_heading} continued after checksum and cleanup failures" + +[[ "${docs_text}" == *'git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}"'* ]] || fail "Bash source build does not fetch the exact selected tag" +[[ "${docs_text}" == *'git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" +[[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" +[[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" +[[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" +[[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" +[[ "$(grep -c 'resolved_binary="$(type -P bitbygit || true)"' "${docs}")" -eq 3 ]] || fail "Unix installations do not bypass aliases and functions during PATH resolution" +[[ "$(grep -c -- '-ef "${install_dir}/bitbygit"' "${docs}")" -eq 3 ]] || fail "Unix installations do not verify resolved file identity" +[[ "$(grep -c '^ command bitbygit --version$' "${docs}")" -eq 3 ]] || fail "Unix installations do not finish with PATH-resolved version output" +[[ "$(grep -c '^& \$ResolvedPath --version$' "${docs}")" -eq 2 ]] || fail "Windows installations do not finish with PATH-resolved version output" +[[ "$(grep -c 'remaining_path="${PATH}"' "${docs}")" -eq 3 ]] || fail "Unix installations do not parse PATH by its colon delimiter" +[[ "${docs_text}" != *'read -r -a path_entries'* ]] || fail "Unix installations use line-oriented PATH serialization" +[[ "${docs_text}" == *'--target-dir "${TARGET_DIR}" --target "${host_target}"'* ]] || fail "Bash source build does not isolate and pin Cargo output" +[[ "${docs_text}" == *'--target-dir $TargetDir --target $HostTarget'* ]] || fail "PowerShell source build does not isolate and pin Cargo output" + +source_remote="${tmp}/source-remote.git" +source_seed="${tmp}/source-seed" +git init --bare --quiet "${source_remote}" +git init --quiet "${source_seed}" +git -C "${source_seed}" config user.email validator@example.invalid +git -C "${source_seed}" config user.name "Installation validator" +printf '[workspace]\nmembers = []\n' > "${source_seed}/Cargo.toml" +git -C "${source_seed}" add Cargo.toml +git -C "${source_seed}" commit --quiet -m "tagged source" +git -C "${source_seed}" tag -a "v${selected_version}" -m "release ${selected_version}" +printf '[workspace]\nmembers = []\n# branch, not tag\n' > "${source_seed}/Cargo.toml" +git -C "${source_seed}" commit --quiet -am "same-named branch" +git -C "${source_seed}" branch "v${selected_version}" +git -C "${source_seed}" push --quiet "${source_remote}" \ + "refs/heads/v${selected_version}:refs/heads/v${selected_version}" \ + "refs/tags/v${selected_version}:refs/tags/v${selected_version}" + +source_dir="${tmp}/source-install" +mkdir -p "${source_dir}/home" "${source_dir}/mock-bin" "${source_dir}/temp" "${source_dir}/protected-source" +printf 'protected\n' > "${source_dir}/protected-source/marker" +ln -s protected-source "${source_dir}/bitbygit" +cat > "${source_dir}/mock-bin/cargo" <<'MOCK' +#!/usr/bin/env sh +if [ "${MOCK_CARGO_FAILURE:-0}" = 1 ]; then + exit 1 +fi +manifest= +target_dir= +target= +locked=0 +release=0 +package= +while [ "$#" -gt 0 ]; do + case "$1" in + build) ;; + --manifest-path) shift; manifest="$1" ;; + --target-dir) shift; target_dir="$1" ;; + --target) shift; target="$1" ;; + --locked) locked=1 ;; + --release) release=1 ;; + -p) shift; package="$1" ;; + *) exit 64 ;; + esac + shift +done +[ -n "${manifest}" ] && [ -f "${manifest}" ] || exit 65 +[ "${target}" = "${MOCK_HOST_TARGET}" ] || exit 66 +[ "${target_dir}" != "${CARGO_TARGET_DIR}" ] || exit 67 +case "${target_dir}" in */cargo-target) ;; *) exit 68 ;; esac +[ "${locked}" = 1 ] && [ "${release}" = 1 ] && [ "${package}" = bitbygit ] || exit 69 +mkdir -p "${target_dir}/${target}/release" +printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${MOCK_SELECTED_VERSION}" > "${target_dir}/${target}/release/bitbygit" +chmod +x "${target_dir}/${target}/release/bitbygit" +printf '%s\n' "${target_dir}/${target}/release/bitbygit" > "${MOCK_CARGO_RECORD}" +MOCK +cat > "${source_dir}/mock-bin/rustc" <<'MOCK' +#!/usr/bin/env sh +[ "$#" -eq 1 ] && [ "$1" = -vV ] || exit 64 +printf 'rustc 1.85.0\nhost: %s\nrelease: 1.85.0\n' "${MOCK_HOST_TARGET}" +MOCK +cp "${tmp}/mock-bin/bitbygit" "${source_dir}/mock-bin/bitbygit" +chmod +x "${source_dir}/mock-bin/"* +source_snippet="$(extract_selected_block "Build from source" bash)" +source_snippet="${source_snippet/https:\/\/github.com\/cosentinode\/bitbygit.git/${source_remote}}" +printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" +extract_selected_path_block "Build from source" > "${source_dir}/path-snippet.bash" +( + cd "${source_dir}" + export HOME="${source_dir}/home" + export TMPDIR="${source_dir}/temp" + export CARGO_TARGET_DIR="${source_dir}/configured-target" + export CARGO_BUILD_TARGET="configured-non-host-target" + export MOCK_HOST_TARGET="${validator_host_target}" + export MOCK_SELECTED_VERSION="${selected_version}" + export MOCK_CARGO_RECORD="${source_dir}/cargo-output" + base_path="${PATH}" + newline_entry="${source_dir}/line"$'\n'"break" + original_path=":${source_dir}/mock-bin::${newline_entry}:${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" + expected_path="${HOME}/.local/bin::${source_dir}/mock-bin::${newline_entry}:${base_path}:" + export PATH="${original_path}" + starting_dir="${PWD}" + export MOCK_CARGO_FAILURE=1 + if source "${source_dir}/snippet.bash"; then + fail "Bash source block continued after a failed build" + fi + [[ "${PWD}" == "${starting_dir}" ]] || fail "failed Bash source block changed the caller working directory" + [[ "${PATH}" == "${original_path}" ]] || fail "failed Bash source block changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "failed Bash source block left temporary build files behind" + fi + + export MOCK_CARGO_FAILURE=0 + bitbygit() { + : > "${source_dir}/function-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + source "${source_dir}/snippet.bash" || fail "Bash source block could not retry a failed build" + [[ -s "${MOCK_CARGO_RECORD}" ]] || fail "Bash source block did not use the isolated Cargo output" + [[ "$(<"${MOCK_CARGO_RECORD}")" != "${CARGO_TARGET_DIR}"* ]] || fail "Bash source block honored an external Cargo target directory" + [[ ! -e "${source_dir}/function-shadow-used" ]] || fail "Bash source block invoked a shadowing function" + unset -f bitbygit + shadow_bitbygit() { + : > "${source_dir}/alias-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + shopt -s expand_aliases + alias bitbygit=shadow_bitbygit + source "${source_dir}/snippet.bash" || fail "Bash source block failed when retried" + [[ ! -e "${source_dir}/alias-shadow-used" ]] || fail "Bash source block invoked a shadowing alias" + unalias bitbygit + unset -f shadow_bitbygit + [[ "${PWD}" == "${starting_dir}" ]] || fail "Bash source block changed the caller working directory" + [[ "${PATH}" == "${expected_path}" ]] || fail "Bash source block did not preserve empty PATH entries while deduplicating the install directory" + path_entry_count=0 + remaining_path="${PATH}" + while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" + [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + done + [[ "${remaining_path}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + [[ "${path_entry_count}" -eq 1 ]] || fail "Bash source block duplicated the install directory on PATH" + resolved_binary="$(type -P bitbygit)" + [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed application through PATH" + [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" + default_path="$(command -p getconf PATH)" + unset PATH + source "${source_dir}/path-snippet.bash" || fail "Bash source block failed with an originally unset PATH" + [[ "${PATH}" == "${HOME}/.local/bin:${default_path}" ]] || fail "Bash source block did not retain default command lookup for an originally unset PATH" + [[ -n "$(type -P git)" ]] || fail "Bash source block lost standard command lookup for an originally unset PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "Bash source block left temporary build files behind" + fi +) +[[ -L "${source_dir}/bitbygit" && "$(readlink "${source_dir}/bitbygit")" == protected-source ]] || fail "Bash source block replaced a pre-existing source symlink" +[[ "$(<"${source_dir}/protected-source/marker")" == protected ]] || fail "Bash source block wrote through a pre-existing source symlink" + +check_links() { + local markdown="$1" + local base + base="$(dirname "${markdown}")" + while IFS= read -r destination; do + case "${destination}" in + https://*) curl -fsSL --retry 3 --output /dev/null "${destination}" || fail "unreachable link ${destination}" ;; + http://*) fail "insecure link ${destination}" ;; + *) + local fragment="" + local target="${destination%%#*}" + if [[ "${destination}" == *#* ]]; then + fragment="${destination#*#}" + fi + if [[ -z "${target}" ]]; then + target="${markdown}" + else + target="${base}/${target}" + fi + [[ -e "${target}" ]] || fail "missing local link ${destination} in ${markdown#"${root}/"}" + if [[ -n "${fragment}" ]]; then + ANCHOR="${fragment}" perl -ne ' + next unless /^#+\s+(.+)/; + $heading = lc $1; + $heading =~ s/[`*_]//g; + $heading =~ s/[^a-z0-9 -]//g; + $heading =~ s/\s+/-/g; + if ($heading eq $ENV{ANCHOR}) { $found = 1; last } + END { exit($found ? 0 : 1) } + ' "${target}" || fail "missing anchor #${fragment} in ${target#"${root}/"}" + fi + ;; + esac + done < <(perl -ne 'while (/\[[^]]+\]\(([^ )]+)(?:\s+"[^"]*")?\)/g) { print "$1\n" }' "${markdown}") +} + +check_links "${root}/README.md" +check_links "${docs}" + +echo "installation docs validation passed on ${host_os}"