PFM-ISSUE-34453 - github-actions: Normalize package-lock.json resolved URLs onto the JFrog npm proxy (release/25.2) - #163
Conversation
…-URL normalization Adds the research document for normalizing the 171 `registry.npmjs.org` `resolved` URLs in package-lock.json onto the JFrog npm proxy. Committed on release/25.2 so it travels upward with the regular upmerge, matching the rollout the ticket prescribes for the normalizer and the invariant check. Verified locally across all seven affected branches: three distinct lockfile states, 171 npmjs entries each, byte-identical across states. A prototype rewrite keeps the dependency graph identical and is idempotent; a simulated bad merge (version + integrity drift) is caught by the invariant check. Findings that shape the implementation: - No `pull_request`-triggered workflow exists in this repo, so the PR guard needs the first self-CI workflow rather than an edit to an existing one. - master and release/26.3 are not downstream of release/26.2, so they need their own normalizer/check commits instead of inheriting them through the upmerge. - fe-check-upmerge only notifies Slack via a `--no-push` dry run; it never performs the merge, so the conflict rule is a human runbook. - A JSON parse/serialize round-trip is byte-identical on these lockfiles, so no formatting-drift risk constrains the implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…package-lock.json normalization Adds the design document and the implementation plan for normalizing the 171 registry.npmjs.org `resolved` prefixes onto the JFrog npm proxy. Design: bash + jq normalizer and invariant check under tools/scripts/lockfile/, plus the repo's first `on: pull_request` workflow as a guard. Bash rather than TypeScript because every node-based option has a bootstrap dependency on the `npm ci` that this fix repairs. Plan: eight phases, `release/25.2` end-to-end followed by a parameterised runbook for the six remaining branches, with a human checkpoint per phase. The jq programs, the tarball-path regex and the two-assertion drift matrix (t1-t6) were verified against the real lockfile during planning: 171 entries rewritten, +4788 bytes, 342 changed lines, 0 non-`resolved` lines, output byte-identical to a raw prefix rewrite, and idempotent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nvariant check and PR guard The composite actions run `npm ci` inside the action directory while the consumer's ~/.npmrc is active. npm rewrites a `registry.npmjs.org` resolved URL onto the configured host and drops the registry's path prefix, producing E404 - masked as *** in logs because the prefix is the JFROG_URL secret. Adds bash + jq tooling under tools/scripts/lockfile/: - normalize-lockfile.sh rewrites every `resolved` prefix onto the JFrog npm proxy and nothing else; idempotent. - check-lockfile.sh makes two independent assertions, both required: the dependency graph must be identical to a baseline (any git ref, merge stage or file), and every `resolved` must carry exactly one prefix - the proxy's. Neither subsumes the other; the fingerprint strips the host on purpose, so it cannot see an entry left on npmjs, and prefix exactness sees nothing else. - 24 bats tests, including six injected-drift cases that assert the *right* assertion fails in each case. bash + jq rather than TypeScript for bootstrap independence: `npx ts-node` requires node_modules, which requires the `npm ci` this repairs. Adds .github/workflows/pr-checks.yml - this repository's first `on: pull_request` workflow, since every existing workflow is workflow_call-only. Both jobs are node-free; bats and shellcheck come from apt-get, never from devDependencies, which would mutate the lockfile this guards. Every failure message names a package path, never a URL, so it survives *** masking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d URLs onto the JFrog npm proxy Rewrites 171 `resolved` prefixes from https://registry.npmjs.org/ onto the cplace JFrog npm proxy. Nothing else changes: no version, no integrity, no dependency edge. Generated by tools/scripts/lockfile/normalize-lockfile.sh and provable by construction - this commit's parent is the baseline, so: ./tools/scripts/lockfile/check-lockfile.sh --baseline HEAD~1 342 changed lines (171 x 2), +4788 bytes (= 171 x the 28-character prefix length difference), zero non-`resolved` lines changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ugh Phase 5
Checks off Phases 1-5 with measured results and records four places where the
plan's own verification commands were wrong:
- the node-free grep on pr-checks.yml matched an explanatory comment;
- `git show --stat` matched `package-lock.json` in the commit message prose;
- PyYAML is not installed here (validated with ruby/psych and actionlint);
- a backticked `resolved` inside a double-quoted err string in lib.sh would
have been executed as command substitution.
Also records the mid-phase switch to runs-on: ${{ vars.SMALL_RUNNER ||
'ubuntu-latest' }} and the proxy re-probe result (166 x 200, 5 x 302).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd the green guard run The first PR run ended in startup_failure at 0s with no jobs and no annotations. Root cause was not the workflow: the repository's Actions policy was allowed_actions=local_only, the UI radio "Allow collaborationFactory actions and reusable workflows", which permits only owner-owned actions and so rejected actions/checkout - owned by the `actions` organization - before any job started. Records the diagnosis, what was ruled out with evidence (the SMALL_RUNNER expression, YAML validity, the pinned SHA, the merge ref), and the measured blast radius of raising the policy: zero. All 91 workflow files across the seven branches are workflow_call-only, nothing under workflow-templates/ is registered, and reusable-workflow runs are governed by the caller's policy, so consumers were never affected. Policy is now allowed_actions=selected with github_owned_allowed=true and verified_allowed=false - actions/* only, narrower than an unrestricted 'all'. Run 31475431467 is green on both jobs, with logs confirming the baseline resolved to release/25.2's tip and both assertions executed. 24/24 bats in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or release/25.2 Canary ran in cplace-paw-fe (PR #184, closed) rather than cplace-remote-filesystem-fe, whose release branches do not go back to 25.2. Run 31487742492 green with zero E404. The composite's own npm ci installed 542 packages / 543 audited - the github-actions lockfile's exact entry count, matching the local run - and `npx ts-node main.ts` then ran successfully on top, so node_modules was functional and not merely populated. Records two traps that would have invalidated a naive canary: - Re-pinning only the consumer's `uses:` tests the WRONG lockfile. The composite runs `cd "$GITHUB_ACTION_PATH/../../.." && npm ci`, so the ref on the COMPOSITE decides which package-lock.json is installed. fe-pr-snapshot.yml pins use-npmrc and artifacts internally at @release/25.2; without re-pinning those the canary would have passed for the wrong reason. Handled with a throwaway canary branch, since deleted. - The job is label-gated on 'snapshot' and the trigger omits `opened`, so opening a PR fires nothing. Also records the local before/after npm ci reproduction (E404 with the path prefix dropped, vs. added 542 packages) and that no artifact was published, because a workflow-file-only change leaves no nx project affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validation complete — ready for review1. Local reproduction — controlled experimentSame machine, same
2. Consumer canary — cplace-paw-fe#184 (throwaway, now closed)
542/543 is this repo's lockfile entry count, matching the local run — so the tree under test is what actually got installed.
No artifact was published (a workflow-file-only change leaves no nx project affected), confirmed via 3. Blocker found and fixed: repository Actions policyThe first run of the new guard ended in Now What to focus review onThis is the only PR of the seven with novel content. The other six will carry byte-identical tooling plus the same machine-checked lockfile transformation, so this review is the one that counts.
Known limitation, stated up front: this guard reports but cannot block until the Rule Sets follow-up lands. |
…ot merge The six remaining branches are technically independent of #163's merge - the tooling is copied from an already-pushed commit, each branch normalizes its own pre-fix lockfile, and each PR's guard runs on itself. The binding constraint is byte-identity: since the tooling files must be identical across all seven branches, any change from #163's review would have to be force-pushed to seven PRs. Approval, not merge, is what makes the content safe to replicate. Also records the measured review setup: release/25.2 has no branch protection, only master requires an approval, zero rulesets, no CODEOWNERS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a bash+jq toolchain to normalize package-lock.json resolved URLs onto the cplace JFrog npm proxy, adds a PR workflow guard to detect drift/reintroduction, and updates the lockfile accordingly to prevent consumer-runner npm ci failures caused by ~/.npmrc registry rewriting.
Changes:
- Add lockfile normalization + invariant-check scripts (jq fingerprint + two-assertion checker) with bats test coverage.
- Add a new
on: pull_requestworkflow (pr-checks.yml) to run the lockfile invariant check plus shellcheck/bats. - Normalize
package-lock.jsonresolvedentries and document the approach intools/scripts/lockfile/README.mdandspecs/….
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/scripts/lockfile/lib.sh | Shared constants/helpers for normalization/checking (proxy constant, regexes, jq helpers). |
| tools/scripts/lockfile/fingerprint.jq | Registry-independent fingerprint transform for graph invariance comparisons. |
| tools/scripts/lockfile/normalize-lockfile.sh | Rewrites resolved URLs onto the proxy while self-asserting graph invariance. |
| tools/scripts/lockfile/check-lockfile.sh | Baseline-vs-candidate invariant checker (graph invariance + prefix exactness). |
| tools/scripts/lockfile/test-helper.bash | Shared bats fixture builders and mutation helper for drift injection. |
| tools/scripts/lockfile/normalize-lockfile.bats | Behavioural tests for the normalizer script. |
| tools/scripts/lockfile/check-lockfile.bats | Behavioural tests for the invariant checker, including drift-class coverage. |
| tools/scripts/lockfile/README.md | Operational documentation: rollout, conflict resolution, interpreting guard failures. |
| .github/workflows/pr-checks.yml | New PR workflow to run lockfile invariant + shell script lint/tests. |
| .prettierignore | Excludes package-lock.json from prettier checks. |
| specs/2026-08-10_normalize-package-lock-resolved-urls/design.md | Design doc capturing constraints/decisions for the solution. |
| specs/2026-08-10_normalize-package-lock-resolved-urls/plan.md | Implementation plan and rollout steps across branches. |
| specs/2026-08-10_normalize-package-lock-resolved-urls/research.md | Research evidence backing the approach and constraints. |
| package-lock.json | Normalized resolved URL prefixes to the JFrog npm proxy. |
Suppressed comments (2)
tools/scripts/lockfile/check-lockfile.sh:151
mktempwithout a template is not portable (it fails on macOS/BSD). Use a portable template path for the baseline temp file.
baseline_file="$(mktemp)"
tools/scripts/lockfile/lib.sh:36
RESOLVED_URL_REis intended to validate standard npm tarball URLs (as described in the error message), but it currently allows any trailing filename. Consider requiring\.tgzto align validation with the documented contract.
readonly RESOLVED_URL_RE='^https?://[^/]+/.*(?:@[^/]+/)?[^/]+/-/[^/]+$'
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… the xxd dependency Two fixes from Copilot's review of PR #163: - TARBALL_PATH_RE and RESOLVED_URL_RE now require a `.tgz` suffix instead of accepting any filename, so the regexes match what the comments and error messages promise. All 542 entries are .tgz today, the fingerprint of the real lockfile is unchanged (223e40e6ff642ff3), and a new bats case proves a .zip tarball URL now fails by package path rather than being silently rehosted. - The trailing-newline test used `xxd`, which ships with vim-common rather than coreutils. The workflow installs only bats and shellcheck, so that was an undeclared dependency on the runner image - and it contradicted this plan's own criterion that nothing may rely on the image happening to ship a tool. Replaced with `od`. bats 25/25, shellcheck clean, check-lockfile.sh still green against HEAD~2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… review Measured, without touching any branch: - No drift since research. All seven branches still show 171 npmjs entries and the three expected lockfile states; the last commit touching any lockfile is from 2025, so there is no Dependabot churn to account for. - The normalizer dry-runs cleanly against all six remaining branches. The +4788 delta holds across every state (it is 171 x 28, independent of entry count): 262061 -> 266849 for 25.3/25.4, 270244 -> 275032 for 26.x/master. 342 changed lines, 0 non-resolved, idempotent in every case. - .prettierignore is byte-identical on all seven branches, so the conflict this plan warned about cannot occur. - No branch already contains tools/scripts/lockfile or pr-checks.yml, so every copy is a clean add. - The canary trap recurs per state: each branch pins its composites to its own release, so the 25.4 and 26.x canaries each need their own throwaway branch with internal refs re-pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benno42
left a comment
There was a problem hiding this comment.
🚀 Looks good. Just two questions.
…unbook and reusable PR body Adds consumer-survey.md: 299 repo/branch combinations across 41 FE repos measured for registry.npmjs.org. 8 affected branches across 3 repos, 291 clean. cplace-loomeo-fe 22.4-23.3 are essentially un-migrated (~20500 of the 20537 total entries) and look dormant; cplace-paw-fe 25.2/25.3 (14 each) and cplace-bayer-prompt-fe sprint-56/57 (3 each) are the residue cases the normalizer is built for. Two findings that change 34454s scope: paw-fe release/25.2 and 25.3 look latently broken for the same reason at the workflow-level npm ci, and four consumer repos carry local-path resolved values that assert_resolvable would hard-fail on, so the tooling cannot be pointed at them unmodified. Also records the per-state canary runbook (both Phase 6 traps) and a reusable PR description for the six replication PRs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and 25.3 are broken, not latent The survey flagged 14 npmjs entries on each branch as a possible masked failure. Verified it is a real one: - npm ci on release/25.2's real lockfile with the cplace ~/.npmrc and a cold cache fails: E404 GET https://cplace.jfrog.io/readdirp/-/readdirp-3.6.0.tgz - cplace-paw-fe has 13 caches and ZERO on release/25.2 or release/25.3 - the cache key embeds hashFiles('**/package-lock.json'), so a cross-branch hit is impossible by construction - a different lockfile is a different key - no restore-keys in fe-install-deps, fe-pr-snapshot or fe-licenses, so only an exact match sets cache-hit=true and nothing can skip Install modules Per GitHub's documented scoping a PR run CAN restore the base branch's cache, so "a feature branch gets no cache" is not what makes this fail - the absent cache and the content-addressed key are. Recorded because the base-branch rule is the one people forget. Any PR into those two branches reaching fe-install-deps, fe-licenses, fe-pr-snapshot or fe-pr-close fails at Install modules with a masked E404. It is unobserved only because no recent PR has targeted them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gistry-host, and inventory what still needs fixing Normalizing seven branches fixes this repo's lockfile, but it does not help consumer repositories, and cplace-paw-fe release/25.2 and 25.3 are broken today for exactly the same reason (verified: cold npm ci fails E404, no cache exists on those branches, and the cache key is content-addressed so no cross-branch hit is possible). use-npmrc now appends `replace-registry-host=never` to the ~/.npmrc it writes. npm then fetches each `resolved` URL verbatim instead of rewriting its host and dropping the registry path prefix. Measured against real lockfiles with the real secret: github-actions, un-normalized -> added 542 packages cplace-paw-fe release/25.2 -> added 2576 packages JFrog URLs stay authenticated by the secret, so unlike reverting use-npmrc to a workspace-level .npmrc this adds no dependency on anonymous JFrog access - that alternative was measured at 730 anonymous requests per run and would collide with PFM-ISSUE-34454. This is a mitigation, not the fix: entries still on npmjs are fetched directly, bypassing Xray and curation. So use-npmrc also runs warn-foreign-registry.sh against the CONSUMER's lockfile and emits a ::warning annotation plus a job summary naming the offending package paths. Those warnings are the inventory of lockfiles still to normalize; when none report, the mitigation line comes out. The two compose safely in either order - on a normalized lockfile the flag is a no-op - so the mitigation can be removed lazily per branch. The check is advisory by construction: missing lockfile, missing jq and invalid JSON all exit 0 silently, and local-path `resolved` values (present in 4 of 41 FE repos) are ignored rather than reported. It must never fail someone else's build. bats 34/34, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
|
| surface | where npm ci runs |
whose lockfile | fixed by |
|---|---|---|---|
| composite | the action's checkout | this repo's | normalization (this PR) |
| consumer | the workspace | the consumer's | nothing, until now |
That mattered more than expected. A survey of 299 repo/branch combinations across 41 FE repos found 8 affected branches — and cplace-paw-fe release/25.2 and release/25.3 are broken right now, each with 14 registry.npmjs.org entries. Reproduced with a cold cache and the real ~/.npmrc:
npm error code E404
npm error 404 Not Found - GET https://cplace.jfrog.io/readdirp/-/readdirp-3.6.0.tgz
Nothing masks it: that repo has zero caches on those branches, and the cache key is hashFiles('**/package-lock.json') — content-addressed, so a cross-branch cache hit is impossible by construction. It is unobserved only because no recent PR has targeted those branches.
What the one line does
echo 'replace-registry-host=never' >> ~/.npmrc
npm's default (replace-registry-host=npmjs) rewrites a lockfile's npmjs URLs onto the configured registry and drops that registry's path prefix — the bug. never makes npm fetch each resolved URL verbatim. Measured against real lockfiles with the real secret:
| github-actions, un-normalized | added 542 packages |
cplace-paw-fe release/25.2 |
added 2576 packages |
JFrog URLs stay authenticated by the secret, so this adds no dependency on anonymous JFrog access.
Why not just revert use-npmrc to a workspace-level .npmrc
That was the first instinct, and it was measured rather than argued:
- It does not fix the consumer surface. A consumer's
npm ciruns in the workspace, where that file lives — socplace-paw-festays broken. - It costs 730 anonymous JFrog requests per run, creating exactly the dependency PFM-ISSUE-34454 exists to remove. If the anonymous shutdown landed while that revert was in place, every composite would break — worse than today.
This is a mitigation, and it inventories its own removal
Under it, entries still on npmjs are fetched directly from npmjs, bypassing Xray and curation. So use-npmrc also runs warn-foreign-registry.sh against the consumer's lockfile:
⚠️ 14resolvedentries inpackage-lock.jsonpoint somewhere other than the cplace npm proxy… First 10 affected packages ▸
Those warnings are the removal criteria. When no pipeline reports one, the line comes out — by then it is already a no-op, since a normalized lockfile has no foreign URLs left to rewrite. The two compose safely in either order, so removal is lazy and per-branch, not a coordinated switchover. Tracked on PFM-ISSUE-34454, which now has a dedicated section for it.
The check is advisory by construction — it runs in every consumer's pipeline, so missing lockfile, missing jq and invalid JSON all exit 0 silently, and local-path resolved values (found in 4 of 41 FE repos) are ignored rather than reported as false positives. Four of the 34 bats tests exist solely to pin that property.
One consequence worth flagging for PFM-ISSUE-34454: a clean JFrog access log no longer proves everything resolves through the proxy while this is in place, because npmjs-direct requests never reach JFrog at all.
design.md gains Dimension 9 and plan.md gains Phase 4b/6b covering all of this.
Mitigation canary: green on the branch that is broken todaycplace-paw-fe#185, run Result
Two details that make this a real test rather than a lucky one:
Why the internal re-pin was necessaryThe canary branch re-pins What this does and does not proveProven: the mitigation fixes the consumer surface on a real runner, on a branch that genuinely fails without it. Not claimed: the rest of paw-fe's pipeline (build, e2e, code-quality) is still running and is not the subject — those exercise paw-fe's own code, not this change. Canary PR and both throwaway branches will be deleted. |
…design and plan design.md gains Dimension 9. It records that Dimension 8 declared consumer-repo lockfiles out of scope on a false premise - those pipelines are not "currently green": cplace-paw-fe release/25.2 and 25.3 are broken today. That splits the problem into two failure surfaces the design had treated as one, and only the mitigation covers the consumer side. Also records why reverting use-npmrc to a workspace-level .npmrc was rejected on measurement (fixes only the composite surface; 730 anonymous JFrog requests per run), and why the flag is applied unconditionally rather than conditionally (it is a no-op on a clean lockfile, so the warning carries the signal instead). plan.md gains Phase 4b (the mitigation, with its measured evidence) and Phase 6b (a consumer-surface canary against paw-fe release/25.2, where the internal use-npmrc re-pin is what makes the test valid at all). PFM-ISSUE-34454 has been updated with a dedicated section owning the removal: removal criteria, the survey blast radius, the local-path finding, and a warning that a clean JFrog access log no longer proves proxy-only resolution while the mitigation is in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
paw-fe#185 against cplace-paw-fe release/25.2 - the branch carrying 14 registry.npmjs.org entries that fails npm ci today. install-deps SUCCEEDS with the mitigation: echo 'replace-registry-host=never' >> ~/.npmrc ##[warning]14 entries in package-lock.json do not resolve via the cplace npm proxy... added 2439 packages, and audited 2440 packages in 8m Install modules -> success The 8m install time confirms a genuinely cold cache - nothing was restored, which is exactly the condition under which this branch fails. Had a cache been restored, Install modules would have been skipped and the run would have proved nothing. The advisory warning independently counted 14, matching the survey, and surfaced in the run UI without failing the job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d record a near-miss The mitigation canary went green across the whole paw-fe pipeline (15 jobs), not just install-deps, on a branch that cannot install without it. PR and both throwaway branches deleted. Also records a process near-miss: the two documentation commits for Dimension 9 and Phase 4b/6b were made while still standing on the throwaway canary branch, pushed to its remote, and then orphaned when that branch was deleted local and remote. Recovered by cherry-picking the SHAs out of the reflog. Noted so the next canary does not repeat it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#163 Both raised by @Benno42, both confirmed by reproduction. 1. The PR guard blocked every legitimate dependency change. check-lockfile.sh compared the whole dependency graph against the base branch, so a PR that added or updated a package failed even when its lockfile was perfectly normalized - and the failure then advised running the normalizer, which cannot fix a graph difference. Reproduced: adding one entry correctly on the proxy, with zero npmjs URLs, exits 1. Graph invariance is the right assertion for verifying a NORMALIZATION commit ("prove I changed only prefixes"), and the wrong one for everyday pull requests. The ticket's actual criterion is prefix exactness alone. Adds --prefix-only and switches pr-checks.yml to it. Since that needs no baseline, the guard no longer needs git or fetch-depth: 0 either. The graph-failure message no longer suggests the normalizer and points at --prefix-only instead. 2. Root-package drift was silently ignored. Investigating the second comment - whether the graph comparison should skip the "" root key like assert_resolvable does - showed the opposite problem: it was ALREADY being skipped by accident. The root key is the empty string, so when it was the only drifted entry jq emitted a blank line and `[[ -n "${drift}" ]]` read that as "no drift". Changes to this project's own declared dependencies passed silently. Now rendered as "<root package>". The root must NOT be filtered: unlike assert_resolvable, where the root has no `resolved` by construction and would be a guaranteed false positive, the root entry holds the declared dependencies and a change there is exactly the drift this assertion exists to catch. bats 42/42, shellcheck clean, actionlint clean. The Phase 5 normalization verification flow (--baseline HEAD~1) is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
specs/2026-08-10_normalize-package-lock-resolved-urls/design.md:537
- This integration statement is false in the current diff:
use-npmrc/action.ymlis modified to appendreplace-registry-host=neverand run the advisory scanner. Update the architecture summary so the material consumer-runtime mitigation is not documented as out of scope.
- `.github/actions/use-npmrc/action.yml` is **not modified** — that is PFM-ISSUE-34454's territory.
tools/scripts/lockfile/check-lockfile.sh:75
- The union comparison treats a missing top-level key and an explicitly present
nullkey as equal because both$A[$key]and$B[$key]evaluate tonull. As a result, adding or removing null-valued top-level metadata passes the claimed whole-document graph invariant. Compare key presence withhasas well as values.
| [ ((($A | keys) + ($B | keys)) | unique)[]
| select(. != "packages")
| select(($A[.] | tojson) != ($B[.] | tojson))
| "top-level key: " + . ]
tools/scripts/lockfile/README.md:125
- This guard description contradicts the mode table immediately above and the workflow:
pr-checks.ymlnow invokes--prefix-only, so graph invariance is not run on pull requests. Rewrite this section to distinguish the PR guard's single prefix assertion from the two assertions used by baseline verification; otherwise readers may incorrectly assume ordinary PRs are checked for graph drift.
`.github/workflows/pr-checks.yml` runs `check-lockfile.sh` on every pull request. It makes **two independent
assertions, both of which must pass**:
specs/2026-08-10_normalize-package-lock-resolved-urls/design.md:529
- The implemented PR guard no longer compares against the base commit; it runs
check-lockfile.sh --prefix-onlyso legitimate dependency changes are allowed. This architecture summary still documents the superseded behavior.
This issue also appears on line 537 of the same file.
**Guard (every PR):** `pr-checks.yml` → `lockfile` job runs `check-lockfile.sh` against the base commit → on failure,
prints offending package paths plus the remediation command; `scripts` job runs shellcheck + bats.
…iew round All ten raised by @senthanal (nine) and @Copilot (one) reproduced before being fixed. Four of them FAILED OPEN - the check reported success on a lockfile it had not actually verified, which is worse than any false positive. Failing open: - assert_graph_invariant reported PASS whenever its own jq failed. main() calls it inside a `||` list, which disables `set -e` for the function body, so an unchecked failure left `drift` empty and fell through to "PASS: dependency graph identical to baseline". Reproduced with fingerprint.jq moved aside and a poisoned integrity: PASS, exit 0. Every step is now checked and dies. - --prefix-only silently ignored an explicitly passed --baseline, so `--baseline HEAD~1 --prefix-only` - the natural thing to type once the README calls --prefix-only "the PR guard" - answered a weaker question and exited 0 on a poisoned integrity. The combination is now rejected. - count_foreign_entries used a different predicate than the rewrite, so the normalizer reported "0 rewritten / already normalized" while silently rewriting the file and dropping a path segment. README Flow 1 makes that count the documented verification signal. Both now use one shared predicate. - A v1 lockfile guarded only with `(.packages // {})` would pass vacuously, reporting "resolves entirely via the proxy" having examined nothing. Now rejected by an explicit lockfileVersion check. Wrong answers, quietly: - resolve_baseline hardcoded `package-lock.json`, so a nested candidate was compared against the wrong file - relevant because consumer-survey.md plans to point this tooling at the FE repos. - A second positional silently replaced the first, so `check base.json cand.json` dropped the baseline, fell back to HEAD, and printed a drift report naming every package in the repo. - --prefix-only hard-failed on workspace and link: entries that the mode promises to allow, and that warn-foreign-registry.sh already tolerates. - "FAIL: 1 distinct registry prefixes found (expected exactly 1)" - the rollout case, phrased so the check reads as broken. Robustness and docs: - use-npmrc invoked the advisory script as a bare unquoted path, so anything preventing it from being reached (exec bit, spaces, a Windows action_path) failed the consumer's job at exit 127 - the one thing the script's own "never fail a build" contract cannot cover. Now `bash "<path>" || true`. - README Flow 3 still described the two-assertion guard, so a reader would conclude CI proves integrity hashes were not poisoned. It does not. bats 51/51, shellcheck clean. Real lockfile still reports 171 / +4788 and stays idempotent; both CI and normalization-verification flows verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fail closed Raised by @Benno42. Of the three locations named, two were already fixed in 7fee8d4; this one was still live and is the most serious instance of the class, because it does not merely report a false PASS - it WRITES the lockfile. if ! diff -q <(fingerprint_of "${lockfile}") <(fingerprint_of "${tmp}") `if !` disables set -e for the condition, so when fingerprint_of failed both process substitutions produced empty output, diff called them identical, the self-assertion "passed", and the rewritten file was copied over the original. Reproduced with fingerprint.jq moved aside: exit 0, "normalized", and the lockfile overwritten with its only verification step silently skipped. Both fingerprints are now materialised and checked before the comparison, and the jq rewrite itself is guarded. Every failure path says "left untouched" and leaves the file byte-identical. bats 52/52, shellcheck clean; the real lockfile still reports 171 / +4788 and stays idempotent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…solve the use-npmrc contradictions Checkboxes. Phases 1-4 carried zero ticked criteria while 5, 6, 4b and 6b were ticked. `git log -S` confirms Phase 1's criteria were never committed as ticked at all, so the marks made during implementation were lost - most plausibly to the mid-session reformat that rewrote plan.md. Restored with the evidence each was verified against, and the four criteria that are reviewer judgement rather than machine-checkable are labelled as such instead of ticked. use-npmrc contradictions. Both documents still forbade exactly what Phase 4b / Dimension 9 did: plan.md:116 "Modifying `use-npmrc` ..." under What We're NOT Doing design.md:537 "`use-npmrc/action.yml` is **not modified**" Both are now struck through and annotated with what superseded them and why - the exclusion assumed consumer pipelines were green, and two are not. The four composite actions remain genuinely unmodified, so that half of each statement is preserved rather than deleted. Also records that master is explicitly in scope and that #163 is the seed: the tooling set is settled here and replicated, while each PR still carries its own branch-specific differences (lockfile content, base branch). The byte-identity gate compares only the tooling paths, which is what keeps that distinction honest. Factual corrections in the same class: Phase 6b claimed the Phase 6 canary ran on master (both canary PRs were against release/25.2); Desired End State omitted warn-foreign-registry.sh and use-npmrc from the byte-identical set; stale suite counts (24/24, 25/25, 34/34) annotated against the current 52; design.md Key Components gained the advisory script and the drift-case count corrected from t1-t4 to t1-t6; the guard data-flow now states it runs --prefix-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n-foreign-registry
Found by the correctness reviewer. warn-foreign-registry.sh carried its own copy
of the proxy URL and its own `startswith($proxy)` predicate - the exact bug
lib.sh documents as already found and fixed once in count_foreign_entries. An
entry on the right host with a stray path segment satisfies `startswith` and was
silently reported as compliant:
.../cplace-npm/extra/beta/-/beta-2.0.0.tgz
advisory : SILENT
check : FAIL
That mattered more here than anywhere else. These warnings are the inventory
that decides when the replace-registry-host=never mitigation can be removed, so
a false negative could green-light removing it while lockfiles are still broken.
Now sources lib.sh and uses the shared JQ_REGISTRY_ENTRIES predicate, so the
advisory and the guard cannot disagree - which also removes the duplicated
proxy constant the craft reviewer flagged separately. Sourcing is guarded on
both sides: an unreachable or unloadable lib.sh exits 0 silently, because the
advisory contract cannot be met by a script that dies referencing an undefined
constant under `set -u`.
Three tests added: the stray-segment case, an agreement test asserting the
advisory warns exactly when check-lockfile fails, and one pinning the silent
exit when lib.sh is hidden. bats 55/55, shellcheck clean. Missing file, invalid
JSON and a missing packages section all still exit 0 silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five reviewer agents over origin/release/25.2...HEAD. 15 findings kept, 0 dropped in verification, 0 merged. Four fixed during the review. Two fail-open defects lead: the advisory scan reproduced the startswith($proxy) bug already fixed in count_foreign_entries (fixed, 4a160a7), and an uppercase URL scheme bypasses the PR guard entirely - reproduced, so that finding was upgraded from inferred rather than downgraded. Also records that the guard shipped as --prefix-only while the plan still specifies --baseline base.sha with fetch-depth: 0, which needs reconciling before the tooling is replicated to six more branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… 1.4 Three of the eleven open findings from review iteration 1. 1.3 is the one that mattered: jq's test() is case-sensitive, so an entry resolving to HTTPS://attacker.example.com/evil/-/evil-1.0.0.tgz was not a registry entry at all and --prefix-only - the sole automated guard on every pull request - reported OK, exit 0. npm reads the scheme case-insensitively, so the scheme is now matched that way in both places it is spelled, in JQ_REGISTRY_ENTRIES and in RESOLVED_URL_RE. The .tgz suffix stays case-sensitive. Two tests: the guard rejects such an entry, and the normalizer rewrites it onto the proxy - the guard's remediation advice has to stay true. 1.2: fp_base/fp_cand were cleaned up by an explicit rm -f at the end of assert_graph_invariant, which every `|| die` between them skipped, including the fingerprint failure path a bats test drives on every run. Both mktemp calls move into main() beside baseline_file, under one widened EXIT trap; the function takes the two paths as arguments. 1.4: the guard shipped as --prefix-only in 604f95e while plan.md still specified --baseline base.sha on a fetch-depth: 0 checkout, and Phase 4 was ticked throughout. Phase 4's block now shows what shipped, with the reason and its four consequences. Key Discovery 6, the fetch-depth reference and design.md's matching implication are struck through as superseded. Phase 5's CI evidence is kept verbatim but labelled as predating the switch, since the current workflow cannot produce those lines. Suite is 57. shellcheck -x clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s into plan Phase 9 Mode A triage of the eight open iteration-1 findings. [1.15] is ticked in review.md as a no-change resolution: the widened trust boundary is accepted, documented in three places and owned by PFM-ISSUE-34454. The seven that need a code change - [1.5], [1.9], [1.10], [1.11], [1.12], [1.13], [1.14] - become Phase 9, so their boxes stay unchecked until it is implemented. Phase 9 also carries an argument-by-argument audit of every jq invocation, which found a latent defect the review missed: normalize-lockfile.sh and fingerprint.jq transform every entry with a string `resolved`, including the root "" key that JQ_REGISTRY_ENTRIES excludes. capture() on a non-match yields empty rather than raising, and `.x |= empty` deletes the key, so a root entry resolving to a non-tarball value loses `resolved` silently - and fingerprint.jq shares the flaw, so the self-assertion cannot see it. A root entry on npmjs is rewritten while the run reports `entries rewritten: 0`. Phase 9 must land before Phase 7 replicates the tooling to the six remaining branches, or every fix in it has to be made seven times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fter /-/ The 2026-08-13 consumer canary reported 18 foreign entries in cplace-paw-fe release/25.2 where only 14 are foreign. The four extras are on the correct proxy host but carry a tarball path the regex rejected: a repeated scope (@cplace-next/cf-frontend-sdk/-/@cplace-next/cf-frontend-sdk-25.2.30.tgz) or an interposed version segment (@fortawesome/fontawesome-pro/-/5.15.4/...tgz). Both are served by JFrog and installed by npm without complaint. The false positives are not cosmetic. The advisory's warnings are the inventory that decides when replace-registry-host=never can be removed, so a permanent non-zero count means it can never be removed; and assert_resolvable rejected the same four, so normalize-lockfile.sh refused to touch that lockfile at all. TARBALL_PATH_RE and RESOLVED_URL_RE are widened from [^/]+\.tgz to .+\.tgz, in lockstep: everything the validator accepts must stay capturable, or an entry passes validation and is then handed to a capture that yields empty - and `|= empty` deletes the key rather than raising. Measured: paw-fe now reports 14, normalizes 14 (+392 bytes) and then passes the guard with the four odd-shaped entries untouched; this repo's guard passes and its normalizer stays idempotent (0 entries, 0 bytes); the fingerprint is byte-identical on all seven branches, so Phase 5's evidence stands. bats 65/65, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ixes and review findings Discharges the seven review findings needing a code change, plus the four defects found by auditing every jq invocation. The audit fixes: - normalize-lockfile.sh and fingerprint.jq now carry the shared predicate's own terms. Both transformed every entry with a string `resolved`, including the root "" key that JQ_REGISTRY_ENTRIES excludes, so a root entry on npmjs was rewritten while the run reported "entries rewritten: 0, already normalized" - and a root `resolved` that is not a tarball URL lost its key outright, because a non-matching capture yields empty and `|= empty` deletes. fingerprint.jq made the same deletion on both sides, so nothing could observe it. - lib.sh records what jq's capture actually does. The old comment claimed it raises on a non-match, which is what made the above read as safe. - check-lockfile.sh version-checks the resolved baseline before fingerprinting. A lockfileVersion 1 baseline produced `null (null) has no keys` followed by "cannot fingerprint the baseline (is fingerprint.jq present?)" - a jq trace and a wrong diagnosis. assert_supported_lockfile takes a display name so the message names the ref rather than a mktemp path. - check-lockfile.sh reports how many entries it examined and refuses to report compliance from an empty set. A lockfile with only link:/workspace entries printed "exactly 0 registry prefix, matching the expected proxy". It returns 2 for that case, so main withholds "run the normalizer" - advice that cannot fix zero entries. - An offender differing from the proxy only in scheme is now named as a plaintext-http downgrade rather than left reading as a path mistake. The review findings: [1.5] a :2: merge-stage baseline test driven through a real conflict, [1.9] $GITHUB_ACTION_PATH matching its four siblings, [1.10] the README's self-contradicting section, [1.11] the missing-lockfileVersion arm in both suites, [1.12] design.md and README recording that assert_resolvable does not run in --prefix-only, [1.13] single-quoted jq fragments in place of the escaped double-quoted ones, [1.14] plan.md annotated with the shipped regex. bats 74/74, shellcheck clean. On the real lockfile the guard passes with 542 entries examined and the normalizer stays idempotent; the baseline form still passes across f08ac98; the fingerprint is byte-identical on all seven branches after the fingerprint.jq guards. All 15 review findings are now resolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
This repo ships a mixed
package-lock.json: 171 entries resolve viahttps://registry.npmjs.org/, the other 371 via the cplace JFrog npm proxy.The composite actions run
npm ciinside the action directory, on the consumer's runner, while that consumer's~/.npmrcis active. npm rewrites an npmjsresolvedURL onto the configured host and drops the registry's path prefix (pacote/lib/remote.js:new URL(resolvedURL.pathname, this.registry)), producingE404— masked as***because the prefix is theJFROG_URLsecret.This PR is no longer tooling-only. It now contains a runtime behaviour change affecting every consumer:
Why it is here rather than in a follow-up: normalizing this repo's lockfile fixes the composite surface only. A consumer's own
npm ciruns in the workspace against its own lockfile, and a survey of 299 repo/branch combinations across 41 FE repos foundcplace-paw-ferelease/25.2andrelease/25.3are broken today — 14 npmjs entries each, verified failing with a cold cache. Nothing masks it: those branches have no cache, and the key is content-addressed to the lockfile.What it costs: entries still on npmjs are fetched directly from npmjs, bypassing the proxy — no Xray, no curation. That is a deliberate, temporary supply-chain trade to stop live breakage.
How it gets removed:
use-npmrcalso runs an advisorywarn-foreign-registry.shagainst the consumer's lockfile, emitting a::warningplus job summary. Those warnings are the inventory; when no pipeline reports one, the line comes out. Owned by PFM-ISSUE-34454, which has a dedicated section for it. The two compose safely — on a normalized lockfile the flag is a no-op.Verified end-to-end: cplace-paw-fe#185, full pipeline green (15 jobs) on
release/25.2, withInstall modulessucceeding (added 2439 packages … in 8m, cold cache) and the annotation correctly reporting 14.What is in the PR
tools/scripts/lockfile/{lib.sh,fingerprint.jq,normalize-lockfile.sh,check-lockfile.sh}.github/actions/use-npmrc/action.yml,tools/scripts/lockfile/warn-foreign-registry.sh.github/workflows/pr-checks.yml(this repo's firston: pull_requestworkflow)tools/scripts/lockfile/*.bats— 51 testspackage-lock.json— 171 prefixes, its own committools/scripts/lockfile/README.md,specs/2026-08-10_normalize-package-lock-resolved-urls/Why bash + jq in a TypeScript repo
Bootstrap independence.
npx ts-nodeneedsnode_modules, which needs thenpm cithat is broken.Two modes, and why CI uses the weaker one
--prefix-only— every entry resolves via the proxy. This is the PR guard.--baseline <ref>— the above plus the dependency graph is unchanged. Verifies a normalization commit.Graph invariance forbids any dependency change, so gating PRs on it would fail every legitimate
npm install. Consequence, stated plainly: a green CI run is not evidence that a lockfile diff changed only prefixes. Where that matters, run the baseline form locally.Evidence
"resolved"line; idempotentsedprefix rewritegrep -c registry.npmjs.org package-lock.json→ 0~/.npmrc:E404→added 542 packagesEvery failure message names a package path, never a URL, so it survives
***masking.Review history
Two rounds found 12 defects, all reproduced before fixing. Several failed open — reporting success on a lockfile that was never actually verified. See the inline threads; each fix has a regression test.
Known limitation
The guard reports but cannot block until PFM-ISSUE-34465 (Rule Sets) lands.
release/25.2has no branch protection at all today.Rollout
Branch 1 of 7.
masterandrelease/26.3are not downstream of26.2, so this cannot ride the upmerge. The other six carry byte-identical tooling plus the same machine-checked lockfile transformation — this is the review that counts.Spec:
specs/2026-08-10_normalize-package-lock-resolved-urls/Refs PFM-ISSUE-34453. Blocks PFM-ISSUE-34454.
🤖 Generated with Claude Code