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;