Skip to content
86 changes: 72 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Restore Discourse sync cache
uses: actions/cache/restore@v4
Expand All @@ -87,41 +89,97 @@ jobs:
pip install uv
uv pip install --system zensical

- name: Compute docs affected by this PR
id: diff
env:
BASE_REF: ${{ github.base_ref }}
DOCS_SYNC_REF_NAME: ${{ github.base_ref }}
DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }}
run: |
set -euo pipefail
# GHA reserves GITHUB_REF_NAME; unset so base worktree's
# pre-DOCS_SYNC_REF_NAME sync_to_discourse.py also falls back to master.
unset GITHUB_REF_NAME

git fetch --depth=1 origin "$BASE_REF"
BASE_SHA=$(git rev-parse "origin/$BASE_REF")
echo "Base: $BASE_REF ($BASE_SHA)"

uv run --python 3.12 python tools/render_docs.py > /tmp/head_hashes.json

WORKTREE=$(mktemp -d)
git worktree add --detach "$WORKTREE" "$BASE_SHA"
cp tools/render_docs.py "$WORKTREE/tools/render_docs.py"
(cd "$WORKTREE" && uv run --python 3.12 python tools/render_docs.py > /tmp/base_hashes.json)
git worktree remove --force "$WORKTREE"

python3 - <<'PY' > /tmp/affected.txt
import json
h = json.load(open('/tmp/head_hashes.json'))
b = json.load(open('/tmp/base_hashes.json'))
for p in sorted(set(h) | set(b)):
if h.get(p) != b.get(p):
print(p)
PY

AFFECTED=$(cat /tmp/affected.txt || true)
echo "AFFECTED_DOCS:"
printf '%s\n' "$AFFECTED"

ONLY_ARGS=$(printf '%s ' $AFFECTED)
ONLY_ARGS=${ONLY_ARGS% }
DOC_COUNT=$([ -n "$AFFECTED" ] && printf '%s\n' "$AFFECTED" | wc -l | tr -d ' ' || echo 0)
ANY_CHANGE=$([ -n "$AFFECTED" ] && echo 1 || echo "")

{
echo "any_change=$ANY_CHANGE"
echo "only_args=$ONLY_ARGS"
echo "doc_count=$DOC_COUNT"
} >> "$GITHUB_OUTPUT"

- name: Dry-run Discourse sync
if: steps.diff.outputs.any_change == '1'
env:
DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }}
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }}
DISCOURSE_API_USER: ${{ secrets.DISCOURSE_API_USER }}
DISCOURSE_CATEGORY_MAP: ${{ vars.DISCOURSE_CATEGORY_MAP }}
ONLY_ARGS: ${{ steps.diff.outputs.only_args }}
DOCS_SYNC_REF_NAME: ${{ github.base_ref }}
run: |
uv run --python 3.12 python tools/sync_to_discourse.py \
--dry-run --verbose --only $ONLY_ARGS 2>&1 | tee sync_preview.txt

- name: Write no-change preview
if: steps.diff.outputs.any_change != '1'
run: |
uv run --python 3.12 python tools/sync_to_discourse.py --dry-run --verbose 2>&1 | tee sync_preview.txt
cat > sync_preview.txt <<'EOF'
No documentation or sync tooling changes detected in this PR.
Skipping Discourse dry-run.
EOF

- name: Build PR comment
id: comment
env:
ANY_CHANGE: ${{ steps.diff.outputs.any_change }}
DOC_COUNT: ${{ steps.diff.outputs.doc_count }}
run: |
# Extract the summary table (everything after the === line)
SUMMARY=$(sed -n '/^=\{10,\}/,/^=\{10,\}$/p' sync_preview.txt | head -20)

# Count actions from the summary
CREATED=$(grep -oP 'Created:\s+\K\d+' sync_preview.txt || echo "0")
UPDATED=$(grep -oP 'Updated \(Normal\):\s+\K\d+' sync_preview.txt || echo "0")
UPDATED_IDX=$(grep -oP 'Updated \(Index\):\s+\K\d+' sync_preview.txt || echo "0")
SKIPPED_CACHE=$(grep -oP 'Skipped \(Cached\):\s+\K\d+' sync_preview.txt || echo "0")
SKIPPED_MATCH=$(grep -oP 'Skipped \(Discourse Match\):\s+\K\d+' sync_preview.txt || echo "0")
SKIPPED_CAT=$(grep -oP 'Skipped \(No Category\):\s+\K\d+' sync_preview.txt || echo "0")
SIDEBARS=$(grep -oP 'Sidebars Updated:\s+\K\d+' sync_preview.txt || echo "0")
FAILED=$(grep -oP 'Failed:\s+\K\d+' sync_preview.txt || echo "0")
TOTAL=$(grep -oP 'Total Parsed:\s+\K\d+' sync_preview.txt || echo "0")

CHANGES=$((CREATED + UPDATED + UPDATED_IDX))
CHANGES=$((CREATED + UPDATED + SIDEBARS))

{
echo 'body<<COMMENT_EOF'
echo "### Discourse Sync Preview"
echo ""
if [ "$CHANGES" -eq 0 ]; then
if [ "$ANY_CHANGE" != "1" ]; then
echo "No documentation or sync tooling changes detected. No forum topic changes needed."
elif [ "$CHANGES" -eq 0 ]; then
echo "Docs are up to date. No forum topic changes needed."
else
echo "**${CHANGES} topic(s) would be modified** (${CREATED} created, $((UPDATED + UPDATED_IDX)) updated) out of ${TOTAL} total."
echo "**${CHANGES} topic(s) would be modified** (${CREATED} created, ${UPDATED} updated, ${SIDEBARS} index/sidebar) across ${DOC_COUNT} affected doc(s)."
fi
if [ "$FAILED" -ne 0 ]; then
echo ""
Expand Down
4 changes: 4 additions & 0 deletions tools/discourse_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@ def first_post_id(self, topic_id: int) -> int | None:
return None
return posts[0].get("id")

def get_topic(self, topic_id: int) -> dict[str, Any] | None:
"""Fetch topic metadata (title, category_id, post_stream)."""
return self._get(f"/t/{topic_id}.json")

def get_post_raw(self, post_id: int) -> str | None:
"""Fetch the current raw markdown content of a post.

Expand Down
41 changes: 41 additions & 0 deletions tools/render_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Render each nav doc and emit {path: sha256} JSON.

Used by the PR preview CI job to detect which docs render differently
between the base branch and the PR HEAD without touching Discourse.
Link rewriting uses an empty topic_id_map so the hash is reproducible
across refs.
"""

from __future__ import annotations

import hashlib
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from converter import convert
from nav_parser import parse_all
from sync_to_discourse import DOCS_DIR, ZENSICAL_TOML, build_footer


def main() -> None:
out: dict[str, str] = {}
for entry in parse_all(ZENSICAL_TOML):
fp = DOCS_DIR / entry.path
if not fp.exists():
continue
converted = convert(
fp.read_text(encoding="utf-8"),
file_path=entry.path, topic_id_map={},
)
body = converted.rstrip("\n") + build_footer(entry.path)
out[entry.path] = hashlib.sha256(body.encode("utf-8")).hexdigest()
json.dump(out, sys.stdout, sort_keys=True)
sys.stdout.write("\n")


if __name__ == "__main__":
main()
Loading
Loading