coderifts 5.0.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,34 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 6.0.0
4
+
5
+ _Nothing yet._
6
+
7
+ ## 5.0.1
8
+
9
+ ### Fixed
10
+ - **A fresh install shipped the guard with the conditional-write hole.**
11
+ `coderifts@5.0.0` declared `@coderifts/agent-guard: ^11.0.0`, which resolved to
12
+ 11.0.1 — precisely the tarball the guard's own 12.0.0 round reproduced the
13
+ defect against, where `requireConditionalWrite` did not fire on an ordinary
14
+ mutation and the side effect **ran** with `enforced: true`. The guard is a
15
+ **direct** dependency with no `peerDependencies` and no bundled copy, so this
16
+ range *is* the adopter's resolution, not advice. Now `^12.0.0`, with
17
+ `MIN_GUARD_MAJOR` raised to 12. Cost measured as zero: the CLI imports only
18
+ `deployGate`, `asVerifiedDeployReceiptView`, `DEPLOY_RECEIPT_VIEW_SPEC` and
19
+ `matchGlob`, whose modules are byte-identical 11.0.1 → 12.0.0.
20
+ - **Two defects in the publish gate itself**, both proven by replaying the real
21
+ script. The resolved check only ever compared *downward* — `resolved >= floor`,
22
+ never whether the resolved version sits **inside** the declared range — so
23
+ declaring `^11.0.0` with 12.0.0 staged for a cost measurement printed OK and
24
+ exited 0, which is exactly the shape that makes "we measured 12 and shipped
25
+ ^11" indistinguishable from a correct release. And an unresolvable install was
26
+ a **pass**. Three failure modes added (resolved below the declared range at
27
+ full precision, resolved above it, and not resolvable at all), with no
28
+ opt-out — an `allowUnresolved` flag was written and deliberately removed, and
29
+ a test asserts it stays absent.
30
+
31
+ ## 5.0.0
4
32
 
5
33
  ### Fixed
6
34
  - **P0 — `setup-required-check --apply` no longer reports `APPLIED` over a
@@ -59,6 +87,63 @@
59
87
  template is unchanged and stays non-blocking; it carries the same three lines
60
88
  commented, with a note saying why the two differ.
61
89
 
90
+ ## 4.13.0 — never published (folded into 5.0.0)
91
+
92
+ Bumped in-repo (`917611d`) but never tagged or released to npm; 5.0.0 superseded
93
+ it, so adopters received this content **as part of 5.0.0**. Recorded separately
94
+ because the version number appears in the source history and in
95
+ `assert-guard-major.js`'s floor-raise log.
96
+
97
+ ### Changed
98
+ - **Guard floor raised to 11.** Cost measured as zero the way the 10-raise was —
99
+ the CLI imports only `deployGate`, `asVerifiedDeployReceiptView`,
100
+ `DEPLOY_RECEIPT_VIEW_SPEC` and `matchGlob`, none of which 11.0.0 touched, and
101
+ the suite staged against 11 returned an identical 532/530/2 with a
102
+ byte-identical failure set. It rises anyway, because a semantics floor is about
103
+ what the CLI may **run** against, not what it happens to import today: 10.x's
104
+ `computeBundleFingerprint` disagreed with the server, which is exactly the
105
+ class this gate exists to refuse.
106
+ - The two tests pinning the old floor were updated, and the invariant made
107
+ explicit: **the floor only ever rises**.
108
+
109
+ ### Added
110
+ - `/api/v1/agent/preflight` returns `scorer_version` — the one field that makes
111
+ the single-spec fingerprint reproducible by a consumer holding their own
112
+ before/after/policy. Purely additive; no surface moves and `tools_sha256` is
113
+ unchanged.
114
+
115
+ ## 4.12.0
116
+
117
+ ### Changed
118
+ - **Guard floor raised to 10.** Cost measured as zero: the CLI imports only
119
+ `deployGate`, `asVerifiedDeployReceiptView`, `DEPLOY_RECEIPT_VIEW_SPEC` and
120
+ `matchGlob` — never `withCodeRifts` — so 10.0.0's strict-composition break
121
+ cannot reach it. Verified by staging guard 10 into the install and running the
122
+ suite (guard-conformance 6/6, the same two pre-existing failures as the guard-9
123
+ baseline). The gate was satisfied, not weakened: before the install it
124
+ correctly reported FAIL on the stale resolve.
125
+
126
+ ## 4.11.0
127
+
128
+ ### Fixed
129
+ - **P0 — the issuer binding pointed at a check that cannot block.** GitHub passes
130
+ a required status check on `success`, `skipped` **or `neutral`**, so our App's
131
+ advisory check genuinely cannot block; a false comment in
132
+ `webhook-integration.js` claiming otherwise is what produced the regression.
133
+ The enforcing issuer is `app_id 15368` (github-actions), which the contract-gate
134
+ Action posts under, so that binding is now VERIFIED — with the shared-identity
135
+ residual **named** rather than implied: anyone who can edit
136
+ `.github/workflows` can post under it. A binding to our own App id grades
137
+ UNVERIFIABLE rather than NOT_VERIFIED, because `MERGEGATE_ENFORCE` is
138
+ server-side and invisible from here.
139
+ - **P0 — the installer wrote what our own verifier rejects.** It now writes
140
+ `checks[]` with an explicit `app_id`, because an omitted `app_id` silently
141
+ resolves to whoever posted last, which is unauditable. It never re-binds
142
+ another team's existing check, and the success path prints what it does **not**
143
+ achieve. A coupling test asserts the installer's and the verifier's issuer
144
+ constants are equal. The verifier was not weakened — the name-only and
145
+ legacy-contexts paths are byte-identical, asserted by two unchanged tests.
146
+
62
147
  ## 4.10.0
63
148
 
64
149
  ### Added
package/dist/cli.js CHANGED
@@ -3028,7 +3028,7 @@ var require_package = __commonJS({
3028
3028
  "package.json"(exports2, module2) {
3029
3029
  module2.exports = {
3030
3030
  name: "coderifts",
3031
- version: "5.0.1",
3031
+ version: "6.0.0",
3032
3032
  description: "Detect breaking API changes from the command line. Works locally or with the CodeRifts cloud API.",
3033
3033
  author: "CodeRifts <hello@coderifts.com>",
3034
3034
  license: "MIT",
@@ -3067,7 +3067,7 @@ var require_package = __commonJS({
3067
3067
  },
3068
3068
  scripts: {
3069
3069
  build: "node scripts/copy-corpus.js && node scripts/build.js",
3070
- prepublishOnly: "node scripts/assert-guard-major.js && bash ../../scripts/freeze-gate.sh && npm run build",
3070
+ prepublishOnly: "node scripts/assert-guard-major.js && node scripts/assert-changelog-version.js && bash ../../scripts/freeze-gate.sh && npm run build",
3071
3071
  "assert-guard-major": "node scripts/assert-guard-major.js",
3072
3072
  postinstall: "node scripts/postinstall.js",
3073
3073
  test: "node --test test/*.test.js"
@@ -253975,6 +253975,16 @@ var require_github_enforcement = __commonJS({
253975
253975
  RULESET_ABSENT: "ruleset_absent",
253976
253976
  /** Rulesets could not be read at all. */
253977
253977
  RULESET_UNREADABLE: "ruleset_unreadable",
253978
+ /** A ruleset requires our context but does not TARGET the branch that matters (1104a). */
253979
+ RULESET_REF_NOT_TARGETED: "ruleset_ref_not_targeted",
253980
+ /** Active, bound, targeted — but strict latest-head is off (1104b). Recorded, not fatal. */
253981
+ RULESET_STRICT_POLICY_OFF: "ruleset_strict_policy_off",
253982
+ /** 1102 — no active targeting ruleset requires a workflow at all. */
253983
+ REQUIRED_WORKFLOW_ABSENT: "required_workflow_absent",
253984
+ /** 1102 — a workflows rule exists but carries no sha, so it names a path and not bytes. */
253985
+ REQUIRED_WORKFLOW_UNPINNED: "required_workflow_unpinned",
253986
+ /** 1102 — a required workflow is pinned to a full commit sha. */
253987
+ REQUIRED_WORKFLOW_SHA_PINNED: "required_workflow_sha_pinned",
253978
253988
  REQUIRED_CHECK_ABSENT: "required_check_absent",
253979
253989
  ACTIVE_JOB: "active_job",
253980
253990
  NO_ACTIVE_JOB: "no_active_job",
@@ -254005,6 +254015,41 @@ var require_github_enforcement = __commonJS({
254005
254015
  const n = Number(value);
254006
254016
  return Number.isInteger(n) && n > 0 ? n : null;
254007
254017
  }
254018
+ function refPatterns(rs, key) {
254019
+ const rn = rs && rs.conditions && rs.conditions.ref_name;
254020
+ if (!rn || !Array.isArray(rn[key])) return null;
254021
+ return rn[key].map((x) => String(x));
254022
+ }
254023
+ function targetsDefaultBranch(entry, defaultBranch) {
254024
+ if (entry.target === "tag") return false;
254025
+ const inc = entry.ref_include;
254026
+ if (!Array.isArray(inc)) return null;
254027
+ const ref = defaultBranch ? `refs/heads/${defaultBranch}` : null;
254028
+ const matches = (pat) => {
254029
+ if (pat === "~ALL" || pat === "~DEFAULT_BRANCH") return true;
254030
+ if (!ref) return null;
254031
+ if (pat === ref) return true;
254032
+ if (!pat.includes("*") && !pat.includes("[") && !pat.includes("?")) return false;
254033
+ const rx = new RegExp(`^${pat.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, ".")}$`);
254034
+ return rx.test(ref);
254035
+ };
254036
+ let included = false;
254037
+ for (const pat of inc) {
254038
+ const m = matches(pat);
254039
+ if (m === null) return null;
254040
+ if (m) {
254041
+ included = true;
254042
+ break;
254043
+ }
254044
+ }
254045
+ if (!included) return false;
254046
+ for (const pat of entry.ref_exclude || []) {
254047
+ const m = matches(pat);
254048
+ if (m === null) return null;
254049
+ if (m) return false;
254050
+ }
254051
+ return true;
254052
+ }
254008
254053
  function rulesetRequiredCheckEntries(rulesets) {
254009
254054
  const out = [];
254010
254055
  const list = Array.isArray(rulesets) ? rulesets : [];
@@ -254023,7 +254068,15 @@ var require_github_enforcement = __commonJS({
254023
254068
  enforcement: typeof rs.enforcement === "string" ? rs.enforcement : null,
254024
254069
  integration_id: numericAppId(c && c.integration_id),
254025
254070
  bypass_visible: Object.prototype.hasOwnProperty.call(rs, "bypass_actors"),
254026
- bypass_count: Array.isArray(rs.bypass_actors) ? rs.bypass_actors.length : null
254071
+ bypass_count: Array.isArray(rs.bypass_actors) ? rs.bypass_actors.length : null,
254072
+ // 1104a — a ruleset can be ACTIVE and target nothing that matters. MEASURED against the
254073
+ // live API: `target` and `conditions.ref_name` are both in the detail document we already
254074
+ // fetch, so this is a grading change and needs no new call.
254075
+ target: typeof rs.target === "string" ? rs.target : null,
254076
+ ref_include: refPatterns(rs, "include"),
254077
+ ref_exclude: refPatterns(rs, "exclude"),
254078
+ // 1104b — same document, one boolean.
254079
+ strict_policy: typeof params.strict_required_status_checks_policy === "boolean" ? params.strict_required_status_checks_policy : null
254027
254080
  });
254028
254081
  }
254029
254082
  }
@@ -254031,7 +254084,7 @@ var require_github_enforcement = __commonJS({
254031
254084
  return out;
254032
254085
  }
254033
254086
  var RULESET_BYPASS_CAVEAT = " Bypass actors were not readable on this surface, so this does not establish that nobody can bypass the ruleset.";
254034
- function evaluateRuleset(rulesets) {
254087
+ function evaluateRuleset(rulesets, defaultBranch = null) {
254035
254088
  if (rulesets == null) {
254036
254089
  return {
254037
254090
  status: STATUS.UNVERIFIABLE,
@@ -254062,12 +254115,41 @@ var require_github_enforcement = __commonJS({
254062
254115
  }
254063
254116
  const bound = active.find((e) => e.integration_id === ENFORCING_ISSUER_APP_ID);
254064
254117
  if (bound) {
254118
+ const targeted = targetsDefaultBranch(bound, defaultBranch);
254119
+ if (targeted === false) {
254120
+ return {
254121
+ status: STATUS.NOT_VERIFIED,
254122
+ reason_code: REASON.RULESET_REF_NOT_TARGETED,
254123
+ reason: `active ruleset "${bound.ruleset}" requires "${CHECK_NAME}" bound to the enforcing issuer, but its conditions do not target ${defaultBranch ? `refs/heads/${defaultBranch}` : "the default branch"}${bound.target === "tag" ? " (the ruleset targets tags, which never gate a merge to a branch)" : ""} \u2014 include: ${JSON.stringify(bound.ref_include)}, exclude: ${JSON.stringify(bound.ref_exclude || [])}`,
254124
+ ruleset_names: names,
254125
+ app_id: ENFORCING_ISSUER_APP_ID,
254126
+ ref_targeted: false
254127
+ };
254128
+ }
254129
+ if (targeted === null) {
254130
+ return {
254131
+ status: STATUS.UNVERIFIABLE,
254132
+ reason_code: REASON.RULESET_REF_NOT_TARGETED,
254133
+ reason: `active ruleset "${bound.ruleset}" binds "${CHECK_NAME}" to the enforcing issuer, but its ref conditions could not be read, so whether it targets the branch that matters CANNOT BE DETERMINED FROM HERE.${RULESET_BYPASS_CAVEAT}`,
254134
+ ruleset_names: names,
254135
+ app_id: ENFORCING_ISSUER_APP_ID,
254136
+ ref_targeted: null
254137
+ };
254138
+ }
254065
254139
  return {
254066
254140
  status: STATUS.VERIFIED,
254067
254141
  reason_code: REASON.RULESET_ISSUER_BOUND,
254068
- reason: `active ruleset "${bound.ruleset}" requires "${CHECK_NAME}" bound to integration_id ${ENFORCING_ISSUER_APP_ID} (${GITHUB_ACTIONS_APP_SLUG}) \u2014 the contract-gate Action, which concludes failure and blocks the merge.${RULESET_BYPASS_CAVEAT}`,
254142
+ reason: `active ruleset "${bound.ruleset}" requires "${CHECK_NAME}" bound to integration_id ${ENFORCING_ISSUER_APP_ID} (${GITHUB_ACTIONS_APP_SLUG}) \u2014 the contract-gate Action, which concludes failure and blocks the merge. It targets ${defaultBranch ? `refs/heads/${defaultBranch}` : "the default branch"}.` + (bound.strict_policy === true ? " strict_required_status_checks_policy is ON, so a stale head cannot merge." : bound.strict_policy === false ? " RESIDUAL: strict_required_status_checks_policy is OFF, so a branch whose head is behind the base can merge on a check that passed against older bytes." : " strict_required_status_checks_policy was not readable here.") + RULESET_BYPASS_CAVEAT,
254069
254143
  ruleset_names: names,
254070
- app_id: ENFORCING_ISSUER_APP_ID
254144
+ app_id: ENFORCING_ISSUER_APP_ID,
254145
+ ref_targeted: true,
254146
+ strict_policy: bound.strict_policy,
254147
+ strict_policy_residual: bound.strict_policy === false ? REASON.RULESET_STRICT_POLICY_OFF : null,
254148
+ // 1104c — NEVER an absence we cannot see. bypass_actors is in the ruleset schema but was
254149
+ // absent from all six live rulesets read without a credential (measured 2026-08-27), so
254150
+ // "not present in the response" is reported as NOT VISIBLE and never as "none configured".
254151
+ bypass_visible: bound.bypass_visible === true,
254152
+ bypass_count: bound.bypass_visible === true ? bound.bypass_count : null
254071
254153
  };
254072
254154
  }
254073
254155
  const other = active.find((e) => e.integration_id != null);
@@ -254088,9 +254170,66 @@ var require_github_enforcement = __commonJS({
254088
254170
  ruleset_names: names
254089
254171
  };
254090
254172
  }
254091
- function evaluateRequiredCheckAcrossSurfaces(protection, rulesets) {
254173
+ function evaluateRequiredWorkflowPin(rulesets, defaultBranch = null) {
254174
+ if (rulesets == null) {
254175
+ return { status: STATUS.UNVERIFIABLE, reason_code: REASON.RULESET_UNREADABLE, pinned: null, workflows: [] };
254176
+ }
254177
+ const found = [];
254178
+ for (const rs of Array.isArray(rulesets) ? rulesets : []) {
254179
+ if (!rs || typeof rs !== "object") continue;
254180
+ if (rs.enforcement !== "active") continue;
254181
+ const entryShape = {
254182
+ target: typeof rs.target === "string" ? rs.target : null,
254183
+ ref_include: refPatterns(rs, "include"),
254184
+ ref_exclude: refPatterns(rs, "exclude")
254185
+ };
254186
+ if (targetsDefaultBranch(entryShape, defaultBranch) !== true) continue;
254187
+ for (const rule of Array.isArray(rs.rules) ? rs.rules : []) {
254188
+ if (!rule || rule.type !== "workflows") continue;
254189
+ const list = rule.parameters && Array.isArray(rule.parameters.workflows) ? rule.parameters.workflows : [];
254190
+ for (const w of list) {
254191
+ if (!w || typeof w !== "object") continue;
254192
+ found.push({
254193
+ ruleset: String(rs.name || rs.id || "ruleset"),
254194
+ path: typeof w.path === "string" ? w.path : null,
254195
+ repository_id: Number.isInteger(w.repository_id) ? w.repository_id : null,
254196
+ ref: typeof w.ref === "string" ? w.ref : null,
254197
+ // The whole question. sha is optional in the schema, so its ABSENCE is the finding.
254198
+ sha: typeof w.sha === "string" && /^[0-9a-f]{40}$/i.test(w.sha) ? w.sha : null
254199
+ });
254200
+ }
254201
+ }
254202
+ }
254203
+ if (found.length === 0) {
254204
+ return {
254205
+ status: STATUS.NOT_VERIFIED,
254206
+ reason_code: REASON.REQUIRED_WORKFLOW_ABSENT,
254207
+ pinned: false,
254208
+ workflows: [],
254209
+ reason: `no active ruleset targeting this branch requires a workflow, so the check that gates the merge is bound only to the SHARED issuer ${ENFORCING_ISSUER_APP_ID} (${GITHUB_ACTIONS_APP_SLUG}) \u2014 anyone who can edit .github/workflows can post under it`
254210
+ };
254211
+ }
254212
+ const pinned = found.filter((w) => w.sha != null);
254213
+ if (pinned.length === 0) {
254214
+ return {
254215
+ status: STATUS.NOT_VERIFIED,
254216
+ reason_code: REASON.REQUIRED_WORKFLOW_UNPINNED,
254217
+ pinned: false,
254218
+ workflows: found,
254219
+ reason: `a required workflow is configured (${found.map((w) => w.path || "?").join(", ")}) but NONE carries a sha. \`sha\` is optional in the rule schema, and without it the ruleset names a path rather than bytes, so rewriting that file still changes what runs`
254220
+ };
254221
+ }
254222
+ return {
254223
+ status: STATUS.VERIFIED,
254224
+ reason_code: REASON.REQUIRED_WORKFLOW_SHA_PINNED,
254225
+ pinned: true,
254226
+ workflows: found,
254227
+ reason: `active ruleset "${pinned[0].ruleset}" requires workflow ${pinned[0].path} pinned to sha ${pinned[0].sha} in repository_id ${pinned[0].repository_id}. Moving that pin requires editing the ruleset (repository administration), which is a different permission from workflow write. This does NOT establish that any pull request ran it, nor that bypass actors cannot skip the ruleset.`
254228
+ };
254229
+ }
254230
+ function evaluateRequiredCheckAcrossSurfaces(protection, rulesets, defaultBranch = null) {
254092
254231
  const classic = evaluateRequiredCheck(protection);
254093
- const ruleset = evaluateRuleset(rulesets);
254232
+ const ruleset = evaluateRuleset(rulesets, defaultBranch);
254094
254233
  const merged = (base) => ({
254095
254234
  ...base,
254096
254235
  surfaces_inspected: ["classic_branch_protection", rulesets == null ? "rulesets:unread" : "rulesets"],
@@ -254210,8 +254349,22 @@ var require_github_enforcement = __commonJS({
254210
254349
  var STATUS = Object.freeze({
254211
254350
  VERIFIED: "VERIFIED",
254212
254351
  NOT_VERIFIED: "NOT_VERIFIED",
254213
- UNVERIFIABLE: "UNVERIFIABLE"
254214
- });
254352
+ UNVERIFIABLE: "UNVERIFIABLE",
254353
+ UNSUPPORTED_PLAN: "UNSUPPORTED_PLAN",
254354
+ PROVIDER_ERROR: "PROVIDER_ERROR"
254355
+ });
254356
+ var FRESHNESS = Object.freeze({ FRESH: "FRESH", STALE_EVIDENCE: "STALE_EVIDENCE" });
254357
+ function supportsStrongClaim(status) {
254358
+ return status === STATUS.VERIFIED;
254359
+ }
254360
+ var EVIDENCE_TTL_SECONDS = 3600;
254361
+ function evidenceFreshness(report, nowIso) {
254362
+ if (!report || !report.expires_at) return null;
254363
+ const now = Date.parse(nowIso || (/* @__PURE__ */ new Date()).toISOString());
254364
+ const exp = Date.parse(report.expires_at);
254365
+ if (!Number.isFinite(now) || !Number.isFinite(exp)) return null;
254366
+ return now > exp ? FRESHNESS.STALE_EVIDENCE : FRESHNESS.FRESH;
254367
+ }
254215
254368
  function resolveGitHubAuth(env = process.env) {
254216
254369
  const user = String(env && (env.GITHUB_TOKEN || env.GH_TOKEN) || "").trim();
254217
254370
  if (user) {
@@ -254542,6 +254695,10 @@ var require_github_enforcement = __commonJS({
254542
254695
  NO_TOKEN_INSTRUCTION,
254543
254696
  LAYER_IDS,
254544
254697
  STATUS,
254698
+ FRESHNESS,
254699
+ EVIDENCE_TTL_SECONDS,
254700
+ supportsStrongClaim,
254701
+ evidenceFreshness,
254545
254702
  REASON,
254546
254703
  CHECK_NAME,
254547
254704
  GATE_ACTION,
@@ -254555,6 +254712,8 @@ var require_github_enforcement = __commonJS({
254555
254712
  evaluateLayers,
254556
254713
  evaluateRequiredCheck,
254557
254714
  evaluateRuleset,
254715
+ evaluateRequiredWorkflowPin,
254716
+ targetsDefaultBranch,
254558
254717
  evaluateRequiredCheckAcrossSurfaces,
254559
254718
  verifiedRequiresBinding,
254560
254719
  scanLocalWorkflows,
@@ -254941,7 +255100,7 @@ var require_setup_required_check = __commonJS({
254941
255100
  }
254942
255101
  } catch {
254943
255102
  }
254944
- const rulesetVerdict = evaluateRuleset(rulesetDocs);
255103
+ const rulesetVerdict = evaluateRuleset(rulesetDocs, branch);
254945
255104
  const rulesetNamed = detectRulesetRequiredCheck(rulesetDocs || [], CHECK_NAME);
254946
255105
  const ruleset = {
254947
255106
  enforced: rulesetVerdict.status === STATUS.VERIFIED,
@@ -255314,6 +255473,8 @@ var require_enforce_check = __commonJS({
255314
255473
  var chalk = require_source();
255315
255474
  var {
255316
255475
  EVIDENCE_SPEC,
255476
+ EVIDENCE_TTL_SECONDS,
255477
+ supportsStrongClaim,
255317
255478
  APP_INSTALLATION,
255318
255479
  NO_TOKEN_INSTRUCTION,
255319
255480
  resolveGitHubAuth,
@@ -255325,6 +255486,35 @@ var require_enforce_check = __commonJS({
255325
255486
  var { isValidRepo } = require_status();
255326
255487
  var { resolveOwnerRepo } = require_setup_required_check();
255327
255488
  if (process.env.NO_COLOR) chalk.level = 0;
255489
+ var crypto = require("node:crypto");
255490
+ var EVIDENCE_SCOPE = Object.freeze({
255491
+ grades: "repository configuration \u2014 what the provider would require of any pull request",
255492
+ covers: Object.freeze([
255493
+ "required status check present and named exactly",
255494
+ "the check bound to an issuer that can block (integration_id / app_id)",
255495
+ "ruleset enforcement is active rather than evaluate",
255496
+ "the ruleset targets the branch that matters",
255497
+ "strict latest-head policy recorded",
255498
+ "bypass actors reported as VISIBLE or NOT VISIBLE, never as absent"
255499
+ ]),
255500
+ does_not_cover: Object.freeze([
255501
+ "which GitHub App actually posted a check run on any pull request",
255502
+ "the head_sha a check run executed against",
255503
+ "whether any check run concluded success",
255504
+ "a recomputable external proof hash over a specific run"
255505
+ ]),
255506
+ why_not: "Those are facts about one pull request at one moment, not about configuration. Reading them needs a per-PR check-runs query this command does not make; a passing config is not a passing run."
255507
+ });
255508
+ function responseDigest(read) {
255509
+ if (!read) return null;
255510
+ return {
255511
+ endpoint: read.endpoint ? `GET ${read.endpoint}` : null,
255512
+ http_status: read.status == null ? null : read.status,
255513
+ // Hash of the BODY as we received it. Not the verdict — the INPUT to the verdict, so a
255514
+ // disagreement can be localised to the answer or to our grading of it.
255515
+ body_sha256: read.body === void 0 || read.body === null ? null : `sha256:${crypto.createHash("sha256").update(JSON.stringify(read.body)).digest("hex")}`
255516
+ };
255517
+ }
255328
255518
  var USAGE = [
255329
255519
  "Usage: coderifts enforce --check --repo owner/repo [--env <github-environment>] [--branch <name>] [--json]",
255330
255520
  "",
@@ -255437,7 +255627,12 @@ var require_enforce_check = __commonJS({
255437
255627
  const report2 = {
255438
255628
  command: "enforce --check",
255439
255629
  spec: EVIDENCE_SPEC,
255630
+ // OBSERVED_AT WAS NOT ADDED. `queried_at` already carries exactly that fact, and a
255631
+ // second name for one fact is the defect this round was told to look for.
255440
255632
  queried_at: queriedAt,
255633
+ expires_at: new Date(Date.parse(queriedAt) + EVIDENCE_TTL_SECONDS * 1e3).toISOString(),
255634
+ evidence_ttl_seconds: EVIDENCE_TTL_SECONDS,
255635
+ evidence_scope: EVIDENCE_SCOPE,
255441
255636
  repo: `${ownerRepo.owner}/${ownerRepo.repo}`,
255442
255637
  branch: options.branch || null,
255443
255638
  environment: environmentName,
@@ -255481,14 +255676,33 @@ var require_enforce_check = __commonJS({
255481
255676
  const report = {
255482
255677
  command: "enforce --check",
255483
255678
  spec: EVIDENCE_SPEC,
255679
+ // OBSERVED_AT WAS NOT ADDED. `queried_at` already carries exactly that fact, and a
255680
+ // second name for one fact is the defect this round was told to look for.
255484
255681
  queried_at: queriedAt,
255682
+ expires_at: new Date(Date.parse(queriedAt) + EVIDENCE_TTL_SECONDS * 1e3).toISOString(),
255683
+ evidence_ttl_seconds: EVIDENCE_TTL_SECONDS,
255684
+ evidence_scope: EVIDENCE_SCOPE,
255485
255685
  repo: `${ownerRepo.owner}/${ownerRepo.repo}`,
255486
255686
  branch,
255487
255687
  environment: environmentName,
255488
255688
  provider: "github",
255489
255689
  auth: authPublic,
255690
+ // The raw answers behind the verdict, so a disagreement can be localised to the provider's
255691
+ // response or to our grading of it.
255692
+ responses: {
255693
+ protection: responseDigest(protectionRead),
255694
+ environment: responseDigest(environmentRead)
255695
+ },
255490
255696
  ...evaluated
255491
255697
  };
255698
+ if (report.claim && report.claim.inescapable_deploy === true) {
255699
+ const unsupported = (report.layers || []).filter((l) => !supportsStrongClaim(l.status));
255700
+ if (unsupported.length > 0) {
255701
+ throw new Error(
255702
+ `enforce --check: inescapable_deploy is true with ${unsupported.length} layer(s) not VERIFIED (${unsupported.map((l) => `${l.id}=${l.status}`).join(", ")}) \u2014 refusing to emit`
255703
+ );
255704
+ }
255705
+ }
255492
255706
  if (options.json) log(JSON.stringify(report, null, 2));
255493
255707
  else log(renderHuman(report));
255494
255708
  return { exitCode: 0, report };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderifts",
3
- "version": "5.0.1",
3
+ "version": "6.0.0",
4
4
  "description": "Detect breaking API changes from the command line. Works locally or with the CodeRifts cloud API.",
5
5
  "author": "CodeRifts <hello@coderifts.com>",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "scripts": {
41
41
  "build": "node scripts/copy-corpus.js && node scripts/build.js",
42
- "prepublishOnly": "node scripts/assert-guard-major.js && bash ../../scripts/freeze-gate.sh && npm run build",
42
+ "prepublishOnly": "node scripts/assert-guard-major.js && node scripts/assert-changelog-version.js && bash ../../scripts/freeze-gate.sh && npm run build",
43
43
  "assert-guard-major": "node scripts/assert-guard-major.js",
44
44
  "postinstall": "node scripts/postinstall.js",
45
45
  "test": "node --test test/*.test.js"
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * CLI release gate (prepublishOnly): refuse to publish a version the CHANGELOG does not name.
6
+ *
7
+ * WHY THIS EXISTS — measured, not hypothetical. On 2026-08-27 packages/cli/CHANGELOG.md listed
8
+ * 4.10.0 and then "Unreleased", while npm carried 4.11.0, 4.12.0, 5.0.0 and 5.0.1. Four published
9
+ * versions were undocumented and a fifth (5.0.0) was published while still labelled Unreleased.
10
+ * Two distinct failure modes produced that, and BOTH are caught by the one check below:
11
+ *
12
+ * 1. The bump commit never touched the changelog at all. Measured: of the last eight CLI version
13
+ * bumps, four omitted it (4.11.0, 4.12.0, 4.13.0, 5.0.1) — and those four are exactly the
14
+ * versions that went missing. The correlation is not partial.
15
+ * 2. The bump commit DID write the entry, but under "## Unreleased", and nobody retitled it at
16
+ * publish. Atomicity alone does not save you: @coderifts/agent-guard 12.0.0 was written
17
+ * atomically and still shipped as "Unreleased", and 11.0.1 before it had to retitle a stale
18
+ * heading by hand.
19
+ *
20
+ * So the invariant is NOT "the bump touched the changelog" — it is that the version being published
21
+ * appears as its own heading. A human step that must be remembered at publish time is the step that
22
+ * gets skipped; this makes it fail the publish instead.
23
+ *
24
+ * Dependency-free on purpose: a publish gate that needs an install to run is a gate that stops
25
+ * running (same reasoning as assert-guard-major.js).
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const PKG_PATH = path.join(__dirname, '..', 'package.json');
32
+ const CHANGELOG_PATH = path.join(__dirname, '..', 'CHANGELOG.md');
33
+
34
+ function fail(msg) {
35
+ console.error(`assert-changelog-version: FAIL — ${msg}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ /**
40
+ * Headings this file recognises as "this version is documented".
41
+ * A heading may carry a trailing note (e.g. "## 4.13.0 — never published"), so match the version
42
+ * token at a word boundary rather than requiring the line to be exactly the version.
43
+ */
44
+ function headingNamesVersion(line, version) {
45
+ if (!/^##\s+/.test(line)) return false;
46
+ const rest = line.replace(/^##\s+/, '').trim();
47
+ const esc = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ return new RegExp(`^v?${esc}(\\b|$)`).test(rest);
49
+ }
50
+
51
+ function main() {
52
+ if (!fs.existsSync(PKG_PATH)) fail(`package.json not found at ${PKG_PATH}`);
53
+ if (!fs.existsSync(CHANGELOG_PATH)) fail(`CHANGELOG.md not found at ${CHANGELOG_PATH}`);
54
+
55
+ let version;
56
+ try {
57
+ version = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')).version;
58
+ } catch (err) {
59
+ fail(`package.json is not readable JSON: ${err.message}`);
60
+ }
61
+ if (typeof version !== 'string' || !version.trim()) fail('package.json has no version string');
62
+
63
+ const lines = fs.readFileSync(CHANGELOG_PATH, 'utf8').split('\n');
64
+ const headings = lines.filter((l) => /^##\s+/.test(l));
65
+ if (headings.length === 0) fail('CHANGELOG.md has no "## " headings at all');
66
+
67
+ const match = headings.find((l) => headingNamesVersion(l, version));
68
+ if (!match) {
69
+ const listed = headings.slice(0, 4).map((h) => h.replace(/^##\s+/, '').trim());
70
+ fail(
71
+ `package.json is ${version} but CHANGELOG.md has no "## ${version}" heading.\n`
72
+ + ` Top headings are: ${listed.join(' · ')}\n`
73
+ + ' Publishing now is how a version goes undocumented. Retitle the Unreleased section to\n'
74
+ + ` ${version}, or add its section, then publish.`,
75
+ );
76
+ }
77
+
78
+ // "Unreleased" must never be the section that documents the version being published.
79
+ if (/^##\s+unreleased\b/i.test(match)) {
80
+ fail(`the section naming ${version} is the Unreleased heading — retitle it before publishing`);
81
+ }
82
+
83
+ console.log(`assert-changelog-version: OK — ${version} is documented ("${match.trim()}")`);
84
+ }
85
+
86
+ main();
87
+
88
+ module.exports = { headingNamesVersion, PKG_PATH, CHANGELOG_PATH };