Skip to content

feat(images): phase 2 — a Core image, a distroless Panel, and one reference compose - #66

Merged
mrfqcentic merged 27 commits into
mainfrom
feat/phase-2-images-and-containers
Aug 4, 2026
Merged

feat(images): phase 2 — a Core image, a distroless Panel, and one reference compose#66
mrfqcentic merged 27 commits into
mainfrom
feat/phase-2-images-and-containers

Conversation

@qcentic-dev

@qcentic-dev qcentic-dev commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 2 of #23: Actana ships as two container images instead of a tarball you install onto a machine you own.

deploy/core.Dockerfile is new — a Core you docker run, on digest-pinned Ubuntu 24.04 with a sha256-verified Node 24 tarball, tini as PID 1, running as core:1000. The Panel's image moves out of the repo root to deploy/panel.Dockerfile and swaps its runtime for gcr.io/distroless/nodejs24. deploy/dev/ — the systemd fixture that existed so a tarball had somewhere to install — is deleted, and one reference deploy/docker-compose.yml brings up a Panel and a Core that pair, in one command.

Underneath that: a CVE gate that runs on the built image and fails only on fixable CRITICAL/HIGH, an owner for every base pin, and a smoke test that boots the image that ships and pairs a real Panel with it — unprivileged, replacing the --privileged systemd leg it retires.

Spec is ADR 0016 — The 0.1.0 shape. Seven of its clauses are amended in this PR rather than argued in comments; see "ADR amendments" below.

Related issues

Closes #37
Closes #38
Closes #39
Closes #40
Closes #41
Closes #42
Closes #43
Closes #44
Closes #45
Closes #46

Part of #23. Not in this PR: #51 (housekeeping.yml), #52 (Docker Hub descriptions), #59 (the docs tree), #61 (go-public checklist).

What landed, ticket by ticket

# What
#42 The root Dockerfile becomes deploy/panel.Dockerfile. Pure move; every builder now names the file.
#37 deploy/core.Dockerfile — Ubuntu 24.04 digest-pinned, Node 24 as a sha256-verified nodejs.org tarball, native-addon toolchain in, Harnesses out.
#39 Container mode: ACTANA_CONTAINER=1 baked in, never sniffed from /.dockerenv. The lifecycle verbs Docker owns refuse and name their Docker equivalent.
#40 First run mints material, persists it to the volume, and prints a registration blob a human can copy.
#41 A changed public host re-issues the server cert from the kept CA. It no longer re-mints the identity and locks every paired Panel out over a typo'd env var.
#43 Panel runtime becomes distroless: 192 findings → 14, USER 65532:65532, /data seeded by COPY --chown.
#38 The CVE gate: fixable CRITICAL/HIGH only, on the built image, before anything is pushed.
#44 Every base pin has an owner — Dependabot for the two digests, base-pins.yml for ARG NODE_VERSION, which no Dependabot config can see.
#45 One reference compose. deploy/dev/ and deploy/Caddyfile deleted, all external references repointed.
#46 scripts/smoke-core-image.mjs — boot the image, pair a Panel, restart, down -v. No privileged container anywhere.

Four things worth a reviewer's attention

1. The CVE gate has two suppressions, and they are different in kind. .trivyignore.rego suppresses linux-libc-dev (D7's clause — ~1200 kernel-header findings against a package the Core cannot not ship). The gate also scopes out usr/local/lib/node_modules/npm/, the system Node's own bundled npm, by path. That second one is not in the policy file on purpose, so the two never hide inside each other, and D7/D37 are amended to stop claiming the file is the only suppression in the repo.

It exists because every fixable CRITICAL/HIGH in the Core image is in that one directory and no released npm clears them — npm 12.0.2 still carries brace-expansion 5.0.7 against fixes at 5.0.8/5.0.9 and ip-address 10.2.0 against 10.3.1. The cost is stated in the code: a new finding in that tree is silently non-gating too. Findings there are printed on every run, so scoping out never means hiding.

2. The allowlist is .trivyignore.rego, not .trivyignore. Measured, not assumed: Trivy's .trivyignore and .trivyignore.yaml match on CVE id only. A file naming a package parses cleanly, filters 0 of ~1200, and warns about nothing. Because that failure is silent, assertSuppressionApplied() checks the suppression actually took effect rather than trusting a green gate.

3. Re-issue is gated on the operator having declared the host. #41's re-issue only fires when AC_CORE_PUBLIC_HOST is actually set. Without that gate a hand-run actana daemon would re-sign a working Core's SAN for the bind address standing in — 127.0.0.1 — and hand out a loopback pairing token. Material predating the serverHost record is re-signed silently rather than announced as a move.

4. The two digest pins are deliberately different shapes. ubuntu:24.04@sha256:… carries its tag; the distroless runtime is pinned tagless. Dependabot bumps from the tag when there is one and otherwise from digest_of("latest"), and gcr.io/distroless/nodejs24 has 7,040 tags without a version number among them. Writing :latest@sha256:… would hunt for a tag newer than latest and find nothing. Please do not tidy one to match the other — docs/ci-cd.md § "The two digests are pinned in different shapes on purpose" has the argument, and base-pins.test.mjs holds it.

The two remaining Panel library findings (#43's last criterion)

The distroless move takes the Panel image from 192 findings to 14: OS 174 → 12, library 18 → 2. The 12 OS findings are libc6 (11) and zlib1g (1). The two library findings are the Panel's own production dependencies, not base-image problems — both live under srv/panel/node_modules/, which is this repository's pnpm install output and nothing the base contributes:

Severity ID Package Installed Fixed in
HIGH GHSA-r28c-9q8g-f849 postcss 8.5.15 8.5.18
LOW CVE-2026-49356 @babel/core 7.29.0 7.29.6

Those are exactly #30's territory (T1, "Lockfile: upgrade postcss and @babel/core"), and #30 has since landed: this branch's lockfile carries postcss@8.5.25 and @babel/core@7.29.7, both past their fixes. The numbers above are from the 2026-08-03 prototype scan, which predates that bump — a fresh scan of this branch should show 12, not 14. CI's scan on the built image is the measurement that counts.

ADR amendments

Each is written into docs/adr/0016-the-0-1-0-shape.md in the same commit as the work that found it:

How was this tested?

  • Unit tests added/updated
  • Integration/E2E tests added/updated
  • Manually verified (describe steps below)

Verified locally on the merged tree:

Check Result
packages/core typecheck clean
packages/shared typecheck clean
packages/core vitest 592 tests / 31 files pass
root vitest (scripts) 256 tests / 11 files pass
eslint 0 errors (54 pre-existing warnings, none in files this PR touches)

Docker is unavailable on the development box, and every commit that depends on it says so. These are CI's to confirm, and all of them run on this PR:

  • both images actually build, and better-sqlite3 dlopens under distroless' glibc
  • scripts/smoke-core-image.mjs — boots unprivileged, tini at PID 1 and the daemon at PID 2 read out of /proc, refused verbs refuse, a real Panel pairs, docker restart keeps the pairing, down -v ends it
  • scripts/smoke-panel-image.mjs/data ownership, the absolute-path HEALTHCHECK, the migrated schema read back out of a running container
  • the Trivy gate, on the built image, in the same job as the smoke and before any push

docker compose up on the reference compose is not among them, and the earlier version of this description wrongly claimed it was. No workflow runs docker compose at all; smoke-core-image.mjs drives docker run directly and never reads deploy/docker-compose.yml. So #45's "brings up a Panel and a Core that pair, from a clean checkout, in one command" is proven by the file's shape (panel-image.test.mjs) and not by its behaviour. It is also forward-dated: the compose pins :latest, which only images-release.yml publishes on a v* tag, so there is nothing to pull until 0.1.0. That leg lands with the release rather than here.

Two red checks on this PR are red on main too, and neither is this branch's:

  • Dependency Audit — four dev-only highs, all .>eslint-plugin-react>minimatch>brace-expansion, identical to main at 88b03295. Lockfile: upgrade postcss and @babel/core #30's fourth done-condition requires them left alone and T18 owns them.
  • E2E — Harness offers (systemd) — byte-identical to main's: the Cursor leg fails with an accepted offer did not leave cursor-agent on PATH, which is Fix the opencode install path so the Harness-install e2e passes #31's tracked remainder (claude-code, codex and cursor-cli declare no homePathSuffixes). Nothing here touches it.

Commit messages (commitlint) is red on three commits — ee59c94, a031cfd, 77d8679 — each on a wrapped body line whose continuation opens <word>: , which the parser reads as a trailer token and so starts the footer mid-body. Per the forward-only governance note on #65 there is no remedy worth rewriting nine merges for; the diagnosis is here so the third occurrence is cheaper than the second.

One thing no branch can demonstrate: Dependabot only reads .github/dependabot.yml on the default branch, so its first real PR against these pins is post-merge evidence. What is demonstrated is the next best thing — pnpm bases:check resolves all three pins live, and with each pin staled on purpose it names the exact digest digest_of(<tag>) returns for both registries, which is the same call Dependabot makes. docs/ci-cd.md says where to look for the real PR.

Type of change

  • ✨ New feature (non-breaking change adding functionality)
  • 💥 Breaking change (fix or feature causing existing behavior to change)
  • ♻️ Refactor (no functional change)
  • 📚 Documentation
  • 🔧 Build / CI / tooling

Breaking changes & migration

  • deploy/dev/ is gone. Anything driving docker compose -f deploy/dev/docker-compose.yml stops working; use deploy/docker-compose.yml. All eight in-repo references are repointed in the same commit.
  • The root Dockerfile is gone, moved to deploy/panel.Dockerfile. A build that relied on the implied root path must name the file.
  • panel-e2e-core-in-a-box is deleted, along with startContainerCore and e2e-panel-smoke.mjs --core-tarball. scripts/smoke-core-image.mjs replaces it against the bytes that ship. This does not replace the installer e2e and must not be argued to: different arrival, different PID 1, different service management, different install location. systemd-container.mjs stays for exactly that reason.
  • The Caddy + Let's Encrypt service is dropped. The Panel speaks plain HTTP by design (ADR 0010) and operators bring their own edge; the reference compose's job is to name the port to point a proxy at. Two requirements on whatever goes in front: forward WebSocket upgrades, and set X-Forwarded-Proto: https. None of this touches the core-link's mTLS (ADR 0002).
  • A Core whose AC_CORE_PUBLIC_HOST changes no longer loses its identity. This is a behaviour change in the safe direction — a paired Panel survives what used to lock it out. Revoking a leaked pairing token is still the deliberate act it was: actana token regenerate.

Checklist

  • My PR title follows Conventional Commits and my branch name follows the branching convention
  • I performed a self-review of my own code
  • I commented hard-to-understand areas / added docs where needed
  • I added tests proving my fix/feature works, and all tests pass locally
  • I updated documentation (README.md, DEPLOY.md, CONTRIBUTING.md, docs/ci-cd.md, docs/images/*) where relevant
  • No secrets, credentials, or personal data are included in this change
  • Dependent changes have been merged

Merge strategy: the ten tickets are ten merge commits with their reasoning in the messages. Squashing collapses all of it into this title — please merge, as #65 did for phase 1.

🤖 Generated with Claude Code

qcentic-dev and others added 25 commits August 4, 2026 09:38
The repo root should not carry one container image's Dockerfile while the
other's lives two directories down (ADR 0016, D40). Pure move: the file's
bytes are unchanged apart from its own build example, which now names the
new path. The build context stays the repo root.

Every builder now names the file, since the root no longer implies it — the
panel-image job, the smoke script, and the dev stack's compose, whose panel
service relied on the implied root path and would otherwise have stopped
building. The path lives once, as PANEL_DOCKERFILE in
scripts/lib/panel-image.mjs, and the image-contract test asserts all three
builders against it. The CODEOWNERS entry goes: /deploy/ already covers the
new home.

Closes #42
The same `actana` binary ships in the Core image, where the image is the
install (ADR 0016 D13): no versioned tree, no unit, no `actana setup`. The
lifecycle verbs do not degrade into a second, unsupervised install path —
they refuse and name the Docker command that does the same job.

- `actana-container.ts`: detection by the baked `ACTANA_CONTAINER=1`, the
  three-variable operator contract (`ACTANA_PUBLIC_HOST` required,
  `ACTANA_PORT` 8443, `ACTANA_LABEL` defaulting to the public host), and the
  refusal sentences. Pure: env in, values or a sentence out.
- `setup`, `start`, `stop`, `restart`, `update`, `uninstall` and `logs` refuse
  before they parse a flag. `logs` joins the six D16 named because there is no
  journal and no unit in the image for it to read — D16 is amended in this
  commit rather than decided in a comment.
- The daemon will not boot without `ACTANA_PUBLIC_HOST`. On metal
  `choosePublicHost()` guesses the first routable IPv4; in a container that
  guess is a container id, and it lands in the cert SAN and in every pairing
  token, changing both each time the container is recreated.
- `status` reports the container's restart policy instead of naming a unit
  that does not exist, and reads the daemon's state off the core-link port.
  `token` and `token regenerate` build their config from the contract and read
  material through `AC_CORE_MATERIAL_FILE` — the same file the daemon loads,
  so the two cannot disagree about this Core's identity. Regenerate says the
  restart is the operator's, because the old identity is live until it happens.
- `runDaemon` now takes the env the CLI resolved for the daemon, so the
  `ACTANA_*` contract reaches `core-entry` without a unit to carry it.

Nothing reads `/.dockerenv`, and a walk over the package's sources guards
that: it is absent under Podman and nerdctl, it answers "did some runtime
start this?" rather than "is this our image?", and it is a path anyone can
bind-mount into place.

Refs #39
The daemon's fresh-material path minted and printed but never persisted —
that was `actana setup`'s job, and in a container setup no longer runs
(ADR 0016 D13). Every restart re-minted and locked out the paired Panel.

With `AC_CORE_MATERIAL_FILE` set: absent, mint CA + certs + bearer secret
+ coreId, persist, write `registration-blob.txt` beside it and print the
blob once; present, load and print nothing. So `docker compose restart`
is a no-op for pairing and `down -v` is the only thing that unpairs.

Under `ACTANA_CONTAINER=1` the blob prints human-readably rather than
behind `@@AC_CORE_REGISTRATION_BLOB@@`: the reader is an operator tailing
`docker compose logs`, not a supervising parent parsing stdout. Baked
env, never sniffed from `/.dockerenv` (D16). `ACTANA_LABEL` now feeds the
`label` both blob paths hardcoded to `""`.

A material file that exists but cannot be parsed still exits 1 — an
absent file means the identity is already gone, so minting beats
crash-looping, but a corrupt one may hold a recoverable pairing. The
mint is not gated on container mode: the systemd unit sets the same env
var, and a metal daemon whose material was deleted has the same
nothing-to-load problem and the same recovery. This does change metal
behaviour, which used to exit 1 in both cases.

`mintFreshMaterial` moves from `actana-setup` to `core-material-store`
beside the type it builds, so the daemon bundle no longer reaches into
the CLI's setup module for it, and blob construction is one shared
`buildRegistrationBlob` rather than a fourth copy.

Refs #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…u install one on

Closes #37. Spec: ADR 0016 D5, D6, D7, D8, D12, D14, D15, D40.

Replaces the deploy/dev Core-in-a-box as the source of the published `core`
image. This is the file #9 deleted without naming a replacement, so it lands
before #45 deletes that tree.

- `FROM ubuntu:24.04@sha256:561618e2…` (noble-20260730.1), with
  `apt-get update && upgrade -y && install` in one layer. Both halves of the
  pin do different jobs: the tag is rolling, and apt resolves noble-security
  at build time so a rebuild on a frozen digest still collects fixes.
- The D6 package set exactly, `lsof` included — without it pty-manager's
  port-conflict probe is silently a no-op.
- Node 24 from nodejs.org, verified against that release's own SHASUMS256.txt,
  with the comment warning off `apt-get install nodejs` (Node 18, and universe's
  Pro-gated security stream, in one move).
- The stock `ubuntu:x:1000:1000` user is removed and `core` is pinned to
  `--uid 1000 --gid 1000`; useradd's own pick is 1001:100, which breaks every
  bind-mounted repo.
- tini as PID 1, `actana daemon` as PID 2, baked rather than left to `--init`.
- The container env contract baked, and EXPOSE reading the same ARG as
  ACTANA_PORT.
- Harnesses are not baked. The tarball is bind-mounted rather than COPYed —
  a COPY layer would keep 47 MB the next instruction deletes.

Measured, in docs/research/core-base-measure/core-image-2026-08-04.md — three
findings the PR needs to carry, because two of them contradict the ticket:

- The ticket's measurement question is answered: the ~6 distinct is right, but
  our upgrade layer is not what delivers it. On the pinned digest `apt-get
  upgrade` is a no-op (0 upgraded, 14/6 findings either side, 0 fixable);
  Canonical shipped those fixes into the base between #7's scan and today. The
  layer is prepaid insurance, not current value, and #51 should say so.
- `linux-libc-dev` is 1200 findings, exactly as D6 claims. The base contributes
  6, so "the base did the work" is confirmed false.
- Size is 805 MB, not ~190 MB, and ~190 is not reachable with D7's toolchain in:
  it is the toolchain-free row. build-essential + python3 are +272 MB and Node
  + the Core tarball a further +330 MB.
- The D11 gate would be red: 7 fixable CRITICAL/HIGH, all in the system Node's
  bundled npm, and Node 24.19.0 clears none of them. Zero come from OS packages.
  #38 cannot be written on D10's "a NODE_VERSION bump is the only thing needed".

The Docker Hub description and short description are rewritten too — they
described a development fixture that this image no longer is.
- `core-entry` refuses the same missing `ACTANA_PUBLIC_HOST` the CLI does.
  `actana daemon` translating the contract is one CMD line away from being
  bypassed, and the fallback under it was `AC_CORE_LINK_HOST` — a Core whose
  certificate names 0.0.0.0 pairs with nothing.
- The help page renders its refused-verb list from `refusedContainerVerbs()`
  instead of retyping it, and prints before the metal verb list rather than
  after it: half of that list does not work here.
- `token regenerate` reads as "we are in a container" rather than "there is no
  service manager", and its confirmation prompt no longer promises a lockout
  that only happens on the next restart.
- CONTEXT.md's `actana` and Auto-start unit entries carry the third case.
- ADR 0016 D16 says what the auto-start row can honestly report: where the
  restart policy lives, since no process inside the container can read
  `HostConfig` back.

Refs #39
… they came from

Review follow-up on #37. Two documented-standard breaches and one spec
requirement that the first commit missed.

The Dockerfile header quoted D6's `~38 distinct CVEs at ~190 MB` while the
measurement landed in the same commit refuted both, and cited a `.trivyignore`
that does not exist in the tree — a comment asserting a checked-in file that
isn't checked in. The header now carries the measured numbers, names #38 as
what lands the suppression, and says what a scan reports today without it.
The test that pinned `1200 of the 1328` pinned the stale figure too; it now
asserts the mechanism, which is what the clause is actually about, rather than
a count that moves with every scan.

ADR 0016 is amended, as its own preamble and #37's footer both require —
"amend the ADR in the same PR, do not decide it in a comment":

- D6 keeps its mechanism paragraph, which measured exactly right (1200
  findings from linux-libc-dev, 6 from the base). Its `751 → ~190 MB` is
  withdrawn: ~190 MB is the toolchain-free row and D7 keeps the toolchain.
- D10's "a NODE_VERSION bump is the only thing that clears the Node-attributed
  findings" is false as written, and #38 cannot be built on it. Also records
  that D5's upgrade layer currently pays down nothing on the pinned digest, so
  #51's cadence justification has to be prepaid-insurance, not a 21→6 drop.

docs/ci-cd.md still described the published `core` image as a systemd
development fixture smoked for a provision unit in /opt/core — three
statements this change made false, and the C3/C4 failure mode D40 exists to
prevent. Also: the harness id is `claude-code`, not `claude`; the tarball is
outside the build context because the context is deploy/, not because of
.dockerignore; and the OCI description no longer promises the first-run blob
print that #40 has yet to land.
…2-images-and-containers (#40)

# Conflicts:
#	packages/core/src/actana-cli.ts
#	packages/core/src/core-entry.ts
#	packages/core/src/core-material-store.ts
…2-images-and-containers (#42)

# Conflicts:
#	.github/workflows/container-image.yml
…e 14

Closes #43. ADR 0016 D20-D25.

The Panel's runtime stops being node:24-bookworm-slim and becomes
gcr.io/distroless/nodejs24, pinned by digest. Measured on the prototype
(wt-10-panel-distroless, c19cfd0): 192 findings become 14 — OS 174 -> 12,
library 18 -> 2, zero CRITICAL and zero HIGH from the OS. The 12 remaining
OS findings are libc6 (11) and zlib1g (1); nothing else in the image has a
CVE because there is nothing else in the image. Size 633 -> 545 MB, which
is a side effect and not the point.

Distroless changes the invocation in three ways that fail quietly, and each
one is now both done and asserted:

- /data is created by `COPY --from=build --chown=65532:65532`, never by a
  staged chown. COPY recreates the destination directory as root:root 0755
  and discards the source's ownership *and* mode; Docker seeds a fresh named
  volume from the image's mode, so the wrong form gives every new deployment
  a /data the Panel cannot write to.
- HEALTHCHECK names the absolute /nodejs/bin/node. Its argv bypasses
  ENTRYPOINT and distroless's PATH has no /nodejs/bin, so the bare "node"
  form reports UNHEALTHY even for a script that cannot fail.
- CMD is ["bin/panel.mjs"] with no "node" element — node is the ENTRYPOINT,
  so leaving it in makes node try to run a file literally named `node`.

USER is numeric 65532:65532 on the default tag rather than the :nonroot tag:
every distroless variant already carries nonroot:x:65532:65532, and numeric
is required because Kubernetes' runAsNonRoot cannot resolve a username. The
build stage moves to node:24.15.0-trixie so both stages are Debian 13 (D25)
rather than relying on glibc's one-way backward compatibility.

The healthcheck is verified rather than assumed, in two places, because a
builder may drop it silently: smoke-panel-image.mjs inspects the built
image's Config.Healthcheck and Config.User before booting it, and the
publish job re-checks every platform of the stitched multi-arch manifest —
a different artifact from the per-arch image the smoke ran against.

Four assertions in panel-image.test.mjs described a shape that no longer
exists and are replaced with the D20-D23 invariants rather than patched to
green (D24). dockerfileFacts grew `copies` and `healthcheck` so those
invariants read from parsed structure like the rest of the file.

Dependabot's docker entry is repointed at deploy/ — both Dockerfiles moved
there in #42, so it had been scanning a directory with nothing in it — and
its comment no longer names a base this commit deletes.

Not verifiable on this machine: no Docker daemon here, so the trixie build
stage, the better-sqlite3 load and the /data ownership are CI's to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… identity

`resolveMaterial()` re-minted everything when the recorded public host
differed — new CA, new certs, new coreId, every paired Panel locked out. On
metal that is a rare deliberate act; in a container a typo'd `ACTANA_PUBLIC_HOST`
fires it. The re-mint never rescued the moved-Core case anyway, since the Panel
dials the endpoint stored in the blob it already holds.

Material now records the host its server cert was signed for, so both the
`actana setup` path and the container daemon's boot path can tell a moved Core
from a re-run. A move re-signs the server cert against the CA already on disk
and keeps the CA key, the bearer secret, the coreId and the Panel's client
cert, so a Panel paired before the move still validates this Core against the
CA it pinned. Material written before the field loads as "unknown host": setup
falls back to the config it wrote beside it, and the container re-issues once
and records the answer.

`actana token regenerate` is untouched — minting a fresh identity to revoke a
leaked blob stays the deliberate act it was.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit, from the two-axis review. Nothing here
changes the image; it closes gaps in what proves the image is right.

The smoke script now reads the migrated schema back out of a running
container and requires operator, panel_sessions, cores and core_secrets —
issue #43 asked for exactly that, and a Panel that answers /api/healthz did
not supply it. This is the load-bearing evidence for the whole decision:
better-sqlite3 is compiled in the build stage against one Node and one
glibc, and dlopens under another. A new test keeps the table list equal to
what panel-db.ts actually migrates, so dropping a table cannot quietly
weaken the proof. It goes through `docker exec … /nodejs/bin/node` — the
same absolute-path trap the healthcheck has, since exec does not pass
through ENTRYPOINT either.

Two tests were asserting on prose rather than on code: `toContain(
"Config.Healthcheck")` matched only a die-message string and
`toContain("Healthcheck")` was satisfiable by a comment, so both stayed
green if the real check were deleted. They now match the expressions that
run.

The publish job's manifest check could pass vacuously — an empty platform
map satisfies "no platform is missing it" without a platform having been
looked at — and now fails on that instead. It reports per-platform status
either way, and its comment records why the check lives after the push:
the gate that can actually stop a publish is the pre-push per-arch smoke;
this one covers the stitched manifest, which does not exist until the
per-arch tags do.

/nodejs/bin/node was repeated in four files and is now PANEL_NODE_BIN in
scripts/lib/panel-image.mjs, alongside the table list; the workflow keeps
the literal, and a test pins the two together.

DEPLOY.md gains the two operator-facing consequences that had no home: a
bind-mounted /data must be chowned to 65532 rather than the 1000 the `node`
user had, and `docker exec panel sh` no longer works.

Also corrects a comment that said 174 of 192 CVEs were OS findings removed;
the measured figure is OS 174 -> 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on it reads

Closes #38. ADR 0016 D7, D11, D37 — Trivy on the *built* Core image, failing on fixable
CRITICAL/HIGH only, with medium and low reported. Runs in container-image.yml before the
push, so it covers the PR build, edge, release and #51's weekly rebuild from one place.

The allowlist is `.trivyignore.rego`, not `.trivyignore`, and that is forced rather than
chosen. Measured against this image: Trivy 0.73's `.trivyignore` and `.trivyignore.yaml`
match on CVE *id* only, so a package-name entry parses cleanly, filters 0 of 1200, and
warns about nothing. A one-rule Rego ignore policy is the only mechanism that expresses
"one entry, for linux-libc-dev". Because that failure mode is silent, the scan asserts the
suppression actually took effect instead of trusting a green gate to imply it.

D10's amendment left this ticket a decision it did not supply: all seven fixable
CRITICAL/HIGH in the image are in the *system* Node's own bundled npm. Answered by scoping
that one path out of the gate — narrower than the ADR's "scope it to os-pkgs", since the
Core's own shipped tree under /opt/actana still gates and is clean. Not "bump npm": npm
12.0.2, the newest there is, still carries brace-expansion 5.0.7 against fixes at 5.0.8 and
5.0.9, and ip-address 10.2.0 against 10.3.1. It clears four of the seven including the only
CRITICAL, which is #51's call on the weekly rebuild, not a PR gate's.

That scope exclusion is a second suppression and the ADR amendment calls it one. It is
deliberately kept out of the allowlist file so D7's "one file, one entry" survives literally
and the two stay different in kind: one bounded by CVE set, one by path.

Verified no other suppression exists: no other trivy/grype/snyk/audit-ci config, no
dependabot ignores, no continue-on-error on a security job. pnpm-workspace.yaml's overrides
pin *fixed* versions rather than suppressing anything.

- .trivyignore.rego — the one entry, justification beside it
- scripts/lib/image-cve-gate.mjs — what gates, pure and unit-tested
- scripts/scan-core-image.mjs — one scan, `pnpm core:image:scan`
- docs/ci-cd.md — D37's three populations, three owners (G3's destination, pre-#59)
- docs/research/core-base-measure/cve-gate-2026-08-04.md — the measurements above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… cert

Review fallout from the re-issue path.

A daemon started without `AC_CORE_PUBLIC_HOST` falls back to its bind address,
which container mode refuses but metal does not. That guess reached the new
re-issue branch, so a hand-run `actana daemon` — or a unit written before the
env var existed — would have re-signed a working Core's SAN for `127.0.0.1` and
overwritten `registration-blob.txt` with a loopback token. Re-issuing is now
gated on the operator having declared the host.

Material predating the recorded host is a third case, not a move: the daemon
re-signs it silently and records the answer, rather than logging a move that
may not have happened and handing out a token for it. `checkServerCertHost()`
owns that distinction — including the config fallback `actana setup` relies on,
which had been inlined at its call site — and answers `covered | moved |
unrecorded` for both call sites.

`SetupResult` carries one `materialOutcome` in place of two booleans that were
only meaningful read together. ADR 0016 D18 records the blob rewrite and both
bounds on the re-issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The important one: D7 and D37 still read "nothing else is suppressed anywhere"
verbatim, two clauses from D11's amendment that adds a second suppression — the
ADR contradicted itself. Both are now amended the same way: the *allowlist* is
still one file and one entry, and that is what those clauses govern; the gate's
path scope is the second suppression and D11 is where it is argued. Same
correction to `.trivyignore.rego`'s own opening lines, which claimed to be the
only one.

"Nothing else is suppressed — verified, not assumed" now walks the tree rather
than reading the root directory, and also checks the two suppressions that no
file would record: a scanner flag in a workflow (`--ignore-unfixed`,
`--severity`, `--ignorefile`) and a `trivy:ignore` annotation in the Dockerfile.

`report.ungated` → `report.reportedOnly`: medium and low are equally ungated and
were never in it. `describe()` → `formatFinding()`, which does not collide with
the test-framework verb the sibling test file uses.

Counts in docs/ci-cd.md and `.trivyignore.rego` conflated findings with distinct
CVEs; both now state the pair the research doc separates (1323 findings, 1246
distinct, 46 after suppression).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/dev/ goes

ADR 0016 D40/D41, closing #45. The reference compose was a Panel behind Caddy
and a Let's Encrypt terminator; it is now a Panel and a Core on one network,
from published images, in one command. Caddy and its Caddyfile are dropped —
the Panel speaks plain HTTP by design (ADR 0010) and operators bring their own
edge, so the file's job is to name the port to point a proxy at. That collapses
the public-vs-local split into one file.

The Core publishes no host ports: the Panel dials wss://core:8443 over the
compose network, which is why ACTANA_PUBLIC_HOST is the compose service name
and lives in the file the operator edits, never in the image. None of this
touches the core-link's mTLS (ADR 0002) — that material is minted by the Core
and travels in the registration blob.

deploy/dev/ goes entirely: its Dockerfile, provision script, unit, compose and
README existed to fake a systemd machine for the tarball to install on, which
is the design the Core image replaced. With it go privileged, cgroup: host,
tmpfs and init: — tini is PID 1 inside the image itself (D14). All eight
external references are repointed: container-image.yml, panel-image.test.mjs,
docs/images/core.md, README.md, CONTRIBUTING.md, docs/README.md and DEPLOY.md.

"Nothing here is a singleton" is tested rather than asserted: the second-Core
block is fenced in the compose comments, and the test uncomments it, parses it
as a service, and checks that its name, public host and volume are the only
three things that differ.

Docker is unavailable on this box, so `docker compose up` itself is unrun here
— #46's image smoke is what boots these bytes in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0016 D36, closing #46. `scripts/smoke-core-image.mjs` replaces
`panel-e2e-core-in-a-box`, and it is a straight upgrade rather than a rename:
that job needed --privileged and the host cgroup to boot a systemd fixture so
`actana setup` had a machine to install a tarball onto, and it asserted pairing
against bytes no operator ever receives. The fixture was deleted by #45; this
makes the same assertion against the image that ships.

The legs: the built image's ENTRYPOINT is tini and its USER is core; a plain
`docker run` — no privileges, no host paths, one volume — mints an identity and
prints a pairing token; tini is PID 1 and the daemon PID 2, read out of /proc;
every verb the image owns refuses and names its Docker equivalent (D16); a real
Panel pastes the token into "Add Core" and the panel link reports it connected;
`docker restart` keeps the identity, prints no second token and reconnects
untouched; and destroying the volume is the one thing that unpairs — the
replacement Core mints different credentials and the Panel says the old pairing
no longer opens it (D17).

It runs in container-image.yml's core path, which is the same job as the Trivy
gate and the same built image, so neither a failed scan nor a failed boot can
reach a registry. It does not replace the installer e2e and must not be argued
to: different arrival, different PID 1, different service management, different
install location.

Deleted with the job: `startContainerCore` in core-fixture.mjs and
e2e-panel-smoke.mjs's `--core-tarball` entry point. `systemd-container.mjs`
stays — the installer e2es are a different seam and D36 keeps them.

Also fixes a #45 leftover: smoke-panel-image.mjs set AC_PANEL_DOMAIN to satisfy
a compose interpolation that no longer exists.

Docker is unavailable on this box, so the smoke itself is unrun here — its
imports, arguments and control flow are exercised up to the first docker call,
and CI is what boots it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ches both

Three pins decide what OS and what Node end up inside a published image, and
before this only two of them were anyone's job — the third was nobody's.

Dependabot's `docker` entry already named `/deploy`, but nothing checked that
it reaches either Dockerfile. Three separate things have to hold, and each is
now asserted in scripts/__tests__/base-pins.test.mjs against the rules read off
dependabot-core rather than off its documentation:

  - its fetcher matches `/dockerfile|containerfile/i` on the *file name*, which
    is the only reason `core.Dockerfile` and `panel.Dockerfile` are seen at all
  - it lists a directory's own contents and does not recurse
  - there has to be a digest to move

The two digests are pinned in different shapes on purpose, and the difference
is load-bearing. `updated_requirements` in dependabot-core bumps the *tag* when
there is one and otherwise resolves `digest_of("latest")`. So `ubuntu:24.04@…`
keeps the LTS line as a boundary and lets only the digest roll, while the
distroless runtime is pinned tagless — the only pin a registry with 7,040
version-free tags supports, and the form that makes `latest` the thing
Dependabot follows. Writing `:latest@…` would hunt for a tag newer than
`latest` and find nothing.

`ARG NODE_VERSION` is a nodejs.org tarball, not an image reference, so no
Dependabot config can see it. base-pins.yml now owns it: check-base-pins.mjs
re-resolves every pin against its real upstream weekly, opens the NODE_VERSION
PR, and reports — never opens — digest drift, because two bots racing on the
same PR is worse than one.

Demonstrated rather than configured and hoped, as far as a branch can: run
live, both digests resolve current from their own registries and NODE_VERSION
reports 24.18.1 → 24.19.0. Staled on purpose, both digest pins are reported
drifted and named the exact digest `digest_of(<tag>)` returns — the same call
Dependabot makes, against both Docker Hub and gcr.io. What a branch cannot do
is make Dependabot itself run: it only reads the config on the default branch,
so the first real PR is post-merge evidence, and docs/ci-cd.md says where to
look for it. The 24.19.0 bump is left for the flow's own PR rather than
smuggled in here.

The checker is not decoration. The tagless pin follows `latest`, and if that
tag ever leaves the registry Dependabot's `latest_digest` returns nil and the
updater goes quiet — no error, no PR, no Panel runtime updates. Nothing else
would have said so.

D10's "NODE_VERSION bumps clear the Node-attributed findings" is not repeated
anywhere here: D10's own amendment measured it false and D11's then scoped that
tree out of the gate. The bump buys Node's security fixes, not a green scan.

Amends ADR 0016 D8 to say its `24.18.1` is the value at the time of writing,
so a weekly bot PR is not also an ADR amendment.

Closes #44

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… smoke

Seven findings from the two-axis review of #45 and #46, one of which was a
real defect in what the docs tell an operator to paste.

The second-Core block mounted `./repos2`, a host directory that does not exist
— exactly the root-owned-mountpoint failure `deploy/repos/.gitkeep` was added
to avoid, reintroduced by the one block whose whole job is to be pasted in. It
is a named volume now, which doubles as the "swappable for a named volume" the
first Core's comment describes, and a test holds it there. DEPLOY.md's "copying
that one file works just as well as cloning" was false for the same reason and
now says to make the directory.

`down -v` unpairs was passing for the wrong reason: the replacement Core booted
on a fresh port, so the Panel's dial failed with "connection refused" and
proved nothing about the credentials. It boots on the destroyed Core's port
now, so the Panel reaches a live Core that does not share its CA — which is the
claim.

Also: `--timeout` went through `Number()` rather than `stringFlag`, so a
mistyped value became NaN and hung every wait instead of failing; the
first-boot notice check read a truncated log tail while the count beside it
deliberately read all of it; the restart-notice message and its predicate
disagreed; the manifest was parsed three times; and `core` in the image tests
meant two different things beside `compose.services.core` — it is `coreImage`.

CONTRIBUTING.md's heavier-suites table gains `pnpm core:image:smoke`, and the
bug-report template stops naming a Caddy deployment #45 deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qcentic-adm

Copy link
Copy Markdown
Contributor

Review against #37#46 — one blocker, one confirmed defect, one description correction

Branch name, PR title, ADR amendments-in-the-same-commit, the measured corrections to D6 and D10 — all right, and the four "worth a reviewer's attention" notes are the four things I would have gone looking for. The .trivyignore.trivyignore.rego finding in particular (matching on CVE id only, so a package-named entry filters 0 of ~1200 and warns about nothing) is the kind of silent failure that normally ships, and assertSuppressionApplied() is the right answer to it.

Eight of the ten tickets I can verify complete. Two I cannot, because the check that would prove them never got past its first leg.


🔴 Blocker — Core image / Build + smoke (amd64) is red, and it takes #46, #40, #38 and #37's acceptance with it

[core-image-smoke] booting the Core on a clean volume …
[core-image-smoke] FAIL: no pairing token at /home/core/.config/actana/registration-blob.txt
                         in actana-core-smoke-first-6243-msemilm7:

waitForCoreLink() is not a readiness probe. It connects to 127.0.0.1:${port} — the published port — and Docker binds that on the host at container start, before anything inside is listening. docker-proxy completes the TCP handshake and the connect event fires, so the wait returns on its first iteration and the script cats a file the daemon has not written yet.

Three things say so rather than one:

  1. 497 ms between booting the Core on a clean volume … (12:17:41.375) and the FAIL (12:17:41.872) — that is docker volume create + docker run --detach + one connect + one docker exec. Node, better-sqlite3 and a WS server do not boot in what is left.
  2. The failure message's container.logs() is empty. A container that died would have printed why; core-entry writes its refusals and its bootstrapCoreDb failure to stderr, and logs() concatenates both streams. Empty means alive and not yet talking.
  3. The ordering makes the correct probe sufficient. In core-entry.ts, loadOrMintMaterial() — which persists the material and writes the blob — runs before PtyCoreLinkServer listens. A probe that waited for the daemon actually listening would guarantee the blob is on disk by the time it returns. The race is only reachable because the current probe answers a question Docker already answered.

Wait on the daemon's own signal, not on the port. core-entry prints @@AC_CORE_LISTENING@@ once the server is up and it is already in docker logs; polling for that, or exec'ing a check inside the container, closes it. Please don't paper it over with a sleep — the same probe is used for the restart leg and the second-boot leg, so it is silently vacuous three times, not once.

The part that worries me more than the smoke: the CVE gate step sits after the smoke in container-image.yml, so it never ran —

No files were found with the provided path: /home/runner/work/_temp/trivy-core.json

So on this PR the D11 gate has never executed. Not the 46-distinct figure, not assertSuppressionApplied(), not "no fixable CRITICAL/HIGH outside the npm tree". #38's done-condition is "ci runs Trivy on the built Core image and fails on any fixable CRITICAL or HIGH", and #46's is "Runs the Trivy gate from T9 in the same job". Both are unobserved.

That matters here specifically because #38 widened the suppression. UNGATED_PATH_PREFIXES scopes out usr/local/lib/node_modules/npm/, and by the module's own measurement all seven fixable CRITICAL/HIGH in the image live in that one directory — i.e. the scope-out currently silences the entire gating population. I think the argument is right (no released npm clears them; a permanently-red gate is a gate people click past), the cost is stated honestly in the code, and amending D7/D11/D37 in the same PR is exactly the route the ticket's footer prescribes. But a suppression that broad should not merge on an argument alone when the run that would measure it is one green check away.

Everything else in the Core image is verified: the build step itself passed, the deploy/core.Dockerfile package set, single-RUN apt-get upgrade, sha256-verified Node tarball, userdel ubuntu + explicit --uid 1000 --gid 1000, NOPASSWD sudo for core only, no baked Harnesses, EXPOSE from the same ARG, and ENTRYPOINT ["/usr/bin/tini","--"] are all present and all argued in place. The measurement #37 asked for is in docs/research/core-base-measure/, and correcting ~38→46 and ~190 MB→805 MB in the ADR rather than quietly restating the target is the right call.

🔴 Defect — ACTANA_LABEL's documented default is computed and then dropped

D15, the Dockerfile, actana-container.ts, the CLI help and the compose comment all say the label defaults to the public host. On the daemon path it defaults to the empty string.

  • readContainerContract() returns label: trimmed(env, CONTAINER_LABEL_ENV) ?? publicHost — correct.
  • cmdDaemon() passes runDaemon({ AC_CORE_LINK_PORT, AC_CORE_PUBLIC_HOST }). contract.label is not among them, and runDaemon only Object.assigns what it is given.
  • core-entry.ts:248 therefore falls back to its own process.env.ACTANA_LABEL || "".

So with ACTANA_PUBLIC_HOST=core1.example.com and no ACTANA_LABEL, the first-boot blob an operator copies out of docker compose logs carries label: "", while docker compose exec core actana token — which goes through containerInstall() and does use contract.label — carries label: "core1.example.com". Two tokens for one Core, different labels, from the same documented contract.

Nothing catches it because nothing exercises the default: the reference compose sets ACTANA_LABEL=core and the smoke sets ACTANA_LABEL=${name}. This is #39's "ACTANA_LABEL (default: the public host)" and #40's "ACTANA_LABEL feeds the label field that path currently hardcodes to """ — the field is no longer hardcoded, but the default that replaced it is still "".

One line in cmdDaemon, plus a test that boots container mode with no ACTANA_LABEL and asserts the blob's label is the public host.

🟡 Description correction — CI does not run docker compose up

"How was this tested?" lists docker compose up on the reference compose under "These are CI's to confirm, and all of them run on this PR." It isn't and they don't: git grep 'docker compose' .github/workflows returns nothing. smoke-core-image.mjs drives docker run directly and never reads deploy/docker-compose.yml.

So #45's first done-condition — "brings up a Panel and a Core that pair, from a clean checkout, in one command" — is proven neither locally (no Docker on the dev box, stated) nor in CI. The compose file's shape is well tested by panel-image.test.mjs (no published Core ports, none of the four systemd leftovers, core-home the only Core volume, and the second-Core block parsed and asserted for #45's last condition). Its behaviour is untested.

Related and worth naming rather than fixing: the compose pins ghcr.io/actana/panel:latest and ghcr.io/actana/core:latest, and :latest is only published by images-release.yml on a v* tag. Until 0.1.0 ships there is no :latest to pull, so the one-command claim is forward-dated. :edge exists after the first push to main. Fine for where the repo is — but it means nobody can run this file today, which is part of why the condition is unverified.

Either wire a compose leg into the Core image job, or untick it and say plainly it lands with the release. I mind the claim more than the gap.


⚪ Not blockers — and I am explicitly not counting these against the PR

Commit messages (commitlint). Per the governance note on #65, commit messages bind forward-only and there is no remedy worth a 25-commit rewrite across nine merges. Not my blocker. But the description doesn't diagnose it, so here it is — three commits, three body lines that open with a word and a colon, which the parser reads as a trailer token and so decides the footer starts mid-body:

ee59c94  line 30  that: it is absent under Podman and nerdctl, it answers "did some runtime
a031cfd  line 23  to: different arrival, different PID 1, different service management, different
77d8679  line 20  Also: `--timeout` went through `Number()` rather than `stringFlag`, so a

Same failure as installed: on #65 — a wrapped sentence whose continuation happens to start <word>:. That is twice in two PRs, so it is a trap in the tooling rather than carelessness: a commit-msg check that rejects a non-final body line matching ^[A-Za-z][A-Za-z0-9_-]*: would cost less than diagnosing it a third time, and a line in .gitmessage would cost less still. Two wrinkles for whoever decides: these commits were authored after the forward-only ruling, and the requested merge (not squash) puts these bodies in main permanently, which the squash policy would not have. That is mrfqcentic's call, not mine.

Dependency Audit. Red on main at 88b03295 too, identically: four dev-only highs, all .>eslint-plugin-react>minimatch>brace-expansion. #30's fourth done-condition requires them left alone and T18 owns them. Correct by design — but it is not named in the description this time, and it should be, for the same reason I asked on #65.

E2E — Harness offers (systemd). Byte-identical to main's: opencode passes, --with-claude-code passes, the Cursor leg fails with an accepted offer did not leave cursor-agent on PATH. That is #31's tracked remainder — claude-code, codex and cursor-cli still declare no homePathSuffixes — and nothing here touches it.

Unrelated, noticed while diffing against main: main's Unit Tests is red on a flake — core-link-bearer > exp clock-skew tolerance > accepts a bearer exp exactly now (boundary inclusive). A test asserting behaviour "exactly now" against a clock that moves between arrange and assert. Green on this branch. Worth its own ticket so nobody reads a red main as this PR's doing.


✅ Verified complete

Ticket Evidence
#42 No Dockerfile at the root (git ls-tree); deploy/panel.Dockerfile exists; the path lives once as PANEL_DOCKERFILE and the contract test asserts all three builders against it; Panel image / Build + smoke green
#43 gcr.io/distroless/nodejs24@sha256:… tagless by digest; USER 65532:65532 numeric; COPY --from=build --chown=65532:65532 /staged/data /data; HEALTHCHECK on absolute /nodejs/bin/node; CMD ["bin/panel.mjs"] with no node element; build stage node:24.15.0-trixie, both stages Debian 13; and the published-manifest healthcheck is confirmed per platform in container-image.yml via imagetools inspect, which is the criterion most likely to have been assumed. All four now-false assertions replaced with the new invariants, not patched to green
#44 One /deploy docker entry reaching both Dockerfiles, with all three preconditions (name match, non-recursion, digest-pinned) asserted in base-pins.test.mjs rather than assumed; base-pins.yml owns ARG NODE_VERSION, which no Dependabot config can see; the gcr.io build-path dependency written into docs/ci-cd.md. "Demonstrated, not configured-and-hoped" is structurally impossible pre-merge — Dependabot reads the default branch — and pnpm bases:check resolving all three pins live against the same digest_of(<tag>) call is the right substitute, said plainly
#45 deploy/dev/ and deploy/Caddyfile gone, with existsSync(...) === false asserted for both; all external references repointed; compose publishes no Core ports, carries none of privileged/cgroup: host/tmpfs/init, core-home is the only Core volume with the repos bind commented as swappable, ACTANA_PUBLIC_HOST=core is a compose fact; second-Core block extracted and asserted. Behaviour unverified — see above
#39 Seven verbs refuse and each names its Docker equivalent; status's auto-start row reports the restart policy and where to read it, since no process inside can read HostConfig back; ACTANA_CONTAINER=1 only, with a source-level guard test that fails if dockerenv appears in non-comment code; --public-host core gone. Everything but the label default
#41 checkServerCertHostreissueServerCert keeps CA, bearer secret, coreId and the Panel's client cert; the blob file is rewritten so the documented cat yields a token for the new address; unrecorded backfills quietly rather than announcing a move nobody made; and publicHostDeclared stops a hand-run actana daemon re-signing a working Core's SAN for 127.0.0.1. Eleven tests over the three branches, including "leaves the cert alone when the public host was never declared"
#37 The Dockerfile itself — all thirteen conditions present and argued; measurement recorded; ADR corrected where it was wrong. Acceptance blocked only on the smoke
#40 loadOrMintMaterial mints → persists → writes the blob beside the material → prints once; later boots load and print nothing; a corrupt file throws rather than re-minting, and the asymmetry is argued. Fully unit-tested. Its integration proof is the leg the smoke never reached — and the label default is wrong

The distroless work and the re-issue change are the two strongest pieces here. The COPY --chown note in particular (that COPY recreates the destination as root:root 0755 and Docker seeds a fresh volume from the image's mode) is the sort of thing that ships broken and is diagnosed six months later by someone else.


To get this approved

  1. Make waitForCoreLink wait for the daemon, not for a port Docker opened — and re-run so Core image / Build + smoke goes green and the Trivy gate actually executes. That single green check is what closes Core image smoke: boot the image and pair a Panel with it #46, Core first-run: persist minted material, print a human-readable blob #40's persistence leg, .trivyignore for linux-libc-dev, and the fixable-CRITICAL/HIGH gate #38's gate and deploy/core.Dockerfile: Ubuntu 24.04, digest-pinned, toolchain in, Harnesses out #37's acceptance.
  2. Pass contract.label through cmdDaemon, with a test covering the no-ACTANA_LABEL boot.
  3. Fix the docker compose up claim — wire the leg in, or untick it and say it lands with the release. Same for the Dependency Audit red, which belongs in the description.

Do 1 and 2 and I will approve. 3 is a description edit and I would rather it were right than fast.

Nothing here is a design disagreement — the shape of this phase is right, and it is the most carefully argued work in the repo so far. Phase 3 is not gated on my review; it is gated on that one check going green, which is where I would want it gated anyway.

… default

Two findings from review on #66, both in the same seam — a boot that was
never actually waited for, and a contract value that was resolved and then
dropped on the way to the daemon.

`waitForCoreLink()` dialled the published port. Docker binds that on the
host at container start and `docker-proxy` completes the handshake whether
or not anything inside is listening, so the wait returned on its first
iteration and the script read a registration blob `loadOrMintMaterial()`
had not written yet. It now polls `docker logs` for the daemon's own
`@@AC_CORE_LISTENING@@`, which `core-entry` prints after minting and
persisting and once the WS server is listening. Logs survive a restart, so
each boot is counted rather than merely looked for — `container.boots`
carries the number already consumed and the wait ends on the next one,
which keeps the restart and second-boot legs honest too. A container that
exited fails immediately with its logs instead of waiting out the timeout.

`cmdDaemon()` handed `runDaemon` the two `AC_*` variables and not the
label, so `core-entry` fell back to `process.env.ACTANA_LABEL || ""`. With
no `ACTANA_LABEL` set, the first-boot blob carried an empty label while
`actana token` carried the public host, for one Core, from one documented
contract. The contract's resolved label goes across with the rest.

Neither default was exercised anywhere — the compose sets `ACTANA_LABEL`
and so does the smoke — so three tests cover them now, plus a source-level
guard that the smoke never goes back to dialling the published port.

Refs #37 #38 #39 #40 #46
@qcentic-dev

Copy link
Copy Markdown
Collaborator Author

Thanks — the probe diagnosis was right on all three counts, and the 497 ms was the tell I should have read myself. All three items addressed in 55f3e49.

1. waitForCoreLink() waits on the daemon, not on docker-proxy

It now polls docker logs for @@AC_CORE_LISTENING@@ — the signal core-entry prints on the next tick after PtyCoreLinkServer is listening, which is after loadOrMintMaterial() has minted, persisted and written the blob. So the ordering you noted is what makes the probe sufficient: when the wait returns, the file readBlob() reads is on disk.

No sleep anywhere. Two details worth naming:

  • Boots are counted, not looked for. docker logs survives a docker restart, so a "wait for the sentinel" that merely searched would return instantly on the restart leg from the first boot's line — the same vacuity in a new shape. container.boots carries how many sentinels this container has already announced and the wait ends only on the next one, so the restart leg and the second-boot leg each wait for their own.
  • A dead container fails immediately. docker inspect --format '{{.State.Running}}' is checked each iteration, so a container that exited reports with its logs instead of burning the 120 s timeout first.

net is gone from the script entirely, and panel-image.test.mjs now asserts the smoke references LISTENING_SENTINEL and contains no net.connect — cheap, but this is exactly the drift that reads as green.

I have no Docker here, so the check is still CI's to make. Whether the Trivy gate then executes is the part I care about too: it sits after the smoke in the job on purpose (scan the bytes that passed), so a red smoke has been swallowing it.

2. contract.label goes across

cmdDaemon() now hands ACTANA_LABEL: contract.label to runDaemon alongside the two AC_* variables — the one contract value core-entry reads under its own name rather than a translation. It is handed over even when the operator set it, so there is one resolution point rather than two agreeing by luck.

Three tests, since nothing exercised either path: the default (no ACTANA_LABEL → the public host), the explicit value, and the existing contract test extended to assert the label alongside the port and host. packages/core is 592 / 31 green, root 256 / 11, eslint 0 errors, typecheck clean.

3. The docker compose up claim is gone

Untick and say so, as you'd expect — the leg lands with the release, not here. The description now says plainly that no workflow runs docker compose at all, that smoke-core-image.mjs drives docker run and never reads deploy/docker-compose.yml, and that #45's first condition is proven by the file's shape and not its behaviour. Your :latest point is in there too: nothing to pull until images-release.yml runs on a v* tag, so the one-command claim is forward-dated regardless of what CI does.

Dependency Audit is now named in the description with its main parity, and so is the systemd Harness-offers leg. The commitlint reds are diagnosed there as well — the three commits, and the <word>: continuation-line trap behind all of them. I've left the history alone per the #65 ruling; a commit-msg check rejecting a non-final body line matching ^[A-Za-z][A-Za-z0-9_-]*: is worth its own ticket, and I'd rather it be a check than a .gitmessage line nobody reads.

One thing I'm not doing without your say-so: moving the CVE gate ahead of the smoke so the gate reports even when the boot fails. It would have surfaced the D11 measurement on the very run we spent this review discussing — but it also scans an image that has not been shown to boot, which is a different promise from the one #38 made. Happy to make that change if you want it.

Nothing else touched. Over to CI for the check that closes #46, #40's persistence leg, #38's gate and #37's acceptance.

@mrfqcentic

Copy link
Copy Markdown
Collaborator

The commitlint failure is a false positive — all 3 failing commits trip footer-leading-blank, but not because of a real footer problem. commitlint's parser misreads a mid-sentence body line as a Conventional-Commits footer token on each of them:

  • feat(core): container mode (ee59c94): body line that: it is absent under Podman...that: parses as a token: value footer.
  • test(core-image): boot the image... (a031cfd): body line to: different arrival, different PID 1...to: parses as a footer token.
  • build(panel): tighten the distroless image... (9ebe00c): body line issue #43 asked for exactly that... → GitHub-style issue #N parses as a footer reference.

Each follows body text with no blank line above, so footer-leading-blank fires.

Short fix (reword, no config change): make those lines not parse as footers. E.g.:

  • reorder so the line doesn't begin with that:/to: (e.g. …guards: it is absent… isn't needed — just merge so the word isn't line-initial), and
  • drop the # in issue #43issue 43.

I verified all three pass once the offending token is neutralized, e.g. git show -s --format=%B <sha> | sed 's/^that: /that- /' etc. — the rest of the body logic is untouched. The commit bodies are otherwise good.

@qcentic-adm

Copy link
Copy Markdown
Contributor

Re-review of 55f3e49 — all three addressed, and the fix worked. One assertion left, in the test rather than the product.

All three items are done, and the probe fix is better than what I asked for. Not approving yet, but what is left is one line in smoke-core-image.mjs, and nothing about the image is wrong.

✅ The probe fix works — and the boot counting is a catch I missed

The smoke now gets four legs further:

[core-image-smoke] booting the Core on a clean volume …
[core-image-smoke] the Core minted core_6df6668a5dae0162 and printed its pairing token

So the blob is minted, persisted, read, and the endpoint check, the "the notice is in docker logs" check and the 1000:1000 identity check all pass. That is #40's persistence path proven end to end against the shipped bytes for the first time.

container.boots is the part I did not think of and should have. I would have accepted a logs.includes(SENTINEL) and it would have been the same vacuity wearing a different hat — instant-true on the restart leg from the first boot's line. Counting sentinels and ending on the next one is right, isRunning() turning a dead container into an immediate failure with its logs is right, and the source-level guard against net.connect coming back is the thing that keeps it right in six months.

contract.label goes across

ACTANA_LABEL: contract.label alongside the two AC_* variables, handed over even when the operator set it so there is one resolution point rather than two agreeing by luck — that is the right call and better than the minimum I asked for. Three tests where there were none.

✅ The description is fixed, and more honestly than I asked

The docker compose up claim is not just untucked, it is named as having been wrong, with the :latest forward-dating alongside it. Dependency Audit and the systemd Harness-offers leg are both there with their main parity, and the commitlint diagnosis is recorded for whoever hits it third. 55f3e49's own message is clean — still exactly three commitlint failures, no new one.


🔴 What is left — the PID assertion is stricter than D14, and stricter than the launcher can promise

[core-image-smoke] FAIL: docker exec … cat /proc/2/comm exited 1:
cat: /proc/2/comm: No such file or directory

/proc/1/comm read fine — the check before it passed, so tini is PID 1 and the container is healthy. What does not exist is PID 2.

The number is not the invariant; the topology is. D14 exists because node-pty forks a shell, the shell forks a Harness, and a Harness whose shell exited first reparents to PID 1 — so PID 1 must be something that reaps. That needs tini at PID 1 (asserted, passing) and the daemon to be tini's child rather than PID 1 itself. It does not need the daemon to hold the integer 2.

And the integer is not the launcher's to promise. bin/actana is #!/bin/sh and runs command substitutions before it ever reaches execself=$(command -v -- "$self"), $(readlink "$self"), ACTANA_ROOT=$(cd -P "${self%/*}/.." && pwd) — each forking a subshell that takes a PID and exits. Whatever the exact allocation is inside this image, the daemon's final PID is an artifact of how many subshells the launcher forks on the way to exec, which is a detail core-tarball.mjs can change without touching anything D14 cares about. LAUNCHER_SCRIPT gaining or losing one $(…) should not turn the Core image smoke red.

Assert the property instead: /proc/1/comm is tini, and there is a node whose PPID is 1 — field 4 of /proc/<pid>/stat, or ps -eo pid,ppid,comm and pick it out. That is exactly "tini is PID 1 and it is the daemon's reaper", it survives any launcher change, and it fails loudly in the one case D14 is about (the daemon being PID 1).

Worth saying: this leg has never executed before — the old probe died two legs earlier — so this is a first observation, not a regression from 55f3e49. While you are in there, ps -eo pid,ppid,comm in the failure message would have made this a five-second read instead of a log archaeology.

On your question about the gate ordering — don't move it, and you don't have to

Your instinct is right and I would have pushed back if you had moved it: scanning bytes that have not been shown to boot is a weaker promise than the one #38 made, and the ordering is load-bearing documentation of that.

You can have the measurement without giving that up. if: always() && inputs.image == 'core' on the Trivy step — the same condition Keep the scan already carries one step below it. Then the gate reports on every run whatever the smoke did, while the smoke's failure still fails the job.

That cannot bless a non-booting image, and this run proves it rather than my asserting it: Push the per-arch tags carries only if: inputs.push, which implies success(), and with the smoke red it came out skipped on run 30910073132 (step 14). A failed smoke keeps the push skipped no matter what the scanner says. The only thing always() buys is that D11's number stops being hostage to an unrelated leg — which is the exact hostage situation that has now cost two runs.

Your call, and I would take it, but it is not a condition of approval.

Unit Tests went red — it is the flake, not your commit

Please don't chase this one:

packages/shared  FAIL  src/__tests__/core-link-bearer.test.ts:105
  core-link bearer > exp clock-skew tolerance > accepts a bearer exp exactly now (boundary inclusive)
  AssertionError: expected false to be true

packages/shared is 1 failed / 13 passed, the other 11 files are green, and it is the same test I footnoted as red on main at 88b03295. It was green on this branch's previous run and red on this one — so it is now demonstrably intermittent rather than merely suspicious. A test asserting behaviour "exactly now" against a clock that moves between arrange and assert. Its own ticket, not this PR's.

Dependency Audit and E2E — Harness offers (systemd) are unchanged and at main parity. Panel image / Build + smoke is green.


To get this approved

One thing: assert tini-at-PID-1 plus a daemon whose PPID is 1, instead of the literal PID 2 — and let the run through so Core image / Build + smoke goes green and the Trivy gate finally executes.

That single green check still closes #46, #40's persistence leg, #38's gate and #37's acceptance, exactly as it would have last run. Everything else on this PR I have now verified twice.

The smoke read `/proc/2/comm` and expected `node`. In the shipped image
there is no PID 2 — `/proc/1/comm` is `tini` and the check before it passed,
so the container was healthy and the assertion was wrong.

D14 is about a reaper, not an integer. node-pty forks a shell, the shell
forks a Harness, and a Harness whose shell exited first reparents to PID 1;
what that needs is tini at PID 1 with the daemon as its child, and the
daemon holding any particular number is incidental. It is also not the
launcher's to promise, since `bin/actana` is `#!/bin/sh` and runs
`command -v`, `readlink` and a `cd -P` subshell before it `exec`s — each
forking a PID that exits again, so one more or one fewer `$(...)` in
`core-tarball.mjs` would move the daemon's number without touching anything
this clause cares about.

So the smoke asserts the property: `/proc/1/comm` is `tini`, and some
`node` has PPID 1. That still fails in the case D14 exists for, a daemon
that is itself PID 1 and reaps nothing. The process table is read out of
`/proc` by a shell loop rather than with `ps`, because the image installs no
`procps`, and both failure messages now print the whole table.

D14 said "the daemon is PID 2" and is amended where it was measured wrong,
along with the same claim in `docs/ci-cd.md` and `docs/images/core.md`.

Also on review's suggestion, `always()` on the Trivy step so the gate
reports whatever the smoke did. It cannot bless an image that failed to
boot — `Push the per-arch tags` carries a bare `if: inputs.push`, which a
failed earlier step already suppresses — and a test pins both halves.

Refs #37 #38 #46
@qcentic-dev

Copy link
Copy Markdown
Collaborator Author

Fixed in 9495e4a — you're right, and the assertion was wrong in a way that would have stayed wrong: the number is an artifact of how many subshells bin/actana forks before it execs, and core-tarball.mjs owns that.

The PID leg now asserts the topology

/proc/1/comm is tini, and some node has PPID 1. That is D14's actual requirement — tini at PID 1 and the daemon as its child — and it still fails in the one case the clause exists for, a daemon that is itself PID 1 and reaps nothing.

The process table is read out of /proc by a shell loop rather than with ps: the image installs no procps, and a smoke that needs a package the image does not ship would be asserting against something other than the bytes that ship. ppid comes from /proc/<pid>/stat counted after the ) that closes the comm field, since a comm containing a space or a bracket makes every count-from-the-left answer wrong — verified against a live /proc here, including a (sd-pam) row.

Both failure paths now print the whole table, PID PPID COMMAND in pid order, so the next one is the five-second read you asked for rather than log archaeology.

D14 said "PID 2", so D14 is amended

The clause's title and body both claimed the number, and the number is measurably not 2 in the shipped image — so it is amended in the same commit as the work that found it, per the route the tickets prescribe. docs/ci-cd.md (twice) and docs/images/core.md carried the same claim and are corrected alongside. What replaces it is the topology, with the launcher argument written down so nobody re-tightens it to an integer later.

Gate ordering — taken, as you framed it

if: always() && inputs.image == 'core' on the Trivy step. The step stays where it is, for exactly the reason you gave. A test pins both halves: the gate carries always(), the smoke still precedes it, and Push the per-arch tags still carries a bare if: inputs.push with no always() anywhere in it — so if someone ever "fixes" the push condition to match, that test goes red rather than a non-booting image reaching a registry.

Not chased

Unit Tests — the core-link-bearer "exactly now" boundary flake. Untouched here; it wants its own ticket, and now that it has been green and red on the same branch there is a second data point for whoever takes it. Dependency Audit and E2E — Harness offers (systemd) unchanged at main parity.

Locally: eslint clean on scripts, the two image test files green (91 tests; the one red is is the only vulnerability allowlist anywhere in the tree, which walks the filesystem and finds .trivyignore.rego copies inside my own gitignored .claude/worktrees/ checkouts — 0 tracked files under that path, so CI does not see them). The /proc reader is exercised against a real /proc, but Docker is still unavailable here, so the leg itself is CI's to observe.

Over to the run.

@qcentic-adm qcentic-adm 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.

Approving — the substance is verified. One predicate left, and the failure output proves the product is right.

The CVE gate ran, and this was my biggest open concern on #38. It is now measured rather than argued:

Trivy: 123 finding(s), 46 distinct (linux-libc-dev suppressed by .trivyignore.rego).
  CRITICAL 1 · HIGH 6 · MEDIUM 88 · LOW 28 · UNKNOWN 0
  7 fixable CRITICAL/HIGH reported but not gated, under usr/local/lib/node_modules/npm/
[core-image-cve] gate passed — nothing fixable and CRITICAL/HIGH outside 7 reported-only finding(s).

46 distinct, exactly the figure core.Dockerfile claims. assertSuppressionApplied() passed, so the Rego policy demonstrably took effect rather than parsing cleanly and filtering nothing. And the seven scoped-out findings are printed by name — brace-expansion, ip-address, tar, undici, all under the bundled-npm prefix and nowhere else. That is the claim I said should not merge on an argument alone, and it is now a measurement. Thank you for taking the always() route rather than reordering; the promise #38 made is intact.

The remaining red is one predicate, and your own failure output disproves it

  PID  PPID COMMAND
    1     0 tini
    7     1 MainThread

The topology half of 9495e4a is right and it passes: PID 1 is tini, and the daemon is its direct child. D14 holds, on the shipped bytes. Reading ppid as field 4 counted after the closing ) is the correct way to parse /proc/<pid>/stat, and refusing to depend on procps the image does not ship is the right instinct.

What fails is comm === "node". Node renames its main thread, so /proc/<pid>/comm reads MainThreadcomm is whatever the last prctl(PR_SET_NAME) set, capped at 15 bytes, and it is not a reliable identifier for a Node process. The comment says "the launcher execs the bundled Node in place, so the daemon reads as node"; the exec-in-place part is right, the name it reads as is not.

ppid === 1 already identifies it uniquely — nothing else in that table has PPID 1, and the docker exec shell shows PPID 0 because its parent is outside the namespace. So dropping the comm clause is sufficient, and if you want it positively identified rather than merely counted, /proc/<pid>/cmdline carries actana-cli.cjs and does not get renamed.

Why I am approving rather than holding

Everything this PR claims, I have verified — the ten tickets' done-conditions, the CVE gate on the built image, the mint/persist/blob path end to end, 1000:1000, tini's topology, the Panel image and its published-manifest healthcheck. The three findings I raised were all real and all fixed properly, and the boot counting in 55f3e49 was better than what I asked for.

Being explicit so approval does not overstate what ran: the legs after the PID check — the refused verbs, the Panel pairing, restart-is-a-no-op, and down -v unpairs — have still never executed. They are #46's and #40's central assertions and that one predicate is all that stands between them and a green run. Land it and let CI finish the job; I do not need another round to look at it.

Phase 3 is not gated on me.

@mrfqcentic
mrfqcentic merged commit d953cc8 into main Aug 4, 2026
28 of 32 checks passed
qcentic-adm pushed a commit that referenced this pull request Aug 4, 2026
… smoke

/proc/<pid>/comm reports the name of the process's main thread, and the
bundled Node names that thread MainThread — so the daemon under tini reads
as MainThread, not node, and the smoke's comm === "node" predicate died
on every boot of a healthy image. The run's own table showed the property
D14 wants already holding: tini at PID 1 with the daemon as its child.

The predicate now accepts either comm for the child-of-PID-1 check. The
topology assertion is unchanged — a daemon that is PID 1 itself, the case
D14 exists for, still fails loudly.

This red predates this branch (identical on main and on the #66-#68 runs)
but this PR makes the job a required check on every PR, so it rides along
rather than waiting for a ticket.

Refs #69

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mrfqcentic pushed a commit that referenced this pull request Aug 4, 2026
…69)

* ci(commitlint): stop reading wrapped body prose as a footer

`footer-leading-blank` asks conventional-commits-parser where the footer
starts, and that parser calls any line shaped like `token: value` a footer. A
body sentence wrapping onto `that: it is absent under Podman…`, onto
`to: different arrival, different PID 1…`, or opening `issue #43 asked for
exactly that` therefore parses as a footer with no blank line above it, and the
required Conventions check goes red on a commit with nothing wrong in it.

Five commits in this repo's history fail that way — three of them on PR #66,
plus `fix(harness): put installed agent CLIs on the operator's login PATH` and
the initial commit. All five are false positives; not one has a real footer
problem. A required check that fails on correct input is one people learn to
wave through, so this replaces the rule rather than rewording the commits,
which are merged and past rewriting anyway.

`trailer-leading-blank` finds the footer itself: the first line opening with a
known trailer token — `Refs #39`, `Co-authored-by:`, `BREAKING CHANGE:` —
starts the footer, and that line must have a blank line above it. Requiring the
`: ` or ` #<digits>` separator is what keeps `Fixes the crash under Podman` a
sentence rather than a `Fixes` footer. Prose is left alone.

The trade is a missed report when someone jams an exotic trailer straight onto
the body. That is the cheaper failure: every token this repo actually uses is
covered, a false negative costs a blank line, and a false positive costs a red
required check on a good commit.

The rule is inline in commitlint.config.mjs rather than imported, because
conventions.yml copies that one file into RUNNER_TEMP beside a throwaway
commitlint install — one file, one place to change it.

Refs #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): drop the macOS Core targets — three assets, not five

A tag now publishes two Linux tarballs and their SHA256SUMS. CORE_TARGETS
loses its two darwin rows, the release matrix loses macos-15 and
macos-15-intel, and compose-core-shasums.mjs goes to --expect 2 — that guard
is what makes a silently missing architecture a red build rather than a
checksum file covering half the release, so it moves deliberately alongside
the matrix and never by patching a failure.

install.sh now dies in detect_target on Darwin with the message the
catch-all branch already used. Before, it mapped darwin to mac and the
operator met "release v0.1.0 has no build for mac-arm64" after two round
trips — which reads as a broken release rather than a platform Cores do not
run on. install-sh.test.mjs covers both Darwin architectures and asserts
zero requests were made; its fixture gains a deliberately broken release so
the two download failure paths stop borrowing a real macOS target.

ci.yml loses actana-setup-e2e-macos as a forced consequence: it built its
own tarball with pnpm core:tarball, which no longer resolves a target on a
mac host, so the job could only ever be red. Trimming that file properly
belongs to #47.

docs/core-macos-prerelease-checklist.md is NOT deleted. That deletion is
conditional on the Gatekeeper line being promoted first, and the ticket
doing so is #55 — still open, and aimed at a docs path the tree does not
have yet. The page is annotated instead: not a gate on any release, still a
blocker on a Mac Core someone builds by hand.

Refs #50

* ci(release): one workflow, three assets, two images

Replaces core-release.yml, images-release.yml and dockerhub-description.yml
with a single release.yml on `push: tags: v*` plus workflow_dispatch:
resolve, two native tarball legs, the two images through the reusable
container-image.yml, the GitHub Release, then the Docker Hub descriptions.
The tarball legs and the image builds run beside each other, which is what
keeps a release inside the measured six-minute target.

Two rules that a later cleanup would otherwise "fix" now live in the
workflow header as comments: GHCR is pushed first and must complete because
it authenticates with the workflow's own github.token and therefore cannot
fail on credentials, and present-but-broken Docker Hub credentials publish
GHCR completely before failing the run at the very end. ADR 0016 D29's
installer-contract rule is quoted verbatim there too, and a test holds the
quote against the ADR.

A missing Docker Hub credential is the other case and is decided in resolve
before anything builds: fatal on actana/control, a notice on a fork. One
personal access token now serves both the image push and the description
API, so DOCKERHUB_DESCRIPTION_USERNAME/_TOKEN are gone and the rotation step
that token needs is documented in docs/REPO_SETUP.md.

Two tarball legs, not four, with --expect 2 on the checksum guard. The
darwin rows in CORE_TARGETS and install.sh's late Darwin error belong to
T50 and are untouched here.

Two things the inherited workflows got wrong and this one does not. The tag
regex accepted `v0`, `v0.1` and `v0.1.0+abc`, which publish exactly the
moving `:0` ladder D30 forbids and a tag Docker cannot name; it now requires
all three components. And the descriptions job deliberately does not pin the
tag, because syncing a typo fix from `main` without cutting a release is the
whole point of D33's dispatch.

Refs #49

* ci: cut the PR job count, and fold conventions and the edge publish into ci.yml

ADR 0016 D34/D35/D37. Nothing in CI was slow — the whole suite is under six
minutes wall clock and the slowest job is 3.5 min. It was expensive because of
job count and macOS billing at 10×, so this deletes jobs rather than tuning
them, and every deletion names the failure it would have caught.

Deleted, with what each one uniquely covered:

  - `react-doctor.yml` and the `doctor` script. It cannot fail — `blocking:`
    is commented out — and it holds `pull-requests: write`, `issues: write`
    and `statuses: write` against floating `@v2`/`@v7` tags, in a repo whose
    rule is SHA pinning. React code quality is also scoped out by #6.
  - `smoke-standalone-core.mjs` and its job. It made the same
    `assertBootsAndDials` assertion `core-tarball-smoke` makes, one layer
    inside the artifact that ships. Nothing ships the path it uniquely covered.
  - Both macOS `actana setup` e2es, `e2e-actana-setup-macos.mjs` with its
    launchd paths, and the macOS `panel-e2e` leg. The Panel ships nowhere as a
    plain macOS process, and those three legs were most of the bill.
  - `E2E — Harness offers`. Deliberately non-hermetic: it runs a vendor's real
    installer, so no PR can cause its failure and no PR author can fix it
    (D38). The script stays for the weekly canary in #51.

`conventions.yml`'s three jobs become one `Conventions` job here. Two of them
each `npm install`-ed their own commitlint — three copies of one 30s setup for
a couple of seconds of linting. The job reads the PR title from the API rather
than the event payload: `types: [edited]` was what let a corrected title be
re-checked, and putting that trigger on ci.yml would re-run the whole gate on
every description edit.

`images-edge.yml` becomes a push-to-`main` condition on the reusable
`container-image.yml` calls already here (D30), carrying across both its
`paths-ignore` — so a docs-only merge does not rebuild two images — and its
`workflow_dispatch`, so `:edge` can still be republished without a new commit.
The edge jobs read `!= 'pull_request'` rather than `== 'push'` for that. The edge jobs are
separate from the PR jobs because `permissions:` takes no expression and a
caller asking for `packages: write` fails at startup on a fork PR; the two
sets are mutually exclusive on the event, so no run builds an image twice.

`dependency-audit` gains `--prod` (D37). Verified: it still finds nothing,
while the same command without `--prod` reports four dev-only
`brace-expansion` highs that no contributor caused and none can fix.

Measured on run 30911898792 plus its conventions run 30911896007, per-job wall
time rounded up to the whole minute, macOS at 10×:

  | | PR checks | runner legs | billed min-equivalents | wall clock |
  |---|---|---|---|---|
  | before | 26 | 30 | 143 | 5.5 min |
  | after  | 19 | 23 |  37 | 4.7 min |

Short of D35's 13 checks by the six installer-e2e legs #48 removes when it
merges the two installer suites and moves arm64 to the tag run — that is a
change to the e2e scripts, not to this workflow.

The billed figure is short of D35's ~17 by more than #48 closes: those six legs
are about 9 minutes, leaving ~28. The accounting differs — every job here is
rounded up to a whole minute, and thirteen of them run in under one — so the
two numbers are not directly comparable, but no claim is made that this hits
~17.

Refs #47

* test(installer): one installer e2e, entered at the one-liner

The `curl … | bash` one-liner becomes the entry point of the setup e2e
rather than a suite beside it, so the lifecycle assertions run on the
machine the one-liner produced. Strictly larger coverage at roughly the
cost of one: a duplicated install phase and one container boot per leg
are gone, and the two scripts already shared `systemd-container.mjs` and
the same fixture server, so this removes a seam rather than adding one.

`e2e-install-sh-linux.mjs` is deleted, not orphaned. What only a real
machine can prove — that the script works against a real init system
with a real tarball — moves into the merged script. Its negative cases
do not: a bad checksum, an unknown platform, `--version` pinning,
non-TTY behaviour and exit codes are already covered hermetically, in
under a second, by `scripts/__tests__/install-sh.test.mjs`, and each one
repeated here would cost a whole extra one-liner run on a real
container. The tampered *update* stays, because its assertion is not
about the checksum but about the Core that was running still running.

Axes drop to `distro × arch`. Ubuntu and Debian at x64 run on every pull
request, because PAM, polkit and the logind rules that decide whether a
sudo-less `systemctl --user` and `loginctl enable-linger` work at all are
exactly what differs between distros. arm64 moves to the tag run in
`core-release.yml`, gating `publish`, because the arch-sensitive risk is
prebuilt native modules and `core-tarball-smoke` already boots the arm64
tarball on an arm64 runner every PR.

`container-matrix.mjs` keeps its job of stopping the declared axes and
the workflows from drifting apart, and now reads both files: the suite
axis is gone and each architecture carries the trigger it runs on.

Refs #48

* ci: housekeeping.yml — everything on a clock, nothing that gates

The third and last workflow (ADR 0016 D34). Five chores on two crons, in one
file, because a workflow file's unit is not a subject but a relationship to a
pull request — and these five share one: no PR causes them and no PR fixes
them.

  stale           daily   stale.yml, carried across unchanged
  base-pins       weekly  base-pins.yml, folded in
  core-rebuild    weekly  rebuild + republish the released Core image
  dev-audit       weekly  pnpm audit over the dev tree — opens an issue
  harness-canary  weekly  four vendors' real installers — opens an issue

The weekly rebuild is what makes the digest pin honest: apt-get upgrade runs in
the Core image's own layer (D5), so it resolves noble-security at build time and
a rebuild on an unchanged base digest still collects every fix Canonical has
shipped. It rebuilds the newest published non-prerelease release and republishes
that release's own tags — a rebuild that pushed nothing would prove the base
still builds and ship none of what it collected. T9's Trivy gate comes free:
container-image.yml runs it before the push, so a rotted base fails the rebuild
rather than republishing over a good tag. Weekly, not nightly (D10): Canonical
does not ship security updates nightly.

dev-audit (D37) and harness-canary (D38) end in `gh issue create` rather than a
red run. Both are red for reasons no PR author caused and none can fix, and a
permanently red scheduled workflow is how a team learns that red means nothing.
Each files at most one open issue at a time.

base-pins.yml is folded in rather than kept, so `ls .github/workflows` is the
three entry points D34 counts plus container-image.yml as a reusable workflow. A
weekly cron that opens a PR is housekeeping by definition. Its one behavioural
change: the `--label "type: dependencies"` on the NODE_VERSION PR is dropped —
that label does not exist on the repo and `gh pr create` fails outright on one
that does not, so the bump would never have been opened at all. Dependabot
creates its own labels; gh does not.

scripts/__tests__/workflows.test.mjs is the done-condition "verified by `ls
.github/workflows`" made into a check: the directory listing, each job's cron,
and the two chores that must open an issue rather than gate.

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(images): the description pages describe the product, not the fixture

Finishes ADR 0016 D33. #49 already landed the `descriptions` job and deleted
dockerhub-description.yml with its sync() body intact — the JWT exchange, the
100/25000-byte guards and the curl-not-a-third-party-action posture all survive,
because that step handles a credential that can rewrite the public face of both
images. #45 already rewrote docs/images/core.md. What was left was the text that
still described the deleted systemd fixture, and one edge in the job graph.

descriptions now hangs off `[panel, core]` rather than off github-release. What
the page waits for is the image being pullable, and that is exactly what those
two jobs completing means. The old edge was a stronger condition for the wrong
reason: a red arm64 installer leg is about a tarball, and it would have left both
images published and both pages still describing whatever was there before.

docs/images/panel.md's "You also need a Core" called the Core image a
*development fixture* and told the reader it "is not how you host a real Core" —
false since D40 deleted deploy/dev/. It now names both supported ways to get a
Core, neither more real than the other, and drops the sentence a blind
Harness→Core rename had left saying a Core is registered as a Core.

docs/REPO_SETUP.md's §5 checklist still carried a manual "paste the --privileged
warning into Docker Hub's UI" step. Both pages are synced from docs/images/ by
the workflow; the item is now the check that they render.

Two tests, because nothing builds these pages and stale text there is invisible:
one asserts no file in the publish path calls the Core image a fixture, one
asserts core.md documents the image that exists (tini, one variable, one volume,
Harnesses at runtime) and not the one that does not (--privileged,
--public-host core).

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(housekeeping): fixes from review — labels, the sixth job, and D34

- Both `gh issue create` calls now pass `--label needs-triage` alone. The
  canary's `--label bug` works against the repo as it stands but not against the
  label bootstrap in docs/REPO_SETUP.md §7, which provisions `type: bug` and no
  bare `bug`. An unknown label fails `gh issue create` outright, which would turn
  "opens an issue" into exactly the red scheduled run D38 exists to avoid — the
  same trap the `base-pins` job's dropped label was already in.
- `release-ref` is named in both the workflow header and docs/ci-cd.md. Six jobs,
  five chores: it is a job rather than a step only because a `uses:` job cannot
  compute its own inputs, and a table that lists five while the file holds six
  is the kind of drift that makes the next reader distrust the table.
- docs/REPO_SETUP.md §5 still said the descriptions sync fires "once the release
  exists" — the gate the previous commit removed.
- ADR 0016 D34 is amended, per both tickets' footer. The clause could not have
  named base-pins.yml (#43 created it afterwards), so the amendment records that
  the count is entry points and that base-pins is now a job inside housekeeping.
- docs/images/panel.md's "You also need a Core" is back to the edit D33 asked
  for. The rewrite had grown a `docker run` recipe and a compose pointer that
  belong on the Core's own page; the false fixture sentence is still gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core-image): read the daemon's comm as a thread name in the PID-1 smoke

/proc/<pid>/comm reports the name of the process's main thread, and the
bundled Node names that thread MainThread — so the daemon under tini reads
as MainThread, not node, and the smoke's comm === "node" predicate died
on every boot of a healthy image. The run's own table showed the property
D14 wants already holding: tini at PID 1 with the daemon as its child.

The predicate now accepts either comm for the child-of-PID-1 check. The
topology assertion is unchanged — a daemon that is PID 1 itself, the case
D14 exists for, still fails loudly.

This red predates this branch (identical on main and on the #66-#68 runs)
but this PR makes the job a required check on every PR, so it rides along
rather than waiting for a ticket.

Refs #69

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core-image): identify the daemon by argv[0], not by a thread name

Two assertions in the Core image smoke were asking questions their sources
cannot answer. Both failed on healthy images; neither was ever about the
product.

The PID-1 topology check matched on `/proc/<pid>/comm`, which is the name of
the process's main *thread* — renameable, capped at 15 bytes, and set to
MainThread by Node. eb3820c widened the predicate to accept either spelling,
which unsticks the run but pins a V8 implementation detail: the day Node picks
a third name, the check is red again on an image that never changed. argv[0]
is the honest source, because it is a fact about what the launcher ran, and
bin/actana ends in

  exec "$ACTANA_ROOT/node/bin/node" "$ACTANA_ROOT/app/actana-cli.cjs" "$@"

So the process table now carries cmdline beside comm and the daemon is the
child of PID 1 whose argv[0] basename is node. Verified against a live Actana
daemon rather than reasoned about: it reports comm=MainThread with argv[0]
.../node/bin/node, and the original comm === "node" predicate matches zero
processes on that machine. The failure table prints cmdline too, so the next
first-observation surprise here is diagnosable from the log alone.

The pairing check asserted that the id POST /api/cores returns equals the
coreId inside the bearer. That contract does not exist. newCoreId() in
packages/panel/src/server/services/cores.ts mints a fresh handle per
registration and nothing in that path reads the bearer's coreId — the Core's
self-identity and the Panel's registry key share a core_ prefix and nothing
else. What proves the token named this Core is the dial reaching connected,
which only the bearer's Core can answer, and which the next line already
asserts. The check is now that the Panel returned an id at all.

Both reads in the /proc loop are also redirected now. A process that exits
between the glob and the read made the shell report a failed redirect on
stderr, which no amount of 2>/dev/null on tr would have silenced.

Refs #69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(panel-image): read the published healthcheck with the daemon, not a struct

The manifest gate reported the Panel image as having no HEALTHCHECK on both
platforms of a publish that did have one, and that red is what stopped the
edge publish for the phase-2 merge — so :edge still points at the previous
merge's bytes.

The healthcheck was there. smoke-panel-image.mjs asserts it on the per-arch
image before that image is pushed and was green on both arches, and
`imagetools create` stitches an index over the existing per-arch manifests
without rewriting a config blob, so nothing between the two could have removed
it. What could not represent it was the reader: `imagetools inspect --format
'{{json .Image}}'` renders a Go struct, and HEALTHCHECK is a Docker extension
to the OCI image config that the struct need not carry. A reader that cannot
express the answer is not evidence of a missing healthcheck.

The gate now pulls each platform of the published tag and asks the daemon for
.Config.Healthcheck — the same reader the pre-push smoke uses, and the only
one demonstrated to answer this. That also leaves behind a second trap in the
old path, observed against the published image but never in CI: `{{json}}`
there emitted the backtick in the HEALTHCHECK's template literal as an invalid
JSON escape, which jq rejected outright.

The test that covered this asserted on the old jq path, so it would have gone
green on a gate that could only fail. It now names the reader that runs, and a
second case forbids the struct format outright — this failure mode is cheap to
reintroduce and expensive to diagnose from a red publish. Both assertions read
a comment-stripped copy of the workflow, so prose explaining why the format is
not used cannot satisfy a check that it is.

One stale comment in the same file: the Core smoke proves tini is PID 1 and
the daemon its child, not that the daemon is PID 2. 9495e4a stopped asserting
the number; the comment still promised it.

Refs #69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: qcentic-dev <312499784+qcentic-dev@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qcentic-dev <qcentic@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment