From b1c9c60cc7b083dea3f8f502006cf7339a993008 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 2 Sep 2026 20:27:00 +0500 Subject: [PATCH 1/2] build(ci): let git-ref dependency updates reach main The stock `body-max-line-length` allows 100 characters. Dependabot writes a 144-character compare link into the body of every bump of a dependency pinned by git ref, and there is no wrap point inside a URL, so the rule is unsatisfiable for that commit and the update never lands. Five dependencies here are pinned that way. The limit is kept and measured over what could have been wrapped: a line is exempt only when one of its own whitespace-separated tokens is longer than the limit. A 120-character line of ordinary words still fails. Verified against commitlint 19 with both controls before this was written; the same file is already merged in the client repository, where it unblocked three pull requests. Claude-Session: https://claude.ai/code/session_01WFnbbqyGJC9DJVoF3JhfvW --- .github/workflows/ci.yml | 7 +++++ commitlint.config.mjs | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 commitlint.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed3263..3688876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,3 +73,10 @@ jobs: issues: write # the stale bot closes threads pull-requests: write # the labeler applies labels uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + with: + # The stock configuration rejects any body line over 100 characters, and + # Dependabot writes a 144-character compare link into every git-ref bump. + # Five dependencies here are pinned by git ref, so the rule would make + # their updates unmergeable. `commitlint.config.mjs` keeps the limit and + # exempts only lines that no amount of wrapping could shorten. + commitlint_config: commitlint.config.mjs diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..22cc024 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,57 @@ +// Conventional Commits, with one narrow exemption to the line-length rules. +// +// `body-max-line-length` and `footer-max-line-length` exist so that `git log` +// stays readable in a narrow terminal, and for prose that is the right rule. +// It is not achievable for a line whose content is a single URL. Dependabot +// writes one on every dependency it moves by git ref: +// +// - [Commits](https://github.com///compare/<40 hex>...<40 hex>) +// +// That line is 144 characters and contains no wrap point. With the stock rule +// every such pull request fails commitlint, is blocked from merging, and the +// dependency never lands - which is how a security update stops arriving. +// +// So the limit still applies, and it is measured over everything that could +// have been wrapped. A line is exempt only when one of its whitespace- +// separated tokens is itself longer than the limit: the line is long because +// of something unbreakable, not because nobody wrapped it. A 120-character +// line of ordinary words still fails, which is the case the rule is for. + +const LIMIT = 100; + +const offendingLines = (text) => + (text ?? '') + .split('\n') + .filter((line) => line.length > LIMIT) + .filter((line) => !line.split(/\s+/).some((token) => token.length > LIMIT)); + +const wrappableLineLength = (section) => (parsed) => { + const offenders = offendingLines(parsed[section]); + const detail = offenders + .map((line) => ` ${line.length} chars: ${line.slice(0, 60)}...`) + .join('\n'); + return [ + offenders.length === 0, + `${section} lines must not be longer than ${LIMIT} characters, unless one ` + + `token on the line is longer than that on its own:\n${detail}`, + ]; +}; + +export default { + extends: ['@commitlint/config-conventional'], + plugins: [ + { + rules: { + 'body-max-line-length-wrappable': wrappableLineLength('body'), + 'footer-max-line-length-wrappable': wrappableLineLength('footer'), + }, + }, + ], + rules: { + // Replaced, not relaxed: the two rules below enforce the same limit. + 'body-max-line-length': [0, 'always', LIMIT], + 'footer-max-line-length': [0, 'always', LIMIT], + 'body-max-line-length-wrappable': [2, 'always'], + 'footer-max-line-length-wrappable': [2, 'always'], + }, +}; From d041fcc43d567e8fc4a6af2458b7a19842a65ab2 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 2 Sep 2026 20:37:40 +0500 Subject: [PATCH 2/2] fix(ci): the exemption has to cover every line Dependabot writes The version first written here exempted a line carrying one token longer than the limit. That covers the compare link and nothing else. A grouped update also writes markdown table rows at 102 characters whose longest token is 79, and sentences that carry a link at 121 - both of which the narrow rule still rejected, which is how the client repository stayed blocked after the fix that was supposed to unblock it. The predicate is now the one the comment always claimed: the limit is measured over what the author could have wrapped, with each URL counted as one character. `scripts/check_commitlint_config.mjs` holds seven controls, four that must be exempt and three that must not, and the new `contracts` job runs it. Claude-Session: https://claude.ai/code/session_01WFnbbqyGJC9DJVoF3JhfvW --- .github/workflows/ci.yml | 18 +++++++ commitlint.config.mjs | 47 ++++++++++++----- scripts/check_commitlint_config.mjs | 78 +++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 scripts/check_commitlint_config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3688876..8574027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,24 @@ concurrency: cancel-in-progress: true jobs: + contracts: + name: contracts + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The exemption in `commitlint.config.mjs` decides whether a dependency + # update can merge, and an exemption written one notch too wide reads + # exactly like a correct one. These controls are the difference. + - name: The commitlint exemption is not wider than stated + run: node scripts/check_commitlint_config.mjs + rust: name: rust permissions: diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 22cc024..0954470 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -2,28 +2,46 @@ // // `body-max-line-length` and `footer-max-line-length` exist so that `git log` // stays readable in a narrow terminal, and for prose that is the right rule. -// It is not achievable for a line whose content is a single URL. Dependabot -// writes one on every dependency it moves by git ref: +// It is not achievable for a line that is long because of a URL. Dependabot +// writes several such lines into every dependency bump: // // - [Commits](https://github.com///compare/<40 hex>...<40 hex>) +// | [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) | `0.6.2` | `0.7.0` | // -// That line is 144 characters and contains no wrap point. With the stock rule -// every such pull request fails commitlint, is blocked from merging, and the -// dependency never lands - which is how a security update stops arriving. +// 144 and 102 characters, and neither has a wrap point that would help: the +// first is one link, the second is a table row. With the stock rule every such +// pull request fails commitlint, is blocked from merging, and the dependency +// never lands - which is how a security update stops arriving. // -// So the limit still applies, and it is measured over everything that could -// have been wrapped. A line is exempt only when one of its whitespace- -// separated tokens is itself longer than the limit: the line is long because -// of something unbreakable, not because nobody wrapped it. A 120-character -// line of ordinary words still fails, which is the case the rule is for. +// So the limit still applies, and it is measured over what the author could +// have wrapped. Each URL counts as one character; if the line is still too +// long after that, nobody wrapped it and the rule fires. A 119-character line +// of ordinary words fails, and so does a 130-character line of prose that +// happens to contain a short link. A line whose length is a link is exempt. +// +// A single non-URL token longer than the limit - a hash, a base64 blob, a very +// deep path - is exempt on the same reasoning. +// +// The controls are in `scripts/check_commitlint_config.mjs`, which CI runs. const LIMIT = 100; +// Stops at whitespace and at a closing paren, so the `)` and `.` that end a +// markdown link are counted as the text they are. +const URL_PATTERN = /https?:\/\/[^\s)]+/g; + +const unwrappable = (line) => { + if (line.split(/\s+/).some((token) => token.length > LIMIT)) { + return true; + } + return line.replace(URL_PATTERN, '').length <= LIMIT; +}; + const offendingLines = (text) => (text ?? '') .split('\n') .filter((line) => line.length > LIMIT) - .filter((line) => !line.split(/\s+/).some((token) => token.length > LIMIT)); + .filter((line) => !unwrappable(line)); const wrappableLineLength = (section) => (parsed) => { const offenders = offendingLines(parsed[section]); @@ -32,11 +50,14 @@ const wrappableLineLength = (section) => (parsed) => { .join('\n'); return [ offenders.length === 0, - `${section} lines must not be longer than ${LIMIT} characters, unless one ` + - `token on the line is longer than that on its own:\n${detail}`, + `${section} lines must not be longer than ${LIMIT} characters. A line is ` + + `exempt only when its length comes from a URL or from one very long ` + + `token:\n${detail}`, ]; }; +export const testable = { LIMIT, unwrappable, offendingLines }; + export default { extends: ['@commitlint/config-conventional'], plugins: [ diff --git a/scripts/check_commitlint_config.mjs b/scripts/check_commitlint_config.mjs new file mode 100644 index 0000000..4ea3bf9 --- /dev/null +++ b/scripts/check_commitlint_config.mjs @@ -0,0 +1,78 @@ +// Controls for the line-length exemption in `commitlint.config.mjs`. +// +// The exemption exists so Dependabot's pull requests can merge. It is worth a +// test because it is the kind of rule that is easy to write too wide: an +// exemption that lets every long line through reads exactly like one that lets +// only the unwrappable ones through, and the difference only shows up months +// later as an unreadable `git log`. +// +// Run with `node scripts/check_commitlint_config.mjs`. No dependencies: this +// exercises the predicate directly, not commitlint, so it needs no install. + +import { testable } from '../commitlint.config.mjs'; + +const { LIMIT, unwrappable } = testable; + +const repeat = (word, times) => Array(times).fill(word).join(' '); + +const cases = [ + // Exempt: the line is long because of a link, and there is no useful wrap + // point inside one. All four are real lines from Dependabot commits. + [ + true, + 'a Dependabot compare link', + '- [Commits](https://github.com/example/kcp-sys/compare/32a6c09fc6223f54aea83981a6aa8995931d29be...d7427c22d764deb1860a7d37acc446ed5033464c)', + ], + [ + true, + 'a Dependabot table row', + '| [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) | `0.6.2` | `0.7.0` |', + ], + [ + true, + 'a Dependabot sentence carrying a link', + 'Bumps the cargo-major group with 1 update in the /libs/portable directory: [md5](https://github.com/stainless-steel/md5).', + ], + [ + true, + 'a single token longer than the limit', + `sha256:${'a'.repeat(120)}`, + ], + + // Not exempt: every one of these could have been wrapped, and the rule + // exists for exactly these. + [false, 'unwrapped prose', repeat('word', 24)], + [ + false, + 'prose that merely contains a short link', + `${repeat('word', 22)} https://example.com/x and more words here`, + ], + [ + false, + 'a long line with a link that does not account for its length', + `${repeat('sentence', 14)} https://example.com/`, + ], +]; + +let failed = 0; +for (const [expected, name, line] of cases) { + const actual = unwrappable(line); + const verdict = actual === expected ? 'ok ' : 'FAIL'; + if (actual !== expected) failed += 1; + console.log( + `${verdict} ${name} (${line.length} chars, exempt=${actual}, want=${expected})`, + ); +} + +// The predicate must never be reached for a line inside the limit; if it were, +// a short line could be reported. Guard the caller's contract too. +if (LIMIT !== 100) { + console.log(`FAIL the limit moved to ${LIMIT} without this test moving`); + failed += 1; +} + +if (failed > 0) { + console.error(`\n${failed} control(s) failed`); + process.exit(1); +} +console.log(`\nall ${cases.length} controls passed`);