Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .bumpy/extract-check-and-check-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
mdcode-ts: minor
---

Added `mdcode extract --check`. It works out every target exactly as `extract` would and compares it with the file on disk instead of writing it. It exits 1 with an `out_of_sync` error for each block whose part of a file would change: a region that differs or is missing, a whole file that differs (pass `--force` so existing whole files are compared rather than skipped), or a file that doesn't exist yet. In `--check` results, `unchanged` is a new target action.

Added the `check-sync` GitHub Action (`adrianbrowning/mdcode-ts/.github/actions/check-sync`). It fails a job when Markdown blocks and their files disagree in either direction, using `update --check` and `extract --check --force`, and writes nothing. Each problem becomes an annotation and a row in the job summary.

Fixed `extract` indenting a spliced region a second time when its markers are indented. `update` copies such a region with its indentation, so extracting it used to push every line right. `extract --force` now keeps an overwritten file's final newline, and refuses a target that is a symlink or not valid UTF-8, as region splices already did.
5 changes: 5 additions & 0 deletions .bumpy/update-readme-action.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions .github/actions/check-sync/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: mdcode check sync
description: Fail when Markdown code blocks and the files they link to disagree, in either direction, without changing the checkout.

inputs:
documents:
description: >
Markdown files to check, one path or glob per line (paths may contain
spaces). Leave empty with project or config to check the configuration's
documents.
default: ''
directions:
description: >
Which directions to check, space- or comma-separated: `update` (do the
blocks show their files?) and `extract` (would extracting the blocks
change a file?).
default: update extract
base:
description: >
For update, the directory file= paths resolve against (--base). Default:
each document's own directory, or the configuration's sourceRoot.
default: ''
dir:
description: >
For extract, the directory file= targets resolve against (--dir).
Default: base when set, else each document's own directory, matching
update; with project or config, the configuration's outputRoot.
default: ''
ignore-anonymous:
description: For extract, skip blocks without file= (they have no linked file).
default: 'true'
project:
description: Load mdcode.config.json from the working directory (--project).
default: 'false'
config:
description: Load this configuration file instead (--config).
default: ''
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@<version>`, the
release this action's ref belongs to.
default: ''

outputs:
problems:
description: Number of problems found; 0 when everything is in sync.
value: ${{ steps.check.outputs.problems }}

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: Check Markdown and linked files are in sync
id: check
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
DOCUMENTS: ${{ inputs.documents }}
DIRECTIONS: ${{ inputs.directions }}
BASE: ${{ inputs.base }}
DIR: ${{ inputs.dir }}
IGNORE_ANONYMOUS: ${{ inputs.ignore-anonymous }}
PROJECT: ${{ inputs.project }}
CONFIG: ${{ inputs.config }}
WORKING_DIRECTORY: ${{ inputs.working-directory }}
MDCODE_COMMAND: ${{ inputs.mdcode-command }}
run: node "$GITHUB_ACTION_PATH/check-sync.mjs"
150 changes: 150 additions & 0 deletions .github/actions/check-sync/check-sync.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* The check-sync action: fail when Markdown code blocks and the files they
* link to disagree, in either direction, without writing anything.
*
* files → Markdown: `mdcode update --check`, does each block show its file?
* Markdown → files: `mdcode extract --check --force`, would extract change a file?
*
* Inputs arrive as environment variables (see action.yml). Exit 0 when both
* 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 { 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" },
};

/** The directions to check, from a space- or comma-separated list. */
function directions() {
const asked = env("DIRECTIONS").split(/[\s,]+/).filter(Boolean);
const unknown = asked.filter(direction => !(direction in DIRECTIONS));

if (asked.length === 0 || unknown.length > 0) {
throw new ConfigError(`directions must list update, extract or both; got ${JSON.stringify(env("DIRECTIONS"))}`);
}

return [ ...new Set(asked) ];
}

/** The mdcode runs for one direction: one for update, one per document directory for extract. */
function runs(direction, docs) {
const common = [ "--json", ...configFlags() ];

if (direction === "update") {
return [[ "update", "--check", "--continue-on-error", ...common, ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]];
}

// --force compares existing whole files instead of reporting them skipped; --check writes nothing.
const extract = [ "extract", "--check", "--force", ...(flag("IGNORE_ANONYMOUS") ? [ "--ignore-anonymous" ] : []), ...common ];
// Both directions resolve file= against one root: dir, else update's base. A
// configuration supplies its own outputRoot.
const root = env("DIR") || env("BASE");
const configured = flag("PROJECT") || env("CONFIG") !== "";

if (root || configured || docs.length === 0) {
return [[ ...extract, ...(root ? [ "--dir", root ] : []), ...docs ]];
}

// Like update without --base, resolve each document's file= against its own directory.
const byDir = Map.groupBy(docs, doc => dirname(doc));
return [ ...byDir ].map(([ dir, group ]) => [ ...extract, "--dir", dir, ...group ]);
}

async function main() {
const command = mdcodeCommand();
const asked = directions();
const project = flag("PROJECT") || env("CONFIG") !== "";
const docs = await documents();

if (docs.length === 0 && !project) {
throw new ConfigError("documents is empty; list Markdown files or globs, one per line, or set project: true");
}

const problems = [];

for (const direction of asked) {
for (const args of runs(direction, docs)) {
const envelope = mdcode(command, args);

if (envelope.result === null && envelope.errors.some(({ code }) => code === "invalid_usage")) {
const message = envelope.errors.map(({ message }) => message).join("; ");
const hint = direction === "extract" && /--check/.test(message) ? " This mdcode has no extract --check; pin the action to a release that does." : "";
throw new ConfigError(`mdcode ${args[0]} rejected its arguments: ${message}.${hint}`);
}

problems.push(...envelope.errors.map(error => ({ direction, ...error })));
}
}

// The same drift seen from both sides is one problem.
const merged = [];
for (const problem of problems) {
const twin = merged.find(other => other.direction !== problem.direction && other.code === "out_of_sync" && problem.code === "out_of_sync"
&& other.document === problem.document && other.line === problem.line);

if (twin) {
twin.direction = "both";
twin.messages.push(problem.message);
continue;
}
merged.push({ ...problem, messages: [ problem.message ] });
}

report(merged, asked, docs);
return merged.length === 0 ? 0 : 1;
}

function report(problems, asked, docs) {
const workdir = env("WORKING_DIRECTORY") || ".";
const where = asked.map(direction => DIRECTIONS[direction].label).join(" and ");

for (const problem of problems) {
const label = problem.direction === "both" ? "both directions" : DIRECTIONS[problem.direction].label;
const block = problem.name ? ` (${problem.name})` : "";
const message = `${label}: ${problem.messages.join("; ")}${block}`;
const file = problem.document === undefined ? undefined : posix.normalize(posix.join(workdir, problem.document));
const properties = [
...(file ? [ `file=${escape(file, true)}` ] : []),
...(problem.line ? [ `line=${problem.line}` ] : []),
`title=${escape(`mdcode ${problem.code}`, true)}`,
];

console.log(`::error ${properties.join(",")}::${escape(message)}`);
}

const summary = [];

if (problems.length === 0) {
const scope = docs.length === 0 ? "the configured documents" : `${docs.length} document(s)`;
console.log(`✓ ${scope} in sync: ${where}.`);
summary.push("## mdcode: in sync", "", `Checked ${scope}: ${where}.`);
}
else {
console.log(`✗ ${problems.length} problem(s) between Markdown code blocks and their files.`);
summary.push(
"## mdcode: out of sync",
"",
`${problems.length} problem(s). If the file is right, run \`${DIRECTIONS.update.fix} <document>\`; if the block is right, run \`${DIRECTIONS.extract.fix} <document>\`. Fix read errors in the block's \`file=\` or \`region=\`.`,
"",
"| Direction | Document | Line | Block | File | Problem |",
"| --- | --- | --- | --- | --- | --- |",
...problems.map(problem => `| ${problem.direction === "both" ? "both" : problem.direction} | ${cell(problem.document)} | ${problem.line ?? ""} | ${cell(problem.name)} | ${cell(problem.path)} | ${cell(`${problem.code}: ${problem.messages.join("; ")}`)} |`),
);
}

if (env("GITHUB_STEP_SUMMARY")) {
appendFileSync(env("GITHUB_STEP_SUMMARY"), summary.join("\n") + "\n");
}

if (env("GITHUB_OUTPUT")) {
appendFileSync(env("GITHUB_OUTPUT"), `problems=${problems.length}\n`);
}
}

await runAction("mdcode check-sync", main);
99 changes: 99 additions & 0 deletions .github/actions/shared/mdcode.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading