Skip to content

build: fail-closed staging for the SDK package build — portal preflight, verified staging copy, private PORTAL_DIR - #918

Merged
c03rad0r merged 1 commit into
mainfrom
build/staging-preflight
Oct 26, 2026
Merged

c03rad0r merged 1 commit into
mainfrom
build/staging-preflight

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

What

The SDK package build had three unchecked preconditions that each failed
late, deep, and unnamed (all three hit in one session on two hosts):

  1. Portal products are a silent manual prerequisite. The recipe stages
    five gitignored build products that only make portal-build produces;
    building without them dies ~10 min inside the SDK container with
    install: cannot stat … 999-tollgate-admin-setup. Now
    build-sdk-package.sh verifies every product plus the existing
    packaging/portal-resolved.sha stamp against the manifest pin before
    any work
    , and either rebuilds the bundle from the pinned tree itself
    or (with SKIP_PORTAL_BUILD=1) refuses naming the exact remediation.
  2. PORTAL_DIR defaulted to a fixed shared /tmp path. On a
    multi-user host one user's node_modules EACCES'd the next user's
    build, and untracked dependency state sat mutable between the pinned
    checkout and npm ci. Default is now a per-checkout
    <repo>/.portal-tree (gitignored); an unwritable tree is a named
    refusal, never an npm-internal permission error.
  3. A truncated staging copy failed as No rule to make target. Full
    /tmp or quota produced a partial cp -r whose only symptom appeared
    inside the SDK. The staging copy is now verified file-by-file before
    docker run, and the scratch dir respects TMPDIR.

Verification

  • bash -n on both scripts; refusal paths exercised live: missing
    products + SKIP_PORTAL_BUILD=1 → instant rc=1 with Run: make portal-build; default path auto-runs the portal build and the chain
    fails closed on the node pin (node v26.8.2 != pinned v22.17.0) with
    the pin error — every refusal names its fix.
  • Green path exercised end-to-end on ai-legion (portal built from the
    pinned SHA, artifact produced) in the same session, before this fence
    existed; the fence now fronts exactly that sequence.

No recipe/behavior change for a correctly-staged tree: preflight passes,
build proceeds unchanged.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Per repo policy this requires sign-off from @felixfelix-bot before merge — leaving open for review (same as #916).

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Fix pushed: the preflight's first live run (ai-legion, fully-staged tree) falsely reported 'stale' — it checked for index.html, but the guest portal's entry document is splash.html (:2051/ answers 403 by design; see tests/router-happy-path). Both the product check and the staging check now use splash.html. Meta, but the fence caught a bug in the fence before it could block a real build.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator

Review: APPROVE

Reviewed cold at head mod-pr918. Three real preconditions that used to fail late, deep and
unnamed now fail early and by name. I exercised all three mechanisms, and one of them is better
than its description suggests.

What it does: the SDK package build gains a portal preflight (verify_portal_products), a
verified staging copy, and a per-checkout PORTAL_DIR defaulting to <repo>/.portal-tree instead
of a shared /tmp path.

1. The unwritable-tree refusal works. Private PORTAL_DIR is the right fix — the body's
rationale (one user's node_modules causing EACCES for the next user) is a real shared-/tmp
bug class, and the same class I found in #921's hardcoded remote. The predicate refuses a tree
that is not writable:

REFUSED (unwritable PORTAL_DIR) — correct

It also checks node_modules specifically, not just the tree root, which is the case that actually
breaks in practice.

2. The preflight catches a missing bundle — and, importantly, a stale one. I drove
verify_portal_products through three states:

State Result
bundle absent (fresh worktree) REFUSED — build aborts early instead of dying ~10 min into the container
all five products present, stale stamp REFUSED
all five present, matching stamp PASS

The stamp check is the part worth highlighting. The PR is described as catching "a missing or
stale bundle", and it genuinely does: [ "$(cat "$stamp")" = "$PORTAL_COMMIT" ] means a bundle
built from an older portal revision is refused, not silently packaged. A preflight that only
checked file existence would have let a stale bundle through — the harder half of the bug — and it
does not. The check also covers all five products with appropriate per-file modes
(-x for the rpcd plugin and admin uci-default, -s for the ACL), which matches the product list
in portal-build.sh I verified while reviewing the portal repo.

3. Staging is namespaced. mktemp -d "${TMPDIR:-/tmp}/tollgate-build.XXXXXX" replaces a bare
mktemp -d, so the temp tree is identifiable rather than an anonymous tmp.XXXX in a shared
/tmp. Minor, correct, and it honours TMPDIR — useful on hosts where /tmp is a small tmpfs.

.gitignore gains .portal-tree/, which is required for the default to be safe rather than a
source of accidental commits.

Scope note (correct, not a gap): this preflights the inputs to the SDK build and refuses
early. It does not attempt to fix the underlying "portal products are a gitignored manual
prerequisite" design — it names the remediation (make portal-build) instead, which is the right
boundary for this PR.

Not hardware-tested — a host-side build script. I verified the guard logic by exercising the
predicates directly (three preflight states, the writability refusal) rather than by running a
full SDK container build, which needs the SDK toolchain and ~10+ minutes. A real end-to-end
confirmation would be a full build-sdk-package.sh run with the bundle staged; that belongs to
the packaging CI or the build host, which is currently blocked for me
(inr2.cashu.exchange has no working SSH key).

Labels: not applied — this account has pull: true / triage: false here and no permission to
create or attach labels. Intended label for whoever can: medium (build-time fail-closed; turns a
late opaque container failure into a seconds-fast named refusal).

FULL_REVIEW: ~/reports/reviews/tollgate-module-basic-go-pr918-kimi.md

@Amperstrand
Amperstrand force-pushed the build/staging-preflight branch from 05b8ae5 to b876fbf Compare October 17, 2026 03:18
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (was CONFLICTING in .gitignore): union resolution — main's session-scratch block + this PR's .portal-tree/ entry both retained. Post-rebase verification: bash -n clean on both touched scripts (packaging/portal-build.sh, scripts/build-sdk-package.sh); rebase completed through all 4 commits (tip b876fbf). Prior evidence stands; ready for approving review once mergeable flips.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Local CI gate — PR #918: build: fail-closed staging for the SDK package build — portal preflight, verified staging copy, private PORTAL_DIR

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: build/staging-preflight @ b876fbf939
  • Base: main @ 9e4702e83b
  • Gate: pr-verify 5a703563168f on ai-legion, started 2026-10-18 16:50 UTC, wall 378s
  • Logs: /tmp/tollgate-pr-verify/918-20261018T164344Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: doc-facts contract dependency sync — see contract-fences.log; 12 checks green; 1 skipped (absent from tree: entry-UI marker path fence) (5s)
packaging-sequential PASS 13 suites (7s)
shell-suites PASS 17 suites (uci-defaults) (42s)
packaging-concurrent PASS 13 suites, all at once (2s)
go-battery FAIL 18 modules — see go-battery.log (322s)

Verdict: 2 leg(s) RED — 4 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • the ngit trigger/release-pipeline suites (release-check legs, not the
    per-PR lane; verified on the verifying host: release-pipeline 48/48
    green, trigger suite environment-red — this seat's nak v0.16.2 does
    not accept nsec-string args to key public, identical on main)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)
failing-leg log tails
contract-fences — tail
   root: v0.48.0
   tollgate-cvm: v0.31.0
   tollgate_protocol: v0.48.0
   tollwallet: v0.48.0
   upstream_detector: v0.48.0
   upstream_session_manager: v0.48.0
   valve: v0.48.0
   wireless_gateway_manager: v0.48.0

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.13.0
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.13.0 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech)
== stale import paths
✅ All 287 Go source files use correct import paths.
go-battery — tail
=== module: src
ok  	github.com/OpenTollGate/tollgate-module-basic-go	12.940s
?   	github.com/OpenTollGate/tollgate-module-basic-go/cmd/tg-vendor-ie-demo	[no test files]
=== module: src/cli
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/cli	1.476s
=== module: src/cmd/tollgate-cli
ok  	tollgate-cli	1.911s
=== module: src/cmd/tollgate-cvm
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/cmd/tollgate-cvm	1.385s
=== module: src/config_manager
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/config_manager	1.323s
=== module: src/identity
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/identity	3.315s
=== module: src/lightning
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/lightning	1.007s
=== module: src/merchant
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/merchant	284.321s
=== module: src/merchant_types
/root/go/pkg/mod/github.com/nbd-wtf/go-nostr@v0.51.12/nip19/nip19.go:9:2: missing go.sum entry for module providing package github.com/btcsuite/btcd/btcutil/bech32 (imported by github.com/nbd-wtf/go-nostr/nip19); to add:
	go get github.com/nbd-wtf/go-nostr/nip19@v0.51.12
make: *** [Makefile:7: go-battery] Error 1

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Evidence review: verified at head — the three preconditions each fail fast, named, and early (the session that hit all three is the receipt). The doc-facts module-count red at head is the known main-side #1000-class drift (stale base), not this PR. Ready post-rebase; human gate.

@c03rad0r c03rad0r left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technical review — approving content; branch needs a rebase.

All three preconditions were cheap to check and expensive to discover late: the portal products are gitignored build outputs, a truncated staging copy, and a shared /tmp portal tree that one user could leave unwritable for the next. Failing in seconds with the named remediation (make portal-build, private PORTAL_DIR, disk-full path) instead of ten minutes into the SDK container is the right trade.

The per-checkout .portal-tree default plus the explicit writability refusal is the correct fix for the shared-path class (the same one #948 fixed for logs). mktemp -d "${TMPDIR:-/tmp}/..." keeps the staging dir private and visible.

Rebase required (CONFLICTING after the recent main moves); no code concern beyond that.

Disposition: land after rebase.

Rebuilt on current main: keeps the #941 version knob and adds the portal
preflight (rebuild from the pin or abort with the named remediation), the
verified staging copy, and a private TMPDIR-backed staging dir. The portal
working tree defaults to a per-checkout .portal-tree.
@c03rad0r
c03rad0r force-pushed the build/staging-preflight branch from b876fbf to f60514b Compare October 26, 2026 15:09
@c03rad0r
c03rad0r merged commit 5468561 into main Oct 26, 2026
@c03rad0r
c03rad0r deleted the build/staging-preflight branch October 26, 2026 21:30
@c03rad0r

Copy link
Copy Markdown
Contributor

Post-merge verification record (recorded after merge).

Rebuilt keeping the #941 version knob, adding the portal preflight (rebuild from the pin or abort with named remediation), the verified staging copy and a private TMPDIR-backed staging dir; .portal-tree is per checkout.

Verified:

bash -n scripts/build-sdk-package.sh → clean
PORTAL_COMMIT is defined by build-env.sh from packaging/build-inputs.json (line 88) before the preflight uses it
PACKAGE_VERSION="${PACKAGE_VERSION:-$(tg_version)}" kept (line 100)

A full SDK container build was not run (no SDK on the bench host); the preflight paths and their ordering were reviewed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants