Declaring the requirement broke everyone who adopted it

Press · No. 090

Shipped

press 0.7.1 fixed three problems that turned out to be one problem. The headline case: 0.7.0 declared a new README target for every external consumer without creating those regions in the consumers, so a repository’s check began failing missing the instant its pinned version reached 0.7.0. Both pull requests the fan-out had opened against one consumer went red on arrival. The fix was to make propagation seed a region the consumer doesn’t have yet, in the same pull request that raises the pin, on the grounds that the two are one change rather than two.

The second was the same shape one level down. The check compared the generated values but not the version recorded in the region marker, so in-repo regions sat at v0.1.0 through six releases behind a green tick. A version that nothing verifies is decoration.

This guide builds the rollout that can’t break a consumer on arrival, plus the constraint that stops it from over-correcting into a tool that silently recreates things people deleted on purpose.

Why this isn’t just “add it to the changelog”

Adding a requirement to a shared config is a backward-incompatible change to everyone who consumes it, and semver’s rule is unambiguous about the usual remedy: “Major version X (X.y.z | X > 0) MUST be incremented if any backward incompatible changes are introduced to the public API.” That’s the right rule and it doesn’t help here, because a major bump communicates the break without preventing it. Consumers still get a red build; they just can’t say they weren’t warned.

The pattern that actually prevents the break is Parallel Change, “a pattern to implement backward-incompatible changes to an interface in a safe manner, by breaking the change into three distinct phases: expand, migrate, and contract.” Fowler names the reason it’s hard: “Making a change to an interface that impacts all its consumers requires two thinking modes: implementing the change itself, and then updating all its usages.” Kubernetes formalizes the same instinct into calendar guarantees, where an API element “can not be removed from that version or have its behavior significantly changed” once shipped.

Both assume you can’t reach into the consumer. When you can, which is true for any producer that already opens pull requests in every consumer repo, there’s a shorter path: put the migrate step inside the expand step. The pull request that raises the version also supplies whatever the new version requires, so a consumer never exists in the broken intermediate state at all.

Everything below is Node’s standard library.

The region helpers

Save this as region.mjs. It’s the minimum needed to find, replace, and create a marked block:

const SYNTAX = {
  md: { open: '<!-- ', close: ' -->' },
  css: { open: '/* ', close: ' */' },
};

export function markers(target, version) {
  const s = SYNTAX[target.syntax];
  return {
    start: `${s.open}>>> brand:${target.id} v${version}${s.close}`,
    end: `${s.open}<<< brand:${target.id}${s.close}`,
  };
}

export function findRegion(text, target) {
  const lines = text.split('\n');
  const start = lines.findIndex((l) => l.includes(`>>> brand:${target.id} `));
  const end = lines.findIndex((l) => l.includes(`<<< brand:${target.id}`));
  if (start === -1 || end === -1) return null;
  return {
    start, end,
    body: lines.slice(start + 1, end).join('\n'),
    version: (/ v(\d+\.\d+\.\d+)/.exec(lines[start]) ?? [])[1] ?? null,
  };
}

const block = (target, version, body) => {
  const m = markers(target, version);
  return [m.start, body, m.end];
};

export function splice(text, target, version, body) {
  const found = findRegion(text, target);
  const lines = text.split('\n');
  lines.splice(found.start, found.end - found.start + 1, ...block(target, version, body));
  return lines.join('\n');
}

/** Create a region that isn't there yet, anchored so it lands somewhere sane. */
export function seed(text, target, version, body) {
  const lines = text.split('\n');
  const re = new RegExp(target.init.insertAfter);
  const at = lines.findIndex((l) => re.test(l));
  if (at === -1) throw new Error(`${target.id}: anchor /${target.init.insertAfter}/ matched no line`);
  let i = at + 1;
  while (i < lines.length && lines[i].trim() === '') i += 1;
  lines.splice(i, 0, ...block(target, version, body), '');
  return lines.join('\n');
}

seed takes an anchor rather than appending, because a badge that lands at the bottom of a README is technically present and practically invisible.

The consumer’s check

This is what runs in the consumer’s own CI, against the version it has pinned. Save it as check.mjs:

#!/usr/bin/env node
/** What a consumer runs against its OWN pinned version, on every PR. */
import { readFileSync } from 'node:fs';
import { findRegion } from './region.mjs';

const EXPLAIN = {
  ok: 'in sync',
  missing: 'no region for this target in this checkout',
  'stale-version': 'region records an older release than the pin',
};

export function check({ targets, version, read }) {
  return targets.map((target) => {
    let text;
    try { text = read(target.path); } catch { return { id: target.id, status: 'missing' }; }
    const found = findRegion(text, target);
    if (!found) return { id: target.id, status: 'missing' };
    // The recorded version has to be checked too. Comparing only the body lets
    // a region sit at an ancient release with a green tick forever.
    if (found.version !== version) {
      return { id: target.id, status: 'stale-version', wroteBy: found.version };
    }
    return { id: target.id, status: 'ok' };
  });
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const { targets } = JSON.parse(readFileSync('targets.json', 'utf8'));
  // The consumer checks against the version IT has pinned, not the newest one.
  const pin = /@acme\/brand@(\d+\.\d+\.\d+)/.exec(
    readFileSync('consumer/.github/workflows/ci.yml', 'utf8'))[1];

  const results = check({
    targets, version: pin, read: (p) => readFileSync(`consumer/${p}`, 'utf8'),
  });
  console.log(`checking against pinned ${pin}`);
  for (const r of results) {
    console.log(`  ${r.status === 'ok' ? 'ok  ' : 'FAIL'}  ${r.id.padEnd(13)} ${EXPLAIN[r.status]}`);
  }
  const bad = results.filter((r) => r.status !== 'ok').length;
  process.exit(bad ? 1 : 0);
}

The rollout

Now the producer’s side, and the whole point is the branch where the region doesn’t exist yet. Save this as rollout.mjs:

#!/usr/bin/env node
/** What the PRODUCER runs to raise a consumer onto a new release. */
import { readFileSync, writeFileSync } from 'node:fs';
import { findRegion, seed, splice } from './region.mjs';

const PIN_RE = /(@acme\/brand@)(\d+\.\d+\.\d+)/g;
const CI = 'consumer/.github/workflows/ci.yml';

export function rollout({ targets, version, root, bumpOnly = false }) {
  const rows = targets.map((target) => {
    const path = `${root}/${target.path}`;
    const before = readFileSync(path, 'utf8');
    const body = target.body.replace('{version}', version);
    const found = findRegion(before, target);

    if (found) {
      writeFileSync(path, splice(before, target, version, body));
      return { id: target.id, action: 'updated' };
    }
    // The consumer has no region for this target yet, because the release
    // that declares it is the one arriving right now. Its check will start
    // failing "missing" the instant the pin lands, so the pin bump and the
    // region have to be the SAME change.
    if (bumpOnly) return { id: target.id, action: 'left missing' };
    // Only seedable if the target says where the region goes. A region that
    // was deliberately DELETED looks identical to one never created, and this
    // cannot tell them apart, so an anchor is required consent.
    if (!target.init?.insertAfter) return { id: target.id, action: 'MISSING, not seedable' };
    writeFileSync(path, seed(before, target, version, body));
    return { id: target.id, action: 'seeded' };
  });

  const ci = readFileSync(CI, 'utf8');
  writeFileSync(CI, ci.replace(PIN_RE, (m, p, found) => (found === version ? m : `${p}${version}`)));
  return rows;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const { version, targets } = JSON.parse(readFileSync('targets.json', 'utf8'));
  const bumpOnly = process.argv.includes('--bump-only');
  for (const r of rollout({ targets, version, root: 'consumer', bumpOnly })) {
    console.log(`  ${r.id.padEnd(13)} ${r.action}`);
  }
  console.log(`pin raised to ${version}${bumpOnly ? '  (--bump-only)' : ''}`);
}

Watch it break, then not break

Set up a consumer sitting happily on 0.7.0, and a registry where 0.7.1 declares a README badge it has never had. Note that theme has no init anchor and readme-badge does:

cat > targets.json <<'EOF'
{
  "version": "0.7.1",
  "targets": [
    {
      "id": "theme",
      "path": "brand.css",
      "syntax": "css",
      "body": "  --accent: #E8501F;"
    },
    {
      "id": "readme-badge",
      "path": "README.md",
      "syntax": "md",
      "body": "![brand](https://img.shields.io/badge/brand-{version}-E8501F)",
      "init": { "insertAfter": "^# " }
    }
  ]
}
EOF
mkdir -p consumer/.github/workflows
cat > consumer/brand.css <<'EOF'
/* >>> brand:theme v0.7.0 */
  --accent: #E8501F;
/* <<< brand:theme */

.figure { border-color: var(--accent); }
EOF
cat > consumer/README.md <<'EOF'
# local-fitness

A training log that reads your own data.
EOF
echo "      - run: npx -y @acme/[email protected] check" > consumer/.github/workflows/ci.yml
cp -r consumer consumer.orig

node rollout.mjs --bump-only
echo
node check.mjs; echo "exit: $?"
  theme         updated
  readme-badge  left missing
pin raised to 0.7.1  (--bump-only)

checking against pinned 0.7.1
  ok    theme         in sync
  FAIL  readme-badge  no region for this target in this checkout
exit: 1

The consumer did nothing wrong. It received a pull request that raised its pinned version, and its own CI went red inside that pull request, on a target it had never heard of. Now seed and bump together:

rm -rf consumer && cp -r consumer.orig consumer
node rollout.mjs
echo
node check.mjs; echo "exit: $?"
echo
cat consumer/README.md
  theme         updated
  readme-badge  seeded
pin raised to 0.7.1

checking against pinned 0.7.1
  ok    theme         in sync
  ok    readme-badge  in sync
exit: 0

# local-fitness

<!-- >>> brand:readme-badge v0.7.1 -->
![brand](https://img.shields.io/badge/brand-0.7.1-E8501F)
<!-- <<< brand:readme-badge -->

A training log that reads your own data.

Same pull request, same pin bump, green check, and the badge landed under the title instead of at the bottom.

Don’t let seeding become resurrection

Seeding sounds like something you’d want unconditionally, and that’s the trap. A region that a maintainer deleted on purpose looks byte-for-byte identical to one that was never created, so a tool that always seeds will keep restoring a thing someone keeps removing, and neither side ever finds out they disagree.

That’s what the anchor is for: it isn’t only a placement hint, it’s the target declaring that it’s allowed to be created. theme doesn’t have one, so delete its region and watch:

grep -v 'brand:theme' consumer/brand.css | grep -v -- '--accent' > t && mv t consumer/brand.css
node rollout.mjs
echo
node check.mjs; echo "exit: $?"
  theme         MISSING, not seedable
  readme-badge  updated
pin raised to 0.7.1

checking against pinned 0.7.1
  FAIL  theme         no region for this target in this checkout
  ok    readme-badge  in sync
exit: 1

Reported, not recreated. A human decides which of the two situations this is, which is correct, because the tool genuinely cannot know.

Gotchas

A new requirement breaks consumers at their upgrade, not at your release. Because nothing goes wrong when you ship, this failure arrives detached from its cause. Symptom: your release is green, and some time later a consumer’s unrelated pull request is red for reasons its author cannot explain, since the breaking change is a version number in a file they didn’t touch. press hit this on the release immediately before this one, and both pull requests it had opened against one consumer failed on arrival. The escape is to have the pull request that raises the version also supply what the version now requires, so the consumer never sits in the broken state.

A version recorded but never compared is decoration. Writing the release into a marker makes the artifact look self-describing, and it is genuinely useful in a diff. It is not a check. Symptom: values are correct, everything passes, and the recorded version quietly falls years behind, which press did for six consecutive releases with regions still stamped v0.1.0. The escape is a distinct status for it: compare the recorded version to the pin, report stale-version separately from a content difference, and print the command that fixes it.

Automation that creates missing state will eventually recreate deleted state. “Missing” and “deliberately removed” are the same observation, and the more helpful your tool is about the first, the more reliably it will undo the second. Symptom: a maintainer removes something, it comes back on the next release, they remove it again, and nobody escalates because each individual instance looks like a glitch. The escape is to require an explicit declaration before creating anything, as the init anchor does here, and to fail loudly on the targets that don’t carry one rather than treating absence as an invitation.

Sources

Changelog

  • fix(press): seed new targets, enforce the version, badge the in-repo skills (0.7.1) (#135) (25b2db8)