# Keep omnigent-site docs in sync with merged PRs: on push to main, resolve the # merged PR from the commit, classify its doc impact, label it, and — if it needs # docs — draft an omnigent-site PR tagging the merging maintainer. Plan → classify # (doc-classifier) → label → draft (doc-drafter) → open site PR. # # Docs staging: main always carries the NEXT unreleased version (X.Y.Z.dev0), so # the docs drafted here describe the next release, not what's live. Targeting # omnigent-site `main` would deploy in-progress docs on merge — so instead the PR # targets a per-minor staging branch `X.Y-docs` (derived from omnigent/version.py, # created off site `main` on the first doc PR of the cycle). At release, # publish-changelog opens `X.Y-docs → main` to publish the whole batch at once. # # Why push:[main], not pull_request_target: a fork PR's `closed` event is gated by # GitHub's fork-workflow rules and doesn't fire; a push to main always does, for # fork and internal PRs alike. It also only runs already-merged, trusted code (no # PR-event-with-secrets surface), and never pushes to main, so it can't self-trigger. # # The cross-repo PR uses the omnigent-ci App (already installed on omnigent-site; # sync-openapi-to-site.yml uses it too). If the App is unavailable the draft still # runs and prints its diff to the run summary but doesn't push (relies on # omnigent-site being public for the read-only checkout). # # Security model + residual risk (unsandboxed drafter, secret-scan coverage) live # in .github/agents/doc-drafter/config.yaml. name: Doc sync on: # Every merge to main, incl. fork PRs (see top-of-file for why not pull_request_target). push: branches: [main] workflow_dispatch: inputs: pr: description: "PR number to classify/draft (manual run)." required: true type: string permissions: contents: read pull-requests: write issues: write # labels + PR comments are served by the issues API concurrency: group: doc-sync-${{ inputs.pr || github.sha }} cancel-in-progress: false env: CODE_REPO: omnigent-ai/omnigent SITE_REPO_SLUG: ${{ github.repository_owner }}/omnigent-site OMNIGENT_SKIP_WEB_UI: "true" UV_INDEX_URL: https://pypi.org/simple PIP_INDEX_URL: https://pypi.org/simple jobs: doc-sync: name: Classify and draft docs # Cheap pre-gate; the `plan` step refines (no associated PR, or a # no-doc-update-labeled merge → no-op). if: >- github.repository == 'omnigent-ai/omnigent' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 40 steps: # --- Plan: resolve PR + decide classify-vs-draft-vs-skip from the event --- - name: Plan id: plan env: GH_TOKEN: ${{ github.token }} INPUT_PR: ${{ inputs.pr }} run: | set -euo pipefail python3 -u <<'PYEOF' import json, os, re, subprocess, time NEEDS, NO = "needs-doc-update", "no-doc-update" event = os.environ.get("GITHUB_EVENT_NAME", "") payload = json.load(open(os.environ["GITHUB_EVENT_PATH"])) classify = predraft = False pr = author = title = merger = "" labels = [] repo = os.environ["CODE_REPO"] if event == "workflow_dispatch": pr = os.environ.get("INPUT_PR", "").strip() meta = json.loads(subprocess.run( ["gh", "pr", "view", pr, "--repo", repo, "--json", "author,title,mergedBy,labels"], capture_output=True, text=True).stdout or "{}") author = (meta.get("author") or {}).get("login", "") merger = (meta.get("mergedBy") or {}).get("login", "") title = meta.get("title", "") labels = [l.get("name", "") for l in (meta.get("labels") or [])] elif event == "push": # Resolve the merged PR from the push tip — works for fork and internal # PRs (trusted main history, not a PR event). Single-tip assumption: a # normal merge is one push whose tip is the merge commit; a push carrying # MULTIPLE merges (merge queue / batched) only processes the tip's PR. sha = os.environ.get("GITHUB_SHA", "") # GitHub's commit→PR association index is populated asynchronously, # so a query fired seconds after the merge can return [] even though # the PR exists (eventual consistency — observed a ~7s lag). Retry # with backoff before concluding there's no PR. def query_pulls(): out = subprocess.run( ["gh", "api", f"repos/{repo}/commits/{sha}/pulls", "--jq", "[.[] | {number, author: (.user.login // \"\"), title, labels: [.labels[].name]}]"], capture_output=True, text=True).stdout.strip() return json.loads(out) if out else [] prs = [] for delay in (0, 3, 6, 9): if delay: time.sleep(delay) prs = query_pulls() if prs: break # Fallback: the index never caught up (or this merge strategy isn't # indexed). The squash/merge commit subject embeds the PR number, so # parse it from the push payload (the repo isn't checked out yet at # this step) and fetch that PR directly. if not prs: subject = (((payload.get("head_commit") or {}).get("message") or "") .splitlines() or [""])[0] m = (re.search(r"\(#(\d+)\)\s*$", subject) or re.search(r"^Merge pull request #(\d+)", subject)) if m: num = m.group(1) meta = json.loads(subprocess.run( ["gh", "api", f"repos/{repo}/pulls/{num}", "--jq", "{number, author: (.user.login // \"\"), title, " "labels: [.labels[].name]}"], capture_output=True, text=True).stdout or "{}") if meta.get("number"): print(f"::notice::commit {sha[:8]} not in PR index yet; " f"resolved #{num} from the commit subject.") prs = [meta] if not prs: print(f"::notice::commit {sha[:8]} has no associated PR (direct push?) — nothing to do.") else: if len(prs) > 1: print(f"::warning::commit {sha[:8]} maps to {len(prs)} PRs " f"({[p['number'] for p in prs]}); processing #{prs[0]['number']} only.") p = prs[0] pr = str(p["number"]); author = p.get("author") or ""; title = p.get("title", "") labels = p.get("labels", []) # The commits→pulls list omits merged_by; fetch it from the PR # object. The merger is the maintainer who clicked merge — the right # docs reviewer even when the author is an outside contributor. merger = subprocess.run( ["gh", "api", f"repos/{repo}/pulls/{pr}", "--jq", ".merged_by.login // \"\""], capture_output=True, text=True).stdout.strip() # Label-driven decision, shared by push and manual runs. A pre-existing # label is authoritative — trust it and skip the (slow, costly) classifier: # no-doc-update → skip entirely # needs-doc-update → draft directly # unlabeled → let the classifier decide if pr: if NO in labels: pass # already labeled no-doc → skip elif NEEDS in labels: predraft = True # already labeled needs-doc → draft else: classify = True # unlabeled → classify proceed = classify or predraft out = os.environ["GITHUB_OUTPUT"] with open(out, "a") as fh: fh.write(f"pr={pr}\n") fh.write(f"author={author}\n") fh.write(f"merger={merger}\n") fh.write(f"classify={'true' if classify else 'false'}\n") fh.write(f"predraft={'true' if predraft else 'false'}\n") fh.write(f"proceed={'true' if proceed else 'false'}\n") # Title can contain anything → pass via file, not output. open("/tmp/pr_title.txt", "w").write(title) print(f"event={event} pr={pr} author={author} merger={merger} classify={classify} predraft={predraft}") PYEOF - name: Check LLM credentials id: creds if: steps.plan.outputs.proceed == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | if [ -z "${LLM_API_KEY:-}" ]; then echo "::warning::No LLM credentials — skipping doc sync." echo "available=false" >> "$GITHUB_OUTPUT" else echo "::add-mask::${LLM_API_KEY}" echo "available=true" >> "$GITHUB_OUTPUT" fi # Always check out the TRUSTED default branch (never PR head). - name: Check out omnigent (code) if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false # Derive the per-minor docs staging branch and the release version from the # runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs" # and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the # one branch until release publishes it; the vX.Y.Z label lets maintainers # filter the staged PRs by the release they'll ship in. - name: Resolve docs branch id: docsbranch if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' run: | set -euo pipefail python3 - <<'PYEOF' import os, pathlib, re text = pathlib.Path("omnigent/version.py").read_text() m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text) if not m: raise SystemExit("could not parse X.Y.Z from omnigent/version.py") major, minor, patch = m.groups() branch = f"{major}.{minor}-docs" version = f"v{major}.{minor}.{patch}" with open(os.environ["GITHUB_OUTPUT"], "a") as fh: fh.write(f"branch={branch}\n") fh.write(f"version={version}\n") print(f"::notice::Docs stage on branch {branch} (release {version})") PYEOF - name: Set up Python if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: ".python-version" - name: Set up uv if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true - name: Cache virtualenv if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }} - name: Install dependencies if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' run: uv sync --extra all --extra dev - name: Install Claude Code CLI if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ run: | mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli" npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170 node node_modules/@anthropic-ai/claude-code/install.cjs echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH" - name: Write Omnigent provider config if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }} run: | mkdir -p "$HOME/.omnigent" python3 -c " import pathlib, os, json gw = os.environ['GATEWAY_BASE_URL'] cfg = {'providers': {'databricks-gateway': { 'kind': 'gateway', 'default': ['anthropic'], 'anthropic': { 'base_url': gw + '/anthropic', 'api_key_ref': 'env:LLM_API_KEY', 'models': {'default': 'databricks-claude-opus-4-8'}, }}}} pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2)) " # --- Collect the PR diff + metadata once (used by classify and draft) --- - name: Collect PR context id: ctx if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.plan.outputs.pr }} run: | set -euo pipefail gh api "repos/${CODE_REPO}/pulls/${PR_NUMBER}" \ -H "Accept: application/vnd.github.v3.diff" \ | head -c 524288 > /tmp/pr_diff.txt || true # Record whether the diff hit the 512 KB cap so the prompts can say so. if [ "$(wc -c < /tmp/pr_diff.txt)" -ge 524288 ]; then echo true > /tmp/diff_truncated else echo false > /tmp/diff_truncated fi gh pr view "$PR_NUMBER" --repo "$CODE_REPO" \ --json title,body,files,additions,deletions,changedFiles > /tmp/pr_meta.json - name: Classify id: classify if: steps.plan.outputs.classify == 'true' && steps.creds.outputs.available == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail python3 -u <<'PYEOF' import json, pathlib meta = json.loads(pathlib.Path("/tmp/pr_meta.json").read_text()) diff = pathlib.Path("/tmp/pr_diff.txt").read_text(encoding="utf-8", errors="replace") # The classifier is tools-less (no file access), so its diff must be # inline — but `omnigent run -p` passes the whole prompt as one argv # string, and Linux caps a single arg at ~128 KiB (MAX_ARG_STRLEN). Cap # the inline diff well under that; a verdict tolerates a partial diff. MAX_INLINE_DIFF = 100_000 truncated = pathlib.Path("/tmp/diff_truncated").read_text().strip() == "true" or len(diff) > MAX_INLINE_DIFF diff = diff[:MAX_INLINE_DIFF] trunc_note = ("\n> NOTE: the diff is truncated — you are seeing only part of it. " "If the visible portion is inconclusive, lean toward needs-doc-update.\n" if truncated else "") files = "\n".join(f"- {f['path']} (+{f['additions']}/-{f['deletions']})" for f in meta.get("files", [])[:200]) # Deliberately NOT including the PR title or description: they are # free-form, author-controlled prose (a prompt-injection surface) and add # little over the code itself. Classify from the actual change — the # changed-file list and the diff. prompt = f"""A pull request just merged. Classify its documentation impact per your instructions. Judge ONLY from the changed files and diff below — there is no PR title or description, by design; reason about what the code actually changed. ## Stats +{meta['additions']}/-{meta['deletions']} across {meta['changedFiles']} file(s) {trunc_note} ## Changed files {files if files else '(none reported)'} ## Diff ```diff {diff} ``` Output ONLY the DOC_VERDICT and DOC_REASON lines.""" pathlib.Path("/tmp/classify_prompt.txt").write_text(prompt) PYEOF prompt="$(cat /tmp/classify_prompt.txt)" uv run omnigent run .github/agents/doc-classifier \ -p "$prompt" --no-session 2>classify-stderr.log | tee /tmp/classify_out.txt \ || { echo "::warning::classifier exited non-zero"; cat classify-stderr.log; } python3 - <<'PYEOF' import re, os, pathlib raw = pathlib.Path("/tmp/classify_out.txt").read_text() mv = re.search(r"DOC_VERDICT:\s*(needs-doc-update|no-doc-update)", raw) mr = re.search(r"DOC_REASON:\s*(.+)", raw) verdict = mv.group(1) if mv else "" reason = (mr.group(1).strip() if mr else "")[:300] or "(no reason provided)" pathlib.Path("/tmp/doc_reason.txt").write_text(reason) with open(os.environ["GITHUB_OUTPUT"], "a") as fh: fh.write(f"verdict={verdict}\n") print(f"verdict={verdict!r}") PYEOF - name: Scan classifier output for secrets if: steps.classify.outcome == 'success' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/classify_out.txt 2>/dev/null; then echo "::error::Classifier output contains LLM_API_KEY — aborting." exit 1 fi # --- Decide final action (draft? which label to apply?) --- - name: Decide id: decide if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: PREDRAFT: ${{ steps.plan.outputs.predraft }} DO_CLASSIFY: ${{ steps.plan.outputs.classify }} VERDICT: ${{ steps.classify.outputs.verdict }} run: | set -euo pipefail draft=false; label=none; failed=false if [ "${PREDRAFT}" = "true" ]; then draft=true; label=none # already labeled needs-doc elif [ "${DO_CLASSIFY}" = "true" ]; then case "${VERDICT}" in needs-doc-update) draft=true; label=needs-doc-update ;; no-doc-update) draft=false; label=no-doc-update ;; *) draft=false; label=none; failed=true ;; # no parseable verdict esac fi echo "draft=$draft" >> "$GITHUB_OUTPUT" echo "label=$label" >> "$GITHUB_OUTPUT" echo "failed=$failed" >> "$GITHUB_OUTPUT" echo "::notice::decision draft=$draft label=$label failed=$failed" - name: Apply label and comment if: steps.decide.outputs.label == 'needs-doc-update' || steps.decide.outputs.label == 'no-doc-update' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.plan.outputs.pr }} LABEL: ${{ steps.decide.outputs.label }} DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }} RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" run: | set -euo pipefail gh label create needs-doc-update --repo "$REPO" --color 0E8A16 \ --description "Merged PR needs a user-facing docs update" 2>/dev/null || true gh label create no-doc-update --repo "$REPO" --color C5DEF5 \ --description "Merged PR does not need a docs update" 2>/dev/null || true gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL" REASON="$(cat /tmp/doc_reason.txt 2>/dev/null || echo '')" { echo "" echo "🏷️ **Doc impact: \`$LABEL\`**" echo "" echo "$REASON" if [ "$LABEL" = "needs-doc-update" ]; then echo "" echo "Drafting a docs PR to \`omnigent-ai/omnigent-site\` (staged on \`${DOCS_BRANCH}\` until release)…" fi echo "" echo "Auto-classified on merge. Set the label manually before merging to override. · [run](${RUN_URL})" } > /tmp/label_comment.md gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/label_comment.md # Classifier produced no parseable verdict — leave a recovery pointer. - name: Note classifier failure if: steps.decide.outputs.failed == 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.plan.outputs.pr }} RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" run: | set -euo pipefail { echo "" echo "⚠️ Couldn't auto-classify this PR's documentation impact." echo "" echo "A maintainer can re-run it from the **Doc sync** workflow → **Run workflow**, entering PR number \`${PR_NUMBER}\`. · [run](${RUN_URL})" } > /tmp/unclassified_comment.md gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/unclassified_comment.md # --- Draft path --- # Read-only checkout (omnigent-site is public), no persisted creds so no token # sits in .git/config for the unsandboxed drafter. Write-token minted later. - name: Check out omnigent-site (docs) if: steps.decide.outputs.draft == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: omnigent-ai/omnigent-site path: omnigent-site token: ${{ github.token }} persist-credentials: false # Point the working tree at the docs staging branch BEFORE the drafter runs, # so it sees docs already accumulated this cycle and re-drafts merge cleanly. # Reads need no auth (omnigent-site is public); no creds are persisted, so # the unsandboxed drafter can't read a token from .git/config. If the branch # doesn't exist on the remote yet, create it locally off the default branch — # the first push (with the App token, later) publishes it. - name: Switch site checkout to docs branch if: steps.decide.outputs.draft == 'true' working-directory: omnigent-site env: DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }} run: | set -euo pipefail if git ls-remote --exit-code --heads origin "$DOCS_BRANCH" >/dev/null 2>&1; then git fetch --depth=1 origin "$DOCS_BRANCH" git checkout -B "$DOCS_BRANCH" FETCH_HEAD echo "::notice::Drafting against existing ${DOCS_BRANCH}." else git checkout -B "$DOCS_BRANCH" echo "::notice::${DOCS_BRANCH} does not exist yet — will be created off the default branch." fi - name: Build drafter prompt if: steps.decide.outputs.draft == 'true' env: PR_NUMBER: ${{ steps.plan.outputs.pr }} run: | set -euo pipefail python3 -u <<'PYEOF' import os, pathlib ws = os.environ["GITHUB_WORKSPACE"] truncated = pathlib.Path("/tmp/diff_truncated").read_text().strip() == "true" trunc_note = ("\n> NOTE: the diff was truncated at 512 KB — document only what the visible " "portion supports and flag the rest for manual review.\n" if truncated else "") # Diff goes via a FILE the drafter reads (not inline): a large diff would # blow Linux's ~128 KiB single-argv limit. Re-encode UTF-8 so a byte-cap # split mid-codepoint can't leave a tail sys_os_read chokes on. diff = pathlib.Path("/tmp/pr_diff.txt").read_text(encoding="utf-8", errors="replace") (pathlib.Path(ws) / "_pr_diff.txt").write_text(diff, encoding="utf-8") # No PR title/description by design — author-controlled prose / injection surface. prompt = f"""SITE_REPO={ws}/omnigent-site PR_NUMBER={os.environ['PR_NUMBER']} DIFF_FILE=./_pr_diff.txt Read DIFF_FILE first — it holds the merged PR's full diff and is your only source of truth (there is no PR title or description, by design). Then draft the omnigent-site docs update per your instructions and print the DOC_DRAFT_SUMMARY block. {trunc_note}""" pathlib.Path("/tmp/draft_prompt.txt").write_text(prompt) PYEOF - name: Run drafter id: draft if: steps.decide.outputs.draft == 'true' # cwd = workspace root (holds _pr_diff.txt + the omnigent-site checkout). # Only LLM_API_KEY is in env — same exposure as polly-review. env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail prompt="$(cat /tmp/draft_prompt.txt)" uv run --project "${GITHUB_WORKSPACE}" omnigent run \ "${GITHUB_WORKSPACE}/.github/agents/doc-drafter" \ -p "$prompt" --no-session \ 2>draft-stderr.log | tee /tmp/draft_out.txt \ || { echo "::warning::drafter exited non-zero"; cat draft-stderr.log; } - name: Scan drafter output for secrets if: steps.decide.outputs.draft == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/draft_out.txt 2>/dev/null; then echo "::error::Drafter output contains LLM_API_KEY — aborting before opening a PR." exit 1 fi - name: Detect doc changes id: sitechanges if: steps.decide.outputs.draft == 'true' working-directory: omnigent-site run: | set -euo pipefail if [ -n "$(git status --porcelain)" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "::notice::Drafter produced no doc changes." echo "changed=false" >> "$GITHUB_OUTPUT" fi - name: Scan drafted changes for secrets if: steps.sitechanges.outputs.changed == 'true' working-directory: omnigent-site env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail # Defense in depth: scan the drafted content (tracked + new files) — a # prompt-injected drafter could write the key into a doc file. if [ -n "${LLM_API_KEY:-}" ]; then leaked="$({ git diff HEAD; git ls-files --others --exclude-standard -z | xargs -0 cat 2>/dev/null; } | grep -F "$LLM_API_KEY" || true)" if [ -n "$leaked" ]; then echo "::error::Drafted doc changes contain LLM_API_KEY — aborting before commit/push." exit 1 fi fi # Mint the omnigent-site write-token ONLY now — after the drafter has run and # produced changes. It never coexists with the (PR-influenced) drafter. - name: Mint omnigent-site App token id: site-token if: steps.sitechanges.outputs.changed == 'true' && vars.OMNIGENT_BOT_APP_ID != '' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.OMNIGENT_BOT_APP_ID }} private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }} owner: ${{ github.repository_owner }} repositories: omnigent-site - name: Build site PR body and resolve reviewer id: sitepr if: steps.sitechanges.outputs.changed == 'true' env: GH_TOKEN: ${{ steps.site-token.outputs.token || github.token }} AUTHOR: ${{ steps.plan.outputs.author }} MERGER: ${{ steps.plan.outputs.merger }} PR_NUMBER: ${{ steps.plan.outputs.pr }} run: | set -euo pipefail python3 -u <<'PYEOF' import os, re, pathlib site = os.environ["SITE_REPO_SLUG"]; code = os.environ["CODE_REPO"] author = os.environ.get("AUTHOR", ""); merger = os.environ.get("MERGER", "") pr = os.environ["PR_NUMBER"] title = pathlib.Path("/tmp/pr_title.txt").read_text().strip() raw = pathlib.Path("/tmp/draft_out.txt").read_text() m = re.search(r"", raw) summary = raw[m.end():].strip() if m else "_(drafter produced edits but no summary)_" # Title the docs PR after the DOCS change, not the source PR number (which # already appears in the body). Prefer the drafter's DOC_PR_TITLE line; fall # back to the source PR title, then to the old "document #N" form. LLM output # is untrusted, so sanitize: first line only, strip control chars, collapse # whitespace, drop a stray leading "docs:" (added below), and cap length. mt = re.search(r"^\s*DOC_PR_TITLE:\s*(.+?)\s*$", raw, re.MULTILINE) # Collapse whitespace (incl. tabs) to single spaces FIRST, so a stray tab # separates words rather than being stripped and joining them, then drop # any remaining non-whitespace control chars. draft_title = re.sub(r"\s+", " ", mt.group(1) if mt else "").strip() draft_title = re.sub(r"[\x00-\x1f\x7f]", "", draft_title) draft_title = re.sub(r"^docs:\s*", "", draft_title, flags=re.IGNORECASE).strip()[:60].strip() pr_title = f"docs: {draft_title or title or f'document {code}#{pr}'}" pathlib.Path("/tmp/site_pr_title.txt").write_text(pr_title) print(f"pr_title={pr_title!r}") # Tag the maintainer who MERGED the PR — the author may be an outside # contributor with no site access, but a maintainer always merges. Fall back # to the author when there's no usable merger (e.g. a manual run on an # unmerged PR). Skip bots / the CI identity. def usable(login): return bool(login) and not login.endswith("[bot]") and login != "omnigent-ci" if usable(merger): reviewer, role = merger, "merged by" elif usable(author): reviewer, role = author, "author" else: reviewer, role = "", "" # @-mention in the body AND request review downstream: the review request is # best-effort (GitHub rejects non-collaborators), so the mention is the # durable ping — it reaches concealed org members too. mention = f" · {role} @{reviewer}" if reviewer else "" body = f""" Documentation update for **{code}#{pr}** — {title} {summary} --- Source PR: {code}#{pr}{mention} Drafted automatically by the doc-sync workflow. Review for accuracy before merging. """ body = "\n".join(l[10:] if l.startswith(" "*10) else l for l in body.splitlines()) pathlib.Path("/tmp/site_pr_body.md").write_text(body) with open(os.environ["GITHUB_OUTPUT"], "a") as fh: fh.write(f"reviewer={reviewer}\n") print(f"reviewer={reviewer!r} mention={mention!r}") PYEOF - name: Open or update site PR if: steps.sitechanges.outputs.changed == 'true' && steps.site-token.outputs.token != '' working-directory: omnigent-site env: GH_TOKEN: ${{ steps.site-token.outputs.token }} SITE_TOKEN: ${{ steps.site-token.outputs.token }} PR_NUMBER: ${{ steps.plan.outputs.pr }} REVIEWER: ${{ steps.sitepr.outputs.reviewer }} DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }} VERSION_LABEL: ${{ steps.docsbranch.outputs.version }} run: | set -euo pipefail BRANCH="auto/docs/pr-${PR_NUMBER}" # Descriptive PR/commit title from the sitepr step (drafter's DOC_PR_TITLE, # else the source PR title, else "docs: document #N"). The PR number lives # in the body, so it's kept out of the title. PR_TITLE="$(cat /tmp/site_pr_title.txt)" git config user.name "omnigent-ci[bot]" git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com" # Credentials are NOT persisted in .git/config (so the unsandboxed drafter # couldn't read them); the App token is minted only now (after the drafter) # and used solely for the push URL below. GitHub registers it as a masked # secret, so it's redacted from logs. Reads (ls-remote/fetch) need no auth — # omnigent-site is public. PUSH_URL="https://x-access-token:${SITE_TOKEN}@github.com/${SITE_REPO_SLUG}.git" # Ensure the docs staging branch exists on the remote — it's the PR base. # When fresh, the local $DOCS_BRANCH ref points at the default branch's tip # (the "Switch" step created it from the default-branch checkout), so push # that as the branch's starting point. Idempotent: if a concurrent run beat # us to it, the non-force push is rejected and we carry on (base exists). if ! git ls-remote --exit-code --heads origin "$DOCS_BRANCH" >/dev/null 2>&1; then git push "$PUSH_URL" "$(git rev-parse "$DOCS_BRANCH"):refs/heads/${DOCS_BRANCH}" \ || echo "::notice::${DOCS_BRANCH} already created by a concurrent run — reusing it." fi # Don't clobber human edits: if the rolling branch already exists, only # force-push when we can POSITIVELY confirm its HEAD is the bot's. This # guard fails CLOSED — if the branch exists but we can't read its HEAD # author (fetch failed, FETCH_HEAD absent), we skip rather than risk # force-pushing over human commits. BOT_EMAIL="294685417+omnigent-ci[bot]@users.noreply.github.com" if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then if ! git fetch --depth=1 origin "$BRANCH" >/dev/null 2>&1; then echo "::warning::$BRANCH exists but could not be fetched — skipping (fail-closed, won't risk clobbering)." exit 0 fi LAST_AUTHOR="$(git log -1 --format='%ae' FETCH_HEAD 2>/dev/null || echo '')" if [ "$LAST_AUTHOR" != "$BOT_EMAIL" ]; then echo "::warning::$BRANCH HEAD author is '${LAST_AUTHOR:-}' (not the bot) — skipping auto-redraft." SITE_PR="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true)" [ -n "$SITE_PR" ] && gh pr comment "$SITE_PR" --repo "$SITE_REPO_SLUG" \ --body "doc-sync: this branch's HEAD isn't the automated bot commit — skipping the automated re-draft for ${CODE_REPO}#${PR_NUMBER} to avoid overwriting manual edits." || true exit 0 fi fi git checkout -B "$BRANCH" git add -A git commit -m "$PR_TITLE" # --force is safe here: the guard above ensured the branch carries only # bot commits. git push --force "$PUSH_URL" "$BRANCH" # The vX.Y.Z label marks which release the staged docs will ship in, so # maintainers can filter the site PRs by release. Ensure it exists (with # automated-docs) before applying it below. gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \ --description "Automated documentation update" 2>/dev/null || true gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \ --description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null || true)" if [ -n "$EXISTING" ]; then # --add-label backfills PRs opened before the label existed; it's a no-op # when already present. gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \ --title "$PR_TITLE" \ --add-label "automated-docs" --add-label "$VERSION_LABEL" \ --body-file /tmp/site_pr_body.md || true echo "Updated site PR #$EXISTING." else if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \ --title "$PR_TITLE" \ --label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null || true)" echo "Opened site PR for $BRANCH." else echo "::warning::Could not open the site PR automatically. Branch '$BRANCH' is pushed." fi fi # Always attempt the review request + assignment, decoupled from PR creation # so a non-addable reviewer can't fail the open. GitHub returns 422 for users # it can't add (non-collaborators / concealed org members); tolerate it — the # reviewer is also @-mentioned in the body as a durable fallback ping. The two # calls are independent so one failing doesn't skip the other. Assigning makes # the PR filterable by assignee from the site's PR list. if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \ || echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body." gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \ || echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body." fi - name: Note draft skipped (no site token) if: steps.sitechanges.outputs.changed == 'true' && steps.site-token.outputs.token == '' run: | echo "::warning::Doc edits were drafted but the omnigent-site App token could not be minted" echo "(OMNIGENT_BOT_APP_ID/KEY missing, or the omnigent-ci App lost access to omnigent-site). The PR was not opened." echo "### Doc-sync: drafted but not pushed" >> "$GITHUB_STEP_SUMMARY" { echo '```diff'; (cd omnigent-site && git --no-pager diff); echo '```'; } >> "$GITHUB_STEP_SUMMARY" || true # ::add-mask:: redacts rendered logs, not artifact files — scrub the key from # the artifacts (incl. the otherwise-unscanned stderr logs) before upload. - name: Redact secrets from artifacts if: always() && steps.plan.outputs.proceed == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail [ -n "${LLM_API_KEY:-}" ] || exit 0 python3 - <<'PYEOF' import os, pathlib key = os.environ.get("LLM_API_KEY", "") for f in ["classify-stderr.log", "draft-stderr.log", "/tmp/classify_out.txt", "/tmp/draft_out.txt", "/tmp/site_pr_body.md"]: p = pathlib.Path(f) if not p.is_file() or not key: continue t = p.read_text(encoding="utf-8", errors="replace") if key in t: p.write_text(t.replace(key, "***REDACTED***"), encoding="utf-8") print(f"redacted key from {f}") PYEOF - name: Upload logs on failure if: always() && steps.plan.outputs.proceed == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: doc-sync-${{ steps.plan.outputs.pr }}-${{ github.run_id }} path: | classify-stderr.log draft-stderr.log /tmp/classify_out.txt /tmp/draft_out.txt /tmp/site_pr_body.md retention-days: 7 if-no-files-found: ignore