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
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.
86 changes: 6 additions & 80 deletions .github/actions/check-sync/check-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ]];
Expand All @@ -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();
Expand Down Expand Up @@ -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);
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;
}
}
97 changes: 97 additions & 0 deletions .github/actions/update-readme/action.yml
Original file line number Diff line number Diff line change
@@ -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@<version>`, 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"
Loading
Loading