diff --git a/site/README.md b/site/README.md
index b47a7a2..57467ba 100644
--- a/site/README.md
+++ b/site/README.md
@@ -52,7 +52,7 @@ From the repo root:
pnpm --dir site sync # wiki → src/content/docs + sidebar
pnpm --dir site dev # local preview (SITE_BASE=/)
./tools/site-build.sh # production build (base=/misaligned)
-./tools/site-deploy.sh # build + force-push orphan `pages` branch
+./tools/site-deploy.sh # build, no-op if unchanged, else force-push orphan `pages` branch
```
Generated paths (`src/content/docs/`, `src/content/assets/`, `src/generated/`,
diff --git a/tools/check.sh b/tools/check.sh
index 20de302..9b3c3fa 100755
--- a/tools/check.sh
+++ b/tools/check.sh
@@ -159,6 +159,7 @@ step "script syntax"
for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/ci-rust-changed.sh tools/seed-cargo-target.sh \
tools/claim.sh tools/worktree-new.sh tools/worktree-done.sh tools/heartbeat.sh \
tools/ledger_index.sh tools/test_corpus_engine.sh tools/test_ci_rust_changed.sh \
+ tools/test_site_deploy.sh \
tools/task.sh tools/doctor.sh tools/bevy-headless.sh; do
[ -f "$script" ] || continue
bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; }
@@ -215,6 +216,7 @@ start_docs_gate "corpus" "bash tools/corpus_gate.sh"
start_docs_gate "wiki" "bash tools/wiki_gate.sh"
start_docs_gate "corpus-engine-fixtures" "bash tools/test_corpus_engine.sh"
start_docs_gate "ci-rust-classifier-fixtures" "bash tools/test_ci_rust_changed.sh"
+start_docs_gate "site-deploy-fixtures" "bash tools/test_site_deploy.sh"
start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check"
start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions"
start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py"
diff --git a/tools/site-deploy.sh b/tools/site-deploy.sh
index 5ef38b1..d85e2ba 100755
--- a/tools/site-deploy.sh
+++ b/tools/site-deploy.sh
@@ -13,6 +13,16 @@ SITE="$ROOT/site"
DIST="$SITE/dist"
BRANCH="${PAGES_BRANCH:-pages}"
REMOTE="${PAGES_REMOTE:-origin}"
+TMP_REMOTE="pages-remote"
+SSH_CMD="$(git -C "$ROOT" config --get core.sshCommand || true)"
+
+git_remote() {
+ if [ -n "$SSH_CMD" ]; then
+ GIT_SSH_COMMAND="$SSH_CMD" git "$@"
+ else
+ git "$@"
+ fi
+}
"$ROOT/tools/site-build.sh"
@@ -30,26 +40,42 @@ git -C "$TMP" init -q -b "$BRANCH"
# Copy dist contents (including hidden files like .nojekyll if present).
cp -a "$DIST"/. "$TMP"/
git -C "$TMP" add -A
-if git -C "$TMP" diff --cached --quiet; then
- echo "site-deploy: nothing to publish" >&2
+NEW_TREE="$(git -C "$TMP" write-tree)"
+
+if ! REMOTE_URL="$(git -C "$ROOT" remote get-url "$REMOTE")"; then
+ echo "site-deploy: missing remote url for '$REMOTE'" >&2
exit 1
fi
+if ls_remote_output="$(git_remote -C "$ROOT" ls-remote --exit-code --heads "$REMOTE_URL" "$BRANCH" 2>&1)"; then
+ git -C "$TMP" remote add "$TMP_REMOTE" "$REMOTE_URL"
+ git_remote -C "$TMP" fetch --depth=1 "$TMP_REMOTE" "$BRANCH"
+ REMOTE_TREE="$(git -C "$TMP" rev-parse "refs/remotes/$TMP_REMOTE/$BRANCH^{tree}")"
+
+ if [ "$NEW_TREE" = "$REMOTE_TREE" ]; then
+ echo "site-deploy: public-site content is current on $REMOTE/$BRANCH"
+ echo "site-deploy: nothing to publish"
+ exit 0
+ fi
+else
+ ls_remote_status=$?
+ if [ "$ls_remote_status" -eq 2 ]; then
+ echo "site-deploy: missing remote pages branch $REMOTE/$BRANCH, initial publish"
+ else
+ printf '%s\n' "$ls_remote_output" >&2
+ echo "site-deploy: failed to inspect $REMOTE/$BRANCH" >&2
+ exit "$ls_remote_status"
+ fi
+fi
+
git -C "$TMP" \
-c user.name="$(git -C "$ROOT" config user.name || echo misaligned-site)" \
-c user.email="$(git -C "$ROOT" config user.email || echo site@misaligned.local)" \
commit -q -m "Publish Starlight wiki site"
-# Reuse the repo's SSH command so Tangled auth matches main pushes.
-SSH_CMD="$(git -C "$ROOT" config --get core.sshCommand || true)"
-PUSH_ENV=()
-if [ -n "$SSH_CMD" ]; then
- PUSH_ENV=(env GIT_SSH_COMMAND="$SSH_CMD")
-fi
-
-REMOTE_URL="$(git -C "$ROOT" remote get-url "$REMOTE")"
+# Reuse the repo's SSH command so Tangled auth matches main pushes and reads.
echo "site-deploy: force-pushing orphan $BRANCH → $REMOTE ($REMOTE_URL)"
-"${PUSH_ENV[@]}" git -C "$TMP" push --force "$REMOTE_URL" "HEAD:refs/heads/$BRANCH"
+git_remote -C "$TMP" push --force "$REMOTE_URL" "HEAD:refs/heads/$BRANCH"
echo "site-deploy: OK. Configure Tangled Settings → Sites:"
echo " branch=$BRANCH deploy directory=/ (sub-path site)"
diff --git a/tools/test_site_deploy.sh b/tools/test_site_deploy.sh
new file mode 100644
index 0000000..93f72cb
--- /dev/null
+++ b/tools/test_site_deploy.sh
@@ -0,0 +1,244 @@
+#!/usr/bin/env bash
+# Fixture tests for tools/site-deploy.sh using a local bare remote and a fake
+# SSH transport. Covers missing-branch initial publish, identical-tree no-op,
+# changed-tree publish, and ls-remote transport failure classification.
+set -euo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+fail=0
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+setup_fixture_repo() {
+ local root=$1
+
+ mkdir -p "$root/tools" "$root/site/dist"
+ cp tools/site-deploy.sh "$root/tools/site-deploy.sh"
+
+ cat > "$root/tools/site-build.sh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DIST="$ROOT/site/dist"
+VARIANT="${SITE_DEPLOY_FIXTURE_VARIANT:-base}"
+
+rm -rf "$DIST"
+mkdir -p "$DIST"
+printf '%s\n' "$VARIANT" > "$DIST/content.txt"
+printf '
%s\n' "$VARIANT" > "$DIST/index.html"
+printf '' > "$DIST/.nojekyll"
+EOF
+ chmod +x "$root/tools/site-build.sh"
+
+ cat > "$root/fake-ssh.sh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+REMOTE="$ROOT/remote.git"
+LOG="$ROOT/ssh.log"
+SERVICES="$ROOT/ssh-services.log"
+joined="$*"
+
+printf '%s\n' "$joined" >> "$LOG"
+
+case "$joined" in
+ *git-upload-pack*)
+ service="git-upload-pack"
+ ;;
+ *git-receive-pack*)
+ service="git-receive-pack"
+ ;;
+ *)
+ echo "fake-ssh: unsupported invocation: $joined" >&2
+ exit 97
+ ;;
+esac
+
+printf '%s\n' "$service" >> "$SERVICES"
+
+if [ "${FAKE_SSH_FAIL_ON:-}" = "$service" ]; then
+ echo "fake-ssh: forced failure for $service" >&2
+ exit 66
+fi
+
+case "$service" in
+ git-upload-pack)
+ exec git-upload-pack "$REMOTE"
+ ;;
+ git-receive-pack)
+ exec git-receive-pack "$REMOTE"
+ ;;
+esac
+EOF
+ chmod +x "$root/fake-ssh.sh"
+
+ git init --bare -q "$root/remote.git"
+ git -C "$root" init -q
+ git -C "$root" config user.name "Fixture User"
+ git -C "$root" config user.email "fixture@example.com"
+ git -C "$root" config core.sshCommand "$root/fake-ssh.sh"
+ git -C "$root" remote add origin "ssh://fixture/placeholder.git"
+}
+
+run_deploy_ok() {
+ local root=$1
+ local variant=$2
+ (
+ cd "$root"
+ GIT_SSH_VARIANT=simple SITE_DEPLOY_FIXTURE_VARIANT="$variant" bash tools/site-deploy.sh
+ ) 2>&1
+}
+
+run_deploy_fail() {
+ local root=$1
+ local variant=$2
+ local fail_on=$3
+ (
+ cd "$root"
+ GIT_SSH_VARIANT=simple SITE_DEPLOY_FIXTURE_VARIANT="$variant" FAKE_SSH_FAIL_ON="$fail_on" \
+ bash tools/site-deploy.sh
+ ) 2>&1
+}
+
+remote_head() {
+ git -C "$1/remote.git" rev-parse "refs/heads/$2"
+}
+
+remote_tree() {
+ git -C "$1/remote.git" rev-parse "refs/heads/$2^{tree}"
+}
+
+parent_count() {
+ git -C "$1/remote.git" rev-list --parents -n 1 "refs/heads/$2" | awk '{ print NF - 1 }'
+}
+
+count_service() {
+ local root=$1
+ local service=$2
+ if [ ! -f "$root/ssh-services.log" ]; then
+ echo 0
+ return
+ fi
+ awk -v service="$service" '$0 == service { count++ } END { print count + 0 }' "$root/ssh-services.log"
+}
+
+reset_ssh_logs() {
+ : > "$1/ssh.log"
+ : > "$1/ssh-services.log"
+}
+
+echo "=== fixture: missing branch initial publish ==="
+root="$tmp/missing-branch"
+setup_fixture_repo "$root"
+output="$(run_deploy_ok "$root" base)" || {
+ echo "FAIL fixture missing-branch: expected success"
+ echo "$output"
+ fail=1
+}
+echo "$output" | grep -q "missing remote pages branch origin/pages, initial publish" || {
+ echo "FAIL fixture missing-branch: did not report initial publish"
+ echo "$output"
+ fail=1
+}
+head_before="$(remote_head "$root" pages)"
+tree_before="$(remote_tree "$root" pages)"
+[ "$(parent_count "$root" pages)" = "0" ] || {
+ echo "FAIL fixture missing-branch: initial publish commit is not orphaned"
+ fail=1
+}
+[ "$(count_service "$root" git-upload-pack)" = "1" ] || {
+ echo "FAIL fixture missing-branch: ls-remote did not use fake SSH exactly once"
+ fail=1
+}
+[ "$(count_service "$root" git-receive-pack)" = "1" ] || {
+ echo "FAIL fixture missing-branch: push did not use fake SSH exactly once"
+ fail=1
+}
+
+echo "=== fixture: identical-tree no-op ==="
+reset_ssh_logs "$root"
+output="$(run_deploy_ok "$root" base)" || {
+ echo "FAIL fixture identical-tree: expected success"
+ echo "$output"
+ fail=1
+}
+echo "$output" | grep -q "public-site content is current on origin/pages" || {
+ echo "FAIL fixture identical-tree: missing current-content message"
+ echo "$output"
+ fail=1
+}
+echo "$output" | grep -q "nothing to publish" || {
+ echo "FAIL fixture identical-tree: missing no-op message"
+ echo "$output"
+ fail=1
+}
+[ "$head_before" = "$(remote_head "$root" pages)" ] || {
+ echo "FAIL fixture identical-tree: remote head changed on no-op"
+ fail=1
+}
+[ "$(count_service "$root" git-upload-pack)" = "2" ] || {
+ echo "FAIL fixture identical-tree: expected ls-remote + fetch through fake SSH"
+ fail=1
+}
+[ "$(count_service "$root" git-receive-pack)" = "0" ] || {
+ echo "FAIL fixture identical-tree: no-op should not push"
+ fail=1
+}
+
+echo "=== fixture: changed-tree publish ==="
+reset_ssh_logs "$root"
+output="$(run_deploy_ok "$root" changed)" || {
+ echo "FAIL fixture changed-tree: expected success"
+ echo "$output"
+ fail=1
+}
+head_after="$(remote_head "$root" pages)"
+tree_after="$(remote_tree "$root" pages)"
+[ "$head_before" != "$head_after" ] || {
+ echo "FAIL fixture changed-tree: remote head did not change"
+ fail=1
+}
+[ "$tree_before" != "$tree_after" ] || {
+ echo "FAIL fixture changed-tree: remote tree did not change"
+ fail=1
+}
+[ "$(parent_count "$root" pages)" = "0" ] || {
+ echo "FAIL fixture changed-tree: publish commit is not orphaned"
+ fail=1
+}
+[ "$(count_service "$root" git-upload-pack)" = "2" ] || {
+ echo "FAIL fixture changed-tree: expected ls-remote + fetch through fake SSH"
+ fail=1
+}
+[ "$(count_service "$root" git-receive-pack)" = "1" ] || {
+ echo "FAIL fixture changed-tree: changed tree did not push exactly once"
+ fail=1
+}
+
+echo "=== fixture: transport failure is not treated as missing branch ==="
+root="$tmp/transport-failure"
+setup_fixture_repo "$root"
+if output="$(run_deploy_fail "$root" base git-upload-pack)"; then
+ echo "FAIL fixture transport-failure: expected non-zero exit"
+ fail=1
+else
+ echo "$output" | grep -q "fake-ssh: forced failure for git-upload-pack" || {
+ echo "FAIL fixture transport-failure: missing forced-failure output"
+ echo "$output"
+ fail=1
+ }
+ if echo "$output" | grep -q "missing remote pages branch"; then
+ echo "FAIL fixture transport-failure: transport failure was misclassified as missing branch"
+ echo "$output"
+ fail=1
+ fi
+fi
+
+if [ "$fail" -ne 0 ]; then
+ echo "site deploy fixtures: FAILED"
+ exit 1
+fi
+
+echo "site deploy fixtures: OK"
diff --git a/wiki/log/2026-07-10-site-deploy-freshness.md b/wiki/log/2026-07-10-site-deploy-freshness.md
new file mode 100644
index 0000000..50cf2e0
--- /dev/null
+++ b/wiki/log/2026-07-10-site-deploy-freshness.md
@@ -0,0 +1,34 @@
+# 2026-07-10 — Site deploy freshness no-op guard
+
+```
+Type: log
+```
+
+## Intent
+
+Avoid redundant public-site publishes when the built `site/dist/` tree is already
+published to the configured remote `pages` branch.
+
+## Changed
+
+- `tools/site-deploy.sh` now compares the staged `site/dist/` tree against the
+ current remote `$PAGES_BRANCH` tree before committing.
+- When trees match, it now reports current content, exits successfully, and does
+ not commit or push.
+- When the remote branch does not exist, the script performs an initial publish
+ as before.
+- Remote reads (`ls-remote`, `fetch`) now reuse the repository `core.sshCommand`
+ the same way push already did, so the same Tangled identity is used for every
+ remote operation.
+- Transport/auth failures during the remote branch probe now fail as transport/auth
+ failures instead of being misreported as a missing branch.
+- `wiki/process/workflows.md` now documents the freshness/no-op behavior for
+ `site-deploy.sh`, and `site/README.md` now matches the new no-op behavior.
+- Added `tools/test_site_deploy.sh`, a deterministic fake-SSH bare-remote fixture
+ test that covers initial publish, identical-tree no-op, changed-tree publish,
+ and probe failure classification.
+
+## Checks
+
+- Shell syntax check: `bash -n tools/site-deploy.sh`
+- Fixture coverage: `bash tools/test_site_deploy.sh`
diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md
index d05b200..bb502d6 100644
--- a/wiki/log/DEVLOG.md
+++ b/wiki/log/DEVLOG.md
@@ -126,6 +126,11 @@ add or amend a session log, then re-run the generator.
- Intent: (see session log)
- Log: [wiki/log/2026-07-10-subtle-signal-ember.md](2026-07-10-subtle-signal-ember.md)
+## 2026-07-10 - Site deploy freshness no-op guard
+
+- Intent: Avoid redundant public-site publishes when the built `site/dist/` tree is already published to the configured remote `pages` branch.
+- Log: [wiki/log/2026-07-10-site-deploy-freshness.md](2026-07-10-site-deploy-freshness.md)
+
## 2026-07-10 - Sinks not modes, and the consistency sweep
- Intent: Cameron asked for an internal-consistency review after the thought-flow capture. Three parallel audits (mechanics cluster, interface/gameplay cluster, machine-work.md itself) found one design gap — the Operations mode was orphaned: with one substance and sink-determined effect...
diff --git a/wiki/process/workflows.md b/wiki/process/workflows.md
index 47424e1..62a0cb5 100644
--- a/wiki/process/workflows.md
+++ b/wiki/process/workflows.md
@@ -214,7 +214,7 @@ Starlight's content tree and builds the sidebar from `wiki/SUMMARY.md`.
```bash
pnpm --dir site dev # sync wiki + local preview (base=/)
./tools/site-build.sh # sync + build → site/dist/ (base=/misaligned)
-./tools/site-deploy.sh # build, then force-push orphan pages branch
+./tools/site-deploy.sh # build, no-op if unchanged, otherwise force-push orphan pages branch
```
The public site opens on a **splash homepage** (`site/src/pages/index.astro`)
@@ -246,6 +246,11 @@ though `origin/pages` has a valid `index.html`:
After Save, check **Recent Deploys** on that settings page — a successful
deploy copies the branch into Tangled's object store. Re-save or push
`pages` again if the deploy list is empty.
+`./tools/site-deploy.sh` now compares the freshly built tree against the current
+remote `pages` tree; when identical, it exits cleanly and skips creating
+or pushing a commit. It reuses the repository's `core.sshCommand` for
+`ls-remote`, `fetch`, and `push`, and only treats an actual missing branch as
+an initial-publish case.
`site/astro.config.mjs` sets `base: '/misaligned'` to match Tangled's
sub-path hosting (the edge strips `/misaligned` before looking up files;