Skip to content

Release hygiene: one version, one Node line, every env var written down - #340

Merged
Apolloccrypt merged 4 commits into
mainfrom
chore/release-hygiene
Sep 2, 2026
Merged

Release hygiene: one version, one Node line, every env var written down#340
Apolloccrypt merged 4 commits into
mainfrom
chore/release-hygiene

Conversation

@Apolloccrypt

Copy link
Copy Markdown
Owner

Points 1, 2, 8 and 9 from the toekomstbestendigheid report of 2026-09-02. Only
the parts that need no product decision. No tag, no deploy: those belong
together, and docs/RELEASE.md says so.

9. Versions: four places, three answers

Measured before: root package.json 3.1.0, relay/package.json 3.0.0,
admin/package.json 0.9.0-beta, relay image label 3.0.0, admin image label
1.0.0, a VERSION literal in relay.js reading 3.1.0, and
scripts/post-deploy-verify.sh asserting that /health returns 3.0.0 while
the relay already answered 3.1.0. The one check that could have caught the
drift was part of it.

Now the root package.json is the version and nothing else states it:

  • relay.js does require('./package.json').version, and the runtime stage of
    relay/Dockerfile copies package.json in for exactly that read.
  • Both Dockerfiles label from ARG IMAGE_VERSION, defaulted to the same
    string. docker-publish.yml passes the tag on a tagged build and the
    package.json version on main, so the label is never a short sha.
  • post-deploy-verify.sh reads relay/package.json into EXPECT_VER.
  • Admin moves from 0.9.0-beta to the project version.

tests/version-consistency.test.mjs (8 checks) fails the build the next time
any copy drifts, so the list does not have to be remembered.

Verified end to end, not just on paper: the production Dockerfile builds, the
image carries org.opencontainers.image.version=3.1.0, and a relay booted from
it answers {"ok":true,"version":"3.1.0","sector":"relay",...} on /health.

2. Node: two of the three versions were end of life

before after
relay and admin image node:25-alpine3.21 (EOL) node:24-alpine3.24
CI, all 7 job steps 20 (EOL) 24
devcontainer 20 24
engines absent >=22 <25
.nvmrc absent 24

Node 24 is the newest LTS line. >=22 <25 is exactly the two lines still
getting security fixes: it keeps a Node 22 dev machine (the NUC) working while
blocking 20, 23 and 25, all end of life, and it makes adopting Node 26 a
deliberate bump rather than an accident.

Alpine 3.24 is a second, unplanned win: the crypto binding is compiled in the
rust:1.98-alpine stage, which is Alpine 3.24. It was being loaded on a 3.21
runtime. Those now match.

Proof, run locally rather than asserted: all three suites under
node:24-alpine (Node 24.20.0) and a full build of relay/Dockerfile
including the liboqs/musl chain.

8. Environment variables: 40 of 57 documented nowhere

deploy/.env.example documented three names. relay.js, relay/lib and the
admin panel read 57, and 40 of them appeared in neither .env.example.

It now documents 77: all 72 that the relay or admin reads, plus 5 that
docker-compose.yml or install.sh consumes. Each entry says what the
variable does, whether it is required or optional, its default, and the file
that reads it. Grouped by area, generated from the code and then written by
hand.

tests/env-documented.test.mjs holds the line in both directions:

  • E1 a process.env name with no entry fails the build (verified by adding
    one and watching it go red),
  • E2 an entry that nothing reads fails too, unless it is in
    NOT_READ_BY_NODE with a stated reason,
  • E3 that allowlist cannot outlive its reason,
  • E4 every entry carries a read in: line,
  • E5 the header count matches reality.

The extractor also catches env.X in the modules that take an injectable env
object (developer-gate, webauthn, entitlements), which a plain
process.env grep misses. DEVELOPER_ALLOWLIST and NODE_ENV are documented
because of it.

Two honest findings that fell out of the sweep, written into the file rather
than fixed here: LOG_LEVEL and ADMIN_TOTP_SECRET are passed to the relays
by docker-compose.yml and read by nothing. Removing them from the compose
file is a change to the running stack, not a documentation fix.

1. Release process

docs/RELEASE.md: what a version number means, cutting a release in nine
steps, rolling back, and a section on what is still not automated. It names the
gap between docker-publish.yml (which pushes images) and
docs/RUNBOOK-DEPLOY-3.0.0.md (which says there is no registry) instead of
pretending it is closed, and defers that to a deploy-time decision.

CHANGELOG.md gets a ## [3.1.0] - unreleased section covering the 268
commits since v3.0.0, grouped into Security, Added, Changed, Fixed, Removed,
and Build/CI/dependencies, with PR numbers where a commit carried one. One
trap, documented in RELEASE.md so the next person does not fall in it: a
trailing (#nn) is not always a PR. #19 and #21 in this range are security
findings, and PR #21 is an unrelated dependabot bump. Checked with gh pr view
before writing them down.

The existing [Unreleased] body is folded into the 3.1.0 section unchanged,
with the reason stated: the CHANGELOG's 3.0.0 section is dated 2026-05-27 and
the v3.0.0 tag was cut on 2026-06-24, so that backlog never had a released
section to go to. Rewriting it would be guessing at what it meant.

docs/PROJECT-STATUS.md declared itself obsolete in its own second line on
2026-05-27 and kept being cited by three other documents. It is now a pointer
table to the CHANGELOG, RELEASE.md, DEPLOY-3.1.md and gh pr list. The five
findings that R010, cross-repo-coordination.md and
audit-readiness-checklist.md cite by number are kept, each with its status as
measured today: finding 1 is fixed by this PR, 2 is still open and belongs to
the tagging step, 4 and 5 are superseded. ROADMAP.md no longer claims that
PROJECT-STATUS is regenerated each run.

Tests

Everything below ran locally on this branch.

Suite Result
relay unit, as test.yml runs it 176 pass, 0 fail
admin unit 37 pass, 0 fail
root integration, no browser 123 tests, 121 pass, 2 pre-existing skips (110/108/2 on main, so +13 new checks and no new skips)
the same three under node:24-alpine identical
tests/static-sanity.sh PASS, all ten checks including the style guard
bash -n on every .sh clean
npx eslint@9 . clean
silent-suites gate still exactly parasign-signs-quota
docker build -f relay/Dockerfile succeeds; label 3.1.0, Node v24.20.0, /health reports 3.1.0

Note on check 10 of static-sanity: it was red on main because commit
c45d1c6 carries an attribution trailer. This branch's commit has none, per
AGENTS.md, so the gate is green.

Not in this PR

Deliberately, because each needs a decision rather than a change:

  • No tag. Tagging and deploying are one step (docs/RELEASE.md steps 5 to 8).
  • install.sh still pins v3.0.0. It clones that git tag, so bumping it
    before v3.1.0 exists points the self-host installer at something nobody can
    clone. It is step 9 of the release.
  • docs/RUNBOOK-DEPLOY-3.0.0.md keeps its name and its content. Renaming it is
    cheap; reconciling registry-pull versus build-on-server is the actual
    question, and it is a production decision.
  • Points 3, 4, 5, 6, 7 and 10 of the report are untouched.

@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Two things found after the description above was written.

The Node bump broke a gate, and the gate was right. The relay unit job went
red in seven seconds. Node's default node --test reporter for a non-TTY
changed from tap to spec between Node 20 and Node 24, which drops the #
prefix from the diagnostic lines that test/_requires.js prints. The
silent-suites gate greps for exactly that prefix, found nothing, read it as "no
suite asserted anything silently" and failed on the mismatch with its expected
list. Fixed by pinning --test-reporter=tap on that one step, so the gate's
input is an explicit contract rather than a Node default. Verified under
node:24-alpine: 176 pass, 0 fail, gate reports exactly parasign-signs-quota.

Checked the other places that read test output instead of an exit code: the
heartbeat's issue body already filters on both the TAP and the spec forms, so
it needed nothing.

The musl binding was the one real risk of the base change, and it loads.
The crypto binding is compiled in the rust:1.98-alpine stage (Alpine 3.24) and
was being loaded on a node:25-alpine3.21 runtime. Now both are Alpine 3.24.
Proven rather than assumed, in the built image:

$ docker run --rm --entrypoint node <image> -e "require('/app/node_modules/@paramant/core')"
binding geladen op v24.20.0 | exports: kemDecaps,kemEncaps,kemKeygen,mldsaSign,aeadEncrypt,mldsaKeygen

All 11 checks green, heartbeat - production skipped as it should be on a PR.

Four items from the toekomstbestendigheid report of 2026-09-02 that need no
product decision. Nothing here changes behaviour on purpose; what it changes is
that the repo now says one thing instead of four.

One version (point 9). The root package.json is the version. relay.js reads
relay/package.json at runtime instead of restating the number, both image
labels take it as an ARG IMAGE_VERSION build-arg with a matching default,
docker-publish.yml passes the tag on a tagged build, and
scripts/post-deploy-verify.sh reads it rather than asserting a literal. Before
this: root 3.1.0, relay 3.0.0, admin 0.9.0-beta, relay label 3.0.0, admin label
1.0.0, and the deploy check asserting /health returns 3.0.0 while the relay
already answered 3.1.0. tests/version-consistency.test.mjs (8 checks) fails the
build the next time any copy drifts. Verified end to end: the built prod image
reports {"ok":true,"version":"3.1.0",...} on /health.

One Node line (point 2). Node 24, the newest LTS. Images move from
node:25-alpine3.21 to node:24-alpine3.24 by digest, CI and the devcontainer
from 20 to 24, and .nvmrc plus an engines field of ">=22 <25" pin the project
to the two lines that still get security fixes. Both the old versions were end
of life: the image that production would get on its next deploy ran an
unsupported runtime, and so did every CI job. Alpine 3.24 also matches the
rust:1.98-alpine builder stage, so the musl the crypto binding is compiled
against is now the musl it is loaded on. Proof: relay, admin and root suites
all green under node:24-alpine (176 + 37 + 123), and the full production
Dockerfile builds.

Every env var written down (point 8). deploy/.env.example goes from 3 variables
to 77: all 72 the relay and the admin panel read, plus 5 that compose or the
installer consume. Each with what it does, required or optional, its default
and the file that reads it. The code read 57 names and 40 of them were
documented nowhere. tests/env-documented.test.mjs fails on the next
undocumented name, and on documentation for a variable nothing reads.

A release process (point 1). docs/RELEASE.md: how a release is cut, tagged,
published and deployed, including what is not automated. CHANGELOG.md gets a
3.1.0 section covering the 268 commits since v3.0.0, grouped and with PR
numbers, and states honestly that the entries which sat under [Unreleased] are
folded in because the v3.0.0 tag was cut a month after the 3.0.0 section was
written. docs/PROJECT-STATUS.md, obsolete by its own second line since
2026-05-27, becomes a pointer to the CHANGELOG and RELEASE.md; the five
findings other documents cite by number are kept, with their status today.

No tag and no deploy: those belong together, and docs/RELEASE.md says so.
The Node 24 bump turned the relay unit job red in seven seconds, and the cause
is worth writing down rather than papering over.

The silent-suites gate in test.yml greps the log for lines that summary() in
test/_requires.js prints, and TAP prefixes those with "# ". Node's default
reporter for a non-TTY changed from tap to spec between Node 20 and Node 24, so
the prefix disappeared, the grep found nothing, and the gate read that as "no
suite asserted nothing" and failed on the mismatch with its expected list.

The gate was right to fail: its input had silently changed shape. Pinning
--test-reporter=tap on that one step makes the contract explicit instead of
inherited from whatever Node decides a default should be.

Verified under node:24-alpine: 176 pass, 0 fail, and the gate reports exactly
parasign-signs-quota, as it did on Node 20.

Checked the other places that read test output rather than an exit code: the
heartbeat's issue body already filters on both TAP and spec forms
(not ok, and the spec cross and "fail" line), so it needs no change.
Review found two wrong pointers in deploy/.env.example. Both were wrong in the
same way: written from what the variable looked like it did rather than from a
grep. So the fix is the two corrections plus the check that would have caught
them.

HTTP_PORT and HTTPS_PORT were credited to docker-compose.yml as the nginx
container's port mapping. Neither is true. Compose does not read them, and this
repo's compose file has no nginx service at all: production terminates TLS with
the system nginx, as the comment at the top of docker-compose.yml says. They are
read by deploy/preflight.sh, which picks a free port before install, and
deploy/post-install.sh, which compares them against the ports actually published
and rewrites .env to match. Both entries now say that.

PARAMANT_VERSION said "install.sh only". frontend/install.sh and
frontend/install-pi.sh read it too, each with its own fallback pin, and check 9
of tests/static-sanity.sh is what holds all three to that shape. Named all three
here and in docs/RELEASE.md step 9, which had the same omission in prose.

New E6 in tests/env-documented.test.mjs: every file named in a "read in:" line
must exist and must mention the variable, whole-word so PORT is not satisfied by
HTTP_PORT. Verified by putting the old wrong pointer back and watching it name
both lines. A wrong pointer is worse than a missing one, because it sends the
reader to the wrong file with confidence.

Three smaller corrections from the same review:

- RESEND_API_KEY and TOTP_SECRET were written in the required form (a bare
  assignment) while their own text says optional and the code reads both with a
  fallback. Commented out, so form and words agree.
- .github/dependabot.yml still described the relay base as node:22-alpine.
- SECURITY.md names node:22-alpine under a dated 2026-04-13 audit heading. That
  block is a record of what was measured then, so it stays as it was, with a
  note above it saying what the base is now.
- docs/RELEASE.md claimed post-deploy-verify.sh catches a mismatch between the
  tag and /health. It does not: it compares /health against the package.json of
  the checkout it runs from, and from a tagged checkout those agree by
  construction. Rewritten to say what it actually catches, which is production
  still serving an older build after a deploy that looked like it worked.

Tests: relay 176, admin 37, root 124 (122 pass, the same 2 pre-existing skips),
all green on Node 22 and again under node:24-alpine, with the silent-suites gate
reporting exactly parasign-signs-quota. bash -n and eslint clean.
main moved while this branch was open. Rebased onto 99909a1 and folded what
arrived into the CHANGELOG section, because a release section that is a
snapshot of the moment it was written is the exact failure this PR is fixing.

New in the section: the ParaSign product page at /parasign (#325), the
messaging guide (#331), the homepage rewrite for a buyer (#328), the site
claims pinned to the code that makes them true (#327), the heartbeat that
cannot be green without evidence (#338), and the route suites that boot a real
relay.js (#341). Count in the intro goes from 268 commits and 52 PRs to 277 and
58, and the intro now says it was rebased rather than pretending it was written
in one pass.

#341 is worth naming for what it is: point 3 of the toekomstbestendigheid
report was that relay.js, 6488 lines and 68 routes, was loaded by no unit test
at all. It is now loaded by the route suites. That point is not closed, but it
is no longer zero.

Two conflicts, both resolved toward main's newer structure:

- product-heartbeat.yml: #338 moved the live job out into heartbeat.yml. Took
  main's file whole and applied the Node 24 bump to both files instead, so the
  bump follows the job rather than the filename. All seven node-version entries
  across the five workflows now read '24'.
- test.yml: #341 added a second silent-suites gate for the route job and set
  both expected sets to empty. Kept main's exclusion lists and applied
  --test-reporter=tap to both steps.

That second one matters more after the rebase than before. With a non-empty
expected list, the spec reporter made the gate fail, which is how it was found.
With an empty expected list, the spec reporter makes it PASS: the grep finds
nothing, silent is empty, and a gate that can no longer see anything reports
green. A dead gate that reports green is precisely what these two gates exist
to prevent, so both now pin the reporter rather than inherit a Node default.

Tests after the rebase, on Node 22 and again under node:24-alpine: relay 175,
admin 40, root 145 (143 pass, 2 pre-existing skips), both silent-suite gates
empty as expected. static-sanity PASS including the style guard, bash -n and
eslint clean. The root job now runs npm ci first, per #338; tests/README.md and
the local commands in docs/RELEASE.md match what CI does.
@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Rebased onto main (99909a1) and pushed. Head is 9a5ecd5, four commits, all
checks green.

Worth saying why the checks went quiet for half an hour before this: GitHub does
not build a merge ref for a PR it cannot merge, so once main moved ahead no
pull_request runs were queued at all. That reads as "CI is slow", and it is
not; it is the PR being DIRTY. A workflow_dispatch run does go through, which
is how the branch was tested in the meantime, but it scans differently: the
gitleaks step does a full-history scan on that event instead of the PR's own
commits, and it reported five findings. Those are pre-existing. Measured rather
than assumed: full history on origin/main and on this branch both report 29
findings locally, so these four commits add none.

Two conflicts, both resolved toward main's newer structure.

product-heartbeat.yml. #338 moved the live job out into heartbeat.yml.
Took main's file whole and applied the Node 24 bump to both files instead, so
the bump follows the job rather than the filename. All seven node-version
entries across the five workflows now read '24'.

test.yml. #341 added a second silent-suites gate for the new route job and
set both expected sets to empty. Kept main's exclusion lists and applied
--test-reporter=tap to both steps.

That second one is more important after the rebase than before, and it inverts
the failure mode. With a non-empty expected list, Node 24's spec reporter made
the gate go red, which is how it was found in the first place. With an empty
expected list, spec makes it pass: the grep matches nothing, silent is
empty, and a gate that can no longer see anything reports green. A dead gate
reporting green is exactly what both gates exist to prevent. Both now pin the
reporter rather than inherit a Node default. CI confirms they are alive and
empty on this run:

--- suites that asserted nothing in this job:       (none)
--- route suites that asserted nothing in this job: (none)

CHANGELOG. The six PRs that landed while this was open are in the 3.1.0
section: /parasign (#325), the messaging guide (#331), the homepage rewrite
(#328), the site claims pinned to code (#327), the evidence-backed heartbeat
(#338) and the route suites (#341). The intro count goes from 268 commits and
52 PRs to 277 and 58, and now says it was rebased rather than pretending it was
written in one pass. #341 is named for what it is: point 3 of the report was
that relay.js, 6488 lines and 68 routes, was loaded by no unit test. It is now
loaded by the route suites. Not closed, but no longer zero.

docs/RELEASE.md step 3 was rewritten to match what test.yml does today:
the new exclusion lists, the npm ci the root job now needs (#338's
heartbeat-lib test imports @noble/post-quantum), and a line on why the
reporter is pinned. It said "run what CI runs" while running something else,
which is the same class of untruth as the read in: pointers in the previous
commit.

Tests after the rebase, on Node 22 and again under node:24-alpine: relay 175,
admin 40, root 145 (143 pass, the same 2 pre-existing skips). static-sanity
PASS including the style guard, bash -n and eslint clean.

@Apolloccrypt
Apolloccrypt merged commit 88c06c1 into main Sep 2, 2026
11 checks passed
Apolloccrypt added a commit that referenced this pull request Sep 2, 2026
#340 pinned one Node line across four places: `engines` (">=22 <25") in the
root, relay and admin manifests, `.nvmrc`, the `node-version` steps in every CI
job, and the base image in both Dockerfiles. Its own words: the range "makes
adopting Node 26 a deliberate bump rather than an accident".

Dependabot opened #343 and #346 (node 24-alpine3.24 -> 26-alpine3.24) within the
hour. Node 26 is the next even line but does not reach Active LTS until late
October 2026, and no `.npmrc` sets engine-strict, so `npm ci` inside the image
never checks `engines`: both PRs went green while putting the image on a runtime
all three manifests forbid.

The docker updater for /relay and /admin now ignores semver-major on "node"
only. The rust builder keeps its majors (#313 was a real break that had to be
seen), and node minor/patch, Alpine rebuilds and digest updates still arrive
weekly. Adopting a new LTS line stays a hand bump of the whole grid.
Apolloccrypt added a commit that referenced this pull request Sep 2, 2026
docker-compose.yml heeft geen env_file. .env vult alleen ${VAR} in het
compose-bestand zelf in, dus een variabele zonder eigen regel in x-relay-env
bereikt nooit een container. De vier receipt-variabelen uit #342 hadden die
regel niet, dus fase 1d schreef een vlag die nergens aankwam en fase 6h zou
pas aan het eind sterven, met 3.1.0 al live.

PARAMANT_INLINE_RECEIPT_HEADER en de drie PARAMANT_RECEIPT_-variabelen staan
nu in x-relay-env, allemaal met een lege default: de vlag telt alleen bij
exact 1 en de caps vallen leeg terug op hun ingebouwde getallen. Bewezen met
docker compose config: met de vlag in .env rendert hij op alle vijf de
relays, zonder de vlag op geen enkele.

Fase 4 controleert dat nu op de server voordat er iets herstart wordt, zodat
een ontbrekende doorgifte blokkeert op een leesbare assertie in plaats van op
een smoketest achteraf. Runbook: de regel dat compose byte-identiek is aan
41501bb klopte al niet meer sinds #340 en is vervangen door wat er nu speelt.

Verder uit ronde 2:
- de bufferguard telt binnen elk /v2/outbound-blok in plaats van per bestand.
  Een conf met proxy_buffer_size op /v2/inbound liet het outbound-blok leeg;
  nagespeeld en gedicht met een tweetraps-awk die ook idempotent is
- fase 5b resolvet het doelpad met readlink -f en weigert alles dat buiten
  de docroot uitkomt, met een teller die geassert wordt
- 6h claimt de 16 KB alleen als het headerblok er echt boven zit; anders
  WARN met NOT PROVEN en geen ok-regel, zodat de samenvatting niet suggereert
  dat het bewezen is
- de taintscan erft over een toewijzing, dus s3cr="$tok" telt ook. Een hop,
  bewust, en die grens staat in de test opgeschreven
@Apolloccrypt
Apolloccrypt deleted the chore/release-hygiene branch September 5, 2026 18:55
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.

1 participant