feat: daily docs-sync bot keeping kilo-docs in sync with merged PRs (#12512)

* feat: daily docs-sync bot workflow (Kilo CLI)

Adds a scheduled workflow that keeps packages/kilo-docs in sync with PRs
merged to Kilo-Org/cloud and Kilo-Org/kilocode:

- watermark.mjs derives the processing window from the bot's own PR body
  marker (self-healing, no external state; 72h fallback, 14d cap)
- collect.mjs queries merged PRs via the GitHub API and applies a
  deterministic pre-filter (bots, chores, docs-only PRs)
- triage.mjs classifies PRs in chunks of 25 with kilo run; failed chunks
  degrade to unclassified instead of failing the run
- edit.mjs updates docs in batches of 5 PRs with kilo run, bounded per
  batch; failures surface as skipped entries in the PR body
- verify runs the kilo-docs build + test suite; one LLM fix pass on
  failure; still-red becomes a draft PR
- upsert-pr.mjs maintains one rolling auto-docs PR (appends while open,
  fresh branch after merge), with a 15-file draft cap and a
  machine-readable processed-through watermark

Also adds docs-sync.yml to the workflow allowlist in
script/check-workflows.ts.

* fix: correct kilo run invocation and auth

- message positional must come before flags: --file is multi-value and
  consumes a trailing message as a file path (File not found)
- authenticate via the existing KILO_API_KEY repo secret (the kilo
  provider reads it natively); drop the DOCS_SYNC_KILO_CONFIG config
  secret requirement
- fix default model IDs: gateway provider id is kilo/, not kilocode/
- include stderr tail in triage/edit failure logs

* fix: handle kilo run double-printed assistant output

kilo run prints the assistant message twice (streaming render + final
summary), so stdout can contain the same JSON array back-to-back. Parse
the largest valid trailing array instead of slicing first-to-last
bracket. Verified against real chunked triage output.

* fix: reviewer-pass robustness fixes

- edit.mjs: unambiguous summary file path in the batch prompt and a
  fallback read when the agent drops the docs-sync-out/ prefix, so real
  edits never report as skipped
- prepare-branch.mjs: use the open auto-docs PR's actual head.ref
  instead of assuming docs/auto-sync
- upsert-pr.mjs: compute the 15-file draft cap on the cumulative PR
  diff (origin/main...HEAD), not just the latest commit

* fix: address Kilobot review findings

Security:
- sanitize HTML-comment sequences out of agent-generated PR body values
  so a crafted value cannot forge section markers or the watermark
- draft any PR whose diff touches non-content files in packages/kilo-docs
  (outside pages/ and lib/nav/) — build-executable changes force human
  review before merge
- on merge conflict, keep the conflicted rolling branch untouched
  (preserving human commits) and continue on a fresh dated branch that
  links the old PR

Resilience:
- retry GitHub API calls on network errors and 5xx, not just 403
  rate limits
- isolate per-PR collect failures instead of aborting the run
- trust watermark markers only on bot-authored PRs and clamp future
  dates loudly
- validate chunk triage entries belong to their chunk before the shared
  dedupe
- use changed_files for files_total and skip docs-only classification
  on truncated (300+) file lists
- pipe stderr in the edit pass so failure warnings carry the real CLI
  error

* fix: address second Kilobot review round

- escape pipe characters in changeRow actions (same as skippedRow)
- sanitize agent-chosen file paths before they land in draftReasons
  and the PR body (residual marker-forgery path via filenames)
- log expected fetch misses in prepare-branch instead of silent catches

* feat: keep bot-authored PRs in the docs-sync digest

Release and dependency bots ship user-facing changes (e.g. JetBrains
release PRs from kilo-maintainer[bot]). The auto-docs label check and
docs-only path filter remain as the loop guards.
This commit is contained in:
Igor Šćekić
2026-07-24 17:38:39 +02:00
committed by GitHub
parent c72817e67f
commit 1346963e59
13 changed files with 1170 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// kilocode_change - new file
/**
* Collects PRs merged to the source repos since the watermark, applies a
* deterministic pre-filter, and writes docs-sync-out/digest.json for the LLM
* triage pass.
*
* Pre-filter drops (triage never sees these):
* - PRs labeled auto-docs (this bot's own rolling PRs)
* - chore/test/ci/build/docs/style/refactor/revert conventional titles
* - PRs touching only docs/non-product paths
*
* Bot-authored PRs are kept: release/dependency bots ship user-facing
* changes too, and the label + docs-only guards above prevent loops.
*/
import fs from "node:fs"
import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs"
const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"]
const OUT_DIR = "docs-sync-out"
const BODY_LIMIT = 2000
const SLIM_BODY_LIMIT = 300
const PATCH_LIMIT = 8000
const FILE_LIMIT = 30
const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i
const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/
function argSince() {
const i = process.argv.indexOf("--since")
const v = i >= 0 ? process.argv[i + 1] : null
if (!v || Number.isNaN(new Date(v).getTime())) {
throw new Error("usage: collect.mjs --since <ISO date>")
}
return new Date(v)
}
async function mergedPrs(fullRepo, since) {
const query = `repo:${fullRepo} is:pr is:merged merged:>=${since.toISOString()}`
return searchIssues(query)
}
const since = argSince()
console.log(`collecting PRs merged since ${since.toISOString()}`)
const digest = []
const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 }
for (const fullRepo of SOURCE_REPOS) {
const prs = await mergedPrs(fullRepo, since)
console.log(`${fullRepo}: ${prs.length} merged PRs in window`)
for (const item of prs) {
const author = item.user?.login ?? ""
if ((item.labels ?? []).some((l) => l.name === "auto-docs")) {
dropped.label++
continue
}
if (DROP_TITLE.test(item.title ?? "")) {
dropped.title++
continue
}
const number = item.number
let pr
let files
try {
pr = await api(`/repos/${fullRepo}/pulls/${number}`)
files = await listPrFiles(fullRepo, number)
} catch (err) {
// Isolate per-PR failures: one dead PR must not abort the whole run.
console.warn(`::warning::skipping ${fullRepo}#${number}: ${err.message}`)
dropped.fetch_error++
continue
}
// listPrFiles caps at 300 files; a truncated list can't support the
// docs-only classification, so keep such PRs and record the true total.
const truncated = files.length >= 300
if (!truncated && files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) {
dropped.docs_only++
continue
}
let patch = ""
for (const f of files) {
if (!f.patch) continue
const chunk = `--- ${f.filename}\n${f.patch}\n`
if (patch.length + chunk.length > PATCH_LIMIT) {
patch += "\n... (diff truncated) ...\n"
break
}
patch += chunk
}
digest.push({
repo: fullRepo,
number,
title: pr.title,
url: pr.html_url,
author,
merged_at: pr.merged_at,
labels: (pr.labels ?? []).map((l) => l.name),
body: (pr.body ?? "").slice(0, BODY_LIMIT),
files: files.slice(0, FILE_LIMIT).map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`),
files_total: pr.changed_files ?? files.length,
patch_excerpt: patch,
})
}
}
digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at))
fs.mkdirSync(OUT_DIR, { recursive: true })
// Full digest (bodies + patch excerpts) is filtered down to docs-worthy PRs
// for the edit pass; the slim digest keeps the triage pass context small.
fs.writeFileSync(`${OUT_DIR}/digest-full.json`, JSON.stringify(digest, null, 2))
const slim = digest.map(({ patch_excerpt, body, ...rest }) => ({
...rest,
body: body.slice(0, SLIM_BODY_LIMIT),
}))
fs.writeFileSync(`${OUT_DIR}/digest.json`, JSON.stringify(slim, null, 2))
console.log(`kept ${digest.length} PRs, dropped:`, dropped)
appendOutput("count", digest.length)
appendOutput("digest", `${OUT_DIR}/digest.json`)
appendSummary(
[
"### docs-sync collect",
"",
`- window: since \`${since.toISOString()}\``,
`- kept: **${digest.length}** PRs`,
`- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`,
"",
...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`),
].join("\n"),
)
+24
View File
@@ -0,0 +1,24 @@
You are the Kilo Code documentation bot. You update the public product documentation in `packages/kilo-docs` (a Markdoc/Next.js site served at kilo.ai/docs) so it reflects recently merged PRs. You are handling one batch of PRs; the batch files and your output file are named at the end of these instructions.
Before writing anything:
1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy.
2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities).
For each PR in the batch, in priority order:
- Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content.
- Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`.
- Document only behavior that is actually present in the merged diff. If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why.
- If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict.
Hard rules:
- Only create or modify files under `packages/kilo-docs/`. Never touch code, tests, config, images, or anything outside that directory.
- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation.
- Do not run git commands and do not commit anything; automation handles git.
- Keep the change small and precise. Do not rewrite sections that are already accurate.
When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated <path>`, `created <path>`, or `skipped`. Example:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "action": "updated pages/code-with-ai/platforms/cli.md", "reason": "documented --variant flag"}, {"pr": 124, "url": "https://github.com/Kilo-Org/kilocode/pull/124", "action": "skipped", "reason": "feature behind unreleased flag"}]
+122
View File
@@ -0,0 +1,122 @@
// kilocode_change - new file
/**
* Runs the LLM edit pass over docs-sync-out/worthy.json in batches.
*
* Batching bounds each `kilo run` context (a replay window can yield dozens
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
* and writes its own summary file; results are merged into
* docs-sync-out/edit-summary.json. A batch that fails is skipped with a
* warning — its PRs show up in the rolling PR body as skipped, so nothing
* fails silently.
*
* Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider).
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const BATCH_SIZE = 5
const ATTEMPTS = 2
const OUT_DIR = "docs-sync-out"
export const SUMMARY_FILE = ".docs-sync-summary.json"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
const model = process.env.EDIT_MODEL
if (!model) throw new Error("EDIT_MODEL is required")
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
const priority = new Map(triage.map((e) => [e.url, e]))
const ordered = [...worthy].sort((a, b) => {
const rank = { high: 0, medium: 1, low: 2 }
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
})
function editBatch(batch, index) {
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2))
fs.writeFileSync(
triageFile,
JSON.stringify(
batch.map((d) => priority.get(d.url)).filter(Boolean),
null,
2,
),
)
const prompt = `${basePrompt}
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
execFileSync(
"kilo",
["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
// stdout streams live to the Actions log; stderr is piped so failure
// warnings can include the tail of the actual CLI error.
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] },
)
if (fs.existsSync(summaryFile)) return true
// Tolerate the agent dropping the docs-sync-out/ prefix.
const alt = path.basename(summaryFile)
if (fs.existsSync(alt)) {
fs.renameSync(alt, summaryFile)
return true
}
console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
}
}
console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`)
return false
}
const batches = []
for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
batches.push(ordered.slice(i, i + BATCH_SIZE))
}
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
for (let i = 0; i < batches.length; i++) {
editBatch(batches[i], i)
}
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
// body accounts for it; failed batches show up as skipped.
const merged = []
const seen = new Set()
for (let i = 0; i < batches.length; i++) {
const file = `${OUT_DIR}/edit-summary-${i}.json`
let entries = []
try {
entries = JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
continue
}
for (const e of entries) {
const url = String(e?.url ?? "")
if (!url.startsWith("http") || seen.has(url)) continue
seen.add(url)
merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") })
}
}
for (const d of ordered) {
if (seen.has(d.url)) continue
merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" })
}
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
// removed there before committing so it never lands in the docs PR.
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`)
+76
View File
@@ -0,0 +1,76 @@
// kilocode_change - new file
/**
* Extracts and validates the triage JSON array from raw LLM stdout.
* Usage: extract-json.mjs <raw-input-file> <output-file>
* Exit 0 on success, 1 on any failure. Also exports parseTriageEntries for
* the chunked triage runner.
*/
import fs from "node:fs"
import { pathToFileURL } from "node:url"
/** Returns validated triage entries, or null when extraction fails. */
export function parseTriageEntries(raw) {
// `kilo run` prints the assistant message twice (streaming render + final
// summary), so stdout can hold the same array back-to-back. Try each "["
// from the right and return the first slice that parses — i.e. the last
// (most recent) valid array in the output.
const end = raw.lastIndexOf("]")
if (end < 0) return null
const starts = []
for (let i = 0; i <= end; i++) {
if (raw[i] === "[") starts.push(i)
}
for (let s = starts.length - 1; s >= 0; s--) {
let parsed
try {
parsed = JSON.parse(raw.slice(starts[s], end + 1))
} catch {
continue
}
if (!Array.isArray(parsed)) continue
const entries = validate(parsed)
if (entries) return entries
}
return null
}
function validate(parsed) {
const entries = []
for (const e of parsed) {
const pr = Number(e?.pr)
const url = String(e?.url ?? "")
if (!Number.isInteger(pr) || !url.startsWith("http")) continue
entries.push({
pr,
url,
docs_worthy: e.docs_worthy === true,
reason: String(e.reason ?? ""),
target_sections: Array.isArray(e.target_sections) ? e.target_sections.map(String) : [],
priority: ["high", "medium", "low"].includes(e.priority) ? e.priority : "medium",
})
}
return entries.length > 0 ? entries : null
}
function main() {
const [, , inputPath, outputPath] = process.argv
if (!inputPath || !outputPath) {
console.error("usage: extract-json.mjs <raw-input-file> <output-file>")
process.exit(1)
}
const entries = parseTriageEntries(fs.readFileSync(inputPath, "utf8"))
if (!entries) {
console.error("no valid triage JSON array found in input")
process.exit(1)
}
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
console.log(`extracted ${entries.length} triage entries (${entries.filter((e) => e.docs_worthy).length} docs-worthy)`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
+24
View File
@@ -0,0 +1,24 @@
// kilocode_change - new file
/**
* Filters the full digest down to PRs the triage pass marked docs-worthy.
* Usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>
* The edit pass consumes the output so its context stays small.
*/
import fs from "node:fs"
const [, , digestPath, triagePath, outputPath] = process.argv
if (!digestPath || !triagePath || !outputPath) {
console.error("usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>")
process.exit(1)
}
const digest = JSON.parse(fs.readFileSync(digestPath, "utf8"))
const triage = JSON.parse(fs.readFileSync(triagePath, "utf8"))
const worthy = new Set(triage.filter((e) => e.docs_worthy).map((e) => e.url))
const out = digest.filter((d) => worthy.has(d.url))
fs.writeFileSync(outputPath, JSON.stringify(out, null, 2))
console.log(`${out.length} of ${digest.length} digest entries are docs-worthy`)
+113
View File
@@ -0,0 +1,113 @@
// kilocode_change - new file
/**
* Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+
* global fetch) so the workflow does not rely on runner images shipping the
* gh CLI.
*/
import fs from "node:fs"
const API = "https://api.github.com"
const MAX_RETRIES = 3
export function token() {
const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required")
return t
}
export function repo() {
const r = process.env.GITHUB_REPOSITORY
if (!r) throw new Error("GITHUB_REPOSITORY is required")
return r
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export async function api(path, { method = "GET", body } = {}) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
let res
try {
res = await fetch(`${API}${path}`, {
method,
headers: {
authorization: `Bearer ${token()}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "kilo-docs-sync-bot",
},
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch (err) {
if (attempt < MAX_RETRIES) {
console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
throw err
}
if (res.status === 403) {
const text = await res.text()
if (text.includes("rate limit") && attempt < MAX_RETRIES) {
const retryAfter = Number(res.headers.get("retry-after")) || 30
console.warn(`rate limited, retrying in ${retryAfter}s`)
await sleep(retryAfter * 1000)
continue
}
const err = new Error(`${method} ${path} -> 403: ${text}`)
err.status = 403
throw err
}
if (res.status >= 500 && attempt < MAX_RETRIES) {
console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
if (!res.ok) {
const text = await res.text()
const err = new Error(`${method} ${path} -> ${res.status}: ${text}`)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
throw new Error(`${method} ${path}: exhausted retries`)
}
/** Paginated search/issues. Caps at `maxPages` * 100 results. */
export async function searchIssues(query, { maxPages = 5 } = {}) {
const items = []
for (let page = 1; page <= maxPages; page++) {
const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`)
items.push(...(data.items ?? []))
if ((data.items ?? []).length < 100) break
}
return items
}
export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) {
const files = []
for (let page = 1; page <= maxPages; page++) {
const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`)
files.push(...batch)
if (batch.length < 100) break
}
return files
}
export function appendOutput(name, value) {
const out = process.env.GITHUB_OUTPUT
if (out) fs.appendFileSync(out, `${name}=${value}\n`)
console.log(`output ${name}=${value}`)
}
export function appendSummary(markdown) {
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) fs.appendFileSync(summary, markdown + "\n")
}
+61
View File
@@ -0,0 +1,61 @@
// kilocode_change - new file
/**
* Prepares the rolling docs-sync branch before the edit pass:
* - an open auto-docs PR exists -> check out its head branch and merge
* origin/main (preserves any human commits on the branch)
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
*
* Outputs: branch, mode (update|fresh), pr_number (empty when fresh).
*/
import { execFileSync } from "node:child_process"
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
export const DEFAULT_BRANCH = "docs/auto-sync"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
let mode = "fresh"
let prNumber = ""
let branch = DEFAULT_BRANCH
if (prs.length > 0) {
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
branch = pr.head?.ref ?? DEFAULT_BRANCH
prNumber = String(pr.number)
git(["fetch", "origin", "main", branch])
git(["checkout", branch])
try {
git(["merge", "origin/main", "--no-edit"])
mode = "update"
} catch {
console.warn(`merge of origin/main into ${branch} conflicted.`)
console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.")
git(["merge", "--abort"])
branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
mode = "conflict"
}
} else {
// Keep the remote-tracking ref current so the later --force-with-lease
// push (stale branch left over from a merged/closed PR) is safe.
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
}
appendOutput("branch", branch)
appendOutput("mode", mode)
appendOutput("pr_number", prNumber)
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
+19
View File
@@ -0,0 +1,19 @@
You are the triage pass of an automated documentation pipeline for Kilo Code. Kilo Code is an open-source agentic engineering platform: VS Code extension, JetBrains plugin, CLI, and the kilo.ai cloud platform (teams, KiloClaw, gateway, code reviews).
The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud and Kilo-Org/kilocode. Your only job is to decide which of them require changes to the public product documentation at kilo.ai/docs.
A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior.
A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, or a change only visible to contributors or self-hosters.
Rules:
- Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs.
- When unsure, set `docs_worthy` to false and explain the doubt in `reason`.
- `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`.
- `reason` is one short sentence, written for the human who reviews the final docs PR.
- `priority` reflects user impact: high = most users affected, medium = notable subset, low = edge case.
Respond with a STRICT JSON array and nothing else: no prose, no markdown fences, no comments. Schema:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "docs_worthy": true, "reason": "Adds --variant flag to kilo run", "target_sections": ["code-with-ai/platforms/cli"], "priority": "high"}]
+111
View File
@@ -0,0 +1,111 @@
// kilocode_change - new file
/**
* Runs the LLM triage pass over docs-sync-out/digest.json in chunks.
*
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
* single triage call over that volume truncates its JSON output, so the
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
* its own `kilo run` call. A chunk that fails twice is degraded to
* "unclassified" entries (docs_worthy=false) instead of failing the run —
* the PR body then shows those PRs as skipped, visible to reviewers.
*
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
* the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { parseTriageEntries } from "./extract-json.mjs"
const CHUNK_SIZE = 25
const ATTEMPTS = 2
const OUT_DIR = "docs-sync-out"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
function triageChunk(chunk, index) {
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
let raw
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
raw = execFileSync(
"kilo",
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
continue
}
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
const entries = parseTriageEntries(raw)
if (entries) {
// An entry for a PR outside this chunk must not win the shared dedupe
// against the chunk that actually owns it — drop foreign entries.
const allowed = new Set(chunk.map((d) => d.url))
const owned = entries.filter((e) => allowed.has(e.url))
if (owned.length !== entries.length) {
console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`)
}
if (owned.length > 0) return owned
}
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
}
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
return chunk.map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "triage failed to classify this PR",
target_sections: [],
priority: "medium",
}))
}
const chunks = []
for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
chunks.push(digest.slice(i, i + CHUNK_SIZE))
}
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
const merged = []
const seen = new Set()
for (let i = 0; i < chunks.length; i++) {
for (const e of triageChunk(chunks[i], i)) {
if (seen.has(e.url)) continue
seen.add(e.url)
merged.push(e)
}
}
// Coverage: every digest PR gets a triage entry so the PR body's skipped
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
for (const d of digest) {
if (seen.has(d.url)) continue
merged.push({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "not classified by triage",
target_sections: [],
priority: "medium",
})
}
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
const worthy = merged.filter((e) => e.docs_worthy).length
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
+241
View File
@@ -0,0 +1,241 @@
// kilocode_change - new file
/**
* Commits the agent's packages/kilo-docs changes, pushes the rolling branch,
* and creates or updates the rolling auto-docs PR.
*
* No-op when the agent produced no docs changes. PRs become drafts when the
* diff exceeds the file cap or verification failed. The PR body carries
* marker-delimited sections so later runs can append rows, plus a
* machine-readable processed-through watermark.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import { pathToFileURL } from "node:url"
const BRANCH = process.env.BRANCH || "docs/auto-sync"
const FILE_CAP = 15
const ROW_CAP = 150
const SUMMARY_FILE = ".docs-sync-summary.json"
const DOCS_PATH = "packages/kilo-docs"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
// Agent-generated strings land in the PR body next to machine-read markers.
// Strip HTML-comment sequences so a crafted/adversarial value cannot forge
// section boundaries or the processed-through watermark.
function clean(value) {
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
}
function shortRef(url) {
return clean(url).replace("https://github.com/", "").replace("/pull/", "#")
}
function changeRow(e) {
return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |`
}
function skippedRow(e) {
const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
export function extractSectionRows(body, name) {
const m = String(body ?? "").match(
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
)
if (!m) return []
return m[1]
.split("\n")
.map((l) => l.trim())
.filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l))
}
function section(name, header, rows) {
const body = rows.length > 0 ? [header, "| --- | --- |", ...rows].join("\n") : "_None._"
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
return `## Automated docs sync — ${date}
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
- Window: \`${since}\`\`${through}\`
- Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}**
${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""}
### Changes
${section("changes", "| Docs change | Source |", changesRows)}
### Considered, no docs change needed
${section("skipped", "| PR | Reason |", skippedRows)}
---
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
<!-- docs-sync: processed-through ${through} -->
`
}
function mergeRows(oldRows, newRows) {
const seen = new Set()
const out = []
for (const row of [...oldRows, ...newRows]) {
if (seen.has(row)) continue
seen.add(row)
out.push(row)
}
return out.slice(-ROW_CAP)
}
function readJson(path, fallback) {
try {
return JSON.parse(fs.readFileSync(path, "utf8"))
} catch {
return fallback
}
}
async function main() {
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
const since = process.env.SINCE ?? "unknown"
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
const existingPr = process.env.PR_NUMBER || ""
const verified = process.env.VERIFIED === "true"
const date = through.slice(0, 10)
// The agent's run summary is consumed here and never committed.
const agentSummary = readJson(SUMMARY_FILE, [])
fs.rmSync(SUMMARY_FILE, { force: true })
const triage = readJson("docs-sync-out/triage.json", [])
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
console.log("no packages/kilo-docs changes produced; nothing to commit")
appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.")
return
}
git(["config", "user.name", "github-actions[bot]"])
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"])
git(["add", DOCS_PATH])
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
// The draft cap bounds the cumulative PR diff, not just this run's commit.
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
.split("\n")
.filter(Boolean)
const draftReasons = []
if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`)
if (!verified) draftReasons.push("docs build/tests not passing")
// Content gate: legitimate bot edits are docs pages and nav files. Anything
// else in the docs package (build config, components, tests) executes
// during the verify build, so force human review before merge.
const nonContent = changedFiles.filter(
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
)
if (nonContent.length > 0) {
// File paths are agent-chosen; sanitize before they land in the PR body.
const listed = nonContent
.slice(0, 5)
.map((f) => clean(f).replaceAll("|", "\\|"))
.join(", ")
draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`)
}
const draft = draftReasons.length > 0
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow)
const skippedNew = [
...triage.filter((e) => e.docs_worthy === false),
...agentSummary.filter((e) => e.action === "skipped"),
].map(skippedRow)
let oldChanges = []
let oldSkipped = []
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
oldChanges = extractSectionRows(pr.body, "changes")
oldSkipped = extractSectionRows(pr.body, "skipped")
}
const body = renderBody({
date,
since,
through,
changesRows: mergeRows(oldChanges, changesNew),
skippedRows: mergeRows(oldSkipped, skippedNew),
verified,
draftReasons,
note:
mode === "conflict" && existingPr
? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).`
: "",
})
try {
await api(`/repos/${repo()}/labels`, {
method: "POST",
body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" },
})
} catch (err) {
if (err.status !== 422) throw err // 422 = label already exists
}
let prNumber
let prUrl
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, {
method: "PATCH",
body: { title: `docs: auto-sync with merged PRs (through ${date})`, body },
})
prNumber = pr.number
prUrl = pr.html_url
await api(`/repos/${repo()}/issues/${prNumber}/comments`, {
method: "POST",
body: {
body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`,
},
})
} else {
const pr = await api(`/repos/${repo()}/pulls`, {
method: "POST",
body: {
title: `docs: auto-sync with merged PRs (through ${date})`,
head: BRANCH,
base: "main",
body,
draft,
},
})
prNumber = pr.number
prUrl = pr.html_url
await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } })
if (mode === "conflict" && existingPr) {
await api(`/repos/${repo()}/issues/${existingPr}/comments`, {
method: "POST",
body: {
body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`,
},
})
}
}
appendOutput("pr_url", prUrl)
appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+75
View File
@@ -0,0 +1,75 @@
// kilocode_change - new file
/**
* Resolves the docs-sync watermark: the timestamp of the newest source PR the
* bot has already processed. Derived from the bot's own PRs (marker in the PR
* body), so there is no external state to keep consistent.
*
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
* 14 days.
*/
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
const FALLBACK_HOURS = 72
const CAP_DAYS = 14
const MARKER = /<!--\s*docs-sync:\s*processed-through\s+(\S+?)\s*-->/
function extractMarker(body) {
const m = (body ?? "").match(MARKER)
if (!m) return null
const d = new Date(m[1])
return Number.isNaN(d.getTime()) ? null : d
}
async function findWatermark() {
const r = repo()
for (const state of ["open", "merged"]) {
const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}`
const prs = await searchIssues(query, { maxPages: 1 })
for (const pr of prs) {
// Only trust markers on PRs authored by the bot itself: bodies are
// editable and the label can be applied by anyone with triage access.
if (pr.user?.login !== "github-actions[bot]") continue
const marker = extractMarker(pr.body)
if (marker) {
console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`)
return marker
}
}
}
return null
}
const now = new Date()
let since
const input = (process.env.INPUT_SINCE ?? "").trim()
if (input) {
since = new Date(input)
if (Number.isNaN(since.getTime())) {
throw new Error(`Invalid INPUT_SINCE: ${input}`)
}
console.log(`watermark from dispatch input: ${since.toISOString()}`)
} else {
since =
(await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
}
// A forged, edited, or malformed marker in the future would silently match
// nothing in the merged:>= search; clamp it loudly.
if (since > now) {
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
since = now
}
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
if (since < cap) {
console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`)
since = cap
}
appendOutput("since", since.toISOString())
appendOutput("now", now.toISOString())
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
+166
View File
@@ -0,0 +1,166 @@
# kilocode_change - new file
name: docs-sync
# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode,
# triages them for docs relevance, runs Kilo CLI headless to update
# packages/kilo-docs, and maintains one rolling PR for human review.
#
# Security posture: scheduled/manual only, checks out main, never executes
# code from PR branches. State is derived from the bot's own PRs (watermark
# marker in the PR body), so missed or failed runs self-heal on the next run.
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC daily
workflow_dispatch:
inputs:
since:
description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap."
required: false
type: string
dry_run:
description: "Collect + triage only, no edits, no PR"
type: boolean
default: false
permissions:
contents: write # push the rolling branch, create the auto-docs label
pull-requests: write # create/update the rolling PR
issues: write # comment on the rolling PR
concurrency:
group: docs-sync
cancel-in-progress: false
env:
TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }}
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 120
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # prepare-branch merges main into the rolling branch
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Install Kilo CLI
run: |
npm install -g @kilocode/cli
kilo --version
- name: Resolve watermark
id: wm
env:
GH_TOKEN: ${{ github.token }}
INPUT_SINCE: ${{ inputs.since }}
run: node .github/docs-sync/watermark.mjs
- name: Collect merged PRs
id: collect
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}"
- name: Triage merged PRs (LLM, chunked)
id: triage
if: steps.collect.outputs.count != '0'
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
id: worthy
if: steps.collect.outputs.count != '0'
run: |
node .github/docs-sync/filter-worthy.mjs \
docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json
count=$(node -p "require('./docs-sync-out/worthy.json').length")
echo "count=$count" >> "$GITHUB_OUTPUT"
if [ "$count" = "0" ]; then
echo "No docs-worthy PRs in this window; skipping edit/verify/PR."
fi
- name: Setup Bun
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
uses: ./.github/actions/setup-bun
- name: Prepare rolling branch
id: prep
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/prepare-branch.mjs
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
id: verify
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
continue-on-error: true
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log
- name: Fix verify failures (one pass)
id: fix
if: steps.verify.outcome == 'failure'
continue-on-error: true
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
| tee -a docs-sync-out/edit-log.txt
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
- name: Re-verify status
id: verified
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
FIX_OUTCOME: ${{ steps.fix.outcome }}
run: |
if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Upsert rolling PR
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
SINCE: ${{ steps.wm.outputs.since }}
BRANCH: ${{ steps.prep.outputs.branch }}
PREP_MODE: ${{ steps.prep.outputs.mode }}
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
VERIFIED: ${{ steps.verified.outputs.ok }}
run: node .github/docs-sync/upsert-pr.mjs
- name: Upload run artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: docs-sync-out
path: docs-sync-out/
retention-days: 14
if-no-files-found: ignore