Files
ai-for-dummies/.agents/scripts/publish-pages.sh
T
Marcos Paulo 25ef5af63e
verify-and-publish / gate (push) Successful in 14m30s
verify-and-publish / publish (push) Has been skipped
fix: refuse to publish a build that logged a vite error
The build output went to /dev/null and only the exit code was checked,
which `astro build` returns as 0 even when vite cannot resolve an asset.
Run by hand -- the gate is not in the loop then -- this script would have
force-pushed that build over the live site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 09:15:42 +00:00

124 lines
4.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build the site and publish it to the `pages` branch.
#
# .agents/scripts/publish-pages.sh # publish
# .agents/scripts/publish-pages.sh --dry-run # build and report, push nothing
# .agents/scripts/publish-pages.sh --pending X # main is *about* to become X
#
# `--pending` exists for the pre-push hook. Git has no post-push hook, so the
# hook necessarily runs before main lands on the remote and the usual "HEAD must
# equal origin/main" check cannot hold yet. The caller asserts the SHA the push
# will create, and the hook only asserts it after confirming the push is a
# fast-forward.
#
# `pages` is what the Gitea Pages Server actually serves. Publishing overwrites
# the live site. There is no staging environment between here and visitors.
#
# This never checks `pages` out. It writes a tree straight from `dist/` with
# plumbing (`write-tree` + `commit-tree`), so your working tree is untouched and
# a failure halfway through leaves nothing behind. The commit is parented on the
# current `pages`, so the branch keeps its history and rollback is one push.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
dry_run=0
pending=''
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=1 ;;
--pending)
shift
pending="${1:-}"
;;
*)
echo "publish-pages: unknown argument '$1'" >&2
exit 2
;;
esac
shift
done
fail() {
echo "publish-pages: $1" >&2
exit 1
}
# Publishing a build made from uncommitted work means the live site shows
# something no commit describes, and nobody can reproduce it later.
[ -z "$(git status --porcelain)" ] || fail 'working tree is dirty; commit or stash first'
branch=$(git rev-parse --abbrev-ref HEAD)
[ "$branch" = 'main' ] || fail "publishing from '$branch'; only main is publishable"
git fetch --quiet origin pages
head=$(git rev-parse HEAD)
if [ -n "$pending" ]; then
[ "$head" = "$(git rev-parse "$pending")" ] ||
fail "HEAD is $head but the pending push is $pending"
else
git fetch --quiet origin main
[ "$head" = "$(git rev-parse origin/main)" ] ||
fail 'HEAD is not origin/main; push main first so the site matches a pushed commit'
fi
previous=$(git rev-parse origin/pages)
echo "publish-pages: building $head"
# `astro build` exits 0 even when vite fails to resolve an asset, so the exit
# code alone is not enough to know the build is whole. The gate greps for this
# too; repeat it here because this script is also run by hand.
build_log=$(mktemp)
trap 'rm -f "$build_log"' EXIT
pnpm run build >"$build_log" 2>&1 || {
cat "$build_log" >&2
fail 'astro build failed'
}
if grep -q '\[ERROR\]' "$build_log"; then
cat "$build_log" >&2
fail 'astro build logged an error and still exited 0; refusing to publish'
fi
# A build can succeed and still emit a stub -- that is exactly how this site
# would go down. Check the routes exist before overwriting anything live.
for route in index full-guide/index summary/index models/index agents/index \
skills/index rules/index skills-review/index \
hands-on/starter/index hands-on/rules/index; do
[ -s "dist/$route.html" ] || fail "dist/$route.html missing or empty; refusing to publish"
done
# GIT_INDEX_FILE must name a path that does not exist yet: git reads an existing
# empty file as a truncated index and dies with "index file smaller than
# expected". mktemp -d gives a private directory to put that path in.
index_dir=$(mktemp -d)
index="$index_dir/index"
trap 'rm -rf "$index_dir"; rm -f "$build_log"' EXIT
# `--force` because the repository .gitignore lists `dist`; here `dist` *is* the
# work tree, so those rules would otherwise exclude everything we mean to ship.
GIT_INDEX_FILE="$index" git --work-tree=dist add --all --force .
tree=$(GIT_INDEX_FILE="$index" git write-tree)
if [ "$tree" = "$(git rev-parse "$previous^{tree}")" ]; then
echo "publish-pages: dist is identical to the published tree; nothing to do"
exit 0
fi
subject="chore: publish $(git rev-parse --short "$head")"
commit=$(git commit-tree "$tree" -p "$previous" -m "$subject
Built from main $head
$(git log -1 --format=%s "$head")")
if [ "$dry_run" -eq 1 ]; then
echo "publish-pages: would push $commit to pages (previous $previous)"
echo "publish-pages: dry run, nothing pushed"
exit 0
fi
echo "publish-pages: rollback point is $previous"
echo " git push --force origin $previous:refs/heads/pages"
# AF_PUBLISHING stops the pre-push hook recursing into this script.
AF_PUBLISHING=1 git push --force origin "$commit:refs/heads/pages"
echo "publish-pages: published $commit"