detect-prerelease-pins.sh: any LAN/loopback string in a build file trips the gate, so a project with a private-network default can never pass #334
Description
Found while evaluating 0.24.0-rc.3 in an adopter project.
The detector's pattern #2 flags "ANY private-network or loopback host in a manifest/lockfile". That catches strings which are not package sources at all, and there is no way for a project to pass.
Repro
An Android module with a LAN default for its own backend:
// android/app/build.gradle.kts:21
buildConfigField("String", "SERVER_URL",
"\"${project.findProperty("SERVER_URL") ?: "http://10.0.0.5:8000"}\"")The detector reports:
✖ project points at a private-network or loopback package registry
./android/app/build.gradle.kts:21: ... "http://10.0.0.5:8000" ...
exit 1
That is an application config default — where the built app looks for its own server. It says nothing about where Gradle resolves dependencies from. The project has never been linked to a private registry, and after a full unlink (verified: zero -rc pins, zero registry hosts in any manifest, .npmrc gone) it still exits 1.
Why it matters
The header calls this check load-bearing and says to run it in CI and pre-commit and to "treat a failure here as a build break, never as advice." A permanent false positive inverts that: the only way to get a green build is to stop running the check, or to train everyone to ignore it — which is exactly how the true positive it exists to catch gets waved through.
It also makes the unlink round-trip unverifiable. unlink runs the detector as its final step, so it reported
✗ unlink left pre-release references behind (listed above) — fix them before pushing
when nothing pre-release remained. The real residue in that run was separate and genuine (see #323 — worktree manifests), but the LAN false positive is indistinguishable from it in the output, so the signal that would have told me unlink was incomplete was buried in noise I had to rule out by hand.
Suggested narrowing
Pattern #2 is trying to answer "does this project resolve PACKAGES from a private host". That is a question about resolution config, not about any string in any build file. Options, roughly in order of preference:
- Scope the host scan to the places a package source can actually be declared —
.npmrc/NuGet.config/pip.conf/[[tool.uv.index]]/pyprojectindex URLs /<repositories>and<pluginRepositories>inpom.xml/repositories { }blocks in Gradle — rather than whole-file greps of manifests. - For Gradle specifically, only
repositories/pluginManagementblocks are package sources;buildConfigField,manifestPlaceholders,resValueand friends are app config. - Failing that, an ignore mechanism (a
.prereleaseignore, or honouring a trailing# not-a-registry), so a project with a legitimate LAN default can run the gate at all.
Patterns #1 (the registry host), #3 (vendor pre-release pins) and #4 (bare dist-tags) were all accurate in this evaluation — #3 in particular correctly caught every repinned manifest. It is only the blanket private-host scan that misfires.
Activity
fix(prerelease): the private-host check asks where PACKAGES come from…
docs(changelog): 0.24.0 records the adopter batch — #326/#327, #332/#333
dmealing commentedon Aug 21, 2026
Fixed on
main, taking your first and second suggested narrowings.The manifest list now splits in two for this check. Files that are package-resolution config end to end (
.npmrc,.yarnrc.yml, every lockfile,pip.conf,.pypirc,requirements*.txt,NuGet.config,settings.xml,Pipfile,paket.dependencies) are still scanned whole. Files that carry both package sources and project configuration are scanned only inside the region that declares a source — a Gradlerepositories { }/pluginManagement { }/dependencyResolutionManagement { }block, a pom's<repositories>/<pluginRepositories>/<distributionManagement>/<mirrors>, an msbuild<RestoreSources>, apackage.jsondependency block orregistrykey, a[[tool.uv.index]]/[[tool.poetry.source]]section or index-url key, and agradle.propertieskey naming a repo.No ignore mechanism was added — with the scoping in place it would be an escape hatch on a leak gate, which is its own hazard.
Narrowing costs no detection on the npm path, which is worth stating: a real link writes the registry line into
.npmrcand the resolved URL into the lockfile, and both are still scanned whole. Checks 1, 3, 4 and 5 are untouched — you were right that only #2 misfired.One thing found while here:
settings.gradle/settings.gradle.ktswere not in the manifest list at all, so a private registry declared inpluginManagement { repositories { } }was invisible to every check, not just this one. Added.Proven both directions against a fixture carrying each format twice, once as app config (including your exact
buildConfigFieldrepro) and once as a real declaration, with the previous script copied intotools/prerelease/so both runs see the sameregistry.env. Before: 9 false positives, exit 1. After: exit 0 there, and all seven real declarations still fail. The negative half earned its keep on the first run — it caught a case-sensitivity bug in the new properties rule (prereleaseRepoUrlhas a capital R).fix(prerelease): unlink names the lockfile residue it cannot repin aw…