diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9cbb9b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..853e943 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,175 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + UV_MALWARE_CHECK: "1" + +jobs: + static-checks: + name: Static checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Zsh + run: | + sudo apt-get update + sudo apt-get install --yes zsh + - name: Check shell syntax + run: | + bash -n assimilate.sh bashrc bash_profile claude/statusline.sh scripts/ci/*.sh tests/*.sh + zsh -n zshrc + - name: Test VPS container harness + run: | + scripts/ci/test-vps-container.sh + bash tests/clone-pinned.sh + - name: Check Git configuration + run: git config --file gitconfig --list >/dev/null + - name: Check strict JSON + run: | + find nvim -maxdepth 1 -type f -name '*.json' -exec jq empty {} + + jq empty vscode/code_settings.json zed/keymap.json + + pre-commit: + name: Pre-commit + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pre-commit + run: python3 -m pip install --user pre-commit==4.6.2 + - name: Run repository hooks + run: python3 -m pre_commit run --all-files --show-diff-on-failure + + actions-security: + name: Actions security + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Validate workflows with actionlint + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + - name: Audit workflows with zizmor + run: pipx run zizmor==1.29.0 . + + secret-history: + name: Secret history + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Check out full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install pinned Gitleaks + env: + GOBIN: ${{ runner.temp }}/bin + run: go install github.com/zricethezav/gitleaks/v8@v8.30.1 + - name: Scan Git history + run: '"${RUNNER_TEMP}/bin/gitleaks" git --redact --verbose' + + install: + name: Install (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - macos-26 + - ubuntu-24.04 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Linux test prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes zsh + - name: Install dotfiles twice on hosted runner + env: + HOMEBREW_NO_ANALYTICS: "1" + run: | + # The hosted VM and its home directory are discarded after this job. + # Keep the runner's package-manager state while honoring assimilate's + # documented requirement that the repository live at $HOME/dotfiles. + test ! -e "$HOME/dotfiles" + ln -s "$GITHUB_WORKSPACE" "$HOME/dotfiles" + + first_started=$SECONDS + ./assimilate.sh + DOTFILES="$HOME/dotfiles" scripts/ci/verify-assimilate.sh + first_seconds=$((SECONDS - first_started)) + backup_count="$(find "$HOME/backups" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" + + second_started=$SECONDS + ASSIMILATE_SKIP_PLUGIN_SYNC=1 ./assimilate.sh + DOTFILES="$HOME/dotfiles" scripts/ci/verify-assimilate.sh + second_seconds=$((SECONDS - second_started)) + + test "$(find "$HOME/backups" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" = "$backup_count" + + { + echo "### Install timing (${{ matrix.os }})" + echo + echo "| Pass | Seconds |" + echo "| --- | ---: |" + echo "| Clean install | $first_seconds |" + echo "| Idempotency | $second_seconds |" + } >> "$GITHUB_STEP_SUMMARY" + + vps-install: + name: VPS install (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + target: + - ubuntu + - amazon-linux + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install dotfiles twice in a minimal VPS image + env: + VPS_TARGET: ${{ matrix.target }} + run: scripts/ci/run-vps-container.sh run "$VPS_TARGET" diff --git a/.gitignore b/.gitignore index d713c1d..b5f4f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,13 @@ nvim/plugin/ gh/ .claude .wrangler + +# Secrets and machine-local tool state +*.pem +*.key +.env +.env.* +credentials.json +*.secret +Brewfile.lock.json +homebrew/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0f37bf7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/Brewfile b/Brewfile index 4df7c68..8054604 100644 --- a/Brewfile +++ b/Brewfile @@ -12,6 +12,7 @@ brew "neovim" brew "mosh" brew "node" brew "postgresql@17" +brew "pre-commit" brew "tmux" brew "tree" brew "wget" diff --git a/assimilate.sh b/assimilate.sh index cb94c0e..daecd3c 100755 --- a/assimilate.sh +++ b/assimilate.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -ex +set -euxo pipefail OS="$(uname -s)" ARCH="$(uname -m)" @@ -16,9 +16,16 @@ function sym () { # Ensure parent directory exists mkdir -p "$(dirname "$dest")" + # An already-correct link is installed. Leaving it alone makes repeated + # assimilation safe and avoids filling the backup directory on every run. + if [ -L "$dest" ] && [ "$(readlink "$dest")" = "$src" ]; then + return + fi + # Save existing dotfiles (also matches dangling symlinks, where -e alone returns false) if [ -e "$dest" ] || [ -L "$dest" ]; then - backup="$BACKUPS/$(basename $dest)-$(date +%s)" + backup="$(mktemp -d "$BACKUPS/$(basename "$dest").XXXXXX")" + rmdir "$backup" mv "$dest" "$backup" echo "> Moved $dest to $backup" fi @@ -29,6 +36,17 @@ function sym () { function clone_pinned () { url="$1"; dir="$2"; sha="$3" + current_sha="" + if [ -d "$dir/.git" ]; then + current_sha="$(git -C "$dir" rev-parse --verify HEAD 2>/dev/null || true)" + fi + + # The requested content is already installed. In particular, this avoids + # repeating four network fetches during CI's second idempotency pass. + if [ "$current_sha" = "$sha" ]; then + return + fi + # Fetch only the pinned commit, no full history. Avoids `git clone --revision` # (git >= 2.49) so this works on older git too, e.g. AL2023's 2.40 on stale AMIs. # Fetch-by-SHA relies on the server allowing reachable-SHA1-in-want (GitHub does). @@ -80,7 +98,13 @@ if [ "$OS" = "Darwin" ]; then sym zed/keymap.json .config/zed/keymap.json sym ghostty/config Library/Application\ Support/com.mitchellh.ghostty/config - brew bundle install + # Homebrew 6 requires explicit trust before installing formulae from a tap. + # Skip bundle resolution when everything is already present, which makes the + # second CI install substantially cheaper without caching runner state. + if ! brew bundle check --quiet --no-upgrade --file="$DOTFILES/Brewfile"; then + brew trust hashicorp/tap + brew bundle install --no-upgrade --file="$DOTFILES/Brewfile" + fi fi # Sync portable agent settings after Homebrew has supplied jq, yq, and Codex on @@ -96,6 +120,12 @@ else echo "WARN: yq or codex is unavailable — skipping Codex settings sync" >&2 fi +# Install repository security hooks when pre-commit is available (Homebrew +# supplies it on macOS). Linux users can install pre-commit independently. +if command -v pre-commit >/dev/null 2>&1; then + (cd "$DOTFILES" && pre-commit install --allow-missing-config) +fi + # Install oh-my-zsh (clone repo directly; install.sh is just `git clone` once # its zshrc/runzsh/chsh side-effects are disabled) clone_pinned https://github.com/ohmyzsh/ohmyzsh "$HOME/.oh-my-zsh" e7aa0c56e68348afefdd6af4c5bdb314a2bd6640 # 2026-04 master HEAD @@ -103,7 +133,7 @@ clone_pinned https://github.com/ohmyzsh/ohmyzsh "$HOME/.oh-my-zsh" e7aa0c56e6834 # Install rust via rustup-init.sh pinned to a specific GitHub commit (immutable), # with rustc toolchain version locked if [ ! -d "$HOME/.cargo" ]; then - RUSTUP_SHA=e10ffbdbb807c47fdd208119de99e7baae3e0dfe # rustup 1.29.0 + RUSTUP_SHA=28d1352dbcb436d3111c3594b9e1588e94950464 # rustup 1.29.0 tag's commit curl --proto '=https' --tlsv1.2 -sSf \ "https://raw.githubusercontent.com/rust-lang/rustup/$RUSTUP_SHA/rustup-init.sh" \ | sh -s -- -y --no-modify-path --default-toolchain 1.95.0 @@ -154,11 +184,12 @@ clone_pinned https://github.com/erikw/tmux-powerline "$HOME/.tmux/plugins/tmux-p # Install neovim on Linux from a pinned upstream tarball (macOS gets it via Brewfile). # AL2023 doesn't ship neovim in its default dnf repos. Lands in $HOME/.local so no # root needed, and runs before the PackerSync block below so the plugin sync works. -NVIM_VERSION=0.9.5 -NVIM_SHA256=44ee395d9b5f8a14be8ec00d3b8ead34e18fe6461e40c9c8c50e6956d643b6ca -if [ "$OS" = "Linux" ] && [ "$ARCH" = "x86_64" ] && ! command -v nvim >/dev/null; then +NVIM_VERSION=0.12.4 +NVIM_SHA256=012bf3fcac5ade43914df3f174668bf64d05e049a4f032a388c027b1ebd78628 +NVIM_BIN="$HOME/.local/bin/nvim" +if [ "$OS" = "Linux" ] && [ "$ARCH" = "x86_64" ] && { [ ! -x "$NVIM_BIN" ] || [ "$("$NVIM_BIN" --version | head -n 1)" != "NVIM v${NVIM_VERSION}" ]; }; then tmp=$(mktemp -d) - curl -fsSL "https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-linux64.tar.gz" -o "$tmp/nvim.tar.gz" + curl -fsSL "https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-linux-x86_64.tar.gz" -o "$tmp/nvim.tar.gz" if command -v sha256sum >/dev/null; then echo "${NVIM_SHA256} $tmp/nvim.tar.gz" | sha256sum -c - else @@ -166,17 +197,24 @@ if [ "$OS" = "Linux" ] && [ "$ARCH" = "x86_64" ] && ! command -v nvim >/dev/null fi mkdir -p "$HOME/.local/share" "$HOME/.local/bin" tar -xzf "$tmp/nvim.tar.gz" -C "$HOME/.local/share" - ln -sf "$HOME/.local/share/nvim-linux64/bin/nvim" "$HOME/.local/bin/nvim" + ln -sf "$HOME/.local/share/nvim-linux-x86_64/bin/nvim" "$NVIM_BIN" rm -rf "$tmp" - # Make nvim visible to the rest of this script (PackerSync below) +fi + +if [ "$OS" = "Linux" ] && [ "$ARCH" = "x86_64" ] && [ -x "$NVIM_BIN" ]; then + # Prefer the pinned nvim for PackerSync, even when the host has another version. export PATH="$HOME/.local/bin:$PATH" fi -# Install Packer (nvim plugin manager) and run PackerSync — only if nvim is available +# Install Packer (nvim plugin manager) and run PackerSync — only if nvim is available. +# CI skips only this update operation on its second pass; the verification that +# follows still starts Neovim and checks the installed plugin manager. if command -v nvim >/dev/null; then clone_pinned https://github.com/wbthomason/packer.nvim "$HOME/.local/share/nvim/site/pack/packer/start/packer.nvim" ea0cc3c59f67c440c5ff0bbe4fb9420f4350b9a3 # 2023-08-24, matches plugins.lua pin - nvim --headless -c 'autocmd User PackerComplete quitall' -c 'PackerSync' || true + if [ "${ASSIMILATE_SKIP_PLUGIN_SYNC:-0}" != 1 ]; then + nvim --headless -c 'autocmd User PackerComplete quitall' -c 'PackerSync' || true + fi fi echo "> Assimilation successful!" diff --git a/bashrc b/bashrc index 8de032c..1726056 100644 --- a/bashrc +++ b/bashrc @@ -7,6 +7,9 @@ export EDITOR='nvim' export TERM='xterm-256color' export LANG='en_US.UTF-8' +# Ask uv to check resolved packages against malicious-package advisories. +export UV_MALWARE_CHECK=1 + # ---- # Aliases diff --git a/scripts/ci/run-vps-container.sh b/scripts/ci/run-vps-container.sh new file mode 100755 index 0000000..0c8d8de --- /dev/null +++ b/scripts/ci/run-vps-container.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "usage: $0 image|run ubuntu|amazon-linux" >&2 + exit 2 +} + +image_for() { + case "$1" in + ubuntu) + echo "ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea" + ;; + amazon-linux) + echo "amazonlinux:2023@sha256:694092ae18877ed4e3cb9b643759ba95df1f12af12528fefa18f60f79d4c1568" + ;; + *) + echo "unsupported VPS target: $1" >&2 + return 2 + ;; + esac +} + +command="${1:-}" +target="${2:-}" +[ "$#" -eq 2 ] || usage + +image="$(image_for "$target")" + +if [ "$command" = image ]; then + echo "$image" + exit 0 +fi + +[ "$command" = run ] || usage + +repo_root="$(git rev-parse --show-toplevel)" + +docker run --rm --interactive \ + --platform linux/amd64 \ + --env VPS_TARGET="$target" \ + --volume "$repo_root:/workspace:ro" \ + "$image" \ + bash -s <<'CONTAINER' +set -euxo pipefail + +case "$VPS_TARGET" in + ubuntu) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install --yes ca-certificates curl findutils git gzip passwd tar zsh + ;; + amazon-linux) + dnf install --assumeyes ca-certificates curl-minimal findutils git gzip shadow-utils tar util-linux zsh + ;; + *) + echo "unsupported VPS target: $VPS_TARGET" >&2 + exit 2 + ;; +esac + +useradd --create-home --shell /bin/bash dotfiles +cp -a /workspace /home/dotfiles/dotfiles +chown -R dotfiles:dotfiles /home/dotfiles/dotfiles + +runuser --user dotfiles -- env \ + HOME=/home/dotfiles \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + bash -c ' + set -euo pipefail + cd "$HOME/dotfiles" + + first_started=$SECONDS + ./assimilate.sh + DOTFILES="$HOME/dotfiles" scripts/ci/verify-assimilate.sh + first_seconds=$((SECONDS - first_started)) + backup_count="$(find "$HOME/backups" -mindepth 1 -maxdepth 1 | wc -l | tr -d " ")" + + second_started=$SECONDS + ASSIMILATE_SKIP_PLUGIN_SYNC=1 ./assimilate.sh + DOTFILES="$HOME/dotfiles" scripts/ci/verify-assimilate.sh + second_seconds=$((SECONDS - second_started)) + test "$(find "$HOME/backups" -mindepth 1 -maxdepth 1 | wc -l | tr -d " ")" = "$backup_count" + + echo "> Install timing: clean=${first_seconds}s idempotency=${second_seconds}s" + ' +CONTAINER diff --git a/scripts/ci/test-vps-container.sh b/scripts/ci/test-vps-container.sh new file mode 100755 index 0000000..a4c506d --- /dev/null +++ b/scripts/ci/test-vps-container.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +runner="scripts/ci/run-vps-container.sh" + +test -x "$runner" +test "$("$runner" image ubuntu)" = "ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea" +test "$("$runner" image amazon-linux)" = "amazonlinux:2023@sha256:694092ae18877ed4e3cb9b643759ba95df1f12af12528fefa18f60f79d4c1568" +grep -Fq -- '--interactive' "$runner" +grep -Fq 'curl-minimal' "$runner" + +grep -Fq 'set -euxo pipefail' assimilate.sh +grep -Fq '28d1352dbcb436d3111c3594b9e1588e94950464' assimilate.sh +grep -Fq 'NVIM_VERSION=0.12.4' assimilate.sh +grep -Fq 'current_sha="$(git -C "$dir" rev-parse --verify HEAD 2>/dev/null || true)"' assimilate.sh +grep -Fq 'brew bundle check --quiet --no-upgrade --file="$DOTFILES/Brewfile"' assimilate.sh +grep -Fq 'ASSIMILATE_SKIP_PLUGIN_SYNC' assimilate.sh +grep -Fq 'ASSIMILATE_SKIP_PLUGIN_SYNC=1 ./assimilate.sh' "$runner" +grep -Fq 'ASSIMILATE_SKIP_PLUGIN_SYNC=1 ./assimilate.sh' .github/workflows/ci.yml +grep -Fq 'set -euxo pipefail' scripts/ci/verify-assimilate.sh +grep -Fq "bash --noprofile -ic 'test \"\$UV_MALWARE_CHECK\" = 1'" scripts/ci/verify-assimilate.sh +grep -Fq "zsh -ic 'test \"\$UV_MALWARE_CHECK\" = 1'" scripts/ci/verify-assimilate.sh +if grep -Fq -- '--norc' scripts/ci/verify-assimilate.sh; then + echo "VPS shell verification must not disable .bashrc" >&2 + exit 1 +fi + +if "$runner" image alpine >/dev/null 2>&1; then + echo "unsupported VPS target unexpectedly succeeded" >&2 + exit 1 +fi diff --git a/scripts/ci/verify-assimilate.sh b/scripts/ci/verify-assimilate.sh new file mode 100755 index 0000000..af64441 --- /dev/null +++ b/scripts/ci/verify-assimilate.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +: "${DOTFILES:?DOTFILES must point to the checked-out repository}" +export PATH="$HOME/.local/bin:$PATH" + +assert_link() { + local source="$DOTFILES/$1" + local target="$HOME/$2" + + test -L "$target" + test "$(realpath "$target")" = "$(realpath "$source")" +} + +assert_link bash_profile .bash_profile +assert_link gitconfig .gitconfig +assert_link tmux.conf .tmux.conf +assert_link zshrc .zshrc +assert_link nvim .config/nvim +assert_link tmux-powerline/config.sh .config/tmux-powerline/config.sh +assert_link tmux-powerline/themes/theme.sh .config/tmux-powerline/themes/theme.sh +assert_link claude/statusline.sh .claude/statusline.sh + +if [ "$(uname -s)" = Darwin ]; then + assert_link bashrc .bashrc + assert_link hammerspoon .hammerspoon + assert_link vscode/code_settings.json .vscode/settings.json + assert_link zed/settings.json .config/zed/settings.json + assert_link zed/keymap.json .config/zed/keymap.json + assert_link ghostty/config 'Library/Application Support/com.mitchellh.ghostty/config' +else + test ! -L "$HOME/.bashrc" + grep -Fq 'dotfiles/bashrc' "$HOME/.bashrc" +fi + +HOME="$HOME" bash --noprofile -ic 'test "$UV_MALWARE_CHECK" = 1' >/dev/null 2>&1 +HOME="$HOME" zsh -ic 'test "$UV_MALWARE_CHECK" = 1' >/dev/null 2>&1 +test "$("$HOME/.local/bin/delta" --version)" = "delta 0.19.2" +test -x "$HOME/.cargo/bin/rustc" +test -d "$HOME/.oh-my-zsh/.git" +test -d "$HOME/.tmux/plugins/tpm/.git" +test -d "$HOME/.tmux/plugins/tmux-powerline/.git" +if [ -f "$DOTFILES/nvim/lazy-lock.json" ]; then + test -d "$HOME/.local/share/nvim/lazy/lazy.nvim/.git" +else + test -d "$HOME/.local/share/nvim/site/pack/packer/start/packer.nvim/.git" +fi + +nvim_log="$(mktemp)" +if ! HOME="$HOME" nvim --headless -c 'quitall' >"$nvim_log" 2>&1; then + cat "$nvim_log" >&2 + rm -f "$nvim_log" + exit 1 +fi +if grep -Fq 'Error detected while processing' "$nvim_log"; then + cat "$nvim_log" >&2 + rm -f "$nvim_log" + exit 1 +fi +rm -f "$nvim_log" + +git -C "$DOTFILES" diff --exit-code +git -C "$DOTFILES" diff --cached --exit-code diff --git a/tests/clone-pinned.sh b/tests/clone-pinned.sh new file mode 100644 index 0000000..3219d19 --- /dev/null +++ b/tests/clone-pinned.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/clone-pinned.XXXXXX")" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT HUP INT TERM + +# Load only the helper under test without executing the installer. +eval "$(sed -n '/^function clone_pinned () {/,/^}/p' "$repo_root/assimilate.sh")" + +source_repo="$test_root/source" +remote_repo="$test_root/remote.git" +install_dir="$test_root/install" + +git init --quiet "$source_repo" +git -C "$source_repo" config user.email ci@example.invalid +git -C "$source_repo" config user.name CI +git -C "$source_repo" config commit.gpgsign false +printf 'pinned content\n' > "$source_repo/content" +git -C "$source_repo" add content +git -C "$source_repo" commit --quiet -m fixture +sha="$(git -C "$source_repo" rev-parse HEAD)" + +git init --quiet --bare "$remote_repo" +git -C "$source_repo" remote add origin "$remote_repo" +git -C "$source_repo" push --quiet origin HEAD:main + +clone_pinned "$remote_repo" "$install_dir" "$sha" +test "$(git -C "$install_dir" rev-parse HEAD)" = "$sha" + +# If the second call attempts a fetch, moving the remote makes it fail. A +# successful call therefore proves that an already-pinned checkout is offline. +mv "$remote_repo" "$test_root/remote-offline.git" +clone_pinned "$remote_repo" "$install_dir" "$sha" +test "$(git -C "$install_dir" rev-parse HEAD)" = "$sha" + +echo "clone_pinned skips network access for installed commits"