Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 139 additions & 7 deletions .github/scripts/pin-readme-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,150 @@
// 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 { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
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<string, { startOffset: number, endOffset: number }> } }) => 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)
}
}
}

// 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)) {
pinned = pinned.slice(0, offset) + base + pinned.slice(offset)
}

if (pinned === readme) {
console.log('pin-readme-assets: no relative assets/ refs to pin')
} else {
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -169,14 +170,18 @@
"knip": "5.63.1",
"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",
"npm-package-arg": "13.0.0",
"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",
Expand Down
Loading