diff --git a/.agents/scripts/publish-pages.sh b/.agents/scripts/publish-pages.sh new file mode 100755 index 0000000..0de0ab7 --- /dev/null +++ b/.agents/scripts/publish-pages.sh @@ -0,0 +1,107 @@ +#!/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" +pnpm run build >/dev/null + +# 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 + +index=$(mktemp) +trap 'rm -f "$index"' 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" diff --git a/.husky/pre-push b/.husky/pre-push index fbc3a35..3477fd3 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,40 @@ # Tier 2: the real gate. Whole project. Budget < 90s. # Takes a cross-worktree lock so parallel agents queue instead of thrashing. -exec .agents/scripts/gate.sh +# The publish step below re-enters git push. Without this, that inner push would +# fire this hook again, run the gate again, and publish again, forever. +if [ "${AF_PUBLISHING:-0}" = '1' ]; then + exit 0 +fi + +.agents/scripts/gate.sh || exit 1 + +# Publishing to `pages` overwrites the live site. It happens here, on a push of +# main to origin, and nowhere else. +# +# Set AF_NO_PUBLISH=1 to push main without republishing: +# AF_NO_PUBLISH=1 git push +[ "${AF_NO_PUBLISH:-0}" = '1' ] && exit 0 + +remote_name=$1 +[ "$remote_name" = 'origin' ] || exit 0 + +# stdin gives one line per ref being pushed: +# +zero='0000000000000000000000000000000000000000' +while read -r local_ref local_sha remote_ref remote_sha; do + [ "$remote_ref" = 'refs/heads/main' ] || continue + # A deletion has no build to publish. + [ "$local_sha" = "$zero" ] && continue + + # This hook runs before the push lands, so `pages` would go live ahead of + # `main` if the push then failed. Publish only when the push cannot be + # rejected as a non-fast-forward: the remote tip must already be an ancestor. + if [ "$remote_sha" != "$zero" ] && ! git merge-base --is-ancestor "$remote_sha" "$local_sha"; then + echo "pre-push: main is not a fast-forward; not publishing." >&2 + echo " Push main first, then run .agents/scripts/publish-pages.sh" >&2 + continue + fi + + .agents/scripts/publish-pages.sh --pending "$local_sha" || exit 1 +done diff --git a/AGENTS.md b/AGENTS.md index fa1a0e2..b8544bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,14 @@ that pin the site's real content and interactions, read from the built output. `main` is the source of truth. The `pages` branch is what the Gitea Pages Server actually serves, and it now carries **build output**, not a copy of `main`'s -tree. The publish job force-pushes `dist/` over it. The full procedure is in -[`docs/operations-guide.md`](docs/operations-guide.md); read +tree. + +**Pushing `main` republishes the live site.** The `pre-push` hook runs the gate, +then `.agents/scripts/publish-pages.sh`, which builds and force-pushes `dist/` +to `pages`. Use `AF_NO_PUBLISH=1 git push` to land a commit without publishing. +`pages` keeps its history, so rollback is a single force-push to an earlier tip; +`pages-backup-2026-09-06` is the last commit of the hand-written site. The full +procedure is in [`docs/operations-guide.md`](docs/operations-guide.md); read [`.agents/context/publishing.md`](.agents/context/publishing.md) before changing it. diff --git a/docs/operations-guide.md b/docs/operations-guide.md index da4efd6..2595281 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -53,11 +53,21 @@ migration task. flowchart LR E[Edit main] --> V[pnpm run gate] V --> C[Commit] - C --> P[Push main] - P --> G[Gitea Actions: gate] - G -.manual dispatch.-> B[Build + force-push dist to pages] + C --> P[git push main] + P --> H[pre-push hook: gate, then publish-pages.sh] + H --> B[Build + force-push dist to pages] B --> S[Gitea Pages Server] S --> L[Live URL] + P -.also.-> G[Gitea Actions: gate] + G -.manual dispatch.-> B +``` + +**A push of `main` republishes the live site.** The `pre-push` hook runs the +gate and then `.agents/scripts/publish-pages.sh`. There is no staging step +between your push and visitors. To push without publishing: + +```bash +AF_NO_PUBLISH=1 git push ``` ### 1. Start from current `main` @@ -69,9 +79,7 @@ git pull --ff-only git status --short --branch ``` -Do not overwrite unrelated local changes. The untracked `scripts/inspect.py` and -`scripts/__pycache__/` are local visual-test artifacts and are intentionally not -part of the published site. +Do not overwrite unrelated local changes. ### 2. Preview locally @@ -518,23 +526,39 @@ together when the underlying interview workflow changes. ## Safe rollback -Prefer a normal revert so history and the `pages` branch remain -fast-forwardable: +`pages` holds build output, not a copy of `main`, so it is not merged into or +fast-forwarded from `main`. Rolling the _site_ back and rolling the _source_ +back are two separate actions. + +**Roll the live site back immediately**, without touching `main`. The publisher +prints the rollback command every time it runs; `pages` keeps its history, so +any previous tip works: + +```bash +git fetch origin pages +git log --oneline origin/pages | head # pick the tip you want back +git push --force origin :refs/heads/pages +``` + +`pages-backup-2026-09-06` (`37a1e48`) is the last commit of the hand-written +site, kept as a floor under every rollback. + +Confirm with a cache-buster — a stale cached 200 looks exactly like success: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' \ + "https://netcracker.pages.marcospaulo.dev.br/ai-for-dummies/?v=$(date +%s)" +``` + +**Then fix the source.** Revert on `main` and push; the pre-push hook rebuilds +and republishes, which is what makes the site and the source agree again: ```bash git switch main git revert BAD_COMMIT git push origin main -git worktree add /tmp/ai-for-dummies-pages pages -git -C /tmp/ai-for-dummies-pages merge --ff-only origin/pages # local pages is often stale -git -C /tmp/ai-for-dummies-pages merge --no-edit main -git -C /tmp/ai-for-dummies-pages push origin pages -git worktree remove /tmp/ai-for-dummies-pages ``` -Verify the live URL after rollback. Do not use `reset --hard` or force-push for -ordinary content recovery. - ## Completion checklist - [ ] English content is complete without JavaScript.