Skip to content
Draft
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
261 changes: 261 additions & 0 deletions .github/scripts/depgraph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/*!
* Copyright (c) 2026-present, The Dash Core developers
* SPDX-License-Identifier: MIT
* See the accompanying file LICENSE or https://opensource.org/license/MIT
*/

// @ts-check

// Submits `uv.lock` to the dependency graph. GitHub currently natively parses
// `Cargo.lock` but cannot parse `uv.lock`, this script parses it for submission
// to the dependency graph.

const fs = require("node:fs");

// Submission tag, keyed to overwrite autogenerated results from `pyproject.toml`.
const PY_MANIFEST_KEY = "pyproject.toml";

// Identification of this script.
const DETECTOR_PROFILE = {
name: "depgraph.js",
version: "1.0.0",
url: "https://github.com/dashpay/base-sdk",
};

// Matches `name[extras]==version`, capturing name in 1 and version in 2, ends at whitespace, marker or backslash.
const RE_PIN = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([^\s;\\]+)/;

// Matches a distribution name, an extras suffix allowed, and nothing else.
const RE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[^\]]*\])?$/;

// Matches an unindented comment.
const RE_HEADER = /^#/;

// Matches an indented comment.
const RE_OWNED = /^\s+#/;

// Matches an indented `# via`, capturing what trails it, which may be empty.
const RE_VIA = /^\s+#\s+via\b(.*)$/;

// Matches an indented comment holding one token, captured.
const RE_VIA_ITEM = /^\s+#\s+(\S.*)$/;

/**
* PEP 503-style text normalisation.
*
* @param {string} name
* @returns {string}
*/
function normalise(name) {
return name.toLowerCase().replace(/[-_.]+/g, "-");
}

/**
* The package URL for a pinned distribution, local version encoded.
*
* @param {string} name normalised name
* @param {string} version
* @returns {string}
*/
function purlFor(name, version) {
return `pkg:pypi/${name}@${version.replace(/\+/g, "%2B")}`;
}

/**
* Parse a `via` entry.
*
* @param {string} entry
* @param {string} line the line it was read from, named in the error
* @returns {string} normalised name, extras dropped
*/
function viaName(entry, line) {
if (!RE_NAME.test(entry)) {
throw new Error(`unsupported \`via\` entry: ${line.trim()}`);
}
return normalise(entry.replace(/\[.*$/, ""));
}

/**
* Parse `uv export --format requirements-txt --no-hashes` output.
*
* Two shapes are read, a pin and the `# via` beneath it holding a name or list.
*
* @param {string} text
* @returns {Map<string, { version: string, via: string[] }>}
*/
function parseExport(text) {
/** @type {Map<string, { version: string, via: string[] }>} */
const packages = new Map();
/** @type {{ version: string, via: string[] } | null} */
let current = null;
let listing = false;

for (const raw of text.split("\n")) {
const line = raw.replace(/\r$/, "");

if (line.trim() === "" || RE_HEADER.test(line)) {
current = null;
listing = false;
continue;
}

if (current !== null && RE_OWNED.test(line)) {
const via = RE_VIA.exec(line);
if (via) {
const rest = via[1].trim();
listing = rest === "";
if (!listing) {
current.via.push(viaName(rest, line));
}
continue;
}

const listed = RE_VIA_ITEM.exec(line);
if (listed && listing) {
current.via.push(viaName(listed[1].trim(), line));
}
continue;
}

// Extras are matched so they cannot hide a pin.
const pin = RE_PIN.exec(line);
if (pin === null) {
throw new Error(`unsupported requirement: ${line.trim()}`);
}

const name = normalise(pin[1]);
const held = packages.get(name);
if (held !== undefined && held.version !== pin[2]) {
throw new Error(
`${name} is pinned at both ${held.version} and ${pin[2]}`,
);
}

current = { version: pin[2], via: [] };
packages.set(name, current);
listing = false;
}

return packages;
}

/**
* Build the `resolved` map a snapshot carries, keyed and cross-referenced
* by the package URL.
*
* All entries are scoped `development`, since they make up the devshell.
*
* @param {Map<string, { version: string, via: string[] }>} packages
* @param {string} project normalised name of the workspace project
* @returns {Record<string, { package_url: string, relationship: string, scope: string, dependencies: string[] }>}
*/
function resolveGraph(packages, project) {
/** @type {Record<string, { package_url: string, relationship: string, scope: string, dependencies: string[] }>} */
const resolved = {};

for (const [name, pkg] of packages) {
if (pkg.via.length === 0) {
throw new Error(`${name} has no \`via\`; export --no-emit-project`);
}
for (const parent of pkg.via) {
if (parent !== project && !packages.has(parent)) {
throw new Error(`${name} names ${parent}, not a pin nor ${project}`);
}
}

const purl = purlFor(name, pkg.version);
resolved[purl] = {
package_url: purl,
relationship: pkg.via.includes(project) ? "direct" : "indirect",
scope: "development",
dependencies: [],
};
}

// `via` names parents, a snapshot states children, so invert the edges.
for (const [name, pkg] of packages) {
const child = purlFor(name, pkg.version);
for (const parent of pkg.via) {
const owner = packages.get(parent);
if (owner === undefined) {
continue;
}
resolved[purlFor(parent, owner.version)].dependencies.push(child);
}
}

return resolved;
}

/**
* @param {{ sha: string, ref: string, resolved: Record<string, object> }} params
* @returns {object}
*/
function buildSnapshot({ sha, ref, resolved }) {
return {
version: 0,
job: {
id: process.env.GITHUB_RUN_ID,
correlator: `${process.env.GITHUB_WORKFLOW}-${process.env.GITHUB_JOB}`,
},
sha,
ref,
detector: DETECTOR_PROFILE,
scanned: new Date().toISOString(),
manifests: {
[PY_MANIFEST_KEY]: {
name: PY_MANIFEST_KEY,
file: { source_location: PY_MANIFEST_KEY },
resolved,
},
},
};
}

/**
* @param {{ github: import("@actions/github").getOctokit, context: import("@actions/github").context, core: any }} params
*/
module.exports = async ({ github, context, core }) => {
const source = process.env.REQUIREMENTS;
if (source === undefined) {
throw new Error("REQUIREMENTS names the export to submit; it is unset");
}

const project = process.env.PROJECT;
if (project === undefined) {
throw new Error("PROJECT names the workspace project; it is unset");
}

const packages = parseExport(fs.readFileSync(source, "utf8"));
if (packages.size === 0) {
throw new Error(`${source} states no pinned versions`);
}

const resolved = resolveGraph(packages, normalise(project));
const snapshot = buildSnapshot({
sha: context.sha,
ref: context.ref,
resolved,
});

const entries = Object.values(resolved);
const direct = entries.filter((e) => e.relationship === "direct").length;
core.info(`submitting ${entries.length} packages, ${direct} direct`);

const { data } = await github.request(
"POST /repos/{owner}/{repo}/dependency-graph/snapshots",
{
owner: context.repo.owner,
repo: context.repo.repo,
...snapshot,
},
);
if (data.result === "INVALID") {
throw new Error(`snapshot refused: ${data.message}`);
}
core.info(`snapshot ${data.id}: ${data.message}`);
};

module.exports.parseExport = parseExport;
module.exports.resolveGraph = resolveGraph;
module.exports.buildSnapshot = buildSnapshot;
22 changes: 17 additions & 5 deletions .github/workflows/build_msrv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,22 @@ jobs:
node-version: 24

- name: Set up Python
id: python
uses: actions/setup-python@v6
with:
python-version-file: pyproject.toml

- name: Set up uv
uses: astral-sh/setup-uv@v10.0.1
with:
version: 0.12.9
enable-cache: true
cache-dependency-glob: uv.lock

- name: Install Python dependencies
run: pip install ".[dev]"
run: |
uv sync --locked --extra dev --python '${{ steps.python.outputs.python-path }}'
echo "${PWD}/.venv/bin" >> "${GITHUB_PATH}"

- name: Install CodeQL
id: setup-codeql
Expand Down Expand Up @@ -82,22 +92,24 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.codeql
key: codeql-packs-${{ hashFiles('contrib/codeql/codeql-pack.lock.yml') }}
key: codeql-packs-${{ hashFiles('maint/codeql/*/codeql-pack.lock.yml') }}

- name: Run linters
run: python3 contrib/lint_all.py --exclude lint_codeql
run: |
python3 maint/lint_all.py --exclude lint_codeql
python3 maint/lint/lint_codeql.py check
env:
RUSTUP_TOOLCHAIN: 1.85.0

- name: Run CodeQL
run: python3 contrib/lint/lint_codeql.py --with-suite=rust-security-and-quality
run: python3 maint/lint/lint_codeql.py run --lang=rust --with-suite=rust-security-and-quality
env:
RUSTUP_TOOLCHAIN: 1.85.0

- name: Check PR commit messages
if: github.event_name == 'pull_request'
run: >
python3 contrib/lint/lint_unconv.py
python3 maint/lint/lint_unconv.py
-r "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"

build:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build_nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:

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

- name: Test package (with coverage)
if: matrix.config.name == 'full'
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,22 @@ jobs:
run: cargo install wasm-pack@0.15.0

- name: Set up Python
id: python
uses: actions/setup-python@v6
with:
python-version-file: pyproject.toml

- name: Set up uv
uses: astral-sh/setup-uv@v10.0.1
with:
version: 0.12.9
enable-cache: true
cache-dependency-glob: uv.lock

- name: Install Python dependencies
run: pip install ".[dev]"
run: |
uv sync --locked --extra dev --python '${{ steps.python.outputs.python-path }}'
echo "${PWD}/.venv/bin" >> "${GITHUB_PATH}"

- name: Test documentation tooling
run: pytest
Expand Down
49 changes: 49 additions & 0 deletions .github/workflows/repo_depgraph.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Push dependency graph

on:
push:
branches: [develop]
paths:
- uv.lock
- .github/scripts/depgraph.js
- .github/workflows/repo_depgraph.yml
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true

permissions:
contents: write

jobs:
submit:
name: Submit uv lockfile
runs-on: ubuntu-24.04-arm

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false

- name: Set up uv
uses: astral-sh/setup-uv@v10.0.1
with:
version: 0.12.9
enable-cache: true
cache-dependency-glob: uv.lock

- name: Export resolved dependencies
run: uv export --locked --format requirements-txt --no-emit-project --all-extras --no-hashes -o "${RUNNER_TEMP}/requirements.txt"

- name: Submit snapshot
uses: actions/github-script@v8
env:
REQUIREMENTS: ${{ runner.temp }}/requirements.txt
PROJECT: dash-base-sdk
with:
script: |
const script = require("./.github/scripts/depgraph.js");
await script({ github, context, core });
Loading
Loading