Compare commits
8
Commits
v0.0.1
...
5c89acf337
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c89acf337 | ||
|
|
3c2d0d41f0 | ||
|
|
31a6f0c5e5 | ||
|
|
51d8281abb | ||
|
|
266dde7396 | ||
|
|
32cd9b8e3e | ||
|
|
e14a29c5de | ||
|
|
da4441b24a |
@@ -123,57 +123,12 @@ jobs:
|
|||||||
|
|
||||||
# The licence inventory has to cover every package whose bytes ship, and
|
# The licence inventory has to cover every package whose bytes ship, and
|
||||||
# the lockfile does not answer that question: it lists what could be
|
# 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
|
# reached, not what rollup kept. The bundle is what this reads. The logic
|
||||||
# recorded as tree-shaken away, and if application code starts importing
|
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
||||||
# one of them, no lockfile, no version and no dependency set changes —
|
# exactly as it runs here (milestone-14 deviation 24).
|
||||||
# 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.
|
|
||||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: |
|
run: npm run assert-bundled
|
||||||
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"
|
|
||||||
|
|
||||||
package:
|
package:
|
||||||
runs-on: ubuntu-24.04
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.0.1] - 2026-08-07
|
## [0.0.1] - 2026-08-09
|
||||||
|
|
||||||
First release. Everything below is new.
|
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)")
|
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||||
.dependOn(&aarch64_run.step);
|
.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, .{
|
addDist(b, options, web_assets, .{
|
||||||
.version = version_option,
|
.version = version_option,
|
||||||
.version_string = version_string,
|
.version_string = version_string,
|
||||||
|
|||||||
+60
-106
@@ -8,41 +8,12 @@ Do this before you run the binary, not after. The whole point of the checksum
|
|||||||
file is that it is signed, so a tampered mirror cannot hand you a matching
|
file is that it is signed, so a tampered mirror cannot hand you a matching
|
||||||
tarball and a matching checksum at the same time.
|
tarball and a matching checksum at the same time.
|
||||||
|
|
||||||
> Verification: no nxdns release exists yet. The repository has no tags, no
|
> Verification: every command on this page was run on 2026-08-09 against the
|
||||||
> release page and no pushed image, so nothing on this page could be run against
|
> published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
|
||||||
> a real release asset and no command here was pointed at
|
> holding only the key fetched from keys.openpgp.org. Every transcript below is
|
||||||
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
|
> that run's output. Where a block shows a failure — a `BAD signature`, a
|
||||||
> used, and every block says which one applies to it.
|
> `FAILED` hash — the failure was produced deliberately by tampering with a
|
||||||
>
|
> copy of the real file, and the surrounding text says how.
|
||||||
> The URL shapes were probed against `gitea.com`, a public instance of the same
|
|
||||||
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
|
|
||||||
> does have releases. `git.mial.net` reports `1.27.1`, and its
|
|
||||||
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
|
|
||||||
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
|
|
||||||
> `releases/download/v0.15.1/checksums.txt` and
|
|
||||||
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
|
|
||||||
> object and delivered the same 1,842-byte file under `-L`;
|
|
||||||
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
|
|
||||||
>
|
|
||||||
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
|
|
||||||
> stand-in files: two random-byte files named like the release tarballs, an
|
|
||||||
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
|
|
||||||
> computed over the three, signed by a **throwaway demonstration key generated
|
|
||||||
> for this page**. That key has the shape the real one will have — an ed25519
|
|
||||||
> primary key plus a separate ed25519 signing subkey, with the signature made by
|
|
||||||
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
|
|
||||||
> structure a subkey-signed release produces. The fingerprints printed in those
|
|
||||||
> transcripts are the throwaway key's, they are not the project's, and they will
|
|
||||||
> not match anything you download. The only edit to that run's output is the
|
|
||||||
> version in every filename, which became `<version>`.
|
|
||||||
>
|
|
||||||
> The container blocks were not run against nxdns — there is no published image.
|
|
||||||
> The two `docker buildx imagetools inspect --format` shapes were run here
|
|
||||||
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
|
|
||||||
> pins; the digest form printed
|
|
||||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
|
|
||||||
> the platform form printed a list. The `docker create`/`docker cp` comparison
|
|
||||||
> was run against an image built from this checkout rather than a pulled one.
|
|
||||||
|
|
||||||
## What a release contains
|
## What a release contains
|
||||||
|
|
||||||
@@ -94,11 +65,8 @@ want for the placeholder:
|
|||||||
VERSION=<version>
|
VERSION=<version>
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified against nxdns: there is no release to redirect to, so the first
|
> Verified: the two-command form, run against this repository, printed `0.0.1`
|
||||||
> block prints an empty line here and every URL built from it is a 404. The
|
> with `v0.0.1` published.
|
||||||
> exact two-command form was run against `gitea.com/gitea/tea`, a public
|
|
||||||
> repository on Gitea `1.27.0+dev` that does have releases, and printed
|
|
||||||
> `0.15.1`.
|
|
||||||
|
|
||||||
Pin the version in anything you script or automate. `latest` is convenient for
|
Pin the version in anything you script or automate. `latest` is convenient for
|
||||||
a person at a terminal and a liability in a machine that upgrades itself.
|
a person at a terminal and a liability in a machine that upgrades itself.
|
||||||
@@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
|
|||||||
position, as `releases/download/latest/`. The tarball filenames contain the
|
position, as `releases/download/latest/`. The tarball filenames contain the
|
||||||
version, so this alias never saves you from knowing it for those two.
|
version, so this alias never saves you from knowing it for those two.
|
||||||
|
|
||||||
> Not verified against nxdns: no release, so every URL above is a 404 today.
|
> Verified against `v0.0.1`: all five assets downloaded through the versioned
|
||||||
> Both URL forms, including the 404 for GitHub's spelling, were exercised
|
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
|
||||||
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
|
> identical, and GitHub's spelling answered 404.
|
||||||
> the `latest` alias each answered 303 to the same stored object and delivered
|
|
||||||
> the same 1,842-byte `checksums.txt` when the redirect was followed.
|
|
||||||
|
|
||||||
## 3. Check the signature over `SHA256SUMS.txt`
|
## 3. Check the signature over `SHA256SUMS.txt`
|
||||||
|
|
||||||
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
|
|||||||
gpg --import
|
gpg --import
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified: the key is not published yet. Run on this host, that URL
|
> Verified: the key is published, and that exact `curl | gpg --import` reported
|
||||||
> returned 404, and so did the `by-email` lookup for the same address. The
|
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
|
||||||
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
|
> imported` into a clean `GNUPGHOME`.
|
||||||
> unrelated key that is on keys.openpgp.org. Until this key is published there,
|
|
||||||
> get it from a source you can check some other way.
|
|
||||||
|
|
||||||
Then verify:
|
Then verify:
|
||||||
|
|
||||||
@@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||||
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
gpg: There is no indication that the signature belongs to the owner.
|
||||||
Primary key fingerprint: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
|
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||||
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
|
Subkey fingerprint: 019D 00DF 8417 EBFD A547 1E5E F731 9CC0 24FB 5A96
|
||||||
```
|
```
|
||||||
|
|
||||||
**Those two fingerprints and that user id belong to a throwaway key generated
|
The *structure* is what to read: three lines, not one. `using EDDSA key` and
|
||||||
to produce this transcript.** They are not the project's, and what you see will
|
|
||||||
carry the project's uid and the fingerprint in this page instead. The
|
|
||||||
*structure* is what to read: three lines, not one. `using EDDSA key` and
|
|
||||||
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
||||||
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
||||||
one published above.
|
one published above. The subkey fingerprint can change — a signing subkey is
|
||||||
|
revoked and replaced on its own — but the primary fingerprint is the
|
||||||
|
project's identity and stays.
|
||||||
|
|
||||||
Exit status 0, and `Good signature`. That warning is normal and is not a
|
Exit status 0, and `Good signature`. That warning is normal and is not a
|
||||||
failure: it says you have not told GnuPG you believe the key belongs to the
|
failure: it says you have not told GnuPG you believe the key belongs to the
|
||||||
@@ -210,22 +173,18 @@ including one an attacker talked you into importing.
|
|||||||
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
||||||
|
|
||||||
```
|
```
|
||||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||||
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||||
```
|
```
|
||||||
|
|
||||||
> Verified on this host. A throwaway ed25519 primary key was generated into a
|
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
|
||||||
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
|
> public key — which is why the `[unknown]` trust marker and the warning are
|
||||||
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
|
> there rather than being written in by hand. The good-signature transcript is
|
||||||
> — the same construction the release workflow uses — so the transcripts above
|
> the real release's; the `BAD signature` transcript is the same command
|
||||||
> are what a subkey-signed release actually prints, rather than what a key
|
> against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
|
||||||
> signing with its primary would. The verification ran from a second
|
> 1. The `sed`/`tr` pipeline printed
|
||||||
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
|
> `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
|
||||||
> trust marker and the warning are there rather than being written in by hand.
|
|
||||||
> The second transcript is the same command after one newline was appended to
|
|
||||||
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
|
|
||||||
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
|
|
||||||
|
|
||||||
## 4. Check the hashes
|
## 4. Check the hashes
|
||||||
|
|
||||||
@@ -263,12 +222,11 @@ Check the signature before the hashes, not after. An attacker who can replace
|
|||||||
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
||||||
thing in the set they cannot forge.
|
thing in the set they cannot forge.
|
||||||
|
|
||||||
> Verified on this host against the stand-in files: all three transcripts are
|
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
|
||||||
> real `sha256sum` output over two random-byte files named like the release
|
> three `OK` lines. The three transcripts above are the same command over
|
||||||
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
|
> copies of the real assets, with the aarch64 tarball absent for the first two
|
||||||
> tarball deleted for the first two blocks and one byte appended to the other
|
> and one byte appended to the x86_64 tarball for the third. Only the version
|
||||||
> for the third. Only the version in the filenames was replaced with
|
> in the filenames was replaced with `<version>`.
|
||||||
> `<version>`.
|
|
||||||
|
|
||||||
## 5. Look inside before extracting
|
## 5. Look inside before extracting
|
||||||
|
|
||||||
@@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
|||||||
Zig version. The version has to match the tag you downloaded, and the commit
|
Zig version. The version has to match the tag you downloaded, and the commit
|
||||||
has to match the commit the tag points at.
|
has to match the commit the tag points at.
|
||||||
|
|
||||||
> Not verified on this host: there is no release tarball to list or extract.
|
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
|
||||||
|
> six files with the stated modes, no symlinks and no absolute or `..` paths,
|
||||||
|
> and the extracted binary printed `nxdns 0.0.1
|
||||||
|
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
|
||||||
|
> then `zig 0.16.0`.
|
||||||
|
|
||||||
## 6. Verify the container image
|
## 6. Verify the container image
|
||||||
|
|
||||||
@@ -343,23 +305,13 @@ docker rm nxdns-verify
|
|||||||
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified against nxdns: no image is published, so no command here was run
|
> Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
|
||||||
> against `git.mial.net/mokhtar/nxdns`. The two
|
> the `:0.0.1` tag resolves to were the same string
|
||||||
> `docker buildx imagetools inspect --format` shapes were run on this host
|
> (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
|
||||||
> against `alpine:3.22` on Docker Hub — the digest form printed
|
> and `:latest` resolved to it too), the platform form printed exactly
|
||||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
|
> `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
|
||||||
> which is the digest this project's builder stage pins, and the platform form
|
> pinned reference succeeded, and the binary copied out of that pulled image
|
||||||
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
|
> hashed identical to the `nxdns` in the x86_64 tarball.
|
||||||
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
|
|
||||||
> must not contain: Alpine's index carries attestation entries, and nxdns's
|
|
||||||
> build turns them off. Nothing was checked about how nxdns's index will
|
|
||||||
> actually look.
|
|
||||||
>
|
|
||||||
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
|
|
||||||
> here against an image built from this checkout rather than a pulled one, and
|
|
||||||
> the two hashes matched: the binary copied out of the image and
|
|
||||||
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
|
|
||||||
> file.
|
|
||||||
|
|
||||||
## What the signature proves, and what it does not
|
## What the signature proves, and what it does not
|
||||||
|
|
||||||
@@ -431,14 +383,16 @@ release used. The Zig version is the second line of `nxdns version`, and both
|
|||||||
it and the Node version are pinned to exact patch releases at the top of
|
it and the Node version are pinned to exact patch releases at the top of
|
||||||
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||||
|
|
||||||
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
|
> Verified against `v0.0.1`, and the result is the caveat above in action. The
|
||||||
> were run to completion, with the version read out of `build.zig.zon`: `dist`
|
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
|
||||||
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
|
> `Good signature` under the same signing subkey as the release, and
|
||||||
> described above. `zig build verify-dist` was run on the result too and exited
|
> `zig build dist` produced both tarballs. The hashes did **not** match the
|
||||||
> 0. What could not be run is everything that needs a release: the clone, the
|
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
|
||||||
> checkout and `git verify-tag` need a tag that does not exist, and there is no
|
> version matched the pin exactly; the Node version did not (24.14.1 against
|
||||||
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
|
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
|
||||||
> this section is about has never been performed.
|
> listed above. That is a measurement of what an unpinned rebuild gives you,
|
||||||
|
> not evidence of tampering: the signature, checksum and image checks earlier
|
||||||
|
> on this page all passed against the same release.
|
||||||
|
|
||||||
## If a check fails
|
## If a check fails
|
||||||
|
|
||||||
|
|||||||
+110
-15
@@ -580,12 +580,87 @@ was reproduced before it was fixed.
|
|||||||
would have turned a licence check into an unpinned fetch. Reproduced: it
|
would have turned a licence check into an unpinned fetch. Reproduced: it
|
||||||
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
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
|
### Not verified, and why
|
||||||
|
|
||||||
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
|
- **The workflows' validation history.** Before any live run, `release.yml` and
|
||||||
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
|
`gates.yml` were validated by YAML parse and `bash -n`, plus two steps lifted
|
||||||
registry probe against a fake registry (five response shapes) and the whole
|
out and run directly: the registry probe against a fake registry (five
|
||||||
bundled-package check against the real `web/` build, proven able to fail.
|
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
|
Everything else that talks to the registry or the Gitea API — `buildx build
|
||||||
--push`, `imagetools`, draft creation, asset upload, publication, the
|
--push`, `imagetools`, draft creation, asset upload, publication, the
|
||||||
adopt-an-existing-tag path — is unexercised.
|
adopt-an-existing-tag path — is unexercised.
|
||||||
@@ -620,24 +695,44 @@ was reproduced before it was fixed.
|
|||||||
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
||||||
tarballs **in the same directory**. (Cross-directory reproducibility is
|
tarballs **in the same directory**. (Cross-directory reproducibility is
|
||||||
ruling 12 and is not claimed here.)
|
ruling 12 and is not claimed here.)
|
||||||
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
- [x] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
||||||
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
||||||
byte-identical to the tarball binaries. Verified for the native amd64
|
byte-identical to the tarball binaries. The v0.0.1 run built and pushed
|
||||||
image only; the arm64 half needs a runner with buildx.
|
both platforms on the runner; the published index lists exactly
|
||||||
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
`linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
|
||||||
on `master`; `origin/main` is gone. The first two are in the files; no
|
both binaries against the tarballs before publication.
|
||||||
workflow has run and `origin/main` still exists (manual prerequisite).
|
- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
||||||
|
on `master`; `origin/main` is gone. Proven live: pushes to `master` run
|
||||||
|
the gates through `ci.yml`, and release runs 484-493 ran them through
|
||||||
|
`release.yml`.
|
||||||
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
||||||
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
||||||
runtime closure. The dependency drift guard was proven able to fail:
|
runtime closure. The dependency drift guard was proven able to fail:
|
||||||
removing an inventory entry, staling a dependency version, staling the Zig
|
removing an inventory entry, staling a dependency version, staling the Zig
|
||||||
version, changing the base image digest, editing a pinned licence text and
|
version, changing the base image digest, editing a pinned licence text and
|
||||||
dropping a package from the recorded bundle each produce a named failure.
|
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
|
||||||
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
a disposable published tag instead: publication cannot be disabled without
|
||||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
|
forking the flow it is supposed to prove, so `v0.0.0` ran the real path
|
||||||
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
|
end to end — five assets, verifying checksums and signature, a
|
||||||
published release, from a clean directory, on this host.
|
multi-architecture image — and was then deleted (release, git tag, both
|
||||||
|
registry versions). Five attempts; the failures and their fixes are
|
||||||
|
deviations 23-26.
|
||||||
|
- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||||
|
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
|
||||||
|
on the first attempt after the dry-run fixes.
|
||||||
|
- [x] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||||
|
published release, from a clean directory, on this host, with a clean
|
||||||
|
`GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every
|
||||||
|
command on the page passed: the `releases/latest` redirect printed
|
||||||
|
`0.0.1`, both tarball downloads and the `latest` alias worked (and
|
||||||
|
GitHub's spelling answered 404 as documented), the signature verified
|
||||||
|
with matching primary and subkey fingerprints, `sha256sum -c` said OK for
|
||||||
|
all three files, the tarball layout and modes matched, `nxdns version`
|
||||||
|
printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the
|
||||||
|
platform list was exactly `linux/amd64 linux/arm64`, and the binary
|
||||||
|
copied out of the pulled-by-digest image hashed identical to the tarball
|
||||||
|
binary.
|
||||||
- [x] No `zig build cross` or source-only-distribution text remains on any
|
- [x] No `zig build cross` or source-only-distribution text remains on any
|
||||||
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
||||||
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
||||||
|
|||||||
+2541
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"lint": "oxlint src vite.config.ts",
|
"lint": "oxlint src vite.config.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"useTabs": true,
|
"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