ci(security): add a supply-chain gate, checksums and an SBOM
The project shipped signed Android builds and unsigned desktop binaries
with no vulnerability scanning of any kind. Nothing checked the ~500
crate Rust graph or the JS packages against an advisory feed, and nothing
checked that what we redistribute inside an MIT bundle permits it.
The first cargo-deny run found eight vulnerabilities and one
unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and
rand -- every one of them closed by a `cargo update` nobody had a reason
to run. That update is in this commit; 740 Rust tests and clippy
-D warnings pass on the new lockfile.
Two structural fixes matter as much as the gate itself:
- deny.toml scopes the graph to the targets we actually ship. Without
it the Apple targets pull in plist -> quick-xml and report two DoS
advisories against a crate that is in no binary we release. Ignoring
those by ID would silence them everywhere, including where they
would matter; scoping makes them correctly absent.
- libmpv is pinned by rev instead of branch = "master". A branch means
the revision is whatever Cargo.lock happens to hold and any
`cargo update` silently substitutes new upstream code -- in the one
dependency that is not from crates.io and that links a C library
into the player. The rev is the commit already locked, so this pins
current behaviour rather than changing it.
Licence findings are recorded rather than waved through. libmpv and
libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the
system library; deny.toml carries the two obligations that follow (keep
the linkage dynamic, ship libmpv's licence text with any bundle carrying
the .so). MPL-2.0 crates are file-level copyleft and fine unmodified.
Releases now publish SHA256SUMS (verified in-job with `sha256sum -c`
before upload) and a CycloneDX SBOM for both halves, so "does this
release contain <vulnerable crate>?" has an answer that is not "rebuild
the tag and re-resolve it".
Workflows pin jellytau-builder:2026.08 instead of :latest. While every
job said :latest, rebuilding the image changed what every build compiled
against, including rebuilds of old release tags.
Also folded in, because both were the same class of problem:
- publish-docs.yml downloaded mdBook from GitHub releases into
/usr/local/bin at job time -- a toolchain install in CI, which
CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN
at publish time. It is in the builder image now.
- extract-traces.ts only ever read .ts/.svelte/.rs, so every
requirement implemented by *configuration* was invisible to the
matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry
TRACES comments nothing read, and each counted as uncovered while
being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves
to 89 accordingly. CI workflows stay excluded and there is a test
saying why: traceability-check.yml quotes "a TRACES: comment" beside
deliberately-undefined example IDs, which the extractor would read
as real traces and then fail its own dangling-ID check.
Supply-chain requirement is DR-216.
🔴 The builder image must be rebuilt and pushed
(scripts/build-builder-image.sh 2026.08) before this reaches master --
the workflows now name a tag and tools that do not exist in the registry
yet.
This commit is contained in:
@@ -49,12 +49,39 @@ else
|
||||
fi
|
||||
|
||||
# Step 4: Push to registry
|
||||
#
|
||||
# Two tags, on purpose:
|
||||
#
|
||||
# <date> what the workflows pin (e.g. :2026.08). CI must name an immutable
|
||||
# tag -- while every job said :latest, rebuilding the image silently
|
||||
# changed what every build, including a rebuild of an old release
|
||||
# tag, compiled against. That is the opposite of reproducible.
|
||||
# latest convenience for local `docker compose` runs and for anyone pulling
|
||||
# the image by hand.
|
||||
#
|
||||
# Date tags rather than per-commit SHA tags: the Gitea runner shares a 74 GB
|
||||
# disk with two other projects, and SHA-tagged images accumulated there until it
|
||||
# filled. Keep at most a couple of dated tags live and prune the rest
|
||||
# (`docker image prune -a` on the runner).
|
||||
#
|
||||
# To bump: build+push a new dated tag, then update the `image:` lines in
|
||||
# .gitea/workflows/*.yml in the same commit as whatever needed the new tool.
|
||||
echo "📤 Pushing image to registry..."
|
||||
docker push ${FULL_IMAGE_NAME}
|
||||
|
||||
if [ "$IMAGE_TAG" != "latest" ]; then
|
||||
echo "🏷️ Also tagging as :latest for local use..."
|
||||
LATEST_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:latest"
|
||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${LATEST_IMAGE_NAME}
|
||||
docker push ${LATEST_IMAGE_NAME}
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
|
||||
echo ""
|
||||
echo "Update your workflow to use:"
|
||||
echo "Workflows must pin the dated tag, not :latest --"
|
||||
echo " container:"
|
||||
echo " image: ${FULL_IMAGE_NAME}"
|
||||
echo ""
|
||||
echo "Currently pinned in .gitea/workflows/:"
|
||||
grep -ho "jellytau-builder:[A-Za-z0-9._-]*" "$(git rev-parse --show-toplevel)"/.gitea/workflows/*.yml 2>/dev/null | sort -u | sed "s/^/ /"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
findDanglingIds,
|
||||
formatMatrixFileLink,
|
||||
generateMarkdown,
|
||||
isTracedSourceFile,
|
||||
MIN_COVERAGE_PERCENT,
|
||||
type TracesData,
|
||||
} from "./extract-traces";
|
||||
@@ -30,6 +31,52 @@ import {
|
||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
describe("isTracedSourceFile", () => {
|
||||
// The extractor used to accept only .ts/.svelte/.rs under src/, src-tauri/src/
|
||||
// and scripts/. Every requirement implemented by *configuration* was therefore
|
||||
// invisible to the matrix that measures it: eslint.config.js (DR-205), the
|
||||
// pre-commit hook (DR-207), rust-toolchain.toml (DR-206) and deny.toml
|
||||
// (DR-216) all carry TRACES comments that were never read. Each one counted
|
||||
// against coverage as an uncovered requirement while being, in fact, covered.
|
||||
it("accepts the source extensions it always did", () => {
|
||||
expect(isTracedSourceFile("src/lib/utils/logger.ts")).toBe(true);
|
||||
expect(isTracedSourceFile("src/routes/settings/+page.svelte")).toBe(true);
|
||||
expect(isTracedSourceFile("src-tauri/src/lib.rs")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts tooling files that implement a requirement", () => {
|
||||
expect(isTracedSourceFile("eslint.config.js")).toBe(true);
|
||||
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
|
||||
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
|
||||
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
|
||||
// .gitea/workflows/traceability-check.yml explains the gate, so it contains
|
||||
// lines like "a `TRACES:` comment ... (DR-189 and UT-188 lived in three
|
||||
// source files, defined nowhere)". The extractor's pattern would read that
|
||||
// as a trace and manufacture references to IDs that do not exist, failing
|
||||
// traces:validate. A file that *describes* traceability is not a file that
|
||||
// implements a requirement.
|
||||
expect(isTracedSourceFile(".gitea/workflows/traceability-check.yml")).toBe(false);
|
||||
expect(isTracedSourceFile(".gitea/workflows/build-and-test.yml")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects files that merely mention a requirement in prose", () => {
|
||||
// requirements.md defines IDs; traceability.md is generated *from* traces.
|
||||
// Scanning either would make every requirement trace to itself.
|
||||
expect(isTracedSourceFile("docs/requirements.md")).toBe(false);
|
||||
expect(isTracedSourceFile("docs/traceability.md")).toBe(false);
|
||||
expect(isTracedSourceFile("README.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects generated and vendored trees", () => {
|
||||
expect(isTracedSourceFile("node_modules/foo/index.ts")).toBe(false);
|
||||
expect(isTracedSourceFile("src-tauri/target/debug/build/x.rs")).toBe(false);
|
||||
expect(isTracedSourceFile("src-tauri/gen/android/app/build.gradle.kts")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countDefinedRequirements", () => {
|
||||
it("counts a well-formed table row as a defined requirement", () => {
|
||||
const md = `
|
||||
|
||||
+80
-13
@@ -56,7 +56,7 @@ export interface TracesData {
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export const MIN_COVERAGE_PERCENT = 88;
|
||||
export const MIN_COVERAGE_PERCENT = 89;
|
||||
|
||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||
@@ -75,6 +75,71 @@ function extractRequirementIds(tracesString: string): string[] {
|
||||
return matches.map((m) => `${m[1]}-${m[2]}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooling files that implement a requirement.
|
||||
*
|
||||
* The walker below only visits `src/`, `src-tauri/src/` and `scripts/`, and only
|
||||
* picks up `.ts`/`.svelte`/`.rs`. That made every requirement implemented by
|
||||
* *configuration* invisible to the matrix that measures it — DR-205
|
||||
* (eslint.config.js), DR-206 (rust-toolchain.toml), DR-207 (the pre-commit
|
||||
* hook) and DR-216 (deny.toml) all carry TRACES comments that nothing read, so
|
||||
* each was counted as uncovered while being covered.
|
||||
*
|
||||
* An explicit list rather than "also scan .toml/.js/.yml": most config files in
|
||||
* this repo implement nothing, and one class of file is actively dangerous to
|
||||
* scan — see `isTracedSourceFile`.
|
||||
*/
|
||||
const TOOLING_FILES = new Set([
|
||||
"eslint.config.js",
|
||||
"vitest.config.ts",
|
||||
"scripts/hooks/pre-commit",
|
||||
"src-tauri/deny.toml",
|
||||
"src-tauri/rust-toolchain.toml",
|
||||
]);
|
||||
|
||||
/** Directory names that never contain hand-written traced source. */
|
||||
const EXCLUDED_SEGMENTS = new Set([
|
||||
"node_modules",
|
||||
"target",
|
||||
"build",
|
||||
".git",
|
||||
".svelte-kit",
|
||||
"docs-site",
|
||||
// Tauri regenerates src-tauri/gen/ on every android/desktop init; the
|
||||
// canonical Android sources live in src-tauri/android/ and are synced into it.
|
||||
"gen",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decide whether a repo-relative path should be scanned for TRACES comments.
|
||||
*
|
||||
* Exported for scripts/extract-traces.test.ts — the file-walking half needs a
|
||||
* filesystem, this half is a pure decision and is where the mistakes live.
|
||||
*
|
||||
* Deliberately excluded:
|
||||
* - `docs/requirements.md` *defines* IDs and `docs/traceability.md` is
|
||||
* generated from traces; scanning either would make requirements trace to
|
||||
* themselves.
|
||||
* - `.gitea/workflows/*.yml` — traceability-check.yml explains the gate in
|
||||
* prose, quoting "a `TRACES:` comment" on the same line as example IDs that
|
||||
* are deliberately undefined. The extractor would read those as real traces
|
||||
* and then fail its own dangling-ID check.
|
||||
*/
|
||||
export function isTracedSourceFile(relativePath: string): boolean {
|
||||
const p = relativePath.split(path.sep).join("/");
|
||||
if (p.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment))) {
|
||||
return false;
|
||||
}
|
||||
if (TOOLING_FILES.has(p)) {
|
||||
return true;
|
||||
}
|
||||
const isSourceExtension = p.endsWith(".ts") || p.endsWith(".svelte") || p.endsWith(".rs");
|
||||
if (!isSourceExtension) {
|
||||
return false;
|
||||
}
|
||||
return p.startsWith("src/") || p.startsWith("src-tauri/src/") || p.startsWith("scripts/");
|
||||
}
|
||||
|
||||
function getAllSourceFiles(): string[] {
|
||||
const baseDir = BASE_DIR;
|
||||
// `scripts` is scanned too: build tooling implements requirements (e.g.
|
||||
@@ -90,23 +155,16 @@ function getAllSourceFiles(): string[] {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
|
||||
// Skip node_modules, target, build
|
||||
if (
|
||||
relativePath.includes("node_modules") ||
|
||||
relativePath.includes("target") ||
|
||||
relativePath.includes("build") ||
|
||||
relativePath.includes(".git")
|
||||
) {
|
||||
// Directory pruning still happens here so the walk does not descend
|
||||
// into node_modules/target at all; isTracedSourceFile repeats the rule
|
||||
// for individual files (and is the version under test).
|
||||
if (entry.isDirectory() && !isTracedSourceFile(path.join(relativePath, "x.ts"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (
|
||||
entry.name.endsWith(".ts") ||
|
||||
entry.name.endsWith(".svelte") ||
|
||||
entry.name.endsWith(".rs")
|
||||
) {
|
||||
} else if (isTracedSourceFile(relativePath)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +180,15 @@ function getAllSourceFiles(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint.config.js, deny.toml and rust-toolchain.toml sit at the repo root or
|
||||
// in src-tauri/ rather than under a walked root, so they are added by name.
|
||||
for (const toolingFile of TOOLING_FILES) {
|
||||
const fullPath = path.join(baseDir, toolingFile);
|
||||
if (fs.existsSync(fullPath) && !files.includes(fullPath)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user