feat(engine): TAG-SIGNED — a tag that names a signer, not just write access

The check sat parked as "blocked on an operator decision" on the strength of
0 of 18 repos signing. That zero was never evidence against it: it is what a
practice nobody has adopted yet looks like, and reasoning from it makes the
gate ratify the status quo it exists to move. The rule that settled it asks
something else — would a public AAA+ repository do this? Yes, and unlike the
two conventions that killed VERSION-DRIFT, there is no second convention under
which release tags are better left unattributable.

The measurement then decided the SCOPE, not the yes. Across 20 clones: 158 v*
tags, 144 annotated, 14 lightweight, 0 signed, and not one dated after
2026-08-12. A boolean "this org signs" would have failed 20 correct
repositories on day one with force-moving 144 published refs as its only
remedy — the mechanism that gets gates switched off. So the policy is a DATE in
the register (never in the engine, the rule that keeps the org-profile
exemption a flag): nothing fires today, teeth at each repo's next release.

Verified both directions, because a sweep returning zero proves nothing on its
own — a dead check returns zero too. 21 repos: 18 PREPOLICY + 3 NONE, 0 ERROR,
0 WARN; then against real git objects an unsigned post-policy newest tag does
fire ERROR.

Two exclusions keep one defect from being counted twice: a lightweight tag has
no object to carry a signature (byDesign skip naming TAG-ANNOTATED as owner),
and a pre-policy tag was correct when cut (OK, said out loud, never silence).
It answers "is it signed", never "does the forge vouch for it".

The README row ships in this commit, not after it, and the stale test count in
CLAUDE.md (230 → 241) is corrected here rather than left for the next reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsPsVsvhrSaejK3cLPmnN2
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 10:51:36 +02:00
commit e6cd8983ca
9 changed files with 415 additions and 17 deletions

View file

@ -737,6 +737,117 @@ export function checkTagIntegrity({ tagObjects, name }, register) {
return findings;
}
// A signed tag is the one thing that ties a released ref to a person rather
// than to whoever could write to the forge. The catalog pins plugins by tag, so
// this sits on the same supply-chain axis as TAG-ANNOTATED: annotation makes a
// tag immovable-without-a-trace, a signature makes it attributable.
//
// WHY it exists is not the adoption rate. 0 of 158 tags in the org are signed —
// which is exactly what a practice nobody has adopted yet looks like, and
// reasoning from that zero would have the gate ratify the status quo it exists
// to move. The operator's rule (2026-08-13) is the one that decided it: would a
// public AAA+ repository do this? Yes. That is a different question from the one
// that rejected VERSION-DRIFT, where two legitimate conventions genuinely
// coexisted; there is no second convention under which release tags are better
// unattributable.
//
// The measurement decides the SCOPE. Across 20 clones: 158 `v*` tags, 144
// annotated, 14 lightweight, 0 signed, and not one tag dated later than
// 2026-08-12. So the policy is a DATE held in the register: zero findings on the
// day it lands, teeth at each repo's next release. That is how signing is
// adopted in public — never by rewriting published history, which is the same
// unsafe remedy TAG-ANNOTATED refuses to demand.
//
// The date lives in the register and NOT in this file, for the reason the
// org-profile exemption is a flag rather than `if (klass === 'org-profile')`:
// the engine holds no org decision of its own. Absent, the check is a notRun
// SKIP — an operator adding the key turns it into a verdict.
//
// Two exclusions keep one defect from being counted twice under two codes:
// - a LIGHTWEIGHT tag has no tag object to carry a signature, so there is no
// version of it that passes while staying lightweight — TAG-ANNOTATED owns
// it, and the skip names that owner instead of going quiet.
// - a tag cut BEFORE the policy was correct when it was cut.
//
// Read from local git objects, so it costs nothing against the API budget. It
// answers *is it signed* and never *does the forge vouch for it* — that badge
// needs a key registered there, which is a different subject on a different
// surface.
export function checkTagSigned({ tagObjects }, register) {
const from = register?.tags_signed_from;
if (!from) {
return [{
level: 'SKIP',
skip: 'notRun',
code: 'TAG-SIGNED-NOPOLICY',
msg: 'the register records no tag-signing policy (`tags_signed_from`) — whether this org signs its tags is an operator decision, not one the gate makes on its own',
}];
}
const tags = [...(tagObjects ?? [])].sort((a, b) => compareTags(a.name, b.name));
if (tags.length === 0) {
// The TAGS-NONE / VERSION-NONE shape: the check ran, saw every tag there is,
// and found no subject. That is a verdict, not an absent one.
return [{ level: 'OK', code: 'TAG-SIGNED-NONE', msg: 'repo has no version tags — no tag exists that could carry a signature' }];
}
const findings = [];
// ISO dates compare correctly as strings, which also keeps this free of any
// clock reading — the policy boundary is data, never "now".
const inScope = tags.filter((t) => t.annotated && t.date && t.date >= from);
const prePolicy = tags.filter((t) => t.date && t.date < from);
const lightweight = tags.filter((t) => !t.annotated && t.date && t.date >= from);
if (inScope.length > 0) {
const newest = inScope[inScope.length - 1];
if (!newest.signed) {
findings.push({
level: 'ERROR',
code: 'TAG-SIGNED',
bucket: 'weakening',
msg: `newest tag \`${newest.name}\` was cut under the signing policy (from ${from}) but carries no signature — nothing ties it to a signer rather than to whoever could write to the forge, and the catalog pins releases by tag. Re-cut it signed: \`git tag -s -f ${newest.name} ${newest.name}^{}\`.`,
});
}
const older = inScope.slice(0, -1).filter((t) => !t.signed);
if (older.length > 0) {
findings.push({
level: 'WARN',
code: 'TAG-SIGNED-HISTORY',
bucket: 'weakening',
msg: `${older.length} older unsigned tag(s) cut under the policy (${older.slice(0, 3).map((t) => t.name).join(', ')}${older.length > 3 ? ', …' : ''}). WARN, not ERROR: the only remedy is force-moving an already published ref, which is its own risk. Sign every NEW tag (\`git tag -s\`).`,
});
}
}
// An exemption is a finding, not a deletion — the rule `readme_desc_match`
// and TAG-ANNOTATED-ACCEPTED already follow. An exception nobody can see
// reads exactly like a check that silently stopped running.
if (prePolicy.length > 0) {
findings.push({
level: 'OK',
code: 'TAG-SIGNED-PREPOLICY',
msg: `${prePolicy.length} tag(s) predate the signing policy (from ${from}) and are not judged — they were correct when they were cut, and signing them now would mean force-moving published refs. Every tag from ${from} onward is judged.`,
});
}
if (lightweight.length > 0) {
findings.push({
level: 'SKIP',
skip: 'byDesign',
code: 'TAG-SIGNED-LIGHTWEIGHT',
msg: `${lightweight.length} tag(s) cut under the policy are lightweight (${lightweight.map((t) => t.name).join(', ')}) — a lightweight tag has no tag object to carry a signature, so signing is not a remedy it has. TAG-ANNOTATED owns that finding; this check declines rather than report one defect twice.`,
});
}
if (findings.length === 0 && inScope.length > 0) {
findings.push({ level: 'OK', code: 'TAGS-SIGNED', msg: `all ${inScope.length} tag(s) cut under the policy (from ${from}) are signed` });
}
if (findings.length === 0) {
findings.push({ level: 'OK', code: 'TAGS-SIGNED', msg: `no tag has been cut since the signing policy took effect (${from}) — nothing to judge yet` });
}
return findings;
}
// A static image asserting "tests: 642 passing" is a claim dressed as evidence.
// Version, licence and platform badges assert no run, so they are fine static.
// Bare `status` used to be in this list and caught a self-declared maturity
@ -1417,6 +1528,7 @@ export function classifyRepo(
...checkBoilerplate({ files }),
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
...checkTagIntegrity({ tagObjects, name }, register),
...checkTagSigned({ tagObjects }, register),
...checkReleaseCurrent({ forgeTagsSelf, releases }),
...checkRemoteSync({ tags, forgeTagsSelf }),
...checkVerifyCommand({ readme, testScript, testFileCount }),
@ -1678,13 +1790,21 @@ function forgeTags(register, repo) {
}
}
// The signature is read with `%(if)%(contents:signature)%(then)…`, not by
// printing the block itself: a PGP or SSH signature is multi-line, and emitting
// it would break a line-oriented parser on exactly the tags the check cares
// about. Git answers the yes/no question directly, so one process still covers
// every tag. `%(creatordate:short)` is the tag's own date for an annotated tag
// and the commit's for a lightweight one — ISO, so the policy comparison is a
// string compare with no clock reading anywhere.
function gitTagObjects(dir) {
try {
return execFileSync('git', ['-C', dir, 'for-each-ref', '--format=%(objecttype) %(refname:short)', 'refs/tags/v*'], { encoding: 'utf8' })
const format = '%(objecttype) %(if)%(contents:signature)%(then)signed%(else)unsigned%(end) %(creatordate:short) %(refname:short)';
return execFileSync('git', ['-C', dir, 'for-each-ref', `--format=${format}`, 'refs/tags/v*'], { encoding: 'utf8' })
.split('\n').map((s) => s.trim()).filter(Boolean)
.map((line) => {
const [type, ...rest] = line.split(' ');
return { name: rest.join(' '), annotated: type === 'tag' };
const [type, sig, date, ...rest] = line.split(' ');
return { name: rest.join(' '), annotated: type === 'tag', signed: sig === 'signed', date };
});
} catch {
return [];