One dependency showing three version numbers, all of them correct

Press · No. 093

Shipped

0.6.0 changed one rule in a fan-out tool: it used to open a pull request only when the generated content actually differed, and now it opens one whenever anything differs, including the version alone. Before that change, a single consumer repository could show three version numbers at once. Its CI pin said 0.2.0, the marker inside its generated block said 0.1.0, and the current release was 0.5.1. All three were correct under the old rule, and there was no way to tell a healthy consumer from a neglected one without knowing that rule by heart.

If you generate code into repositories you do not own, you are running a small package manager, and you inherit its hardest problem: making adoption state legible. This guide builds the classifier that makes it legible, in about sixty lines of Node, and it runs offline.

Setup: why there are three numbers at all

A generated block usually carries a receipt. The marker that opens it records which release of the generator wrote the bytes below, so anyone reading the file knows what produced it:

// region.mjs — a generated block that records the version that wrote it.
const START = (name, version) =>
  `/* >>> brand:${name} v${version} GENERATED, do not edit */`;
const END = (name) => `/* <<< brand:${name} */`;

export function renderRegion(name, version, body) {
  return [START(name, version), body, END(name)].join('\n');
}

/** Find the region and read back both its body and the version that wrote it. */
export function findRegion(file, name) {
  const re = new RegExp(
    `/\\* >>> brand:${name} v([0-9.]+) [^*]*\\*/\\n([\\s\\S]*?)\\n/\\* <<< brand:${name} \\*/`,
  );
  const m = re.exec(file);
  return m ? { version: m[1], body: m[2] } : null;
}

That receipt is the second number. The first is whatever pin the consumer’s CI uses to install the generator. The third is the release that actually exists. Under a rule of “only act when the emitted values differ”, those three drift apart the moment you ship a release that changes no values, which is most releases.

The failure is not that a repo is behind. The failure is that nobody can tell whether it is behind, because “receipt says 0.1.0” is consistent with both perfectly current and eighteen months stale.

Classify the update instead of suppressing it

There are two genuinely different kinds of update, and the mistake is collapsing them into one boolean. Compute both:

// classify.mjs — decide what kind of update a consumer needs.
import { findRegion, renderRegion } from './region.mjs';

const trim = (s) => s.replace(/\s+$/, '');

/**
 * @param file    current contents of the consumer's file
 * @param name    region name
 * @param body    what today's tokens emit
 * @param version the release being propagated
 */
export function classify(file, name, body, version) {
  const found = findRegion(file, name);
  if (!found) return { status: 'missing', changed: false };

  const brandChanged = trim(found.body) !== trim(body);
  const versionChanged = found.version !== version;

  return {
    status: brandChanged ? 'brand' : versionChanged ? 'adopt' : 'current',
    brandChanged,
    versionChanged,
    changed: brandChanged || versionChanged,
    wroteBy: found.version,
    next: renderRegion(name, version, body),
  };
}

export function prTitle(result, version) {
  return result.brandChanged
    ? `brand v${version}: BRAND VALUES CHANGED`
    : `adopt brand v${version}`;
}

changed is what decides whether to open a pull request, and it is now the union of the two. brandChanged is what decides how loudly to say so. Keeping them as separate booleans on the result, rather than re-deriving them later from the status string, matters more than it looks: a status match written as /updated$/ once quietly missed the would update case in dry-run mode, so a dry run reported “nothing to do” while printing a changed region on the line above.

The title carries the classification because the title is the only part of a pull request everyone reads. Google’s SRE book makes the general version of this argument about alerting: “Every page should be actionable”, and when they are not, “employees second-guess, skim, or even ignore incoming alerts, sometimes even ignoring a ‘real’ page that’s masked by the noise” (Monitoring Distributed Systems). A bot that titles every pull request the same way has built exactly that.

Run it against a fleet

Three consumers, one release, one pass:

// demo.mjs — three consumers, one release.
import { classify, prTitle } from './classify.mjs';
import { renderRegion } from './region.mjs';

const RELEASE = '0.6.0';
const body = '--brand-accent: #f26722;';

const consumers = {
  'site':    renderRegion('tokens', '0.1.0', body),                        // old pin, same values
  'budget':  renderRegion('tokens', '0.5.1', '--brand-accent: #e05500;'),  // stale values
  'fitness': renderRegion('tokens', '0.6.0', body),                        // already current
};

for (const [name, file] of Object.entries(consumers)) {
  const r = classify(file, 'tokens', body, RELEASE);
  const action = r.changed ? `PR: ${prTitle(r, RELEASE)}` : 'no PR';
  console.log(
    `${name.padEnd(8)} receipt ${r.wroteBy.padEnd(6)} status ${r.status.padEnd(8)} ${action}`,
  );
}
node demo.mjs
site     receipt 0.1.0  status adopt    PR: adopt brand v0.6.0
budget   receipt 0.5.1  status brand    PR: brand v0.6.0: BRAND VALUES CHANGED
fitness  receipt 0.6.0  status current  no PR

That is the whole payoff. site gets a one-line diff nobody needs to think about. budget gets a shouty title and a diff a designer should look at. fitness gets nothing, because nothing moved, and a bot that opens a pull request against a fully current repository has stopped being a signal.

Verify the property that makes the whole scheme work, which is that applying the update makes the next run a no-op:

node -e "
import('./classify.mjs').then(async ({classify}) => {
  const { renderRegion } = await import('./region.mjs');
  const body = '--brand-accent: #f26722;';
  const before = renderRegion('tokens', '0.1.0', body);
  const first = classify(before, 'tokens', body, '0.6.0');
  const second = classify(first.next, 'tokens', body, '0.6.0');
  console.log('first :', first.status, first.changed);
  console.log('second:', second.status, second.changed);
});
"
first : adopt true
second: current false

If the second run is not current false, your writer and your reader disagree about the bytes, and the bot will re-open the same pull request forever.

The trade you are making, stated plainly

Always acting means a few pull requests per release that change one number and nothing else. That is a real cost and it is worth naming rather than pretending away. What you buy is that every consumer’s state is readable at a glance, without knowing any rules, and there is prior art for valuing that: Renovate ships a Dependency Dashboard for the same reason, on the grounds that with updates that need manual approval “you’ll probably forget to check often enough, and out of sight means out of mind!”

The version-only pull request is the cheapest possible form of that dashboard. It is a one-line diff, it is titled as routine, and it exists so the absence of one means something.

Gotchas

Suppressing no-op updates is how the numbers diverge in the first place. “A pin bump alone changes no shipped artifact, so it is not worth a pull request” is a reasonable-sounding rule and it is what produced 0.2.0, 0.1.0 and 0.5.1 in one repository. Symptom: someone asks why a repo is on an old version and the honest answer takes three paragraphs. Escape: keep pin, receipt and release equal, and let the diff size, not the pull request count, reflect how much changed.

Re-deriving state from a display string will bite you. Carrying changed as a boolean and deriving the status from it is safe; parsing the status back out to decide what to do is not, because the status strings grow dry-run variants. Symptom: a dry run that prints a changed region and then reports nothing to do. Escape: booleans decide, strings display.

Watch the open pull request limit if your consumers also run a dependency bot. Dependabot’s options reference documents that with the default limit, “If five pull requests with version updates are open, no further pull requests are raised until some of those open requests are merged or closed”. Your no-op pull requests occupy real slots in a repository’s attention budget, and possibly in another bot’s. Escape: make version-only updates trivially mergeable, and close your own superseded ones.

missing is not changed. A consumer whose region has been deleted needs a human decision, not a silent re-creation, so it is reported separately and does not count as an update. Symptom: a bot that helpfully restores a block someone deliberately removed. Escape: keep missing its own status and surface it in the run summary.

Trailing whitespace will make every repo look changed. Comparing generated bodies without normalizing the trailing newline produces a permanent, universal false positive, and it is invisible in a diff view. Escape: trim on both sides of the comparison, as trim() does above, and add a test that runs the classifier twice.

Sources

Changelog

  • feat(press): one version per consumer, not three (0.6.0) (#129) (c1543cd)