From 198b188011a3b73ab5fa7c1ffefe3c82c2ffe39b Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Tue, 6 Oct 2026 18:01:32 +0100 Subject: [PATCH] feat: update-readme GitHub Action (#28) --- .bumpy/update-readme-action.md | 5 + .github/actions/check-sync/check-sync.mjs | 86 +----- .github/actions/shared/mdcode.mjs | 99 ++++++ .github/actions/update-readme/action.yml | 97 ++++++ .../actions/update-readme/update-readme.mjs | 215 +++++++++++++ .intent/review-state.json | 96 ++++-- CLAUDE.md | 2 +- TESTING.md | 2 + packages/mdcode/README.md | 76 +++++ .../usage/tests/update-readme-action.test.ts | 289 ++++++++++++++++++ 10 files changed, 868 insertions(+), 99 deletions(-) create mode 100644 .bumpy/update-readme-action.md create mode 100644 .github/actions/shared/mdcode.mjs create mode 100644 .github/actions/update-readme/action.yml create mode 100755 .github/actions/update-readme/update-readme.mjs create mode 100644 packages/usage/tests/update-readme-action.test.ts diff --git a/.bumpy/update-readme-action.md b/.bumpy/update-readme-action.md new file mode 100644 index 0000000..ac81c65 --- /dev/null +++ b/.bumpy/update-readme-action.md @@ -0,0 +1,5 @@ +--- +mdcode-ts: minor +--- + +Added the `update-readme` GitHub Action (`adrianbrowning/mdcode-ts/.github/actions/update-readme`). It treats source files as authoritative: it runs `mdcode update --apply` on the selected documents in a temporary worktree at the base branch's tip, then opens one pull request holding only the Markdown changes, or refreshes the one it opened before. A rerun with nothing new pushes nothing. Once the base branch is in sync, it closes its pull request. It never pushes to the base branch and never writes a source file. diff --git a/.github/actions/check-sync/check-sync.mjs b/.github/actions/check-sync/check-sync.mjs index 6b5bba7..6c7510a 100755 --- a/.github/actions/check-sync/check-sync.mjs +++ b/.github/actions/check-sync/check-sync.mjs @@ -10,32 +10,16 @@ * directions are in sync, 1 when anything drifted or could not be read, 2 when * the action itself was misconfigured or mdcode could not run. */ -import { spawnSync } from "node:child_process"; -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { glob } from "node:fs/promises"; -import { dirname, join, posix } from "node:path"; +import { appendFileSync } from "node:fs"; +import { dirname, posix } from "node:path"; + +import { cell, ConfigError, configFlags, documents, env, escape, flag, mdcode, mdcodeCommand, runAction } from "../shared/mdcode.mjs"; const DIRECTIONS = { update: { label: "files → Markdown (update)", fix: "mdcode update --apply" }, extract: { label: "Markdown → files (extract)", fix: "mdcode extract --force" }, }; -const env = name => (process.env[name] ?? "").trim(); -const flag = name => /^(true|1|yes)$/i.test(env(name)); - -class ConfigError extends Error {} - -/** The command that runs mdcode: the input, else the release this action belongs to, from npm. */ -function mdcodeCommand() { - if (env("MDCODE_COMMAND")) { - return env("MDCODE_COMMAND"); - } - - const manifest = join(import.meta.dirname, "..", "..", "..", "packages", "mdcode", "package.json"); - const { name, version } = JSON.parse(readFileSync(manifest, "utf-8")); - return `npx --yes ${name}@${version}`; -} - /** The directions to check, from a space- or comma-separated list. */ function directions() { const asked = env("DIRECTIONS").split(/[\s,]+/).filter(Boolean); @@ -48,50 +32,9 @@ function directions() { return [ ...new Set(asked) ]; } -/** The documents, one path or glob per line, so paths may contain spaces. */ -async function documents() { - const found = []; - - for (const line of env("DOCUMENTS").split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) { - if (!/[*?[{]/.test(line)) { - if (!existsSync(line)) { - throw new ConfigError(`document ${line} does not exist`); - } - found.push(line); - continue; - } - - const matches = []; - for await (const match of glob(line)) { - matches.push(match.split("\\").join("/")); - } - - if (matches.length === 0) { - throw new ConfigError(`documents pattern ${line} matched no files`); - } - found.push(...matches.sort()); - } - - return [ ...new Set(found) ]; -} - -/** Run mdcode with these arguments and return its JSON envelope. */ -function mdcode(command, args) { - // bash passes every argument through "$@" untouched, so paths with spaces stay whole. - // --norc: bash reads ~/.bashrc when it guesses it runs over ssh, which can print or change PATH. - const run = spawnSync("bash", [ "--noprofile", "--norc", "-c", `${command} "$@"`, "mdcode", ...args ], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }); - - try { - return JSON.parse(run.stdout); - } - catch { - throw new ConfigError(`mdcode did not produce a JSON result (exit ${run.status ?? run.signal}):\n${run.stderr || run.stdout || run.error?.message || ""}`.trimEnd()); - } -} - /** The mdcode runs for one direction: one for update, one per document directory for extract. */ function runs(direction, docs) { - const common = [ "--json", ...(flag("PROJECT") ? [ "--project" ] : []), ...(env("CONFIG") ? [ "--config", env("CONFIG") ] : []) ]; + const common = [ "--json", ...configFlags() ]; if (direction === "update") { return [[ "update", "--check", "--continue-on-error", ...common, ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]]; @@ -113,14 +56,6 @@ function runs(direction, docs) { return [ ...byDir ].map(([ dir, group ]) => [ ...extract, "--dir", dir, ...group ]); } -/** Escape text for a workflow command's message or property value. */ -function escape(text, property = false) { - const escaped = String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); - return property ? escaped.replaceAll(":", "%3A").replaceAll(",", "%2C") : escaped; -} - -const cell = text => String(text ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); - async function main() { const command = mdcodeCommand(); const asked = directions(); @@ -212,13 +147,4 @@ function report(problems, asked, docs) { } } -try { - process.exitCode = await main(); -} -catch (error) { - if (!(error instanceof ConfigError)) { - throw error; - } - console.log(`::error title=mdcode check-sync::${escape(error.message)}`); - process.exitCode = 2; -} +await runAction("mdcode check-sync", main); diff --git a/.github/actions/shared/mdcode.mjs b/.github/actions/shared/mdcode.mjs new file mode 100644 index 0000000..77800b3 --- /dev/null +++ b/.github/actions/shared/mdcode.mjs @@ -0,0 +1,99 @@ +/** + * Helpers shared by this repository's consumer GitHub Actions (check-sync, + * update-readme). Each action's composite step runs its own script, which + * imports these. GitHub downloads the whole repository at the action's ref, + * so this file and packages/mdcode/package.json are always beside them. + */ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { join } from "node:path"; + +/** An action input, from the environment variable action.yml maps it to. */ +export const env = name => (process.env[name] ?? "").trim(); + +/** A boolean action input. */ +export const flag = name => /^(true|1|yes)$/i.test(env(name)); + +/** The inputs are wrong or mdcode could not run: the action exits 2. */ +export class ConfigError extends Error {} + +/** The command that runs mdcode: the input, else the release this action belongs to, from npm. */ +export function mdcodeCommand() { + if (env("MDCODE_COMMAND")) { + return env("MDCODE_COMMAND"); + } + + const manifest = join(import.meta.dirname, "..", "..", "..", "packages", "mdcode", "package.json"); + const { name, version } = JSON.parse(readFileSync(manifest, "utf-8")); + return `npx --yes ${name}@${version}`; +} + +/** The documents input: one path or glob per line, so paths may contain spaces. */ +export async function documents() { + const found = []; + + for (const line of env("DOCUMENTS").split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) { + if (!/[*?[{]/.test(line)) { + if (!existsSync(line)) { + throw new ConfigError(`document ${line} does not exist`); + } + found.push(line); + continue; + } + + const matches = []; + for await (const match of glob(line)) { + matches.push(match.split("\\").join("/")); + } + + if (matches.length === 0) { + throw new ConfigError(`documents pattern ${line} matched no files`); + } + found.push(...matches.sort()); + } + + return [ ...new Set(found) ]; +} + +/** The flags that select a configuration file, from the project and config inputs. */ +export function configFlags() { + return [ ...(flag("PROJECT") ? [ "--project" ] : []), ...(env("CONFIG") ? [ "--config", env("CONFIG") ] : []) ]; +} + +/** Run mdcode with these arguments and return its JSON envelope. */ +export function mdcode(command, args) { + // bash passes every argument through "$@" untouched, so paths with spaces stay whole. + // --norc: bash reads ~/.bashrc when it guesses it runs over ssh, which can print or change PATH. + const run = spawnSync("bash", [ "--noprofile", "--norc", "-c", `${command} "$@"`, "mdcode", ...args ], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }); + + try { + return JSON.parse(run.stdout); + } + catch { + throw new ConfigError(`mdcode did not produce a JSON result (exit ${run.status ?? run.signal}):\n${run.stderr || run.stdout || run.error?.message || ""}`.trimEnd()); + } +} + +/** Escape text for a workflow command's message or property value. */ +export function escape(text, property = false) { + const escaped = String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); + return property ? escaped.replaceAll(":", "%3A").replaceAll(",", "%2C") : escaped; +} + +/** Text for one cell of a Markdown table. */ +export const cell = text => String(text ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); + +/** Run a script's main(), turning a ConfigError into an annotation and exit code 2. */ +export async function runAction(title, main) { + try { + process.exitCode = await main(); + } + catch (error) { + if (!(error instanceof ConfigError)) { + throw error; + } + console.log(`::error title=${escape(title, true)}::${escape(error.message)}`); + process.exitCode = 2; + } +} diff --git a/.github/actions/update-readme/action.yml b/.github/actions/update-readme/action.yml new file mode 100644 index 0000000..6402b88 --- /dev/null +++ b/.github/actions/update-readme/action.yml @@ -0,0 +1,97 @@ +name: mdcode update README +description: Update Markdown code blocks from the source files they link to, and open or refresh one pull request with only those Markdown changes. + +inputs: + documents: + description: > + Markdown files to update, one path or glob per line (paths may contain + spaces). Leave empty with project or config to update the + configuration's documents. + default: '' + base: + description: > + The directory file= paths resolve against (--base). Default: each + document's own directory, or the configuration's sourceRoot. + default: '' + project: + description: Load mdcode.config.json from the working directory (--project). + default: 'false' + config: + description: Load this configuration file instead (--config). + default: '' + branch: + description: The branch the action force-pushes its commit to. Never the base branch. + default: mdcode/update-docs + base-branch: + description: The branch the pull request targets. + default: ${{ github.event.repository.default_branch }} + title: + description: Pull request title. + default: 'docs: update code blocks from their source files' + commit-message: + description: Commit message. + default: 'docs: update code blocks from their source files' + author-name: + description: Commit author name. + default: github-actions[bot] + author-email: + description: Commit author email. + default: 41898282+github-actions[bot]@users.noreply.github.com + token: + description: > + Token for pushing the branch and opening the pull request; needs + contents and pull-requests write. Pull requests opened with the default + GITHUB_TOKEN do not start workflows, so pass a GitHub App token or a + fine-grained personal access token for checks to run on them. + default: ${{ github.token }} + working-directory: + description: Directory to run in, relative to the workspace. + default: '.' + node-version: + description: Node.js version to set up (22.17 or later). Empty to use the runner's Node.js. + default: '22' + mdcode-command: + description: > + Command that runs mdcode. Default: `npx --yes mdcode-ts@`, the + release this action's ref belongs to. + default: '' + +outputs: + changed: + description: '`true` when a block changed and the pull request was opened or refreshed, else `false`.' + value: ${{ steps.update.outputs.changed }} + pull-request-number: + description: The pull request's number, when changed is true. + value: ${{ steps.update.outputs.pull-request-number }} + pull-request-url: + description: The pull request's URL, when changed is true. + value: ${{ steps.update.outputs.pull-request-url }} + +runs: + using: composite + steps: + - name: Set up Node.js + if: inputs.node-version != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Update Markdown and open a pull request + id: update + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + DOCUMENTS: ${{ inputs.documents }} + BASE: ${{ inputs.base }} + PROJECT: ${{ inputs.project }} + CONFIG: ${{ inputs.config }} + BRANCH: ${{ inputs.branch }} + BASE_BRANCH: ${{ inputs.base-branch }} + TITLE: ${{ inputs.title }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + AUTHOR_NAME: ${{ inputs.author-name }} + AUTHOR_EMAIL: ${{ inputs.author-email }} + TOKEN: ${{ inputs.token }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + MDCODE_COMMAND: ${{ inputs.mdcode-command }} + run: node "$GITHUB_ACTION_PATH/update-readme.mjs" diff --git a/.github/actions/update-readme/update-readme.mjs b/.github/actions/update-readme/update-readme.mjs new file mode 100755 index 0000000..f6bcf3b --- /dev/null +++ b/.github/actions/update-readme/update-readme.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * The update-readme action: treat linked source files as authoritative, run + * `mdcode update --apply` on the selected Markdown documents, and open or + * refresh one pull request that holds only those documents' changes. + * + * It works in a temporary worktree at the base branch's tip, so the caller's + * checkout is never touched and the pull request is always the update on top + * of base-branch. It never pushes to the base branch, never writes a source + * file (update writes only Markdown, and the commit is checked to hold only + * the documents update wrote), and pushes again only when the branch's + * content would change, so reruns are idempotent. + * + * Inputs arrive as environment variables (see action.yml). Exit 0 when the + * pull request is up to date or nothing needed updating, 1 when a block's + * file= or region= could not be read, 2 when the inputs are wrong or git, gh + * or mdcode failed. + */ +import { spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { mkdtemp, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; + +import { ConfigError, configFlags, documents, env, escape, mdcode, mdcodeCommand, runAction } from "../shared/mdcode.mjs"; + +/** Run a command, returning its stdout; a failure is a ConfigError naming the command. */ +function exec(file, args, options = {}) { + const run = spawnSync(file, args, { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, ...options }); + + if (run.status !== 0) { + const shown = file === "git" ? args.filter(arg => !arg.startsWith("http.")).join(" ") : args.join(" "); + throw new ConfigError(`${file} ${shown} failed (exit ${run.status ?? run.signal}): ${(run.stderr || run.stdout || run.error?.message || "").trim()}`); + } + + return run.stdout; +} + +function remote() { + const server = (env("GITHUB_SERVER_URL") || "https://github.com").replace(/\/$/, ""); + return { server, url: `${server}/${env("GITHUB_REPOSITORY")}.git` }; +} + +/** git with the token as the only credential for the remote, replacing any actions/checkout persisted. */ +function git(args) { + const { server } = remote(); + const basic = Buffer.from(`x-access-token:${env("TOKEN")}`).toString("base64"); + return exec("git", [ "-c", `http.${server}/.extraheader=`, "-c", `http.${server}/.extraheader=AUTHORIZATION: basic ${basic}`, ...args ]); +} + +function gh(args) { + const { server } = remote(); + const host = new URL(server).host; + return exec("gh", args, { env: { ...process.env, GH_TOKEN: env("TOKEN"), ...(host === "github.com" ? {} : { GH_HOST: host }) } }); +} + +/** The open pull request from branch into base, if there is one. */ +function openPullRequest(branch, base) { + const found = JSON.parse(gh([ "pr", "list", "--repo", env("GITHUB_REPOSITORY"), "--head", branch, "--base", base, "--state", "open", "--json", "number,url" ])); + return found[0]; +} + +/** The pull request body: what changed, and how to reproduce it. */ +function body(results) { + const lines = results.flatMap(({ document, blocks }) => blocks + .filter(block => block.changed) + .map(block => `| ${document} | ${block.line} | ${block.name ?? ""} | ${block.read?.region ? `${block.read.file} (region ${block.read.region})` : block.read?.file ?? ""} |`)); + + return [ + "The code blocks below no longer matched the source files they link to, so `mdcode update --apply` brought them up to date. Only Markdown changed; no source file is touched.", + "", + "| Document | Line | Block | Source |", + "| --- | --- | --- | --- |", + ...lines, + "", + "Created by the mdcode-ts [update-readme](https://github.com/adrianbrowning/mdcode-ts/tree/main/.github/actions/update-readme) action. It refreshes this pull request whenever the blocks drift again.", + ].join("\n"); +} + +function output(values) { + if (env("GITHUB_OUTPUT")) { + appendFileSync(env("GITHUB_OUTPUT"), Object.entries(values).map(([ key, value ]) => `${key}=${value}\n`).join("")); + } +} + +async function main() { + const branch = env("BRANCH"); + const base = env("BASE_BRANCH"); + + if (!env("GITHUB_REPOSITORY") || !env("TOKEN")) { + throw new ConfigError("GITHUB_REPOSITORY and token are required"); + } + if (!branch || !base) { + throw new ConfigError("branch and base-branch must both be set"); + } + if (branch === base) { + throw new ConfigError(`branch and base-branch are both ${base}; the action only ever pushes to a branch of its own`); + } + + // Work in a clean worktree at the base branch's tip: the pull request is the + // update on top of base-branch, whatever the caller checked out or changed. + const { url } = remote(); + const tip = git([ "ls-remote", url, `refs/heads/${base}` ]).split(/\s/)[0]; + + if (!tip) { + throw new ConfigError(`base-branch ${base} does not exist in ${env("GITHUB_REPOSITORY")}`); + } + + const prefix = exec("git", [ "rev-parse", "--show-prefix" ]).trim(); + const tree = await realpath(await mkdtemp(join(tmpdir(), "mdcode-update-readme-"))); + const top = exec("git", [ "rev-parse", "--show-toplevel" ]).trim(); + git([ "fetch", "--quiet", "--depth=1", url, tip ]); + exec("git", [ "worktree", "add", "--quiet", "--detach", tree, tip ]); + const caller = process.cwd(); + + try { + process.chdir(join(tree, prefix)); + return await updateIn({ tree, top, prefix, tip, url, branch, base }); + } + finally { + process.chdir(caller); + exec("git", [ "worktree", "remove", "--force", tree ]); + } +} + +/** Update the documents in the worktree, then open or refresh the pull request. */ +async function updateIn({ tree, top, prefix, tip, url, branch, base }) { + const command = mdcodeCommand(); + const configured = configFlags().length > 0; + const docs = await documents(); + + if (docs.length === 0 && !configured) { + throw new ConfigError("documents is empty; list Markdown files or globs, one per line, or set project: true"); + } + + // update --apply writes Markdown only, and nothing at all when any block fails. + const envelope = mdcode(command, [ "update", "--apply", "--json", ...configFlags(), ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]); + + if (envelope.result === null && envelope.errors.some(({ code }) => code === "invalid_usage")) { + throw new ConfigError(`mdcode update rejected its arguments: ${envelope.errors.map(({ message }) => message).join("; ")}`); + } + + if (envelope.errors.length > 0) { + const workdir = env("WORKING_DIRECTORY") || "."; + for (const error of envelope.errors) { + const file = error.document === undefined ? [] : [ `file=${escape(posix.normalize(posix.join(workdir, error.document)), true)}` ]; + // mdcode ran in the worktree; name the caller's checkout instead. + const message = error.message.replaceAll(tree, top); + console.log(`::error ${[ ...file, ...(error.line ? [ `line=${error.line}` ] : []), `title=${escape(`mdcode ${error.code}`, true)}` ].join(",")}::${escape(message)}`); + } + console.log("✗ No pull request: fix the blocks above, whose file= or region= could not be read."); + return 1; + } + + const results = envelope.result?.documents ?? []; + const written = results.map(({ written }) => written).filter(Boolean); + + if (written.length === 0) { + const stale = openPullRequest(branch, base); + if (stale) { + gh([ "pr", "close", String(stale.number), "--repo", env("GITHUB_REPOSITORY"), "--comment", `The code blocks are in sync with their source files on ${base}, so this pull request is no longer needed.` ]); + console.log(`Closed #${stale.number}: the blocks are already in sync.`); + } + else { + console.log("✓ Every code block is in sync with its source file; nothing to do."); + } + output({ changed: "false" }); + return 0; + } + + const author = `${env("AUTHOR_NAME")} <${env("AUTHOR_EMAIL")}>`; + exec("git", [ "add", "--", ...written ]); + exec("git", [ "-c", `user.name=${env("AUTHOR_NAME")}`, "-c", `user.email=${env("AUTHOR_EMAIL")}`, "commit", "--quiet", "--author", author, "-m", env("COMMIT_MESSAGE") ]); + + // git names committed paths from the repository root; mdcode names them from the working directory. + const expected = written.map(path => posix.normalize(posix.join(prefix, path))); + const committed = exec("git", [ "-c", "core.quotePath=false", "diff", "--name-only", "-z", tip, "HEAD" ]).split("\0").filter(Boolean); + const unexpected = committed.filter(path => !expected.includes(path)); + if (unexpected.length > 0) { + throw new ConfigError(`the commit would change ${unexpected.join(", ")}, which mdcode did not write; refusing to push`); + } + + // Idempotent: a branch that already holds this content on this base is left alone. + const existing = git([ "ls-remote", url, `refs/heads/${branch}` ]).split(/\s/)[0]; + let pushed = true; + + if (existing) { + git([ "fetch", "--quiet", "--depth=2", url, existing ]); + pushed = exec("git", [ "rev-parse", `${existing}^{tree}`, `${existing}~1` ]) !== exec("git", [ "rev-parse", "HEAD^{tree}", "HEAD~1" ]); + } + + if (pushed) { + git([ "push", "--quiet", "--force", url, `HEAD:refs/heads/${branch}` ]); + } + + const title = env("TITLE"); + const text = body(results); + const open = openPullRequest(branch, base); + let pr = open; + + if (open) { + gh([ "pr", "edit", String(open.number), "--repo", env("GITHUB_REPOSITORY"), "--title", title, "--body", text ]); + console.log(`${pushed ? "Refreshed" : "Already up to date:"} #${open.number} ${open.url}`); + } + else { + const created = gh([ "pr", "create", "--repo", env("GITHUB_REPOSITORY"), "--base", base, "--head", branch, "--title", title, "--body", text ]).trim(); + pr = { number: Number(created.split("/").pop()), url: created }; + console.log(`Opened #${pr.number} ${pr.url}`); + } + + output({ changed: "true", "pull-request-number": pr.number, "pull-request-url": pr.url }); + return 0; +} + +await runAction("mdcode update-readme", main); diff --git a/.intent/review-state.json b/.intent/review-state.json index f31829c..17895e0 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,11 +3,11 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "3a240b6e007de3d4c1db8507e4f2d4e35fad2788b7eada45181765ec8c560d17", + "fingerprint": "11ab7659c004b4781c934e177be639495f75452eedf9d4f89755d508d356d327", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", + "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", @@ -32,22 +32,22 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "no-change", - "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", + "reason": "The update-readme action is a GitHub Action for consumer repositories, documented in the package README; the skill covers the mdcode CLI and library, whose behaviour this PR does not change.", "evidence": [ - "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "planning:_artifacts": { - "fingerprint": "8a69bcf703e02764565bff53153f281369deccc14e5540753d0fd2059a636629", + "fingerprint": "6f769696dbf92b036c34be3cb3eaa1fe9bf942b467e5e4295f146b5ecb5e854b", "snapshot": { "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", "_artifacts/skill_tree.yaml": "2490b6a1d7a2abab698fe1a91e036ddec9dc76de5cfb15dedd8639ebf2875229", "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", + "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "no-change", - "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", + "reason": "No change to the skill's scope or sources; the actions are outside the skill.", "evidence": [ - "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "source:.bumpy/agent-skill.md": { @@ -92,11 +92,11 @@ ] }, "source:TESTING.md": { - "fingerprint": "9030c5b4778438eba7d65740bc0edcb86434669a165a2b2e8517326c7cfbe4e2", + "fingerprint": "680e4a4d43cd4572bd9ec1c70ca508df660053cf59e1be891ce9ebf761e0982b", "snapshot": { - "TESTING.md": "278678c85357a4f689d31fbe46192c687051aa0250c54922d5a4d8ed8669f26d" + "TESTING.md": "87b36c71f041671f105a4a9cca8ad35996e38994c83bc728bcc69daf0b803528" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "out-of-scope", "reason": "Contributor test-layout doc; lists the new action test.", "evidence": [ @@ -176,15 +176,15 @@ ] }, "source:.github/actions/check-sync/check-sync.mjs": { - "fingerprint": "ed3e9eb0b0a2dd4053d6f44c3b386aed80b18b2daed1211e88a39ad93b8ac304", + "fingerprint": "e97c4c84e100765da8af88f7e18366f8915919473be3e52d255e567189e0596f", "snapshot": { - ".github/actions/check-sync/check-sync.mjs": "315525933185c979d643900e854d21279e0cc68d6866d2c56a01f7aacf0466b5" + ".github/actions/check-sync/check-sync.mjs": "cd8d334364e28378b2cc3b852cbb97df8f570ae4ea321574a96ac76b5ddeb76f" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "out-of-scope", - "reason": "The consumer check-sync action and this repo's CI dogfood step. The action is documented in the package README, not in the skill, which covers the mdcode CLI and library.", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "source:.github/workflows/ci_test.yml": { @@ -234,6 +234,66 @@ "evidence": [ "Changed: packages/usage/tests/check-sync-action.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] + }, + "source:.bumpy/update-readme-action.md": { + "fingerprint": "7327c13e923d273071e55d1789d8f3abcb596a88fa0bbbfb3435c5de3146938f", + "snapshot": { + ".bumpy/update-readme-action.md": "d9c50c66442cf7645cda5900d6666f8ad359adf3f791766e2b8f2200c27804eb" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Changelog entry for the update-readme action.", + "evidence": [ + "Minor bump file." + ] + }, + "source:.github/actions/shared/mdcode.mjs": { + "fingerprint": "2aa44aa5e73e1e28d8acc5060ec641ebe72b44fe972436702d42307a00351ade", + "snapshot": { + ".github/actions/shared/mdcode.mjs": "3dacf99cc572c312a1e0227d4d067b3788a10be1e4da1faa392bd800327c3de7" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:.github/actions/update-readme/action.yml": { + "fingerprint": "f009588e84a1aec0d66156f3a42e5c1598e9245c5c33a97865ab56d7afa83161", + "snapshot": { + ".github/actions/update-readme/action.yml": "4b7c76787aa8df89915f8abfb5c56f9a7cc392985147afb816e72024a0d64503" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:.github/actions/update-readme/update-readme.mjs": { + "fingerprint": "1f6db015cbb035b89c1264382970d90b963788f956ecd1c84b28eabda1e63eee", + "snapshot": { + ".github/actions/update-readme/update-readme.mjs": "9db00951da5a94b6cd955ee502491a59a75808b879381764bf8f95e4cce24abc" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:packages/usage/tests/update-readme-action.test.ts": { + "fingerprint": "a6e12e91559b6c5a00363ee37709ef74d5ccdee0b3cb49e17398e7a6eebc3bc4", + "snapshot": { + "packages/usage/tests/update-readme-action.test.ts": "b71e4314794dd384a4c7825afe07957e6bffab9619933bf590553dc3bb74e8c9" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "no-change", + "reason": "Tests for the consumer action; no skill guidance affected.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] } } } diff --git a/CLAUDE.md b/CLAUDE.md index e37931c..bc09155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ TypeScript port of [szkiba/mdcode](https://github.com/szkiba/mdcode): keeps Mark - **Parser** (`src/parser.ts`): a custom line-by-line state machine instead of remark, so in-place updates keep exact character offsets. `scanFences()` is shared by `parse()` and `updateInfoStrings()` so block indices always agree. - **Mapping rules** (`src/commands/validate.ts`): `extract()` runs `planExtract()` before writing anything, and `update()` reads every `file=` through `readSource()`. `mdcode validate` reports the same rules without writing. With several documents, the CLI's `validateDocuments()` validates them all, plus `sharedTargetErrors()` across them, before `extract` writes any. - **Writes**: `update()` never writes; the CLI decides, and only `--apply` writes the markdown. `watch()` writes only with `apply`. `extract()` plans each target with `planTarget()` and writes it, or with `check` compares it instead. -- **Consumer GitHub Actions** (`.github/actions/check-sync/`): composite actions for other repositories, not this repo's CI. `setup-base` is internal. The script runs the mdcode-ts version in `packages/mdcode/package.json` at the action's ref; this repo's CI runs it with `mdcode-command` set to the workspace build. +- **Consumer GitHub Actions** (`.github/actions/check-sync/`, `.github/actions/update-readme/`): composite actions for other repositories, not this repo's CI; their shared code is `.github/actions/shared/mdcode.mjs`. `setup-base` is internal. The scripts run the mdcode-ts version in `packages/mdcode/package.json` at the action's ref; this repo's CI runs check-sync with `mdcode-command` set to the workspace build. - **Config** (`src/config.ts`): `mdcode.config.json` is loaded only with `--project` or `--config`. Without either, input defaults to stdin. - **Contract** (`src/result.ts`): `COMMAND_NAMES` and `ERROR_CODES` are the source of truth for commands and error codes. diff --git a/TESTING.md b/TESTING.md index 53bad6a..d26ac53 100644 --- a/TESTING.md +++ b/TESTING.md @@ -56,6 +56,8 @@ Two packages, `packages/mdcode` (published as `mdcode-ts`) and `packages/usage`. - `check-sync-action.test.ts` runs the check-sync GitHub Action's script (`.github/actions/check-sync/check-sync.mjs`) the way `action.yml` does, with inputs as environment variables and `mdcode-command` pointing at the built CLI + - `update-readme-action.test.ts` runs the update-readme action's script against a local bare + repository standing in for GitHub, with a stand-in `gh` on `PATH` that records its calls - `skill-sync-markdown-code-blocks.test.ts` grades the task in `tests/skills/sync-markdown-code-blocks/` for the shipped skill: it accepts the skill's solution and rejects the mistakes the skill warns about. Set `SKILL_TASK_DIR` to grade an agent's attempt in another directory instead diff --git a/packages/mdcode/README.md b/packages/mdcode/README.md index 2591fa8..c8e28d2 100644 --- a/packages/mdcode/README.md +++ b/packages/mdcode/README.md @@ -1575,6 +1575,82 @@ from the Markdown, writes no file, and reads only files inside each document's d under the [containment rules](#containment). Run it on `pull_request`, never `pull_request_target`, so a fork's change runs without your repository's secrets. +### GitHub Action: update-readme + +The `update-readme` action treats the source files as authoritative. It runs `mdcode update --apply` +on the selected documents and opens one pull request holding only their Markdown changes, or +refreshes the one it opened before: + +```yaml +name: Update docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-docs + +jobs: + update-readme: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: adrianbrowning/mdcode-ts/.github/actions/update-readme@ # mdcode-ts@ + with: + documents: | + README.md + docs/*.md +``` + +What it does, in order: + +1. Checks out the tip of `base-branch` (default: the repository's default branch) in a temporary git + worktree, so the caller's checkout is never changed and the update always sits on that tip. +2. Runs `mdcode update --apply` there. Only Markdown is written; source files never are, and the + commit is refused if it would change any file other than the documents `update` wrote. +3. Commits to `branch` (default `mdcode/update-docs`) and force-pushes it, unless the branch already + holds exactly this change on this tip, so a rerun with nothing new pushes nothing. +4. Opens a pull request from `branch` into `base-branch`, or updates the title and body of the open + one. The body lists every block that changed and the file it came from. + +When every block is already in sync, it pushes nothing and closes its open pull request, if there is +one: that pull request no longer matches the sources. When a block's `file=` or `region=` cannot be +read, it opens nothing and exits 1 with an annotation per block. It never pushes to `base-branch`; +setting `branch` to the same name is an error. + +| Input | Default | Meaning | +|-------|---------|---------| +| `documents` | | Markdown files to update, one path or glob per line | +| `base` | | The directory `file=` resolves against (`--base`); default each document's own directory | +| `project`, `config` | | Use `mdcode.config.json` or this configuration file, as for check-sync | +| `branch` | `mdcode/update-docs` | The branch the action owns and force-pushes | +| `base-branch` | the default branch | The branch the pull request targets | +| `title`, `commit-message` | `docs: update code blocks from their source files` | | +| `author-name`, `author-email` | `github-actions[bot]` | The commit's author | +| `token` | `github.token` | Pushes the branch and opens the pull request | +| `working-directory`, `node-version`, `mdcode-command` | | As for check-sync. A relative `mdcode-command` path resolves inside the temporary worktree | + +Outputs: `changed` (`true` when the pull request was opened or refreshed), `pull-request-number` and +`pull-request-url`. + +Permissions and safety: + +- The job needs `contents: write` and `pull-requests: write`, and with the default `github.token` the + repository setting *Allow GitHub Actions to create and approve pull requests* must be on. +- Pull requests opened with `github.token` do not start workflows, so your checks will not run on + them. To have them run, pass a GitHub App token (for example from `actions/create-github-app-token`) + or a fine-grained personal access token with contents and pull requests write as `token`. +- Run it on `push` to your default branch, a `schedule` or `workflow_dispatch`: events whose + Markdown has already been reviewed. Never run it on `pull_request_target` or on a fork's code; the + action holds a write token, and a pull request's Markdown decides which files `update` reads. +- It runs no code from the Markdown and offers no `--transform`. It reads only files inside each + document's directory or `base`, under the [containment rules](#containment). --- diff --git a/packages/usage/tests/update-readme-action.test.ts b/packages/usage/tests/update-readme-action.test.ts new file mode 100644 index 0000000..7053a06 --- /dev/null +++ b/packages/usage/tests/update-readme-action.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/** + * The update-readme GitHub Action's script (.github/actions/update-readme), + * run the way action.yml runs it, against a local bare repository standing in + * for GitHub and a stand-in `gh` that records its calls. + */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; + +import { cleanupTempDir, CLI_PATH, createTempDir } from "./test-utils.ts"; + +const SCRIPT = join(import.meta.dirname, "..", "..", "..", ".github", "actions", "update-readme", "update-readme.mjs"); +const BRANCH = "mdcode/update-docs"; + +const dirs: Array = []; + +after(async () => { + await Promise.all(dirs.map(cleanupTempDir)); +}); + +const fence = (info: string, code: string): string => `\`\`\`${info}\n${code}\n\`\`\`\n`; + +/** A stand-in gh: records each call, and keeps open pull requests in a JSON file. */ +const FAKE_GH = `#!${process.execPath} +const { appendFileSync, existsSync, readFileSync, writeFileSync } = require("node:fs"); +const args = process.argv.slice(2); +const state = process.env.FAKE_GH_STATE; +const prs = existsSync(state) ? JSON.parse(readFileSync(state, "utf-8")) : []; +appendFileSync(process.env.FAKE_GH_LOG, JSON.stringify({ args, token: process.env.GH_TOKEN }) + "\\n"); +const value = flag => args[args.indexOf(flag) + 1]; +if (args[1] === "list") { + console.log(JSON.stringify(prs.filter(pr => pr.head === value("--head") && pr.base === value("--base")))); +} +else if (args[1] === "create") { + const number = prs.length + 1; + prs.push({ number, url: "https://github.com/owner/repo/pull/" + number, head: value("--head"), base: value("--base"), title: value("--title"), body: value("--body") }); + writeFileSync(state, JSON.stringify(prs)); + console.log(prs.at(-1).url); +} +else if (args[1] === "edit") { + Object.assign(prs.find(pr => pr.number === Number(args[2])), { title: value("--title"), body: value("--body") }); + writeFileSync(state, JSON.stringify(prs)); +} +else if (args[1] === "close") { + writeFileSync(state, JSON.stringify(prs.filter(pr => pr.number !== Number(args[2])))); +} +`; + +type Repo = { dir: string; work: string; remote: string; bin: string; }; + +/** A bare "GitHub" repository with main holding these files, and a clone of it checked out at main. */ +async function repository(files: Record): Promise { + const dir = await createTempDir(); + dirs.push(dir); + const remote = join(dir, "server", "owner", "repo.git"); + const work = join(dir, "work"); + const bin = join(dir, "bin"); + + await mkdir(remote, { recursive: true }); + await mkdir(bin); + await writeFile(join(bin, "gh"), FAKE_GH, "utf-8"); + await chmod(join(bin, "gh"), 0o755); + execFileSync("git", [ "init", "--quiet", "--bare", "--initial-branch=main", remote ]); + execFileSync("git", [ "clone", "--quiet", remote, work ], { stdio: "ignore" }); + await commit({ dir, work, remote, bin }, files, "initial"); + + return { dir, work, remote, bin }; +} + +function git(repo: Repo, ...args: Array): string { + return execFileSync("git", [ "-c", "user.name=Test", "-c", "user.email=test@example.com", ...args ], { cwd: repo.work, encoding: "utf-8" }).trim(); +} + +/** Commit these files to main and push it, as a developer would. */ +async function commit(repo: Repo, files: Record, message: string): Promise { + for (const [ path, content ] of Object.entries(files)) { + await mkdir(dirname(join(repo.work, path)), { recursive: true }); + await writeFile(join(repo.work, path), content, "utf-8"); + } + git(repo, "add", "--all"); + git(repo, "commit", "--quiet", "-m", message); + git(repo, "push", "--quiet", "origin", "HEAD:refs/heads/main"); +} + +/** The commit a branch of the remote points at, or "" when it does not exist. */ +function remoteBranch(repo: Repo, branch: string): string { + return execFileSync("git", [ "ls-remote", repo.remote, `refs/heads/${branch}` ], { encoding: "utf-8" }).split(/\s/)[0]!; +} + +function show(repo: Repo, ref: string, path: string): string { + return execFileSync("git", [ "--git-dir", repo.remote, "show", `${ref}:${path}` ], { encoding: "utf-8" }); +} + +function changedFiles(repo: Repo, from: string, to: string): Array { + return execFileSync("git", [ "--git-dir", repo.remote, "diff", "--name-only", from, to ], { encoding: "utf-8" }).trim() + .split("\n") + .filter(Boolean); +} + +type GhCall = { args: Array; token: string; }; + +async function ghCalls(repo: Repo): Promise> { + const log = await readFile(join(repo.dir, "gh.log"), "utf-8").catch(() => ""); + return log.trim().split("\n") + .filter(Boolean) + .map(line => JSON.parse(line) as GhCall); +} + +async function openPrs(repo: Repo): Promise> { + return JSON.parse(await readFile(join(repo.dir, "gh.json"), "utf-8").catch(() => "[]")) as Array<{ number: number; title: string; body: string; }>; +} + +type ActionRun = { code: number | null; stdout: string; outputs: Record; }; + +async function action(repo: Repo, inputs: Record = {}): Promise { + const outputs = join(repo.dir, `outputs-${Date.now()}-${Math.random()}.txt`); + const run = spawnSync(process.execPath, [ SCRIPT ], { + cwd: repo.work, + encoding: "utf-8", + env: { + PATH: [ repo.bin, dirname(process.execPath), "/usr/bin", "/bin" ].join(delimiter), + HOME: repo.dir, + GITHUB_SERVER_URL: `file://${join(repo.dir, "server")}`, + GITHUB_REPOSITORY: "owner/repo", + GITHUB_OUTPUT: outputs, + FAKE_GH_STATE: join(repo.dir, "gh.json"), + FAKE_GH_LOG: join(repo.dir, "gh.log"), + DOCUMENTS: "README.md\nmy docs/*.md", + BRANCH, + BASE_BRANCH: "main", + TITLE: "docs: update code blocks", + COMMIT_MESSAGE: "docs: update code blocks", + AUTHOR_NAME: "github-actions[bot]", + AUTHOR_EMAIL: "bot@example.com", + TOKEN: "test-token", + MDCODE_COMMAND: `"${process.execPath}" "${CLI_PATH}"`, + ...inputs, + }, + }); + const raw = await readFile(outputs, "utf-8").catch(() => ""); + + return { + code: run.status, + stdout: run.stdout + run.stderr, + outputs: Object.fromEntries(raw.trim().split("\n") + .filter(Boolean) + .map(line => line.split(/=(.*)/s).slice(0, 2) as [ string, string ])), + }; +} + +const GREET = "// #region greet\nexport const greet = (name: string): string => `Hello, ${name}!`;\n// #endregion\n"; +const SYNCED = { + "src/greet.ts": GREET, + "my docs/hello world.ts": "export const hello = 1;\n", + "README.md": fence("ts file=src/greet.ts region=greet name=greet", "export const greet = (name: string): string => `Hello, ${name}!`;"), + "my docs/guide.md": fence("ts file=\"hello world.ts\" name=hello", "export const hello = 1;"), +}; +const STALE = { "src/greet.ts": GREET.replace("Hello", "Hi"), "my docs/hello world.ts": "export const hello = 2;\n" }; + +describe("update-readme action", () => { + it("opens a pull request holding only the Markdown, leaving sources and the caller's checkout alone", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + const main = git(repo, "rev-parse", "HEAD"); + // Unrelated work in the caller's checkout must not reach the pull request. + await writeFile(join(repo.work, "notes.txt"), "scratch\n", "utf-8"); + git(repo, "add", "notes.txt"); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + const head = remoteBranch(repo, BRANCH); + assert.deepEqual(changedFiles(repo, main, head), [ "README.md", "my docs/guide.md" ], "only the documents update wrote"); + assert.match(show(repo, head, "README.md"), /`Hi, \$\{name\}!`/); + assert.equal(show(repo, head, "src/greet.ts"), STALE["src/greet.ts"], "the source is untouched"); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${head}~1` ], { encoding: "utf-8" }).trim(), main, "the commit sits on main's tip"); + assert.equal(remoteBranch(repo, "main"), main, "main is never pushed to"); + assert.equal(git(repo, "rev-parse", "HEAD"), main); + assert.equal(git(repo, "status", "--porcelain"), "A notes.txt", "the caller's checkout is as it was"); + assert.deepEqual(run.outputs, { "changed": "true", "pull-request-number": "1", "pull-request-url": "https://github.com/owner/repo/pull/1" }); + + const [ pr ] = await openPrs(repo); + assert.match(pr!.body, /\| README\.md \| 1 \| greet \| src\/greet\.ts \(region greet\) \|/); + assert.match(pr!.body, /\| my docs\/guide\.md \| 1 \| hello \| hello world\.ts \|/); + assert.ok((await ghCalls(repo)).every(({ token }) => token === "test-token")); + }); + + it("is idempotent: a rerun with nothing new pushes nothing and opens no second pull request", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + + await action(repo); + const first = remoteBranch(repo, BRANCH); + const again = await action(repo); + + assert.equal(again.code, 0, again.stdout); + assert.match(again.stdout, /Already up to date: #1/); + assert.equal(remoteBranch(repo, BRANCH), first); + assert.equal((await openPrs(repo)).length, 1); + assert.equal((await ghCalls(repo)).filter(({ args }) => args[1] === "create").length, 1); + }); + + it("refreshes the same pull request on top of main's new tip when the sources change again", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + await action(repo); + const first = remoteBranch(repo, BRANCH); + + await commit(repo, { "src/greet.ts": GREET.replace("Hello", "Hey") }, "change again"); + const main = git(repo, "rev-parse", "HEAD"); + const run = await action(repo); + + const head = remoteBranch(repo, BRANCH); + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /Refreshed #1/); + assert.notEqual(head, first); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${head}~1` ], { encoding: "utf-8" }).trim(), main); + assert.match(show(repo, head, "README.md"), /`Hey, \$\{name\}!`/); + assert.equal((await openPrs(repo)).length, 1); + }); + + it("uses the base branch's tip even when the checkout is behind it", async () => { + const repo = await repository(SYNCED); + const behind = git(repo, "rev-parse", "HEAD"); + await commit(repo, STALE, "change the sources"); + const main = git(repo, "rev-parse", "HEAD"); + git(repo, "checkout", "--quiet", behind); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${remoteBranch(repo, BRANCH)}~1` ], { encoding: "utf-8" }).trim(), main); + }); + + it("does nothing on a checkout whose blocks are already in sync", async () => { + const repo = await repository(SYNCED); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /✓ Every code block is in sync with its source file; nothing to do\./); + assert.deepEqual(run.outputs, { changed: "false" }); + assert.equal(remoteBranch(repo, BRANCH), ""); + assert.deepEqual((await ghCalls(repo)).map(({ args }) => args[1]), [ "list" ], "it only looks for a pull request to close"); + }); + + it("closes its pull request once main is in sync, and pushes nothing", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + await action(repo); + const branch = remoteBranch(repo, BRANCH); + + // Someone merges the update by hand. + git(repo, "fetch", "--quiet", "origin", BRANCH); + git(repo, "merge", "--quiet", "--ff-only", "FETCH_HEAD"); + git(repo, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /Closed #1: the blocks are already in sync\./); + assert.deepEqual(run.outputs, { changed: "false" }); + assert.deepEqual(await openPrs(repo), []); + assert.equal(remoteBranch(repo, BRANCH), branch, "nothing is pushed"); + }); + + it("opens no pull request when a block's file= cannot be read", async () => { + const repo = await repository({ ...SYNCED, "README.md": `${SYNCED["README.md"]}\n${fence("ts file=src/missing.ts", "x")}` }); + await commit(repo, STALE, "change the sources"); + + const run = await action(repo); + + assert.equal(run.code, 1); + assert.match(run.stdout, /^::error file=README\.md,line=5,title=mdcode read_failed::src\/missing\.ts does not exist in \/.*\/work; /m); + assert.equal(remoteBranch(repo, BRANCH), ""); + assert.deepEqual(await ghCalls(repo), []); + }); + + it("refuses to use the base branch as its own branch", async () => { + const repo = await repository(SYNCED); + + const run = await action(repo, { BRANCH: "main" }); + + assert.equal(run.code, 2); + assert.match(run.stdout, /^::error title=mdcode update-readme::branch and base-branch are both main/m); + }); +});