From ea9aad6e9bda019223ead5f5f867344e0ce5cd54 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 09:14:56 -0500 Subject: [PATCH] ci(installer): add pr-checks, release, and project-tree sync workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring this repo's CI up to parity with the other Runic Gateway repos. All three are retargeted from RunicGateway/link, which is the closest analog (same Rust toolchain, same release engine, same runner). pr-checks.yml Gates PRs into main on cargo fmt --check, clippy -D warnings, and cargo test --locked, in that order, one job — mirroring release.yml's gates so a green PR implies a green release. release.yml The conventional-commit release engine from link/, with the Rust adapter retargeted: crate at the repo root, binary runicgateway-installer, cross-compiled for x86_64 Linux and Windows. Artifact names follow PLAN.md §3. The generated changelog now carries the checksum-verification block, because releases are deliberately unsigned and SHA256SUMS is the trust anchor (PLAN.md §3) — that makes the verify instructions part of the release, not a doc someone has to find. sync-project-tree.yml (+ .gitea/scripts/gen_tree.py) Regenerates docs/installer/PROJECT_TREE.md on every push to main and opens or force-updates a PR against the docs repo. Verbatim from link/ apart from the repo/path/label env block. Crate guard This repo has no Cargo project yet — Phase 1 creates it. Landing the workflows unguarded would red-X every governance and docs PR until then, and holding them back leaves the repo ungated exactly while its conventions are being set. So both Rust workflows check for a root Cargo.toml first: pr-checks skips its gates with a notice, and release.yml's plan step sets RELEASE=false and exits. Both arm themselves the moment Cargo.toml lands, with no edit here. Verified before pushing: all three files parse as YAML, every run block passes bash -n, and the release plan step was simulated against a throwaway git repo both without a crate (release=false, exit 0) and with one (first-release path -> v0.1.0 with the changelog rendered). Not included: the bundle-manifest workflow (PLAN.md §7) and the release-dispatch hook, which are Phase 0 item 3 and depend on servuo-plugins having a release workflow first. Note for setup: release.yml and sync-project-tree.yml need REGISTRY_USER and REGISTRY_TOKEN (write:repository, plus read/write on RunicGateway/docs) configured for this repo. Co-Authored-By: Claude --- .gitea/scripts/gen_tree.py | 54 +++++ .gitea/workflows/pr-checks.yml | 118 ++++++++++ .gitea/workflows/release.yml | 289 +++++++++++++++++++++++++ .gitea/workflows/sync-project-tree.yml | 111 ++++++++++ 4 files changed, 572 insertions(+) create mode 100644 .gitea/scripts/gen_tree.py create mode 100644 .gitea/workflows/pr-checks.yml create mode 100644 .gitea/workflows/release.yml 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/pr-checks.yml b/.gitea/workflows/pr-checks.yml new file mode 100644 index 0000000..ad34fa1 --- /dev/null +++ b/.gitea/workflows/pr-checks.yml @@ -0,0 +1,118 @@ +# Gate every pull request into `main` on the same Rust checks the release runs, +# so a formatting slip, a lint regression, or a failing test can't reach the +# deployable branch. +# +# Mirrors RunicGateway/link's pr-checks.yml — same gates in the same order as +# release.yml, so a green PR means the release will get past its own gates too. +# The one structural difference is the crate guard below. +# +# ── Crate guard ────────────────────────────────────────────────────────────── +# This repo is in the planning phase and has no Cargo project yet (the design of +# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather +# than leave the repo ungated until then — or land a workflow that red-Xes every +# governance/docs PR — the gates are conditional on a root Cargo.toml existing. +# Before the crate lands, the job reports green with a notice. The moment +# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change. +# +# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar): +# this repo's sole product is the one installer binary, so there is nothing to +# namespace it against. +# +# Enforcement (one-time, in the Gitea UI): +# Repository Settings → Branches → Branch Protection (rule for `main`) +# • Enable Status Check +# • Status check patterns: PR Checks / * +# Note: Gitea only lists a context in its dropdown after it has reported once, +# so let this workflow run on one PR first. The `PR Checks / *` glob matches +# without needing the dropdown. +# +# Runner: the same self-hosted `ubuntu-latest` runner release.yml uses. Rust is +# not assumed to be preinstalled, so the toolchain step bootstraps it the same +# way release.yml does (minus the MinGW cross-compile deps — PRs build for the +# host only; the Windows cross-build stays a release-time concern). + +name: PR Checks + +on: + pull_request: + branches: [main] + +# A newer push to the same PR cancels the in-flight run. +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust-gates: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Detect whether a crate exists yet + id: detect + run: | + set -euo pipefail + if [ -f Cargo.toml ]; then + echo "crate=true" >> "$GITHUB_OUTPUT" + echo "==> Cargo.toml found — running the full gate set." + else + echo "crate=false" >> "$GITHUB_OUTPUT" + echo "==> No Cargo.toml at the repo root yet (planning phase)." + echo " Skipping fmt/clippy/test. These gates arm themselves as" + echo " soon as Phase 1 lands the crate — see docs/installer/PLAN.md." + fi + + # One job runs all three gates on purpose: installing the toolchain costs + # far more than the checks themselves, so splitting fmt/clippy/test into + # parallel jobs would pay that cost three times for no wall-clock win. + - name: Install Rust toolchain (rustfmt + clippy) + if: ${{ steps.detect.outputs.crate == 'true' }} + run: | + set -euo pipefail + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential curl ca-certificates git + + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + fi + echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" + export PATH="${HOME}/.cargo/bin:${PATH}" + rustup component add rustfmt clippy + cargo --version && cargo fmt --version && cargo clippy --version + + # Keyed on Cargo.lock: dependency builds are reused until a dep actually + # changes. A cache miss only makes the run slower, never wrong. + - name: Cache cargo registry and build dir + if: ${{ steps.detect.outputs.crate == 'true' }} + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + # Cheapest gate first — parses only, no compile, so a formatting slip + # fails in seconds instead of after a full build. + - name: cargo fmt --check + if: ${{ steps.detect.outputs.crate == 'true' }} + run: cargo fmt --check + + # --all-targets covers tests and examples, not just the binary. + # -D warnings makes a lint a failure, so the crate starts clean at this bar + # and anything new is a regression introduced by the PR. + - name: cargo clippy + if: ${{ steps.detect.outputs.crate == 'true' }} + run: cargo clippy --locked --all-targets -- -D warnings + + # --locked matches release.yml: it also proves Cargo.lock is in sync with + # Cargo.toml, rather than letting the build silently update it. + - name: cargo test + if: ${{ steps.detect.outputs.crate == 'true' }} + run: cargo test --locked diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..a7b3ef9 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,289 @@ +# Automated build + release for the Runic Gateway installer. +# +# Trigger: every push to `main` (i.e. every merged PR). +# +# Flow (two conceptual halves, kept separate on purpose): +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐ +# │ consumes: the version │ +# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │ +# └───────────────────────────────────────────────────────────────────┘ +# +# This is RunicGateway/link's release.yml with the adapter retargeted at this +# repo's crate — exactly the reuse its header anticipated. Differences from link: +# +# • The crate lives at the REPO ROOT, not in a subdirectory. +# • The crate guard (below) — this repo has no Cargo project yet. +# • Artifact names follow docs/installer/PLAN.md §3. +# +# ── Crate guard ────────────────────────────────────────────────────────────── +# The repo is in the planning phase. With no Cargo.toml there is nothing to +# build, so the plan step forces RELEASE=false and the job exits green having +# done nothing. It starts cutting real releases the moment Phase 1 lands the +# crate — no edit required here. +# +# ── Unsigned releases ──────────────────────────────────────────────────────── +# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is +# the trust anchor. That makes the checksum step below load-bearing rather than +# a nicety — do not drop it, and keep SHA256SUMS attached to every release. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases the current Cargo.toml version as-is +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer): +# REGISTRY_USER — Gitea username the token below belongs to +# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push +# the bump commit + tag and create the release. +# Also: `main` must accept a direct push from that user (disable branch +# protection for it, or add it as an exception) — the bump commit lands on main. +# +# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow. + +name: Release installer + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-installer + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/installer + BIN: runicgateway-installer + LINUX_TARGET: x86_64-unknown-linux-gnu + WINDOWS_TARGET: x86_64-pc-windows-gnu + +jobs: + release: + runs-on: ubuntu-latest + # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]). + # Quoted because the expression contains a colon (`chore(release):`), which an + # unquoted YAML scalar would misparse as a mapping value. + if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}" + steps: + - name: Check out full history (need tags + commit log for the bump) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── RELEASE ENGINE: decide the next version + changelog ────────────── + - name: Plan the release (version + changelog) + id: plan + run: | + set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + + # Planning-phase guard: nothing to build without a crate. + if [ ! -f Cargo.toml ]; then + echo "No Cargo.toml at the repo root yet (planning phase) — nothing to release." + echo "This job arms itself when Phase 1 lands the crate. See docs/installer/PLAN.md." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CARGO_VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" + + BUMP=none + if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi + if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi + + bump() { # -> bumped + IFS=. read -r MA MI PA <<< "$1" + case "$2" in + major) echo "$((MA+1)).0.0" ;; + minor) echo "${MA}.$((MI+1)).0" ;; + patch) echo "${MA}.${MI}.$((PA+1))" ;; + esac + } + + RELEASE=true + if [ -z "$LAST_TAG" ]; then + VERSION="$CARGO_VERSION" # first release: ship what's in Cargo.toml + elif [ "$BUMP" = none ]; then + RELEASE=false # no feat/fix/breaking since last tag + VERSION="${LAST_TAG#v}" + else + VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" + fi + + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "Tag v${VERSION} already exists — nothing to release." + RELEASE=false + fi + + { + echo "## ${BIN} v${VERSION}" + echo + FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" + [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } + [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } + echo "### All changes" + if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi + echo "$SUBJECTS" | sed 's/^/- /' + echo + echo "### Verifying this download" + echo + echo "Releases are **unsigned** — \`SHA256SUMS\` is the trust anchor. Verify before running:" + echo + echo '```bash' + echo "sha256sum -c SHA256SUMS --ignore-missing # Linux" + echo '```' + echo + echo '```powershell' + echo "Get-FileHash .\\${BIN}-windows-x86_64.exe -Algorithm SHA256 # Windows, compare to SHA256SUMS" + echo '```' + echo + echo "Windows will show a SmartScreen \"unrecognized app\" prompt; this is expected for unsigned binaries." + } > dist/CHANGELOG.md + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-}" + + # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── + - name: Install Rust toolchain, Windows target, and MinGW linker + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq + + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + fi + echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" + export PATH="${HOME}/.cargo/bin:${PATH}" + rustup component add rustfmt + rustup target add "${WINDOWS_TARGET}" + + - name: Set the crate version to match the release + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + # Replace only the [package] version (the first `version = "..."`). + sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" Cargo.toml + grep -m1 '^version' Cargo.toml + # Bumping the manifest version desyncs this crate's own entry in + # Cargo.lock, which would make the `--locked` fmt/test/build steps below + # fail ("cannot update the lock file ... --locked was passed"). Sync just + # the workspace member(s) into the lock — dependency pins are untouched. + cargo update --workspace + + # ── RUST ADAPTER: gates ────────────────────────────────────────────── + - name: cargo fmt --check + if: ${{ steps.plan.outputs.release == 'true' }} + run: cargo fmt --check + + - name: cargo test + if: ${{ steps.plan.outputs.release == 'true' }} + run: cargo test --locked + + # ── RUST ADAPTER: build both targets ───────────────────────────────── + - name: cargo build --release (Linux) + if: ${{ steps.plan.outputs.release == 'true' }} + run: cargo build --release --locked --target "${LINUX_TARGET}" + + - name: cargo build --release (Windows, cross via MinGW) + if: ${{ steps.plan.outputs.release == 'true' }} + env: + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + run: cargo build --release --locked --target "${WINDOWS_TARGET}" + + # ── RUST ADAPTER: package artifacts (+ checksums) ──────────────────── + # SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3), + # so it ships with every release and the docs lead with the verify command. + - name: Package artifacts and SHA256SUMS + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" + cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" + ( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS ) + ls -l dist && echo "----" && cat dist/SHA256SUMS + + # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── + - name: Commit version bump and push tag + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + TAG="${{ steps.plan.outputs.tag }}" + # Secrets can arrive with a trailing newline (depending on how they were + # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot + # be parsed"). Strip line breaks before building the URL. Passing them via + # env (not inline ${{ }}) also keeps a newline from breaking this script. + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git config user.name "installer-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + + git add Cargo.toml Cargo.lock + if ! git diff --cached --quiet; then + git commit -m "chore(release): bump version to ${TAG} [skip ci]" + git push origin "HEAD:main" + else + echo "Version unchanged (first release) — no bump commit needed." + fi + git tag "${TAG}" + git push origin "${TAG}" + + # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + - name: Create Gitea release and upload assets + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + BODY="$(cat dist/CHANGELOG.md)" + # Same newline hygiene as the push step: a stray CR/LF in the token would + # corrupt the Authorization header. + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + + REL_ID="$(curl -sSf -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ + | jq -r '.id')" + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do + curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@dist/${f}" >/dev/null + echo " uploaded ${f}" + done diff --git a/.gitea/workflows/sync-project-tree.yml b/.gitea/workflows/sync-project-tree.yml new file mode 100644 index 0000000..d4ee48d --- /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/installer/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/installer + DOCS_PATH: installer/PROJECT_TREE.md + TREE_TITLE: Runic Gateway installer + ROOT_LABEL: installer + PR_BRANCH: chore/sync-installer-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