Compare commits
7
Commits
5c89acf337
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d8baad15
|
||
|
|
3c38e18027
|
||
|
|
1a82b0491a
|
||
|
|
d7f63b9f6e
|
||
|
|
fbd70e6a70
|
||
|
|
95939fedf0
|
||
|
|
75038b61a6
|
@@ -123,57 +123,12 @@ jobs:
|
||||
|
||||
# The licence inventory has to cover every package whose bytes ship, and
|
||||
# the lockfile does not answer that question: it lists what could be
|
||||
# reached, not what rollup kept. Four packages of the non-dev closure are
|
||||
# recorded as tree-shaken away, and if application code starts importing
|
||||
# one of them, no lockfile, no version and no dependency set changes —
|
||||
# only the bundle does. So the bundle is what this reads.
|
||||
#
|
||||
# A second build with sourcemaps, because the shipped build has none: the
|
||||
# `sources` list of each chunk names the packages whose modules went into
|
||||
# it. The output goes to its own directory so the artifact npm run build
|
||||
# produced is the one that gets embedded, untouched.
|
||||
# reached, not what rollup kept. The bundle is what this reads. The logic
|
||||
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
||||
# exactly as it runs here (milestone-14 deviation 24).
|
||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||
working-directory: web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The binary npm ci installed, never `npx`: npx silently downloads a
|
||||
# package it cannot find locally, so a wrong working directory would
|
||||
# turn a licence check into an unpinned fetch from the network.
|
||||
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
|
||||
|
||||
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
|
||||
if [ -z "$maps" ]; then
|
||||
echo "the sourcemap build produced no .map files; this check cannot run blind"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
bundled=$(jq -r '.sources[]' $maps \
|
||||
| grep 'node_modules/' \
|
||||
| sed 's|.*node_modules/||' \
|
||||
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
|
||||
| LC_ALL=C sort -u)
|
||||
|
||||
recorded=$(awk '
|
||||
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
|
||||
grab && /^\[/ { exit }
|
||||
grab && NF { print }
|
||||
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
|
||||
|
||||
if [ -z "$recorded" ]; then
|
||||
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
|
||||
echo
|
||||
echo "the set of npm packages in web/dist has changed (-recorded +current)."
|
||||
echo "Work out what the change means for licenses/inventory.zon first, then record"
|
||||
echo "the new list in that section of licenses/dependency-identity.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "web/dist bundles exactly the recorded packages:"
|
||||
printf '%s\n' "$bundled"
|
||||
run: npm run assert-bundled
|
||||
|
||||
package:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
+175
-1140
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -10,7 +10,7 @@ subject rarely does.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.1] - 2026-08-07
|
||||
## [0.0.1] - 2026-08-09
|
||||
|
||||
First release. Everything below is new.
|
||||
|
||||
|
||||
@@ -218,6 +218,27 @@ pub fn build(b: *std.Build) void {
|
||||
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||
.dependOn(&aarch64_run.step);
|
||||
|
||||
// The release publication tool (milestone-14 deviation 24). It is a host
|
||||
// tool like `dist_stage` and `verify_dist`, and it is installed rather than
|
||||
// run from the build graph: the workflow invokes it once per phase with the
|
||||
// secrets in its environment, and a Run step would have to carry them.
|
||||
const release_tool = hostTool(b, "release");
|
||||
b.step("release-tool", "Install the release publication tool into zig-out/bin")
|
||||
.dependOn(&b.addInstallArtifact(release_tool, .{}).step);
|
||||
|
||||
// Its pure decisions — semver ordering, VALIDSIG field selection, changelog
|
||||
// extraction, the releases-payload shape guard — are the reason it exists,
|
||||
// so they run in the same `zig build test` as everything else.
|
||||
const release_tests = b.addTest(.{
|
||||
.name = "release-tool",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("tools/release.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(release_tests).step);
|
||||
|
||||
addDist(b, options, web_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
|
||||
+86
-5
@@ -580,12 +580,87 @@ was reproduced before it was fixed.
|
||||
would have turned a licence check into an unpinned fetch. Reproduced: it
|
||||
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
||||
|
||||
23. **`actions/checkout` destroys the annotated tag object.** Found by the first
|
||||
live dry run, not by review: on a tag ref, checkout fetches the *commit* SHA
|
||||
into `refs/tags/<tag>`, so the signed tag reads as lightweight and the guard
|
||||
refuses it as unannotated. Both jobs that read the tag object — signature
|
||||
verification in the guard, the tagger date in the publish job — now force-
|
||||
refetch `refs/tags/$TAG` from origin first. The same run also proved the
|
||||
fail-closed secret guard for real: the first dry-run attempt ran with no
|
||||
secrets configured (they were on the wrong repository) and stopped in the
|
||||
guard with nothing built or pushed.
|
||||
|
||||
24. **Publication orchestration moved out of workflow shell into
|
||||
`tools/release.zig`.** Ruling 5 already moved the packaging asserts out of
|
||||
CI shell for one reason — "checks that only exist inside a workflow file are
|
||||
the brittleness this exists to remove" — and the release job was the larger
|
||||
half of the same problem, left in place. Three live failures came out of it,
|
||||
and each was found by executing the workflow, which is the most expensive
|
||||
place to find anything: `actions/checkout` replacing the annotated tag object
|
||||
(deviation 23), the refetch that fixed it having no credentials because
|
||||
`persist-credentials` is off, and the multiline armored subkey escaping the
|
||||
runner's log masker, which masks per line.
|
||||
|
||||
Twelve subcommands, one per step group: `guard-tag`, `guard-ancestry`,
|
||||
`guard-releases`, `resolve`, `changelog`, `image`,
|
||||
`verify-image-binaries`, `sign`, `draft`, `latest`, `publish`, `scrub`. Every
|
||||
behaviour recorded in deviations 10 to 15 and 23 is carried over unchanged —
|
||||
probe-adopt, the VALIDSIG last field, the subkey-only import and signing
|
||||
probe, the array-shape guard on the releases payload, the `:latest` label
|
||||
read, the publish re-read, the per-home `gpgconf --kill`, the tag refetch.
|
||||
What is new is that the semver ordering, VALIDSIG field selection, challenge
|
||||
parsing, changelog extraction, checksum-line parsing, colon-format parsing
|
||||
and payload-shape guard are 25 unit tests in `zig build test` rather than
|
||||
shell that only ever runs on a tag push. `release.yml` keeps the triggers,
|
||||
the concurrency group, the job graph, the SHA pins, the two pinned
|
||||
fingerprints and the fail-closed secret presence check — which stays as
|
||||
shell, deliberately, so that it runs before the tool is even compiled.
|
||||
|
||||
The same reasoning applies to the `jq` pipeline of the bundled-package gate,
|
||||
which moved to `web/scripts/bundledPackages.mjs` with its own vitest
|
||||
coverage and an `npm run assert-bundled` entry point.
|
||||
|
||||
**Secret contract change:** `RELEASE_GPG_SUBKEY` keeps its name but now
|
||||
holds `base64 -w0` of the armored
|
||||
`--export-secret-subkeys` output rather than the armored text. Manual
|
||||
prerequisite 1 and 3 change accordingly. The tool decodes it in memory and
|
||||
writes it to a mode-600 file inside the temporary `GNUPGHOME`. A single-line
|
||||
secret is one the masker can actually mask.
|
||||
|
||||
25. **The first signing subkey was leaked into a job log and rotated.** Dry-run
|
||||
attempt 3 failed inside the credential-less refetch, and the runner printed
|
||||
the failing step's env block; the multiline armored `RELEASE_GPG_SUBKEY`
|
||||
escaped the per-line masker while the single-line passphrase was masked.
|
||||
Exposure: the passphrase-protected secret subkey only — the passphrase and
|
||||
the primary key were never on the runner. Response: both runs that ever saw
|
||||
the secret were deleted (verified 404 via the API and absent from
|
||||
`actions_log` on disk), subkey `B281CECC…` was revoked with the primary,
|
||||
and its replacement `019D00DF…` is the pinned `RELEASE_SIGNING_FPR`. The
|
||||
base64 contract in deviation 24 is the preventive half of this record.
|
||||
|
||||
26. **The image is named by the public registry host, never the server URL.**
|
||||
Attempt 4 reached the registry and failed at `docker login gitea:3000`:
|
||||
inside the cluster `GITHUB_SERVER_URL` is `http://gitea:3000`, docker
|
||||
refuses plain-http registries, and an image named `gitea:3000/…` would be
|
||||
unpullable from anywhere that matters — a wrong name that would have been
|
||||
written into the released `IMAGE-DIGEST.txt`. `release.yml` now pins
|
||||
`REGISTRY_HOST: git.mial.net`; the tool uses it for docker and image
|
||||
naming, and keeps the internal URL for the manifest probe (same registry,
|
||||
no TLS dependency in the tool). The old shell had the identical latent bug;
|
||||
no run ever reached it.
|
||||
|
||||
### Not verified, and why
|
||||
|
||||
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
|
||||
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
|
||||
registry probe against a fake registry (five response shapes) and the whole
|
||||
bundled-package check against the real `web/` build, proven able to fail.
|
||||
- **The workflows' validation history.** Before any live run, `release.yml` and
|
||||
`gates.yml` were validated by YAML parse and `bash -n`, plus two steps lifted
|
||||
out and run directly: the registry probe against a fake registry (five
|
||||
response shapes) and the bundled-package check against the real `web/` build,
|
||||
proven able to fail. The live dry run then superseded this: attempt 5
|
||||
published `v0.0.0` end to end — guard, gates, image push to both platforms,
|
||||
binary-identity assertion, signing, draft, `:latest`, publication — and the
|
||||
assets verified from a clean directory (checksums OK, signature good under
|
||||
the rotated subkey). The throwaway release, tag and registry versions were
|
||||
deleted afterwards.
|
||||
Everything else that talks to the registry or the Gitea API — `buildx build
|
||||
--push`, `imagetools`, draft creation, asset upload, publication, the
|
||||
adopt-an-existing-tag path — is unexercised.
|
||||
@@ -633,7 +708,13 @@ was reproduced before it was fixed.
|
||||
removing an inventory entry, staling a dependency version, staling the Zig
|
||||
version, changing the base image digest, editing a pinned licence text and
|
||||
dropping a package from the recorded bundle each produce a named failure.
|
||||
- [ ] A dry run of `release.yml` completes with publication disabled.
|
||||
- [x] A dry run of `release.yml` completes with publication disabled. Done with
|
||||
a disposable published tag instead: publication cannot be disabled without
|
||||
forking the flow it is supposed to prove, so `v0.0.0` ran the real path
|
||||
end to end — five assets, verifying checksums and signature, a
|
||||
multi-architecture image — and was then deleted (release, git tag, both
|
||||
registry versions). Five attempts; the failures and their fixes are
|
||||
deviations 23-26.
|
||||
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
|
||||
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||
|
||||
+2541
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -13,7 +13,8 @@
|
||||
"lint": "oxlint src vite.config.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// The set of npm packages whose bytes reach web/dist must be exactly the set
|
||||
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
||||
//
|
||||
// The shipped build carries no sourcemaps, so this makes a second build with
|
||||
// them into its own directory: the `sources` list of each chunk names the
|
||||
// modules that went into it, and the artifact `npm run build` produced stays
|
||||
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
|
||||
// on the runner.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const outDir = "dist-sourcemap";
|
||||
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mapFiles(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...mapFiles(child));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// The binary npm ci installed, never `npx`: npx silently downloads a package it
|
||||
// cannot find locally, so a wrong working directory would turn a licence check
|
||||
// into an unpinned fetch from the network.
|
||||
try {
|
||||
execFileSync(
|
||||
join(webRoot, "node_modules", ".bin", "vite"),
|
||||
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
|
||||
{
|
||||
cwd: webRoot,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const maps = mapFiles(outDir);
|
||||
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
|
||||
|
||||
const sourceLists = maps.map((path) => {
|
||||
const raw = readFileSync(join(webRoot, path), "utf8");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
|
||||
}
|
||||
return Array.isArray(parsed.sources) ? parsed.sources : [];
|
||||
});
|
||||
|
||||
const bundled = bundledPackages(sourceLists);
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = readFileSync(identityFile, "utf8");
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
|
||||
}
|
||||
|
||||
const recorded = recordedPackages(identity);
|
||||
if (recorded === null) {
|
||||
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
|
||||
}
|
||||
if (recorded.length === 0) {
|
||||
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
|
||||
}
|
||||
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
if (added.length !== 0 || removed.length !== 0) {
|
||||
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||
fail(
|
||||
[
|
||||
"the set of npm packages in web/dist has changed (-recorded +current).",
|
||||
"Work out what the change means for licenses/inventory.zon first, then record",
|
||||
"the new list in that section of licenses/dependency-identity.txt.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||
@@ -0,0 +1,77 @@
|
||||
// The decisions behind `npm run assert-bundled`, kept separate from the script
|
||||
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
|
||||
//
|
||||
// The licence inventory has to cover every package whose bytes ship, and the
|
||||
// lockfile does not answer that question: it lists what could be reached, not
|
||||
// what rollup kept. Several packages of the non-dev closure are recorded as
|
||||
// tree-shaken away, and if application code starts importing one of them, no
|
||||
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||
// the bundle is what this reads.
|
||||
|
||||
const sectionHeading = "[npm packages bundled into web/dist]";
|
||||
|
||||
// A sourcemap `sources` entry for a dependency ends in
|
||||
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||
// the last `node_modules/` matters: a nested dependency's path carries two.
|
||||
export function packageFromSource(source) {
|
||||
const marker = "node_modules/";
|
||||
const at = source.lastIndexOf(marker);
|
||||
if (at === -1) return null;
|
||||
const rest = source.slice(at + marker.length);
|
||||
const parts = rest.split("/");
|
||||
if (parts.length === 0 || parts[0] === "") return null;
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length < 2 || parts[1] === "") return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
|
||||
export function bundledPackages(sourceLists) {
|
||||
const found = new Set();
|
||||
for (const sources of sourceLists) {
|
||||
for (const source of sources) {
|
||||
const name = packageFromSource(source);
|
||||
if (name !== null) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
|
||||
/// line after the heading, up to the next `[section]`.
|
||||
export function recordedPackages(text) {
|
||||
const recorded = new Set();
|
||||
let grabbing = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!grabbing) {
|
||||
if (line === sectionHeading) grabbing = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) break;
|
||||
if (line !== "") recorded.add(line);
|
||||
}
|
||||
return grabbing ? [...recorded].sort() : null;
|
||||
}
|
||||
|
||||
/// What changed, in the two directions that mean different things: a package
|
||||
/// that started shipping needs a licence decision, and one that stopped needs
|
||||
/// the record corrected.
|
||||
export function comparePackages(recorded, bundled) {
|
||||
const inBundle = new Set(bundled);
|
||||
const inRecord = new Set(recorded);
|
||||
return {
|
||||
added: bundled.filter((name) => !inRecord.has(name)),
|
||||
removed: recorded.filter((name) => !inBundle.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiff(recorded, bundled) {
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
const lines = [];
|
||||
for (const name of removed) lines.push(`-${name}`);
|
||||
for (const name of added) lines.push(`+${name}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bundledPackages,
|
||||
comparePackages,
|
||||
formatDiff,
|
||||
packageFromSource,
|
||||
recordedPackages,
|
||||
} from "./bundledPackages.mjs";
|
||||
|
||||
describe("packageFromSource", () => {
|
||||
it("reads a plain package name", () => {
|
||||
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
|
||||
});
|
||||
|
||||
it("keeps the scope of a scoped package", () => {
|
||||
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the last node_modules, so a nested dependency is named correctly", () => {
|
||||
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
|
||||
});
|
||||
|
||||
it("ignores application sources", () => {
|
||||
expect(packageFromSource("src/lib/api.ts")).toBeNull();
|
||||
expect(packageFromSource("../src/main.tsx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundledPackages", () => {
|
||||
it("sorts and deduplicates across every map", () => {
|
||||
const packages = bundledPackages([
|
||||
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
|
||||
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
|
||||
]);
|
||||
expect(packages).toEqual(["@tanstack/react-router", "react"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when nothing came from node_modules", () => {
|
||||
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordedPackages", () => {
|
||||
const identity = [
|
||||
"[some earlier section]",
|
||||
"ignored",
|
||||
"",
|
||||
"[npm packages bundled into web/dist]",
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"",
|
||||
"react-dom",
|
||||
"",
|
||||
"[a later section]",
|
||||
"not-a-package",
|
||||
].join("\n");
|
||||
|
||||
it("reads only its own section, sorted and deduplicated", () => {
|
||||
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
|
||||
});
|
||||
|
||||
it("distinguishes a missing section from an empty one", () => {
|
||||
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePackages", () => {
|
||||
it("reports both directions", () => {
|
||||
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
|
||||
expect(added).toEqual(["c"]);
|
||||
expect(removed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("reports nothing when the sets match", () => {
|
||||
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
|
||||
expect(formatDiff(["a"], ["a"])).toBe("");
|
||||
});
|
||||
|
||||
it("formats a diff the way the failure prints it", () => {
|
||||
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user