Skip to content

Commit 71e48db

Browse files
authored
sdk%ci: use comment syntax for doc splicing, add uv lockfile, use for dependency tracking, make CodeQL runner multi-lingual, add symlink linter (#33)
2 parents b1b2352 + 6b1362a commit 71e48db

55 files changed

Lines changed: 3492 additions & 669 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/depgraph.js‎

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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;

‎.github/workflows/build_msrv.yml‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,22 @@ jobs:
4545
node-version: 24
4646

4747
- name: Set up Python
48+
id: python
4849
uses: actions/setup-python@v6
4950
with:
5051
python-version-file: pyproject.toml
5152

53+
- name: Set up uv
54+
uses: astral-sh/setup-uv@v10.0.1
55+
with:
56+
version: 0.12.9
57+
enable-cache: true
58+
cache-dependency-glob: uv.lock
59+
5260
- name: Install Python dependencies
53-
run: pip install ".[dev]"
61+
run: |
62+
uv sync --locked --extra dev --python '${{ steps.python.outputs.python-path }}'
63+
echo "${PWD}/.venv/bin" >> "${GITHUB_PATH}"
5464
5565
- name: Install CodeQL
5666
id: setup-codeql
@@ -82,22 +92,24 @@ jobs:
8292
uses: actions/cache@v5
8393
with:
8494
path: ~/.codeql
85-
key: codeql-packs-${{ hashFiles('contrib/codeql/codeql-pack.lock.yml') }}
95+
key: codeql-packs-${{ hashFiles('maint/codeql/*/codeql-pack.lock.yml') }}
8696

8797
- name: Run linters
88-
run: python3 contrib/lint_all.py --exclude lint_codeql
98+
run: |
99+
python3 maint/lint_all.py --exclude lint_codeql
100+
python3 maint/lint/lint_codeql.py check
89101
env:
90102
RUSTUP_TOOLCHAIN: 1.85.0
91103

92104
- name: Run CodeQL
93-
run: python3 contrib/lint/lint_codeql.py --with-suite=rust-security-and-quality
105+
run: python3 maint/lint/lint_codeql.py run --lang=rust --with-suite=rust-security-and-quality
94106
env:
95107
RUSTUP_TOOLCHAIN: 1.85.0
96108

97109
- name: Check PR commit messages
98110
if: github.event_name == 'pull_request'
99111
run: >
100-
python3 contrib/lint/lint_unconv.py
112+
python3 maint/lint/lint_unconv.py
101113
-r "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
102114
103115
build:

‎.github/workflows/build_nightly.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ jobs:
8888

8989
- name: Check formatting
9090
if: matrix.config.name == 'full'
91-
run: python contrib/lint/lint_rust.py
91+
run: python maint/lint/lint_rust.py
9292

9393
- name: Test package (with coverage)
9494
if: matrix.config.name == 'full'

‎.github/workflows/pages.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,22 @@ jobs:
3939
run: cargo install wasm-pack@0.15.0
4040

4141
- name: Set up Python
42+
id: python
4243
uses: actions/setup-python@v6
4344
with:
4445
python-version-file: pyproject.toml
4546

47+
- name: Set up uv
48+
uses: astral-sh/setup-uv@v10.0.1
49+
with:
50+
version: 0.12.9
51+
enable-cache: true
52+
cache-dependency-glob: uv.lock
53+
4654
- name: Install Python dependencies
47-
run: pip install ".[dev]"
55+
run: |
56+
uv sync --locked --extra dev --python '${{ steps.python.outputs.python-path }}'
57+
echo "${PWD}/.venv/bin" >> "${GITHUB_PATH}"
4858
4959
- name: Test documentation tooling
5060
run: pytest

0 commit comments

Comments
 (0)