From a57368da9bb9a7f2b7c8a0e22b22806166f980f1 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 22:25:10 -0400 Subject: [PATCH 1/6] test: cover the README asset pinning fix The npm-publish workflow pins relative README assets/ refs to the release tag's raw-GitHub URL before packing (1c167f610), because registry pages render the README from the tarball where relative refs 404. Nothing exercised that script. Run .github/scripts/pin-readme-assets.mjs against temp-dir fixtures and assert the three rewrite forms (img src, srcset, markdown image), that the tag comes from package.json's version, that absolute refs are left alone, that a second run is a no-op, and that a mixed README is pinned in one pass with the base reported. --- test/pin-readme-assets.test.mts | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 test/pin-readme-assets.test.mts diff --git a/test/pin-readme-assets.test.mts b/test/pin-readme-assets.test.mts new file mode 100644 index 000000000..0ac667f52 --- /dev/null +++ b/test/pin-readme-assets.test.mts @@ -0,0 +1,145 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'vitest' + +const rootPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const scriptPath = path.join( + rootPath, + '.github', + 'scripts', + 'pin-readme-assets.mjs', +) + +const tempDirs: string[] = [] + +function stageWorkspace(options: { + readme: string + version?: string | undefined +}): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pin-readme-assets-')) + tempDirs.push(dir) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'socket', version: options.version ?? '1.2.3' }), + ) + writeFileSync(path.join(dir, 'README.md'), options.readme) + return dir +} + +function runPin(cwd: string): { status: number | null; stdout: string } { + const result = spawnSync(process.execPath, [scriptPath], { + cwd, + encoding: 'utf8', + }) + return { status: result.status, stdout: result.stdout } +} + +function readmeIn(dir: string): string { + return readFileSync(path.join(dir, 'README.md'), 'utf8') +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }) + } +}) + +// Registry pages (npmjs.com) render the README from the published tarball, +// where relative assets/ refs 404 — they only resolve when browsing the repo +// on GitHub. The publish workflow runs this script once before packing so +// every variant ships absolute raw-GitHub URLs pinned to the release tag. +describe('pin-readme-assets', () => { + const base = 'https://raw.githubusercontent.com/SocketDev/socket-cli/v1.2.3/' + + it('pins a relative img src to the release tag raw URL', () => { + const dir = stageWorkspace({ + readme: 'logo\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `logo\n`, + ) + }) + + it('pins a relative srcset ref', () => { + const dir = stageWorkspace({ + readme: + '\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe( + `\n`, + ) + }) + + it('pins a markdown image ref', () => { + const dir = stageWorkspace({ + readme: '![banner](assets/banner.png)\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe(`![banner](${base}assets/banner.png)\n`) + }) + + it('reads the tag from package.json version', () => { + const dir = stageWorkspace({ + readme: '![banner](assets/banner.png)\n', + version: '9.9.9', + }) + runPin(dir) + expect(readmeIn(dir)).toContain( + 'https://raw.githubusercontent.com/SocketDev/socket-cli/v9.9.9/assets/banner.png', + ) + }) + + it('leaves absolute refs untouched', () => { + const readme = + '\n' + + '![ext](https://example.com/assets/banner.png)\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('is idempotent — a second run changes nothing', () => { + const dir = stageWorkspace({ + readme: + '\n' + + '\n' + + '![banner](assets/banner.png)\n', + }) + runPin(dir) + const afterFirst = readmeIn(dir) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(afterFirst) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('pins every ref form in one pass and reports the base', () => { + const dir = stageWorkspace({ + readme: + '\n' + + ' \n' + + ' Socket CLI\n' + + '\n' + + '\n' + + 'See ![the flow](assets/flow.png) for details.\n', + }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(stdout).toContain(`pinned relative assets/ refs to ${base}`) + const pinned = readmeIn(dir) + expect(pinned).not.toContain('"assets/') + expect(pinned).not.toContain('](assets/') + expect(pinned).toContain(`src="${base}assets/light.png"`) + expect(pinned).toContain(`srcset="${base}assets/dark.png"`) + expect(pinned).toContain(`](${base}assets/flow.png)`) + }) +}) From a38c4d9e924ec9ae10ef1ad6e6202da1d9ce956e Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 22:54:49 -0400 Subject: [PATCH 2/6] fix(publish): pin README assets from the AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin script matched raw markdown with string patterns, so an assets/ ref inside a fenced code block or inline code span got rewritten even though registry pages render it as literal text, and reference-style definitions ([ref]: assets/…) were never pinned. Parse the README with mdast-util-from-markdown and derive every edit from parser-reported byte offsets: image, link, and definition nodes whose url starts with assets/ get the release-tag base inserted at the url's own range, and raw-HTML nodes are re-parsed with parse5 source locations so only real src/srcset attribute values are touched. The document is never round-tripped through a serializer, so untouched bytes stay byte-identical. Contract is unchanged: version from package.json, absolute refs untouched, idempotent, same no-op message. Both parsers are devDependencies (mdast-util-from-markdown 2.0.3, parse5 8.0.1); the script runs in CI after install and ships nothing. Tests grow from 7 to 12: reference definitions, blockquote and list refs, fenced-code and inline-code lookalikes staying as written, and a mixed document pinning the real ref while leaving the fence alone. --- .github/scripts/pin-readme-assets.mjs | 137 +++++++++++- package.json | 2 + pnpm-lock.yaml | 290 +++++++++++++++++++++++++- test/pin-readme-assets.test.mts | 68 ++++++ 4 files changed, 487 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pin-readme-assets.mjs b/.github/scripts/pin-readme-assets.mjs index 2899560c8..4f4ce9b19 100644 --- a/.github/scripts/pin-readme-assets.mjs +++ b/.github/scripts/pin-readme-assets.mjs @@ -3,18 +3,141 @@ // repo on GitHub. Rewrite them to the release tag's raw-GitHub URL before the // packs, so every variant ships absolute, immutable asset URLs. The CI // workspace is ephemeral, so no restore pass is needed. Mirrors -// socket-wheelhouse's publish-infra pin-readme pass (src, srcset, and -// markdown ref forms; absolute refs are untouched and the rewrite is -// idempotent — an already-absolute ref has no leading `assets/` to match). +// socket-wheelhouse's publish-infra pin-readme pass; absolute refs are +// untouched and the rewrite is idempotent — an already-absolute ref has no +// leading `assets/` to pin. +// +// The README is parsed to a position-tracked mdast tree and edits land on the +// exact byte ranges the parser reports, never via a scan of the raw markdown: +// `assets/` inside a code fence or an inline code span is content, not a ref, +// and stays as written. Raw HTML arrives as mdast `html` nodes; each node's +// source slice goes through parse5 with source locations on, and only +// `src`/`srcset` attribute values that start with `assets/` are pinned. import { readFileSync, writeFileSync } from 'node:fs' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { parseFragment } from 'parse5' + +const RELATIVE_PREFIX = 'assets/' +const PINNED_ATTRS = new Set(['src', 'srcset']) + const { version } = JSON.parse(readFileSync('package.json', 'utf8')) const base = `https://raw.githubusercontent.com/SocketDev/socket-cli/v${version}/` const readme = readFileSync('README.md', 'utf8') -const pinned = readme - .replaceAll('src="assets/', `src="${base}assets/`) - .replaceAll('srcset="assets/', `srcset="${base}assets/`) - .replaceAll('](assets/', `](${base}assets/`) + +/** + * Byte offsets in the README where `base` gets inserted, each sitting + * immediately before a relative ref's leading `assets/`. + * @type {number[]} + */ +const insertAt = [] + +/** @param {import('mdast').Nodes} node */ +function walkMdast(node) { + if ( + node.type === 'image' || + node.type === 'link' || + node.type === 'definition' + ) { + collectMarkdownUrl(node) + } else if (node.type === 'html') { + collectHtmlAttrs(node) + } + if ('children' in node) { + for (const child of node.children) { + walkMdast(child) + } + } +} + +/** + * An image, link, or definition node carries its destination in `url` and its + * own span in `position`. The destination is the last occurrence of that url + * inside the span (label text precedes it), so the insertion point derives + * from the node position rather than a scan of the document. + * @param {import('mdast').Image | import('mdast').Link | import('mdast').Definition} node + */ +function collectMarkdownUrl(node) { + if (!node.url.startsWith(RELATIVE_PREFIX)) { + return + } + const start = node.position?.start?.offset + const end = node.position?.end?.offset + if (start === undefined || end === undefined) { + return + } + const urlAt = readme.slice(start, end).lastIndexOf(node.url) + if (urlAt === -1) { + return + } + insertAt.push(start + urlAt) +} + +/** + * Parse the html node's source slice with locations on and pin `src`/`srcset` + * values that start with `assets/`, using parse5's attribute byte ranges. + * @param {import('mdast').Html} node + */ +function collectHtmlAttrs(node) { + const nodeStart = node.position?.start?.offset + const nodeEnd = node.position?.end?.offset + if (nodeStart === undefined || nodeEnd === undefined) { + return + } + const html = readme.slice(nodeStart, nodeEnd) + const fragment = parseFragment(html, { sourceCodeLocationInfo: true }) + walkParse5(fragment, element => { + const attrLocations = element.sourceCodeLocation?.attrs + if (!attrLocations) { + return + } + for (const attr of element.attrs) { + if ( + !PINNED_ATTRS.has(attr.name) || + !attr.value.startsWith(RELATIVE_PREFIX) + ) { + continue + } + const location = attrLocations[attr.name] + if (!location) { + continue + } + const attrText = html.slice(location.startOffset, location.endOffset) + const valueAt = attrText.indexOf(attr.value, attr.name.length) + if (valueAt === -1) { + continue + } + insertAt.push(nodeStart + location.startOffset + valueAt) + } + }) +} + +/** + * Visit every element in a parse5 tree, including template contents. + * @param {object} node + * @param {(element: { attrs: Array<{ name: string, value: string }>, sourceCodeLocation?: { attrs?: Record } }) => void} visit + */ +function walkParse5(node, visit) { + if (Array.isArray(node.attrs)) { + visit(node) + } + if (node.content) { + walkParse5(node.content, visit) + } + if (Array.isArray(node.childNodes)) { + for (const child of node.childNodes) { + walkParse5(child, visit) + } + } +} + +walkMdast(fromMarkdown(readme)) + +let pinned = readme +for (const offset of [...new Set(insertAt)].sort((a, b) => b - a)) { + pinned = pinned.slice(0, offset) + base + pinned.slice(offset) +} + if (pinned === readme) { console.log('pin-readme-assets: no relative assets/ refs to pin') } else { diff --git a/package.json b/package.json index c20af76e5..ca2bd8a9b 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "knip": "5.63.1", "lint-staged": "16.1.6", "magic-string": "0.30.19", + "mdast-util-from-markdown": "2.0.3", "meow": "13.2.0", "micromatch": "4.0.8", "mock-fs": "5.5.0", @@ -177,6 +178,7 @@ "npm-run-all2": "8.0.4", "open": "10.2.0", "oxlint": "1.15.0", + "parse5": "8.0.1", "pony-cause": "2.1.11", "postject": "1.0.0-alpha.6", "rollup": "4.50.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d009a3b4a..468bcbcba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: magic-string: specifier: 0.30.19 version: 0.30.19 + mdast-util-from-markdown: + specifier: 2.0.3 + version: 2.0.3 meow: specifier: 13.2.0 version: 13.2.0(patch_hash=00fba6d3f9a0591670dcc98f872839fd1669152891f292799bfd7fdda4d9ce36) @@ -359,6 +362,9 @@ importers: oxlint: specifier: 1.15.0 version: 1.15.0 + parse5: + specifier: 8.0.1 + version: 8.0.1 pony-cause: specifier: 2.1.11 version: 2.1.11 @@ -1935,6 +1941,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/micromatch@4.0.9': resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} @@ -1986,6 +1995,9 @@ packages: '@types/ssri@7.1.5': resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/which@3.0.4': resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} @@ -2513,6 +2525,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + charm@0.1.2: resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} @@ -2688,6 +2703,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@10.0.0: resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} engines: {node: '>=20'} @@ -2743,6 +2761,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2755,6 +2777,9 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2836,6 +2861,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3628,6 +3657,12 @@ packages: engines: {node: '>= 12'} hasBin: true + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3650,6 +3685,69 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4073,6 +4171,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4767,6 +4868,9 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -6593,7 +6697,6 @@ snapshots: '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 - optional: true '@types/deep-eql@4.0.2': {} @@ -6605,6 +6708,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/micromatch@4.0.9': dependencies: '@types/braces': 3.0.5 @@ -6613,8 +6720,7 @@ snapshots: dependencies: '@types/node': 24.3.1 - '@types/ms@2.1.0': - optional: true + '@types/ms@2.1.0': {} '@types/node-fetch@2.6.13': dependencies: @@ -6673,6 +6779,8 @@ snapshots: dependencies: '@types/node': 24.3.1 + '@types/unist@3.0.3': {} + '@types/which@3.0.4': {} '@types/yargs-parser@21.0.3': {} @@ -7254,6 +7362,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + charm@0.1.2: {} check-error@2.1.1: {} @@ -7430,6 +7540,10 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decompress-response@10.0.0: dependencies: mimic-response: 4.0.0 @@ -7479,6 +7593,8 @@ snapshots: depd@2.0.0: optional: true + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: @@ -7489,6 +7605,10 @@ snapshots: meow: 13.2.0(patch_hash=00fba6d3f9a0591670dcc98f872839fd1669152891f292799bfd7fdda4d9ce36) noop-stream: 1.0.0 + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7573,6 +7693,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -8465,6 +8587,27 @@ snapshots: marked@4.3.0: {} + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + media-typer@1.1.0: optional: true @@ -8480,6 +8623,139 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8983,6 +9259,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: optional: true @@ -9713,6 +9993,10 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + universal-user-agent@7.0.3: {} unpipe@1.0.0: diff --git a/test/pin-readme-assets.test.mts b/test/pin-readme-assets.test.mts index 0ac667f52..0bdc5df5b 100644 --- a/test/pin-readme-assets.test.mts +++ b/test/pin-readme-assets.test.mts @@ -122,6 +122,74 @@ describe('pin-readme-assets', () => { expect(stdout).toContain('no relative assets/ refs to pin') }) + it('pins a reference-style definition', () => { + const dir = stageWorkspace({ + readme: '![banner][banner-ref]\n\n[banner-ref]: assets/banner.png\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `![banner][banner-ref]\n\n[banner-ref]: ${base}assets/banner.png\n`, + ) + }) + + it('pins refs inside blockquotes and list items', () => { + const dir = stageWorkspace({ + readme: + '> ![quoted](assets/quoted.png)\n' + + '\n' + + '- [download](assets/file.pdf)\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe( + `> ![quoted](${base}assets/quoted.png)\n` + + '\n' + + `- [download](${base}assets/file.pdf)\n`, + ) + }) + + it('leaves assets/ refs inside fenced code blocks alone', () => { + const readme = + '```md\n' + + '![example](assets/example.png)\n' + + '\n' + + '```\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('leaves assets/ refs inside inline code spans alone', () => { + const readme = 'Point refs like `](assets/x.png)` at the release tag.\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('pins real refs while leaving code-block lookalikes alone', () => { + const dir = stageWorkspace({ + readme: + '![banner](assets/banner.png)\n' + + '\n' + + '```html\n' + + '\n' + + '```\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `![banner](${base}assets/banner.png)\n` + + '\n' + + '```html\n' + + '\n' + + '```\n', + ) + }) + it('pins every ref form in one pass and reports the base', () => { const dir = stageWorkspace({ readme: From 19707b4eb3f6e2f92f4b87a248fd73e9a0acffa8 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 23:10:45 -0400 Subject: [PATCH 3/6] feat(publish): parse the README pin as GFM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub and npm render the README as GFM, so the pin script now parses with micromark-extension-gfm + mdast-util-gfm and the tree matches what those pages actually show: tables, footnotes, strikethrough, and task lists parse as their real constructs instead of falling back to CommonMark paragraphs. For assets/ pinning the output is unchanged — inline refs inside those constructs already parsed at the same byte offsets — so this is about render fidelity as the script grows. A new test pins a ref inside a GFM table cell and a footnote definition. --- .github/scripts/pin-readme-assets.mjs | 11 +- package.json | 2 + pnpm-lock.yaml | 243 ++++++++++++++++++++++++++ test/pin-readme-assets.test.mts | 18 ++ 4 files changed, 273 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pin-readme-assets.mjs b/.github/scripts/pin-readme-assets.mjs index 4f4ce9b19..786ba9515 100644 --- a/.github/scripts/pin-readme-assets.mjs +++ b/.github/scripts/pin-readme-assets.mjs @@ -16,6 +16,8 @@ import { readFileSync, writeFileSync } from 'node:fs' import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' import { parseFragment } from 'parse5' const RELATIVE_PREFIX = 'assets/' @@ -131,7 +133,14 @@ function walkParse5(node, visit) { } } -walkMdast(fromMarkdown(readme)) +// Parse with the GFM extensions so the tree matches how GitHub and npm +// actually render the README (tables, footnotes, strikethrough, task lists). +walkMdast( + fromMarkdown(readme, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }), +) let pinned = readme for (const offset of [...new Set(insertAt)].sort((a, b) => b - a)) { diff --git a/package.json b/package.json index ca2bd8a9b..b04a3ca04 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,9 @@ "lint-staged": "16.1.6", "magic-string": "0.30.19", "mdast-util-from-markdown": "2.0.3", + "mdast-util-gfm": "3.1.0", "meow": "13.2.0", + "micromark-extension-gfm": "3.0.0", "micromatch": "4.0.8", "mock-fs": "5.5.0", "nock": "14.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468bcbcba..19dfabfb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,9 +338,15 @@ importers: mdast-util-from-markdown: specifier: 2.0.3 version: 2.0.3 + mdast-util-gfm: + specifier: 3.1.0 + version: 3.1.0 meow: specifier: 13.2.0 version: 13.2.0(patch_hash=00fba6d3f9a0591670dcc98f872839fd1669152891f292799bfd7fdda4d9ce36) + micromark-extension-gfm: + specifier: 3.0.0 + version: 3.0.0 micromatch: specifier: 4.0.8 version: 4.0.8 @@ -2502,6 +2508,9 @@ packages: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} hasBin: true + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2905,6 +2914,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -3604,6 +3617,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3646,6 +3662,9 @@ packages: map-canvas@0.1.5: resolution: {integrity: sha512-f7M3sOuL9+up0NCOZbb1rQpWDLZwR/ftCiNbyscjl9LUUEwrRaoumH4sz6swgs58lF21DQ0hsYOCw5C6Zz7hbg==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked-terminal@5.2.0: resolution: {integrity: sha512-Piv6yNwAQXGFjZSaiNljyNFw7jKDdGrw70FSbtxEyldLsyeuV5ZHm/1wW++kWbrOF1VPnUgYOhB2oLL0ZpnekA==} engines: {node: '>=14.13.1 || >=16.0.0'} @@ -3657,9 +3676,36 @@ packages: engines: {node: '>= 12'} hasBin: true + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -3688,6 +3734,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -4868,9 +4935,18 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -5158,6 +5234,9 @@ packages: zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@ampproject/remapping@2.3.0': @@ -7328,6 +7407,8 @@ snapshots: ansicolors: 0.3.2 redeyed: 2.1.1 + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -7747,6 +7828,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@9.35.0(jiti@2.6.1)): dependencies: eslint: 9.35.0(jiti@2.6.1) @@ -8495,6 +8578,8 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + longest-streak@3.1.0: {} + loupe@3.2.1: {} lowercase-keys@3.0.0: {} @@ -8575,6 +8660,8 @@ snapshots: drawille-canvas-blessed-contrib: 0.1.3(patch_hash=baf1e92576f78c2c86283e7a3182ddd59d52cd7e86ad9fe21d1c4ccc2274bcf3) xml2js: 0.6.2 + markdown-table@3.0.4: {} + marked-terminal@5.2.0(marked@4.3.0): dependencies: ansi-escapes: 6.2.1 @@ -8587,6 +8674,13 @@ snapshots: marked@4.3.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -8604,6 +8698,80 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -8642,6 +8810,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -9993,10 +10219,25 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universal-user-agent@7.0.3: {} unpipe@1.0.0: @@ -10298,3 +10539,5 @@ snapshots: zod@3.25.76: {} zod@4.1.8: {} + + zwitch@2.0.4: {} diff --git a/test/pin-readme-assets.test.mts b/test/pin-readme-assets.test.mts index 0bdc5df5b..ebc78435e 100644 --- a/test/pin-readme-assets.test.mts +++ b/test/pin-readme-assets.test.mts @@ -190,6 +190,24 @@ describe('pin-readme-assets', () => { ) }) + it('pins refs inside GFM tables and footnotes', () => { + const dir = stageWorkspace({ + readme: + '| Logo | Name |\n' + + '| --- | --- |\n' + + '| ![logo](assets/logo.png) | Socket |\n' + + '\n' + + 'See the screenshot.[^shot]\n' + + '\n' + + '[^shot]: ![shot](assets/shot.png)\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + const pinned = readmeIn(dir) + expect(pinned).toContain(`| ![logo](${base}assets/logo.png) | Socket |`) + expect(pinned).toContain(`[^shot]: ![shot](${base}assets/shot.png)`) + }) + it('pins every ref form in one pass and reports the base', () => { const dir = stageWorkspace({ readme: From b6485666f66a411181348bb16d05dc2179f1bf22 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 23:20:08 -0400 Subject: [PATCH 4/6] fix(release): parse the changelog as markdown The changelog promote located the [Unreleased] block, the next release heading, and the insertion point by scanning raw lines for '## ', and counted entries with a '- ' pattern. A '## ' or '- ' line inside a fenced code block read as structure: the promote could truncate the Unreleased block at a fence line, and a bullet lookalike in shell output counted as a real entry and suppressed the derived section. Structure now comes from the same GFM mdast parse the README pin uses: level-2 headings from parser positions, entries from real listItem nodes. Content inside code fences is content. The exported API and the promoted output for well-formed changelogs are unchanged; the two new tests fail on the line-scan version and pass here. --- package.json | 1 + pnpm-lock.yaml | 3 ++ scripts/release/changelog.mts | 71 ++++++++++++++++++++++++++++------- test/release-version.test.mts | 48 +++++++++++++++++++++++ 4 files changed, 110 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b04a3ca04..1f6b6f46e 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "@types/blessed": "0.1.25", "@types/cmd-shim": "5.0.2", "@types/js-yaml": "4.0.9", + "@types/mdast": "4.0.4", "@types/micromatch": "4.0.9", "@types/mock-fs": "4.13.4", "@types/node": "24.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19dfabfb3..ad7b5f56f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 '@types/micromatch': specifier: 4.0.9 version: 4.0.9 diff --git a/scripts/release/changelog.mts b/scripts/release/changelog.mts index 1f89bdf98..a45f889a3 100644 --- a/scripts/release/changelog.mts +++ b/scripts/release/changelog.mts @@ -12,10 +12,56 @@ * silently replaced by a raw commit subject. */ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' + import type { ConventionalCommit } from './version.mts' +import type { Nodes, Root } from 'mdast' export const UNRELEASED_HEADING = '## [Unreleased]' +/** + * Parse markdown the way GitHub renders it (GFM) into a position-tracked + * mdast tree. Structure questions — where a heading is, whether a block has + * bullets — are answered from this tree, never by scanning raw lines: a `## ` + * or `- ` inside a fenced code block is content, not structure. + */ +function parseMarkdown(source: string): Root { + return fromMarkdown(source, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }) +} + +/** + * 0-based line indexes of the document's level-2 (`## `) headings, from + * parser-reported positions. + */ +function h2LineIndexes(source: string): number[] { + const indexes: number[] = [] + for (const node of parseMarkdown(source).children) { + if ( + node.type === 'heading' && + node.depth === 2 && + node.position?.start.line !== undefined + ) { + indexes.push(node.position.start.line - 1) + } + } + return indexes +} + +function hasListItem(node: Nodes): boolean { + if (node.type === 'listItem') { + return true + } + if ('children' in node) { + return node.children.some(hasListItem) + } + return false +} + // User-visible commit types → the Keep a Changelog section each lands under. A // type absent from this map is internal churn and never reaches the changelog, // matching the repo's "exclude internal changes" rule. @@ -148,11 +194,12 @@ export function generateChangelogSection( } /** - * True when a rendered section carries at least one `- ` bullet, as opposed to - * a bare heading with nothing under it. + * True when a rendered section carries at least one real list item, as + * opposed to a bare heading with nothing under it. Parsed, not pattern + * matched: a `- ` line inside a fenced code block is not an entry. */ export function sectionHasEntries(section: string): boolean { - return section.split('\n').some(line => /^\s*-\s/u.test(line)) + return parseMarkdown(section).children.some(hasListItem) } /** @@ -166,17 +213,15 @@ export function unreleasedRange( lines: readonly string[], ): { end: number; start: number } | undefined { const wanted = UNRELEASED_HEADING.toLowerCase() - const start = lines.findIndex(line => line.trim().toLowerCase() === wanted) - if (start === -1) { + const headings = h2LineIndexes(lines.join('\n')) + const at = headings.findIndex( + index => lines[index]!.trim().toLowerCase() === wanted, + ) + if (at === -1) { return undefined } - let end = lines.length - for (let i = start + 1, { length } = lines; i < length; i += 1) { - if (lines[i]!.startsWith('## ')) { - end = i - break - } - } + const start = headings[at]! + const end = at + 1 < headings.length ? headings[at + 1]! : lines.length return { end, start } } @@ -220,7 +265,7 @@ export function promoteChangelog( source = 'unreleased' } } - const insertAt = remaining.findIndex(line => line.startsWith('## ')) + const insertAt = h2LineIndexes(remaining.join('\n'))[0] ?? -1 const head = insertAt === -1 ? remaining : remaining.slice(0, insertAt) const tail = insertAt === -1 ? [] : remaining.slice(insertAt) const changelog = `${[...head, section, '', ...tail].join('\n').trimEnd()}\n` diff --git a/test/release-version.test.mts b/test/release-version.test.mts index 6b9b824ee..92290643a 100644 --- a/test/release-version.test.mts +++ b/test/release-version.test.mts @@ -327,4 +327,52 @@ describe('promoteChangelog', () => { expect(promoted.source).toBe('derived') expect(promoted.changelog).not.toContain('[Unreleased]') }) + + it('promotes a block whose code fence contains a ## line intact', () => { + const changelog = [ + preamble, + '## [Unreleased]', + '', + '### Changed', + '- The changelog format now looks like:', + '', + '```md', + '## [9.9.9](https://example.com) - 2020-01-01', + '```', + '', + '## [1.1.153](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.153) - 2026-08-04', + '', + '### Changed', + '- Updated the Coana CLI.', + '', + ].join('\n') + const promoted = promoteChangelog({ changelog, derivedSection, heading }) + expect(promoted.source).toBe('unreleased') + expect(promoted.section).toContain( + '```md\n## [9.9.9](https://example.com) - 2020-01-01\n```', + ) + expect(promoted.changelog).not.toContain('[Unreleased]') + expect(promoted.changelog).toContain('- Updated the Coana CLI.') + }) + + it('treats a bullet lookalike inside a code fence as no entries', () => { + const changelog = [ + preamble, + '## [Unreleased]', + '', + '```sh', + '- not a bullet, just shell output', + '```', + '', + '## [1.1.153](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.153) - 2026-08-04', + '', + '### Changed', + '- Updated the Coana CLI.', + '', + ].join('\n') + const promoted = promoteChangelog({ changelog, derivedSection, heading }) + expect(promoted.source).toBe('derived') + expect(promoted.section).toBe(derivedSection) + expect(promoted.changelog).not.toContain('[Unreleased]') + }) }) From 3ba3bbc09c26692817d9132aabca0bc445906f6c Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 23:56:49 -0400 Subject: [PATCH 5/6] test: gate entry-script spawns on .mts support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry-scripts block spawns bare 'node .mts', which only works where the runtime has native type stripping (Node 22.6+, reported by process.features.typescript). On Node 20 the spawn dies with ERR_UNKNOWN_FILE_EXTENSION before any script code runs, which made the whole block structurally red on that CI lane while 22/24 passed. Skip the spawn assertions where the runtime cannot execute .mts at all — these entries are maintainer tooling aimed at the pinned dev Node, while older lanes exist to cover the built product. The spawn assertions now also carry the child's stderr as the assertion message, so the next environmental break names itself instead of reading 'expected 1 to be +0'. --- test/script-run-main.test.mts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/script-run-main.test.mts b/test/script-run-main.test.mts index 9d92b56b0..06a5edc4d 100644 --- a/test/script-run-main.test.mts +++ b/test/script-run-main.test.mts @@ -204,7 +204,17 @@ describe('entry scripts', () => { 'scripts/release/promote.mts', ] - it.each(entries)( + // Bare `node .mts` needs native type stripping, which landed in + // Node 22.6 (`process.features.typescript` reports 'strip' or 'transform' + // there and is undefined before it). These entries are maintainer tooling + // that targets the repo's pinned dev Node; the older CI matrix lanes cover + // the built product, not this tooling, so the spawn assertions skip where + // the runtime cannot execute .mts at all. + // The experimental flag is the capability being probed; undefined is false. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const canRunMts = Boolean(process.features.typescript) + + it.skipIf(!canRunMts).each(entries)( '%s answers --describe without touching the tree', entryPath => { const before = spawnSync('git', ['status', '--porcelain'], { @@ -219,8 +229,10 @@ describe('entry scripts', () => { cwd: rootPath, encoding: 'utf8', }).stdout - expect(result.status).toBe(0) - expect(result.stdout.trim().length).toBeGreaterThan(0) + // Surface the spawned stderr on failure so an environmental break + // (missing runtime capability, bad PATH) explains itself. + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim().length, result.stderr).toBeGreaterThan(0) expect(result.stdout.trim().split('\n')).toHaveLength(1) expect(after).toBe(before) }, From c1e841c74580f1f9b380ae0db03910d8b6744f16 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 23:56:50 -0400 Subject: [PATCH 6/6] ci: drop the EOL Node 20 test lanes Node 20 reached end-of-life on 2026-03-24. The unit and e2e matrices now test the supported majors, 22 and 24. Whether the shipped CLI keeps claiming Node 20 support in engines is a separate product decision and is deliberately not changed here. --- .github/workflows/ci.yml | 3 ++- .github/workflows/e2e-tests.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46a2204c9..dd64727cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,7 +421,8 @@ jobs: strategy: fail-fast: true matrix: - node-version: [20, 22, 24] + # Node 20 reached end-of-life 2026-03-24; test supported majors only. + node-version: [22, 24] os: [ubuntu-latest] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d66870448..ddf8a611c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -16,7 +16,8 @@ jobs: strategy: fail-fast: true matrix: - node-version: [20, 22, 24] + # Node 20 reached end-of-life 2026-03-24; test supported majors only. + node-version: [22, 24] os: [ubuntu-latest] # os: [ubuntu-latest, windows-latest] - Windows tests disbaled (see project https://linear.app/socketdev/project/autofixes-windows-support-fc2f2a45f759) steps: