From 9f9bcc6f6e90dcb4a96092465f63609a8c072c62 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 22 Jul 2026 16:21:22 -0500 Subject: [PATCH] ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main Add a sync-project-tree workflow that regenerates this repo's tracked-file tree and opens (or force-updates) a PR against RunicGateway/docs whenever the layout on main changes. Never writes to the docs repo's main directly. Reuses the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in .gitea/scripts/gen_tree.py (deterministic, dirs-first ordering). Co-Authored-By: Claude --- .gitea/scripts/gen_tree.py | 54 ++++++++++++ .gitea/workflows/sync-project-tree.yml | 111 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 .gitea/scripts/gen_tree.py create mode 100644 .gitea/workflows/sync-project-tree.yml diff --git a/.gitea/scripts/gen_tree.py b/.gitea/scripts/gen_tree.py new file mode 100644 index 0000000..4a00036 --- /dev/null +++ b/.gitea/scripts/gen_tree.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Render an ASCII tree of tracked files, read from stdin (one path per line). + +Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md +snapshot in the RunicGateway/docs repo. Feed it `git ls-files`: + + git ls-files | python3 .gitea/scripts/gen_tree.py + +Deterministic ordering: directories before files, each group sorted +case-insensitively with the raw name as a tiebreak. Output uses the classic +`tree(1)` box-drawing style so the result is stable across runs and platforms. +""" +import sys + + +def build(paths): + root = {} + for p in paths: + p = p.strip().replace("\\", "/") + if not p: + continue + node = root + for part in p.split("/"): + node = node.setdefault(part, {}) + return root + + +def render(node, prefix, lines): + entries = list(node.items()) + # directories (non-empty children dict) before files, then case-insensitive name + entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0])) + for i, (name, child) in enumerate(entries): + last = i == len(entries) - 1 + branch = "└── " if last else "├── " + suffix = "/" if child else "" + lines.append(f"{prefix}{branch}{name}{suffix}") + if child: + render(child, prefix + (" " if last else "│ "), lines) + + +def main(): + try: + sys.stdout.reconfigure(encoding="utf-8", newline="\n") + except AttributeError: + pass + root_label = sys.argv[1] if len(sys.argv) > 1 else "." + tree = build(sys.stdin.read().splitlines()) + lines = [f"{root_label}/"] + render(tree, "", lines) + sys.stdout.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.gitea/workflows/sync-project-tree.yml b/.gitea/workflows/sync-project-tree.yml new file mode 100644 index 0000000..ffb88fe --- /dev/null +++ b/.gitea/workflows/sync-project-tree.yml @@ -0,0 +1,111 @@ +name: sync-project-tree + +# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the +# RunicGateway/docs repo) current. On every push to `main` it regenerates the +# tree from tracked files and, if it changed, opens (or force-updates) a pull +# request against the docs repo. It never writes to the docs repo's `main` +# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the +# other workflows use (the token needs repo read/write on RunicGateway/docs). + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: sync-project-tree + cancel-in-progress: true + +env: + GITEA_HOST: gitea.whitlocktech.com + DOCS_REPO: RunicGateway/docs + SELF_REPO: RunicGateway/website + DOCS_PATH: website/PROJECT_TREE.md + TREE_TITLE: Website + ROOT_LABEL: website + PR_BRANCH: chore/sync-website-tree + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Check out this repo + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Ensure python3 is available + run: | + set -euo pipefail + command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; } + + - name: Render PROJECT_TREE.md from tracked files + run: | + set -euo pipefail + mkdir -p _sync + { + printf '# %s — Project Tree\n\n' "${TREE_TITLE}" + printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n' + printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}" + printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n' + printf '> by hand — changes will be overwritten by the next sync.\n\n' + printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n' + printf 'git-ignored paths are excluded).\n\n' + printf '```text\n' + git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}" + printf '```\n' + } > _sync/PROJECT_TREE.md + echo "----- generated ${DOCS_PATH} -----" + cat _sync/PROJECT_TREE.md + + - name: Open or update the docs PR if the tree changed + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + # Secrets can carry a trailing CR/LF depending on how they were pasted; + # strip line breaks before they land in a URL or Authorization header. + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}" + REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git" + + git clone --depth 1 "${REMOTE}" docs_repo + cd docs_repo + git config user.name "runic-docs-bot" + git config user.email "ci@whitlocktech.com" + + mkdir -p "$(dirname "${DOCS_PATH}")" + cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}" + git add "${DOCS_PATH}" + if git diff --cached --quiet; then + echo "PROJECT_TREE.md already up to date — nothing to sync." + exit 0 + fi + + SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)" + git checkout -B "${PR_BRANCH}" + git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]" + git push --force "${REMOTE}" "HEAD:${PR_BRANCH}" + + # Open a PR only if one isn't already open for this branch (a force-push + # to an existing open PR's head updates it in place). + OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \ + "${API}/pulls?state=open&limit=50" \ + | jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')" + if [ "${OPEN}" = "0" ]; then + curl -sSf -X POST "${API}/pulls" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg head "${PR_BRANCH}" \ + --arg base "main" \ + --arg title "docs(tree): sync ${DOCS_PATH}" \ + --arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \ + '{head: $head, base: $base, title: $title, body: $body}')" \ + >/dev/null + echo "Opened a new docs PR for ${PR_BRANCH}." + else + echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push." + fi