|
| 1 | +/*! |
| 2 | + * Copyright (c) 2026-present, The Dash Core developers |
| 3 | + * SPDX-License-Identifier: MIT |
| 4 | + * See the accompanying file LICENSE or https://opensource.org/license/MIT |
| 5 | + */ |
| 6 | + |
| 7 | +// @ts-check |
| 8 | + |
| 9 | +// Submits `uv.lock` to the dependency graph. GitHub currently natively parses |
| 10 | +// `Cargo.lock` but cannot parse `uv.lock`, this script parses it for submission |
| 11 | +// to the dependency graph. |
| 12 | + |
| 13 | +const fs = require("node:fs"); |
| 14 | + |
| 15 | +// Submission tag, keyed to overwrite autogenerated results from `pyproject.toml`. |
| 16 | +const PY_MANIFEST_KEY = "pyproject.toml"; |
| 17 | + |
| 18 | +// Identification of this script. |
| 19 | +const DETECTOR_PROFILE = { |
| 20 | + name: "depgraph.js", |
| 21 | + version: "1.0.0", |
| 22 | + url: "https://github.com/dashpay/base-sdk", |
| 23 | +}; |
| 24 | + |
| 25 | +// Matches `name[extras]==version`, capturing name in 1 and version in 2, ends at whitespace, marker or backslash. |
| 26 | +const RE_PIN = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([^\s;\\]+)/; |
| 27 | + |
| 28 | +// Matches a distribution name, an extras suffix allowed, and nothing else. |
| 29 | +const RE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[^\]]*\])?$/; |
| 30 | + |
| 31 | +// Matches an unindented comment. |
| 32 | +const RE_HEADER = /^#/; |
| 33 | + |
| 34 | +// Matches an indented comment. |
| 35 | +const RE_OWNED = /^\s+#/; |
| 36 | + |
| 37 | +// Matches an indented `# via`, capturing what trails it, which may be empty. |
| 38 | +const RE_VIA = /^\s+#\s+via\b(.*)$/; |
| 39 | + |
| 40 | +// Matches an indented comment holding one token, captured. |
| 41 | +const RE_VIA_ITEM = /^\s+#\s+(\S.*)$/; |
| 42 | + |
| 43 | +/** |
| 44 | + * PEP 503-style text normalisation. |
| 45 | + * |
| 46 | + * @param {string} name |
| 47 | + * @returns {string} |
| 48 | + */ |
| 49 | +function normalise(name) { |
| 50 | + return name.toLowerCase().replace(/[-_.]+/g, "-"); |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * The package URL for a pinned distribution, local version encoded. |
| 55 | + * |
| 56 | + * @param {string} name normalised name |
| 57 | + * @param {string} version |
| 58 | + * @returns {string} |
| 59 | + */ |
| 60 | +function purlFor(name, version) { |
| 61 | + return `pkg:pypi/${name}@${version.replace(/\+/g, "%2B")}`; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Parse a `via` entry. |
| 66 | + * |
| 67 | + * @param {string} entry |
| 68 | + * @param {string} line the line it was read from, named in the error |
| 69 | + * @returns {string} normalised name, extras dropped |
| 70 | + */ |
| 71 | +function viaName(entry, line) { |
| 72 | + if (!RE_NAME.test(entry)) { |
| 73 | + throw new Error(`unsupported \`via\` entry: ${line.trim()}`); |
| 74 | + } |
| 75 | + return normalise(entry.replace(/\[.*$/, "")); |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Record *parent* against *pkg*, a marker repeat naming it only once. |
| 80 | + * |
| 81 | + * @param {{ via: string[] }} pkg |
| 82 | + * @param {string} parent |
| 83 | + */ |
| 84 | +function addVia(pkg, parent) { |
| 85 | + if (!pkg.via.includes(parent)) { |
| 86 | + pkg.via.push(parent); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * Parse `uv export --format requirements-txt --no-hashes` output. |
| 92 | + * |
| 93 | + * Two shapes are read, a pin and the `# via` beneath it holding a name or list. |
| 94 | + * |
| 95 | + * A resolution fork states one package once per marker, so an entry is keyed by |
| 96 | + * name and version and a repeat merges its `via` into the entry already held. |
| 97 | + * |
| 98 | + * @param {string} text |
| 99 | + * @returns {Map<string, { name: string, version: string, via: string[] }>} |
| 100 | + */ |
| 101 | +function parseExport(text) { |
| 102 | + /** @type {Map<string, { name: string, version: string, via: string[] }>} */ |
| 103 | + const packages = new Map(); |
| 104 | + /** @type {{ name: string, version: string, via: string[] } | null} */ |
| 105 | + let current = null; |
| 106 | + let listing = false; |
| 107 | + |
| 108 | + for (const raw of text.split("\n")) { |
| 109 | + const line = raw.replace(/\r$/, ""); |
| 110 | + |
| 111 | + if (line.trim() === "" || RE_HEADER.test(line)) { |
| 112 | + current = null; |
| 113 | + listing = false; |
| 114 | + continue; |
| 115 | + } |
| 116 | + |
| 117 | + if (current !== null && RE_OWNED.test(line)) { |
| 118 | + const via = RE_VIA.exec(line); |
| 119 | + if (via) { |
| 120 | + const rest = via[1].trim(); |
| 121 | + listing = rest === ""; |
| 122 | + if (!listing) { |
| 123 | + addVia(current, viaName(rest, line)); |
| 124 | + } |
| 125 | + continue; |
| 126 | + } |
| 127 | + |
| 128 | + const listed = RE_VIA_ITEM.exec(line); |
| 129 | + if (listed && listing) { |
| 130 | + addVia(current, viaName(listed[1].trim(), line)); |
| 131 | + } |
| 132 | + continue; |
| 133 | + } |
| 134 | + |
| 135 | + // Extras are matched so they cannot hide a pin. |
| 136 | + const pin = RE_PIN.exec(line); |
| 137 | + if (pin === null) { |
| 138 | + throw new Error(`unsupported requirement: ${line.trim()}`); |
| 139 | + } |
| 140 | + |
| 141 | + const name = normalise(pin[1]); |
| 142 | + const key = `${name}@${pin[2]}`; |
| 143 | + let held = packages.get(key); |
| 144 | + if (held === undefined) { |
| 145 | + held = { name, version: pin[2], via: [] }; |
| 146 | + packages.set(key, held); |
| 147 | + } |
| 148 | + |
| 149 | + current = held; |
| 150 | + listing = false; |
| 151 | + } |
| 152 | + |
| 153 | + return packages; |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * Build the `resolved` map a snapshot carries, keyed and cross-referenced |
| 158 | + * by the package URL. |
| 159 | + * |
| 160 | + * All entries are scoped `development`, since they make up the devshell. |
| 161 | + * |
| 162 | + * A fork can resolve one name to several versions and `via` names only the |
| 163 | + * parent, so an edge is drawn to every version of it rather than guessed at. |
| 164 | + * |
| 165 | + * @param {Map<string, { name: string, version: string, via: string[] }>} packages |
| 166 | + * @param {string} project normalised name of the workspace project |
| 167 | + * @returns {Record<string, { package_url: string, relationship: string, scope: string, dependencies: string[] }>} |
| 168 | + */ |
| 169 | +function resolveGraph(packages, project) { |
| 170 | + /** @type {Map<string, { name: string, version: string, via: string[] }[]>} */ |
| 171 | + const byName = new Map(); |
| 172 | + for (const pkg of packages.values()) { |
| 173 | + const held = byName.get(pkg.name); |
| 174 | + if (held === undefined) { |
| 175 | + byName.set(pkg.name, [pkg]); |
| 176 | + } else { |
| 177 | + held.push(pkg); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + /** @type {Record<string, { package_url: string, relationship: string, scope: string, dependencies: string[] }>} */ |
| 182 | + const resolved = {}; |
| 183 | + |
| 184 | + for (const pkg of packages.values()) { |
| 185 | + if (pkg.via.length === 0) { |
| 186 | + throw new Error(`${pkg.name} has no \`via\`; export --no-emit-project`); |
| 187 | + } |
| 188 | + for (const parent of pkg.via) { |
| 189 | + if (parent !== project && !byName.has(parent)) { |
| 190 | + throw new Error( |
| 191 | + `${pkg.name} names ${parent}, not a pin nor ${project}`, |
| 192 | + ); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + const purl = purlFor(pkg.name, pkg.version); |
| 197 | + resolved[purl] = { |
| 198 | + package_url: purl, |
| 199 | + relationship: pkg.via.includes(project) ? "direct" : "indirect", |
| 200 | + scope: "development", |
| 201 | + dependencies: [], |
| 202 | + }; |
| 203 | + } |
| 204 | + |
| 205 | + // `via` names parents, a snapshot states children, so invert the edges. |
| 206 | + for (const pkg of packages.values()) { |
| 207 | + const child = purlFor(pkg.name, pkg.version); |
| 208 | + for (const parent of pkg.via) { |
| 209 | + for (const owner of byName.get(parent) ?? []) { |
| 210 | + const deps = resolved[purlFor(owner.name, owner.version)].dependencies; |
| 211 | + if (!deps.includes(child)) { |
| 212 | + deps.push(child); |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + return resolved; |
| 219 | +} |
| 220 | + |
| 221 | +/** |
| 222 | + * @param {{ sha: string, ref: string, resolved: Record<string, object> }} params |
| 223 | + * @returns {object} |
| 224 | + */ |
| 225 | +function buildSnapshot({ sha, ref, resolved }) { |
| 226 | + return { |
| 227 | + version: 0, |
| 228 | + job: { |
| 229 | + id: process.env.GITHUB_RUN_ID, |
| 230 | + correlator: `${process.env.GITHUB_WORKFLOW}-${process.env.GITHUB_JOB}`, |
| 231 | + }, |
| 232 | + sha, |
| 233 | + ref, |
| 234 | + detector: DETECTOR_PROFILE, |
| 235 | + scanned: new Date().toISOString(), |
| 236 | + manifests: { |
| 237 | + [PY_MANIFEST_KEY]: { |
| 238 | + name: PY_MANIFEST_KEY, |
| 239 | + file: { source_location: PY_MANIFEST_KEY }, |
| 240 | + resolved, |
| 241 | + }, |
| 242 | + }, |
| 243 | + }; |
| 244 | +} |
| 245 | + |
| 246 | +/** |
| 247 | + * @param {object} params |
| 248 | + * @param {ReturnType<typeof import("@actions/github").getOctokit>} params.github |
| 249 | + * @param {typeof import("@actions/github").context} params.context |
| 250 | + * @param {any} params.core |
| 251 | + */ |
| 252 | +module.exports = async ({ github, context, core }) => { |
| 253 | + const source = process.env.REQUIREMENTS; |
| 254 | + if (source === undefined) { |
| 255 | + throw new Error("REQUIREMENTS names the export to submit; it is unset"); |
| 256 | + } |
| 257 | + |
| 258 | + const project = process.env.PROJECT; |
| 259 | + if (project === undefined) { |
| 260 | + throw new Error("PROJECT names the workspace project; it is unset"); |
| 261 | + } |
| 262 | + |
| 263 | + const packages = parseExport(fs.readFileSync(source, "utf8")); |
| 264 | + if (packages.size === 0) { |
| 265 | + throw new Error(`${source} states no pinned versions`); |
| 266 | + } |
| 267 | + |
| 268 | + const resolved = resolveGraph(packages, normalise(project)); |
| 269 | + const snapshot = buildSnapshot({ |
| 270 | + sha: context.sha, |
| 271 | + ref: context.ref, |
| 272 | + resolved, |
| 273 | + }); |
| 274 | + |
| 275 | + const entries = Object.values(resolved); |
| 276 | + const direct = entries.filter((e) => e.relationship === "direct").length; |
| 277 | + core.info(`submitting ${entries.length} packages, ${direct} direct`); |
| 278 | + |
| 279 | + const { data } = await github.request( |
| 280 | + "POST /repos/{owner}/{repo}/dependency-graph/snapshots", |
| 281 | + { |
| 282 | + owner: context.repo.owner, |
| 283 | + repo: context.repo.repo, |
| 284 | + ...snapshot, |
| 285 | + }, |
| 286 | + ); |
| 287 | + if (data.result === "INVALID") { |
| 288 | + throw new Error(`snapshot refused: ${data.message}`); |
| 289 | + } |
| 290 | + core.info(`snapshot ${data.id}: ${data.message}`); |
| 291 | +}; |
| 292 | + |
| 293 | +module.exports.parseExport = parseExport; |
| 294 | +module.exports.resolveGraph = resolveGraph; |
| 295 | +module.exports.buildSnapshot = buildSnapshot; |
0 commit comments