Skip to content

v0.1.139

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:53
· 74 commits to main since this release
cea9738

A graceful stop, an image with nothing left to fetch, and a guard that had died in silence.

⚠️ This train leaves out of cadence, and none of the three exceptions applies.
docs/RELEASING.md allows one train per day; 0.1.138 shipped this morning.
This is not a security fix, not a broken package on the registry, and not a repair of the release
pipeline — 0.1.138 published all five of its artefacts. It ships today because the maintainer
decided it does, which is what that page says the decision is. Nothing here forced it.

Nothing an operator runs changes shape. Measured on the two tarballs rather than claimed:
64 files → 64, none added, none removed, five changedpackage.json, docs/HOST-CONTRACT.md,
server/cache.js, server/handler.js, bin/serve.js. context, types, database and browser
are untouched, and the bundle a visitor's page executes is byte-identical to 0.1.138
(server/browser.generated.js, sha256 ad3af9dc56479147…, 16 809 bytes; shared.generated.js and
dist/bridge.js likewise).

⚠️ Two changes are worth reading before you deploy, both about how the container stops and what
it contains:

  • docker stop now drains instead of cutting. In-flight requests get up to
    PLAYER_SHUTDOWN_GRACE_MS (default 8 s) to finish. Keep it below your orchestrator's kill
    delay
    docker stop waits 10 s by default.
  • The image no longer carries dumb-init. Node runs as PID 1 and handles the signal itself. If
    you extend the image with something that spawns child processes, add an init back or start it
    with docker run --init.

Fixed

  • ⚠️ The .env.example check read prose as data. It lived inline in ci.yml and pulled every
    backtick-quoted uppercase token out of docs/CONFIGURATION.md. The day that page mentioned
    SIGTERM, SIGINT and SIGKILLsignal names, in a sentence — it demanded them in
    .env.example. A check that asks you to bend your prose to please it teaches its readers to write
    for the machine.
    • ⚠️ The obvious remedy was worse, and measuring said so before it was written. "Read only
      the ### \NAME`` headings"
      looked clean: the page carries two, while documenting
      thirty-nine variables elsewhere in tables and inline mentions. The guard would have gone green
      by looking at almost nothing — the too-tight pattern, fourth time this week.
    • What actually tells a variable from a word is that it exists elsewhere: the code reads it,
      or the example file carries it. The rule now lives in tools/env-exemple.mjs, which asks
      env-lues.mjs for its AST inventory instead of keeping a second one — and counts and names
      what it sets aside
      , because a guard that hides what it did not look at claims coverage it does
      not have.
    • The set-aside is not an escape hatch: a variable the code reads is kept, even when both files
      forget it. Otherwise the exception would swallow the rule.
  • The startup line printed the port it was asked for, not the one it got. With PORT=0 — where
    the OS picks a free one — it announced localhost:0, an address that leads nowhere, at exactly
    the moment you need to know where to knock. Found by the shutdown bench, which could not reach
    the server it had just started.
  • Dockerfile: dumb-init's stated justification no longer held. It said Node had no default
    signal handler, which was true and is not any more. What remains is zombie reaping — real in
    general, empty here: this runtime spawns no subprocess (checked: no spawn, execFile or
    fork in server/, bin/ or context/). It is kept out of caution rather than demonstrated
    need, and the comment now says so instead of asserting a reason that has been fixed elsewhere.
    It also records that dumb-init is the one unpinned input of that image, and why the version
    could not be resolved from where this was prepared.
  • ⚠️ The hourly publication guard had been dead for nineteen hours, and nobody could have seen
    it.
    publication.yml only does a checkout — no npm ci — because none of the tools it ran
    had ever needed node_modules. Then exemples-epingles.mjs gained a dependency on semver in
    0.1.137, to compare version intervals instead of demanding a literal string. That was the right
    change; the workflow did not move with it. From that publication on, the step threw
    ERR_MODULE_NOT_FOUND before measuring anything.
    • It is the worst place to break. The job goes red — on the scheduled runs page, which
      nobody opens. Meanwhile the issue that step maintains stayed frozen on its last true state:
      it still announced 0.1.128 while the registry served 0.1.138. A stale alert that looks alive
      is worse than an absent one — it is AGENTS.md's third storey, an action that resembles a
      success.
    • The breakage was contained: the earlier steps of the same job kept working, which is why
      the version-gap alert for 0.1.138 opened and closed itself correctly. Found by reading that
      job's log after noticing it was red on three consecutive runs — not by an alert.
  • ⚠️ The changelog carried two ### Fixed sections under one version. Same shape as the doubled
    ## [Unreleased] closed a day earlier, one level down: two branches each opened their own
    subsection, git merged both without a conflict. The guard added for the first case reads only
    ## titles, so it stayed green — a rule fixed at one level does not protect the level below.
    It now refuses a repeated ### inside a version too. Found the same way as the first: by a merge,
    not by the guard.
  • docs/RELEASING.md gave a command that returns 404. Its post-release checklist said
    docker manifest inspect ghcr.io/…:<version>, but image.yml pushes the git tag verbatim, so the
    image is :v0.1.138. The gh release view v<version> two lines above already carried the v
    the inconsistency lived four lines apart. Found by following the page during the 0.1.138 release
    and getting the 404, which is the only way it could have been found: a registry answers 404 for
    does not exist and for you asked for the wrong name with the same three digits. The page's own
    closing rule applies to it — a procedure that cannot be carried out is worse than no procedure.

Removed

  • ⚠️ dumb-init is gone from the container image, and the image now fetches nothing at build
    time.
    Its written justification — "Node is PID 1 and has no default signal handler, so
    docker stop would wait ten seconds before killing"
    — was correct, and died when bin/serve.js
    gained a SIGTERM handler: the kernel discards a signal on PID 1 only when no handler exists.
    What remained was zombie reaping, a real job that is empty here — this runtime spawns no
    subprocess.
    • It was the one unpinned input of the image. apk add fetched the package over the network
      with no version: same Dockerfile, same base digest, two different dumb-init three months
      apart. Pinning it by checksum was written, then discarded: four moving parts — a build-time
      network dependency, two digests to maintain, an architecture branch, and a hand-rolled
      downloader because the alpine image carries no certificate store — for a component whose job is
      empty. Removing it deletes the problem instead of checking it, and makes the build reproducible
      unconditionally.
    • ⚠️ The assumption is guarded, not left in a comment. bin/__tests__/sansSousProcessus.test.js
      refuses the first child_process added to server/, bin/ or context/ — and its message
      says the gesture: put an init back, or document docker run --init, then update the bench. The
      decision is re-asked at the exact moment it becomes true again, instead of sleeping.
    • ⚠️ The CMD stays in exec form, and that now matters. CMD node bin/serve.js would put
      /bin/sh at PID 1, and a shell does not relay signals to its child: the handler would never
      run and the graceful shutdown would be worthless. The bench holds that too.
    • Operators who need reaping are not stuck: docker run --init injects one without touching the
      image, and docs/CONFIGURATION.md says so.

Changed

  • claude[bot] joins the CLA exemption list, on the maintainer's explicit decision. It is the
    same agent as claude, under the identity GitHub assigns depending on how the contribution
    arrives: a pull request opened through the API comes out authored by claude[bot], the same one
    opened otherwise comes out as claude. Measured on 25/08 — PR #392 was refused by this check for
    that reason alone, on content identical to what had passed the day before.
    • ⚠️ This is not a technical fix, and it waited on purpose. Widening a CLA exemption list
      decides who contributes without signing — governance, not tooling. On the day the refusal
      landed, the tempting move was to loosen the guard to unblock a pull request; that is precisely
      what one does not do. The PR was closed and reopened through the normal path, the gap was
      reported, and the list moved only once the maintainer decided (26/08) — by the same reasoning as
      its two neighbours, which are already there in their [bot] form.
    • The widening stops at named identities. A bench holds that claude-fork[bot],
      notclaude[bot] and claudebot still have to sign: the exemption covers accounts, never a
      shape. Without it the list could drift toward anything ending in [bot], which would let
      through any third-party app installed on the repository.

Added

  • ⚠️ The standalone server shuts down gracefully — nothing listened for SIGTERM before. The
    choice was between slow and abrupt, and the third way had never been put. Without a handler,
    Node as PID 1 ignores SIGTERM (the kernel discards a signal on PID 1 only when no handler
    exists), so docker stop waited ten seconds and killed. dumb-init fixed that by being PID 1
    itself, making Node a child — where the relayed signal triggers the default action: immediate
    termination
    . Fast, but a document being relayed, a presentation read, a heartbeat were cut
    mid-flight at every deployment.
    • It now stops accepting, closes idle keep-alive connections, lets in-flight requests finish, and
      exits — with or without dumb-init.
    • ⚠️ close() alone never completes: it waits for every connection, and keep-alive holds idle
      sockets open for seconds after their last request. A shutdown that waits for those overruns the
      orchestrator's deadline and gets killed anyway — an abrupt shutdown, only slower.
      closeIdleConnections() closes what is no longer serving, without touching what is.
    • ⚠️ The grace period is bounded, and deliberately under docker stop's ten-second default: a
      deadline that falls after the axe is no deadline. PLAYER_SHUTDOWN_GRACE_MS (default 8000)
      is documented, along with the rule that raising it means raising the orchestrator's too.
    • A second signal exits at once — pressing Ctrl-C twice asks for a stop, not an explanation. The
      handlers are installed only on direct execution, like listen: installed on import they
      would hijack the Ctrl-C of whatever imported the module.
    • Benched against a real child process and real signals — a simulated signal proves nothing.
  • The identity card reports what the read cache actually refused. lectureSaturee gives
    { total, fenetreS, derniereIlYaS } on GET /api/doc?contract=1. The admission ceiling has
    answered 503 Retry-After: 1 for a long time — a refusal, deliberately distinguishable from a
    500 — but nothing counted how often it was reached, so "do we actually saturate?" had no
    observable answer. That is the one question §2 of the CODEX 5.6 audit turns on, and the audit
    itself said to wait for a measurement before merging anything on the hot path. This is that
    measurement; no behaviour changes.
    • ⚠️ total never travels without fenetreS. total: 0 does not mean we do not saturate
      — on a process that started four seconds ago it means nobody has looked yet. That is the same
      trap as presenceFusion: "inconnu", and it is why the three keys are one object rather than
      three fields a host could read apart.
    • derniereIlYaS is null when there has been no refusal, not 0 — which would read as
      just now.
    • Process-local, like the presence* fields: behind a load balancer this is the count of the
      instance that answered. Aggregating is the host's job, and implying otherwise would be worse
      than returning nothing.
    • Additive, so the contract number does not move (rule 2).
  • A guard that refuses a workflow step running a tool the job cannot serve. If a job runs
    node tools/… and that tool needs an installed package, the job must carry npm ci.
    • ⚠️ It follows the dependency through the imports, because that is where the defect lived:
      exemples-en-retard.mjs does not import semver — it imports exemples-epingles.mjs, which
      does. A probe reading only the file named on the command line would have gone green on the exact
      fault it was written for.
    • It is not an oversight rule, it is a distance rule. Whoever adds an import to a tool does
      not re-read nine workflows to see which ones run it, and is right not to: that is not work done
      from memory. Three tools legitimately run with no installation, need nothing, and the guard says
      so in its green line rather than staying silent about them.
  • A guard that refuses a documented image reference the registry would not serve. Outside the
    workflows, every ghcr.io/… reference must be untagged, latest, or v-prefixed — the form
    image.yml actually publishes. It does not hold a second copy of that fact: it confronts
    image.yml, and if the workflow stops requiring ^v[0-9]+\.[0-9]+\.[0-9]+$ the guard reports
    inconclusive rather than keep enforcing a rule the forge no longer applies.
    • ⚠️ Its first version missed the very defect it was written for. < was not in the tag's
      character class, so :<version> — a documentation placeholder, not a real tag — read as
      "no tag at all" and passed. Green on the exact line that had prompted it. The bench caught it;
      this is the third pattern written too tight this week.
    • The rule is deliberately permissive: a tag without the v is the fault, an unexpected tag is
      not. Demanding an exact version shape would accuse latest, an example, or a shell variable.
      Registry API URLs (https://ghcr.io/v2/…) are excluded by the :// before them, and the
      changelog's own quotation of the bad form excludes itself — is not an OCI character, so no
      filename exception has to be remembered.

What changed in the package, by zone — 0.1.1380.1.139

Measured on the two tarballs, by the release workflow. Not written by hand.

Zone What it is Added Removed Changed
documents what a human reads 0 0 1
manifest package.json — version, exports, dependencies 0 0 1
server the code the host executes 0 0 2
context the injected-context implementations 0 0 0
browser-types the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page 0 0 0
browser what the visitors' page executes 0 0 0
cli the command-line entry point 0 0 1
types declarations for the server and context entry points — breaks a build, never runtime 0 0 0
database the schema and the migrations the host applies itself 0 0 0
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/cache.js
~ server/handler.js
~ bin/serve.js