From f4337708bec0e71cab2eff2801222e98bdffafb8 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 17:39:19 +0000 Subject: [PATCH 1/8] ci: check the npm package installs and the manifests agree --- .github/workflows/ci.yml | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b548b4d..777b949 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -603,3 +603,127 @@ jobs: SKIP_BUILD: '1' ALLOW_UNTAGGED: '1' run: bash scripts/release-notes.sh "${GITHUB_REF_NAME}" + + # This job's name is the status check context that branch protection or a + # ruleset would match on. Renaming it, including a capitalisation change, + # stops that check from reporting on every pull request, this rename's own + # included. The scope gate is per-step rather than one job-level `if:` so + # that a scoped-out pull request still reports a green check carrying the + # scope log that says which paths it looked at. A job-level `if:` would + # report `skipped` instead, which a required check accepts but which reads + # on the pull request as though the job never ran. + package: + name: package + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # registry/dist is the package's payload and must not be committed, while + # a dist directory anywhere else under registry/ is build output of a kind + # nothing ships. The rule drawing that line lives in the ROOT .gitignore + # rather than beside the code, because npm applies a nested ignore file to + # the pack walk even for a path the manifest's "files" allowlist names, + # while the allowlist outranks the root file. That placement is exactly + # what hides the rule from every other check here: the root file cannot + # subtract from the tarball, so re-anchoring it to `registry/dist` leaves + # the smoke test and the manifest guard below both at exit 0 while build + # output under registry/src and registry/test silently stops being + # ignored. That is the regression 7d04a6c fixed, and this table is the + # only thing that holds it. Both halves are asserted: a rule that stops + # covering the tree is as wrong as one that reaches past registry/'s own + # build output. Ungated, like the sweeps at the top of the daml job: it + # needs nothing but the checkout, so it reports in seconds and cannot be + # silenced by a later narrowing of the gate below. + - name: Verify the build-output ignore rule + run: | + set -euo pipefail + status=0 + # git check-ignore exits 1 for a path no rule matches, which under -e + # would end the step at the first mismatch and hide every case after + # it. None of these paths need to exist: the command matches patterns. + for path in registry/dist/index.js \ + registry/src/dist/bundle.js \ + registry/test/dist/bundle.js; do + if git check-ignore -q "$path"; then + echo "ok: ${path} is ignored" + else + echo "${path} is not ignored: build output under registry/ is committable" >&2 + status=1 + fi + done + for path in registry/dist-backup/index.js \ + registry/src/index.ts \ + dist/index.js \ + scripts/dist/index.js; do + if git check-ignore -q "$path"; then + echo "${path} is ignored: the rule reaches past registry/'s build output" >&2 + status=1 + else + echo "ok: ${path} is not ignored" + fi + done + exit "$status" + + - name: Scope + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "Not a pull request: verifying unconditionally." + echo "run=true" | tee -a "$GITHUB_OUTPUT" + exit 0 + fi + # A blip on this fetch reds the job having verified nothing, so it is + # retried rather than taken at its word. The step above is ungated and + # sits there so that a red here finds it already run and reported. + for attempt in 1 2 3; do + git fetch --no-tags --prune origin \ + "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" && break + echo "git fetch attempt ${attempt} of 3 failed" >&2 + [ "$attempt" -lt 3 ] || exit 1 + sleep 5 + done + changed="$(git -c core.quotePath=false diff --name-only --no-renames "origin/${BASE_REF}...HEAD")" + echo "Changed files:" + echo "$changed" + # The package's own inputs: both manifests, both lockfiles by way of + # package-lock.json, everything the tarball carries, and the two + # scripts the steps below run. .gitignore is listed because it is in + # neither other gate, so a pull request narrowing the build-output + # rule would otherwise report two green checks having looked at + # nothing; the step above is what reads it once this job is here. + if grep -Eq '^(package\.json$|package-lock\.json$|registry/|scripts/check-registry-deps\.mjs$|scripts/registry-install-smoke\.sh$|\.gitignore$|\.github/workflows/ci\.yml$)' <<<"$changed"; then + echo "run=true" | tee -a "$GITHUB_OUTPUT" + else + echo "run=false" | tee -a "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-node@v4 + if: steps.scope.outputs.run == 'true' + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + # npm ci runs `prepare`, so this compiles registry/src against the ROOT + # dependency set, which is the set a consumer gets. The registry job + # compiles the same source against registry/node_modules; only this one + # would catch a type package that arrives there transitively and is + # declared nowhere. + - name: Install + if: steps.scope.outputs.run == 'true' + run: npm ci + + - name: Check the manifests agree + if: steps.scope.outputs.run == 'true' + run: npm run check:deps + + - name: Install smoke test + if: steps.scope.outputs.run == 'true' + run: npm run smoke:registry From c660316f5dabd0bbba00ea1f7a2c23b4ad317970 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 17:54:12 +0000 Subject: [PATCH 2/8] fix: read the ignore rules without the index, which hides tracked paths --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 777b949..15a5aed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -644,10 +644,15 @@ jobs: # git check-ignore exits 1 for a path no rule matches, which under -e # would end the step at the first mismatch and hide every case after # it. None of these paths need to exist: the command matches patterns. + # --no-index because without it check-ignore consults the index and + # calls every TRACKED path unignored whatever the patterns say. That + # would make the registry/src/index.ts case below vacuous, since it is + # the one tracked path here: a rule that swallowed the service's own + # source would still read as a pass. for path in registry/dist/index.js \ registry/src/dist/bundle.js \ registry/test/dist/bundle.js; do - if git check-ignore -q "$path"; then + if git check-ignore -q --no-index "$path"; then echo "ok: ${path} is ignored" else echo "${path} is not ignored: build output under registry/ is committable" >&2 @@ -658,7 +663,7 @@ jobs: registry/src/index.ts \ dist/index.js \ scripts/dist/index.js; do - if git check-ignore -q "$path"; then + if git check-ignore -q --no-index "$path"; then echo "${path} is ignored: the rule reaches past registry/'s build output" >&2 status=1 else From 11e0ae02b9e5c6f177174802de2c37149dfca200 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 17:54:45 +0000 Subject: [PATCH 3/8] fix: run the manifest guard before the install it guards --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15a5aed..5d044f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -716,6 +716,16 @@ jobs: cache: npm cache-dependency-path: package-lock.json + # Ahead of the install it guards, for the reason the daml job places its + # own sweeps first: the script only reads four tracked JSON files, so it + # needs no node_modules and reports in a moment. A lockfile that has + # drifted from the manifest beside it reds npm ci too, but with npm's + # generic "package.json and package-lock.json are not in sync", and every + # finding this names, on either side, would go unprinted. + - name: Check the manifests agree + if: steps.scope.outputs.run == 'true' + run: npm run check:deps + # npm ci runs `prepare`, so this compiles registry/src against the ROOT # dependency set, which is the set a consumer gets. The registry job # compiles the same source against registry/node_modules; only this one @@ -725,10 +735,6 @@ jobs: if: steps.scope.outputs.run == 'true' run: npm ci - - name: Check the manifests agree - if: steps.scope.outputs.run == 'true' - run: npm run check:deps - - name: Install smoke test if: steps.scope.outputs.run == 'true' run: npm run smoke:registry From 1b0bd10f87e3f6460a764a40b399797dd53d808f Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 17:55:34 +0000 Subject: [PATCH 4/8] fix: report a check-ignore that could not answer instead of passing it --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d044f7..79d6771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -641,9 +641,13 @@ jobs: run: | set -euo pipefail status=0 - # git check-ignore exits 1 for a path no rule matches, which under -e - # would end the step at the first mismatch and hide every case after - # it. None of these paths need to exist: the command matches patterns. + # check-ignore answers 0 when a rule matches and 1 when none does, + # and both are results rather than failures, so the status is held + # rather than left to -e, which would end the step at the first + # mismatch and hide every case after it. Anything above 1 means it + # could not answer at all: taking that for "no rule matched" would + # print the second list as passing while git was failing outright. + # None of these paths need to exist; the command matches patterns. # --no-index because without it check-ignore consults the index and # calls every TRACKED path unignored whatever the patterns say. That # would make the registry/src/index.ts case below vacuous, since it is @@ -652,23 +656,29 @@ jobs: for path in registry/dist/index.js \ registry/src/dist/bundle.js \ registry/test/dist/bundle.js; do - if git check-ignore -q --no-index "$path"; then - echo "ok: ${path} is ignored" - else - echo "${path} is not ignored: build output under registry/ is committable" >&2 - status=1 - fi + rc=0 + git check-ignore -q --no-index "$path" || rc=$? + case "$rc" in + 0) echo "ok: ${path} is ignored" ;; + 1) echo "${path} is not ignored: build output under registry/ is committable" >&2 + status=1 ;; + *) echo "git check-ignore could not answer for ${path}: status ${rc}" >&2 + status=1 ;; + esac done for path in registry/dist-backup/index.js \ registry/src/index.ts \ dist/index.js \ scripts/dist/index.js; do - if git check-ignore -q --no-index "$path"; then - echo "${path} is ignored: the rule reaches past registry/'s build output" >&2 - status=1 - else - echo "ok: ${path} is not ignored" - fi + rc=0 + git check-ignore -q --no-index "$path" || rc=$? + case "$rc" in + 1) echo "ok: ${path} is not ignored" ;; + 0) echo "${path} is ignored: the rule reaches past registry/'s build output" >&2 + status=1 ;; + *) echo "git check-ignore could not answer for ${path}: status ${rc}" >&2 + status=1 ;; + esac done exit "$status" From 964b35dbe768cbc552dfbcc96c34078aee274748 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 17:56:04 +0000 Subject: [PATCH 5/8] ci: gate on the npmrc that reconfigures every step below --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79d6771..4a009cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -713,7 +713,12 @@ jobs: # neither other gate, so a pull request narrowing the build-output # rule would otherwise report two green checks having looked at # nothing; the step above is what reads it once this job is here. - if grep -Eq '^(package\.json$|package-lock\.json$|registry/|scripts/check-registry-deps\.mjs$|scripts/registry-install-smoke\.sh$|\.gitignore$|\.github/workflows/ci\.yml$)' <<<"$changed"; then + # .npmrc is listed for that same reason, and neither gate holds it + # either. It reconfigures npm for every step below rather than + # naming a file any of them read, which is what keeps it out of a + # list written by thinking about inputs: `omit=dev` on its own leaves + # prepare with no compiler, which is exit 127 before anything builds. + if grep -Eq '^(package\.json$|package-lock\.json$|registry/|scripts/check-registry-deps\.mjs$|scripts/registry-install-smoke\.sh$|\.gitignore$|\.npmrc$|\.github/workflows/ci\.yml$)' <<<"$changed"; then echo "run=true" | tee -a "$GITHUB_OUTPUT" else echo "run=false" | tee -a "$GITHUB_OUTPUT" From f4cf64541fe52fd4e9725f389b3fc4a86061a0fb Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 18:31:43 +0000 Subject: [PATCH 6/8] ci: assert where the build-output ignore rule lives, not only what it covers --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a009cc..8d7332f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -632,9 +632,11 @@ jobs: # the smoke test and the manifest guard below both at exit 0 while build # output under registry/src and registry/test silently stops being # ignored. That is the regression 7d04a6c fixed, and this table is the - # only thing that holds it. Both halves are asserted: a rule that stops + # only thing that holds it. Three things are asserted: a rule that stops # covering the tree is as wrong as one that reaches past registry/'s own - # build output. Ungated, like the sweeps at the top of the daml job: it + # build output, and either pattern satisfies both of those from inside + # registry/.gitignore while emptying the package, so where the rule lives + # is read as well. Ungated, like the sweeps at the top of the daml job: it # needs nothing but the checkout, so it reports in seconds and cannot be # silenced by a later narrowing of the gate below. - name: Verify the build-output ignore rule @@ -680,6 +682,30 @@ jobs: status=1 ;; esac done + # Both loops read what the rule COVERS, and the same patterns cover + # the same tree from inside registry/.gitignore, where npm applies + # them to the pack walk as well: measured at 8 tarball entries rather + # than 24, registry/dist gone and the bin target with it, while all + # seven verdicts above stay green. The smoke test below is the only + # other thing that sees it, and only when the gate lets it run, so the + # file carrying the rule is read here too. -v prints the deciding + # source as `::`; the first field is the whole + # assertion. + rc=0 + match="$(git check-ignore -v --no-index registry/dist/index.js)" || rc=$? + case "$rc" in + 0) ignore_file="${match%%:*}" + if [ "$ignore_file" = ".gitignore" ]; then + echo "ok: the rule ignoring registry/dist lives in the root .gitignore" + else + echo "registry/dist is ignored by ${ignore_file} rather than the root .gitignore: a nested ignore file subtracts it from the npm pack walk" >&2 + status=1 + fi ;; + 1) echo "no rule ignores registry/dist, so there is no placement to read" >&2 + status=1 ;; + *) echo "git check-ignore could not answer for registry/dist/index.js: status ${rc}" >&2 + status=1 ;; + esac exit "$status" - name: Scope From b6b5ed8d0158279f94fafb8113f7ac639a604902 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 18:31:54 +0000 Subject: [PATCH 7/8] docs: correct the gate's input list and why .gitignore sits in it --- .github/workflows/ci.yml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d7332f..f1d2ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -733,17 +733,20 @@ jobs: changed="$(git -c core.quotePath=false diff --name-only --no-renames "origin/${BASE_REF}...HEAD")" echo "Changed files:" echo "$changed" - # The package's own inputs: both manifests, both lockfiles by way of - # package-lock.json, everything the tarball carries, and the two - # scripts the steps below run. .gitignore is listed because it is in - # neither other gate, so a pull request narrowing the build-output - # rule would otherwise report two green checks having looked at - # nothing; the step above is what reads it once this job is here. - # .npmrc is listed for that same reason, and neither gate holds it - # either. It reconfigures npm for every step below rather than - # naming a file any of them read, which is what keeps it out of a - # list written by thinking about inputs: `omit=dev` on its own leaves - # prepare with no compiler, which is exit 127 before anything builds. + # The package's own inputs: the root manifest and lockfile by name, + # the registry ones and every packed source through the registry/ + # prefix, and the two scripts the steps below run. LICENSE and + # README.md are packed too, whatever "files" says, and are left out + # on purpose: no check here or downstream asserts either, so gating + # on them would only lengthen the job. .gitignore is listed to keep + # this enumeration complete rather than for coverage, since the step + # above is ungated and reads that file on every event whether or not + # the gate names it, while the three steps below are blind to the + # root rule. .npmrc is the opposite case and this gate is the only + # thing holding it. It reconfigures npm for every step below rather + # than naming a file any of them read, which is what keeps it out of + # a list written by thinking about inputs: `omit=dev` on its own + # leaves prepare with no compiler, exit 127 before anything builds. if grep -Eq '^(package\.json$|package-lock\.json$|registry/|scripts/check-registry-deps\.mjs$|scripts/registry-install-smoke\.sh$|\.gitignore$|\.npmrc$|\.github/workflows/ci\.yml$)' <<<"$changed"; then echo "run=true" | tee -a "$GITHUB_OUTPUT" else From 5a6113116e10e7883a54c88fdef825fb5e3a2c0f Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 18:32:03 +0000 Subject: [PATCH 8/8] docs: state what npm ci actually refuses, since it is not this --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1d2ff4..eb4f4c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -762,10 +762,12 @@ jobs: # Ahead of the install it guards, for the reason the daml job places its # own sweeps first: the script only reads four tracked JSON files, so it - # needs no node_modules and reports in a moment. A lockfile that has - # drifted from the manifest beside it reds npm ci too, but with npm's - # generic "package.json and package-lock.json are not in sync", and every - # finding this names, on either side, would go unprinted. + # needs no node_modules and reports in a moment. npm ci is not a second + # reading of this. It reds only where the lockfile can no longer satisfy + # the range beside it, and then with npm's own "Invalid: lock file's + # express@4.22.2 does not satisfy express@5.2.1"; a widened range the + # lockfile still satisfies installs clean, and a disagreement between the + # two trees is invisible to it whatever the ranges say. - name: Check the manifests agree if: steps.scope.outputs.run == 'true' run: npm run check:deps