Putting a generated banner in a file a machine has to parse
Shipped
0.8.0 taught a token generator to write into GitHub Actions workflow files. It gained a yaml comment syntax for its region splicer, and an emitter that renders a header banner as comment lines rather than as drawn text.
Emitting into YAML is different from emitting into CSS or Python in one way that matters. In a stylesheet, a generated block is content: it is meant to change what renders. In a workflow file, a generated block must be content-free. The file is executed by a machine whose behaviour has to be identical before and after, or your branding has just changed your CI.
This guide builds the splicer, the header, and, more importantly, the assertion that proves the header is invisible to the thing that reads the file.
Setup: why comments are the only safe medium
The YAML specification is unusually direct about this. Comments “must not have any effect on the serialization tree or representation graph. In particular, comments are not associated with a particular node”, and “Comments are a presentation detail and must not be used to convey content information” (YAML 1.2.2).
That is a guarantee you can build a test around: if every line you add begins with #, the parsed document is unchanged by definition, in any conformant parser. It is also a constraint. GitHub Actions workflows are plain YAML files, since “Workflow files use YAML syntax, and must have either a .yml or .yaml file extension” (workflow syntax), so there is no metadata block, no front matter, and no place to put anything that is not either configuration or a comment.
Build the region splicer
The splicer knows how to comment a marker line in each target language, and replaces the block in place if it already exists:
// region.mjs — splice a generated block into a file, in the file's comment syntax.
const COMMENT = { yaml: '#', python: '#', css: ['/*', '*/'], md: ['<!--', '-->'] };
export function renderRegion(name, syntax, body, version) {
const c = COMMENT[syntax];
if (!c) throw new Error(`unknown syntax "${syntax}"`);
const line = (text) =>
Array.isArray(c) ? `${c[0]} ${text} ${c[1]}` : `${c} ${text}`;
return [
line(`>>> brand:${name} v${version} GENERATED, do not edit`),
body,
line(`<<< brand:${name}`),
].join('\n');
}
export function spliceRegion(file, name, syntax, body, version) {
const rendered = renderRegion(name, syntax, body, version);
const re = new RegExp(
`^.*>>> brand:${name} .*$[\\s\\S]*?^.*<<< brand:${name}.*$`,
'm',
);
return re.test(file) ? file.replace(re, rendered) : `${rendered}\n${file}`;
}
Note what it comments: the two marker lines, and nothing else. The body arrives already in the target language, because for every other emitter the body is code. That assumption is the trap the next section walks into.
Build the header, and comment its own body
// header.mjs — the masthead a generated workflow wears.
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - [...s].length));
/**
* Returns the header ALREADY COMMENTED.
*
* Every other emitter returns code in the target's language and lets the region
* renderer comment only the two marker lines. That is correct for a stylesheet
* or a Python module and fatal here: a masthead emitted bare splices box-drawing
* straight into the document and yields YAML that cannot parse.
*/
export function ghaHeader({ title, purpose, byline, generator }, width = 74) {
if (!Number.isInteger(width) || width < 40) {
throw new Error(`width must be an integer >= 40 (got ${width})`);
}
const tracked = [...title.toUpperCase()].join(' ');
const lines = [
'═'.repeat(width),
pad(tracked, width - byline.length) + byline,
'─'.repeat(width),
purpose,
`generated by ${generator}`,
];
return lines.map((l) => `# ${l}`.trimEnd()).join('\n');
}
Skip that final map and the result is a workflow file with a box-drawing rule where a top-level key should be. The parser’s complaint is not obviously about a banner:
yaml.parser.ParserError: expected '<document start>', but found '<block mapping start>'
in "ci.broken.yml", line 7, column 1
Line 7 is name: ci, the first real line of the workflow, which is a long way from the actual mistake on line 2.
Use it
import { readFileSync, writeFileSync } from 'node:fs';
import { ghaHeader } from './header.mjs';
import { spliceRegion } from './region.mjs';
const workflow = readFileSync('ci.yml', 'utf8');
const body = ghaHeader({
title: 'ci',
purpose: 'Lint, test and build every pull request.',
byline: 'brand-tools',
generator: 'brand-tools 0.8.0',
});
writeFileSync('ci.yml', spliceRegion(workflow, 'mast', 'yaml', body, '0.8.0'));
console.log('spliced');
Against an ordinary workflow, node build.mjs produces:
# >>> brand:mast v0.8.0 GENERATED, do not edit
# ══════════════════════════════════════════════════════════════════════════
# C I brand-tools
# ──────────────────────────────────────────────────────────────────────────
# Lint, test and build every pull request.
# generated by brand-tools 0.8.0
# <<< brand:mast
name: ci
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm test
Verify it without a YAML parser
The obvious test is to parse the spliced file and assert it still loads. Resist it. Reaching for a YAML library makes the test depend on a runtime that has that library installed, and it asserts something weaker than what you actually need, which is that the document is identical, not merely still valid.
Assert the stronger, dependency-free property: strip every comment line and you get the original bytes back.
// verify.mjs — the region must be invisible to any YAML parser.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { ghaHeader } from './header.mjs';
import { spliceRegion } from './region.mjs';
const original = readFileSync('ci.original.yml', 'utf8');
const body = ghaHeader({
title: 'ci',
purpose: 'Lint, test and build every pull request.',
byline: 'brand-tools',
generator: 'brand-tools 0.8.0',
});
const spliced = spliceRegion(original, 'mast', 'yaml', body, '0.8.0');
// Parser-free property: strip every comment line and you are back where you
// started, byte for byte. No YAML library, so it runs the same everywhere.
const stripComments = (s) =>
s.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
assert.equal(stripComments(spliced), original, 'the region changed the document');
assert.match(spliced, /^# >>> brand:mast/m, 'the region is missing');
console.log('ok: region present, document unchanged for a parser');
console.log(`stripped length ${stripComments(spliced).length} === original ${original.length}`);
node verify.mjs
ok: region present, document unchanged for a parser
stripped length 137 === original 137
The second assertion is the half people forget. Without it, a splicer that silently emits nothing passes the first assertion perfectly, because a document with no region added is trivially unchanged.
Gotchas
A YAML parser is a poor oracle for workflow files anyway. Loading the spliced workflow with PyYAML in this same scratch directory returns the keys ['name', True, 'jobs']. The on: key came back as the boolean True, because YAML 1.1 treats on as a boolean literal and PyYAML implements 1.1. Symptom: your test asserts on a key name that does not survive the round trip. Escape: assert on the bytes, not on the parse.
A generator that comments only the markers will happily emit unparseable output. The convention “the emitter returns code, the renderer adds comments” is right for every language where the block is content, and wrong for the one case where the block is decoration. Symptom: a ParserError pointing at the first line after your block. Escape: make the decorative emitter comment its own body, and say so in its docstring, because the asymmetry looks like a bug to the next reader.
Do not put a verification claim in the banner. A generated header reading actionlint ✓ is a statement about the file below the region, and the region’s hash covers only the region. Anyone hand-editing the workflow underneath invalidates the claim with no way for the generator to notice. Symptom: a file that advertises a guarantee nobody is checking. Escape: keep claims about a run in the run’s output, where they expire naturally, and keep the header to facts about itself.
Width and alignment need a guard. A width that arrives as a string produces '═'.repeat("74"), which coerces and looks fine, and a width below the byline length silently produces a negative pad. Escape: validate the input, as ghaHeader does with its integer and minimum check, and be aware that a linter such as actionlint will not save you here; it validates workflow semantics like uses: format, expression syntax and permissions, not your comment geometry.
Splicing into a file with no region appends at the top. That is the right default for a masthead and the wrong one for almost anything else. Symptom: a generated block landing at the top of a file where it needed to be at the bottom, or vice versa. Escape: make first-time insertion explicit, with an anchor the target declares, rather than inheriting whatever the splicer happens to do.
Sources
- YAML 1.2.2 specification — comments are a presentation detail with no effect on the representation graph.
- Workflow syntax for GitHub Actions — workflow files are YAML in
.github/workflowswith a.ymlor.yamlextension. - actionlint checks — what a workflow linter does and does not cover.
Changelog
- feat: forge — GitHub Actions workflows that are verified, not hoped for (#140) (1bb13dc)