Every file it had already generated still carried the old name
Shipped
This release renames the skill from forge to ghfactory and regenerates the workflow mastheads it had already spliced into this repository. Renaming the directory, the plugin id, the command and the package was the easy half.
The other half is that this tool writes blocks into other people’s files, and every block says who wrote it. A generator that signs its output has a migration problem the day its name changes, and the files needing migration are not in its repository.
What a rename does to output you already shipped
A generated block in a hand-written file is a small contract. Go’s toolchain states it as a comment matching ^// Code generated .* DO NOT EDIT\.?$, which must appear “before the first non-comment, non-blank text in the file” and exists “to convey to humans and machine tools that code is generated”.
That marker answers one question: is this generated. It does not answer the two questions a rename creates.
The first is whose. Once the generator is called something else, a block saying GENERATED by forge is orphaned. Nothing will claim it, and the next person to read that file goes looking for a tool that no longer exists.
The second is has anyone touched it. This is the one that decides whether you can fix the first automatically. If the body of a block differs from what your generator would emit today, there are two possible reasons and they demand opposite responses: the generator changed, so overwrite; or a human edited inside the region, so stop and ask. A plain diff cannot tell them apart, and guessing wrong either clobbers someone’s work or leaves a stale block forever.
Put three facts in the marker
Write the generator’s identity, its version, and a hash of the body. The hash is doing the same job as Subresource Integrity on a script tag, where the browser “will then calculate the hash of the resource contents using the specified function, and compare the result” before trusting it. Here the comparison is not for security; it tells you whether the bytes are still the ones you wrote.
Save this as region.mjs:
import { createHash } from 'node:crypto';
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 12);
/**
* A generated region inside an otherwise hand-written file.
*
* The opening marker carries three things, and each answers a different
* question later: the generator's identity (who owns this block), its version
* (is the block current), and a receipt over the body (has anyone typed in it).
*/
export function render(body, { region, version, generator }) {
return [
`# >>> ${region} v${version} sha256:${sha(body)} GENERATED by ${generator}, do not edit`,
body,
`# <<< ${region}`,
].join('\n');
}
const OPEN = /^# >>> (\S+) v(\S+) sha256:(\S+) GENERATED by (\S+?), do not edit$/;
export function parse(text, region) {
const lines = text.split('\n');
const start = lines.findIndex((l) => OPEN.test(l) && OPEN.exec(l)[1] === region);
if (start === -1) return null;
const end = lines.findIndex((l, i) => i > start && l === `# <<< ${region}`);
if (end === -1) return null;
const [, , version, receipt, generator] = OPEN.exec(lines[start]);
return { version, receipt, generator, body: lines.slice(start + 1, end).join('\n') };
}
/**
* Three states, and telling them apart is the whole point:
* edited the body no longer matches its own receipt — a human typed here
* stale the body is intact, but a different generator or version wrote it
* ok current generator, current version, untouched body
*/
export function classify(text, { region, version, generator }) {
const found = parse(text, region);
if (!found) return { state: 'absent' };
if (sha(found.body) !== found.receipt) return { state: 'edited', by: found.generator };
if (found.generator !== generator) return { state: 'stale', by: found.generator };
if (found.version !== version) return { state: 'stale', by: `${found.generator} v${found.version}` };
return { state: 'ok', by: found.generator };
}
export function splice(text, body, opts) {
const found = parse(text, opts.region);
if (!found) return text;
const lines = text.split('\n');
const start = lines.findIndex((l) => OPEN.test(l) && OPEN.exec(l)[1] === opts.region);
const end = lines.findIndex((l, i) => i > start && l === `# <<< ${opts.region}`);
return [...lines.slice(0, start), render(body, opts), ...lines.slice(end + 1)].join('\n');
}
The order of the checks in classify matters. Receipt first, identity second: a block someone edited is not stale, it is a conversation, and finding out it was also written by the old generator does not change that.
Classify, do not diff
Now a checker over a directory. It reports every region and exits non-zero when anything is stale, which is what makes it usable in CI. Save it as check.mjs:
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { classify, splice } from './region.mjs';
// Who we are now. The rename lives here and nowhere else.
const OPTS = { region: 'gha-header', version: '0.2.0', generator: 'ghfactory' };
const BODY = ['# CI', '# purpose: run the test suite on every pull request'].join('\n');
const files = readdirSync('workflows').map((f) => join('workflows', f)).sort();
const apply = process.argv.includes('--write');
let stale = 0;
for (const file of files) {
const text = readFileSync(file, 'utf8');
const { state, by } = classify(text, OPTS);
console.log(`${state.padEnd(7)} ${file.padEnd(28)} ${by ?? ''}`);
if (state !== 'stale') continue;
stale++;
if (apply) writeFileSync(file, splice(text, BODY, OPTS));
}
if (stale && !apply) {
console.log(`\n${stale} stale. Re-run with --write to regenerate.`);
process.exit(1);
}
Build three files to exercise all three states: one current, one written by the old generator, and one a human has typed inside.
mkdir -p workflows
node -e "
import('./region.mjs').then(({render})=>{
const fs=require('fs');
const BODY=['# CI','# purpose: run the test suite on every pull request'].join('\n');
const tail='\non: [pull_request]\njobs:\n test:\n runs-on: ubuntu-latest\n';
fs.writeFileSync('workflows/ci.yml', render(BODY,{region:'gha-header',version:'0.2.0',generator:'ghfactory'})+tail);
fs.writeFileSync('workflows/release.yml', render(BODY,{region:'gha-header',version:'0.1.2',generator:'forge'})+tail);
const edited=render(BODY,{region:'gha-header',version:'0.2.0',generator:'ghfactory'}).replace('run the test suite','run the tests (tweaked)');
fs.writeFileSync('workflows/security.yml', edited+tail);
});"
workflows/release.yml now opens with a marker from before the rename:
# >>> gha-header v0.1.2 sha256:07290b37df04 GENERATED by forge, do not edit
# CI
# purpose: run the test suite on every pull request
Run the checker:
node check.mjs
ok workflows/ci.yml ghfactory
stale workflows/release.yml forge
edited workflows/security.yml ghfactory
1 stale. Re-run with --write to regenerate.
Three files, three verdicts, and the exit code is 1. Note what the receipt bought: security.yml claims the current generator and current version, so identity alone says it is fine. The hash says otherwise.
Regenerate the stale, leave the edited
node check.mjs --write
node check.mjs
ok workflows/ci.yml ghfactory
ok workflows/release.yml ghfactory
edited workflows/security.yml ghfactory
The orphaned block was reclaimed. The edited one was not touched, and still reports edited on every run, which is the correct amount of nagging: it is a decision for a person, and the checker’s job is to keep it visible rather than to resolve it.
This is the same shape as Terraform’s drift detection, where a refresh “displays proposed state updates without modifying actual infrastructure, giving operators visibility into drift before deciding whether to accept the changes.” Report first, write only when asked.
Gotchas
The files that need migrating are not in your repository. Every workflow this tool ever wrote carries the old signature until someone regenerates it, and most of them live in repositories the author cannot push to. Only the four in this repo were fixed here. Escape: ship the detector, not just the fix. A check that names the stale files is the only migration you can actually deliver to someone else’s clone, and it needs to be runnable without arguments so a stranger gets a useful answer on the first try.
Some identities cannot be regenerated at all. Release tags already cut keep the old prefix forever, because rewriting published tags is worse than living with two prefixes. Escape: sort a rename’s surfaces into regenerable, redirectable, and permanent before starting, and write the permanent ones into the changelog so nobody later reads them as an oversight.
Renaming the generator and updating its consumers cannot land separately. The masthead body here comes from a sibling brand package, and the version of that package on the registry does not necessarily contain the emitter this release needs. Resolution deliberately prefers the in-repo checkout when one exists, because the alternative is waiting for a publish before the tests can run, which makes the two changes unlandable together. Escape: when two packages in one repository must change together, let the local copy win over the published one during development, and be explicit in a comment that this is why.
A marker a machine writes is a marker a machine must be able to parse. The opening line here is a single regex with four captures, and every field is whitespace-free for that reason. The moment a title or purpose with a space lands in that line, the parser silently stops matching and every region reports absent, which reads exactly like “nothing to do”. Escape: keep free text inside the body, keep the marker line a fixed grammar, and make absent over a directory you expected to have regions a loud result rather than a quiet one.
Sources
- cmd/go documentation — the generated-code marker convention and where the line must appear
- Subresource Integrity, MDN — a content hash carried alongside content, and what a mismatch means
- Manage resource drift, HashiCorp — detecting divergence and reporting it before writing anything