feat(images): phase 2 — a Core image, a distroless Panel, and one reference compose - #66
Conversation
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 (#37)
…2-images-and-containers (#39)
…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>
…2-images-and-containers (#38)
…2-images-and-containers (#41)
…2-images-and-containers (#43)
…/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>
…2-images-and-containers
…2-images-and-containers
Review against #37–#46 — one blocker, one confirmed defect, one description correctionBranch 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 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 —
|
| 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 | checkServerCertHost → reissueServerCert 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
- Make
waitForCoreLinkwait for the daemon, not for a port Docker opened — and re-run soCore image / Build + smokegoes 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. - Pass
contract.labelthroughcmdDaemon, with a test covering the no-ACTANA_LABELboot. - Fix the
docker compose upclaim — wire the leg in, or untick it and say it lands with the release. Same for theDependency Auditred, 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
|
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 1.
|
|
The commitlint failure is a false positive — all 3 failing commits trip
Each follows body text with no blank line above, so Short fix (reword, no config change): make those lines not parse as footers. E.g.:
I verified all three pass once the offending token is neutralized, e.g. |
Re-review of
|
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
|
Fixed in The PID leg now asserts the topology
The process table is read out of Both failure paths now print the whole table, D14 said "PID 2", so D14 is amendedThe 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. Gate ordering — taken, as you framed it
Not chased
Locally: eslint clean on Over to the run. |
qcentic-adm
left a comment
There was a problem hiding this comment.
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 MainThread — comm 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.
… 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>
…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>
Summary
Phase 2 of #23: Actana ships as two container images instead of a tarball you install onto a machine you own.
deploy/core.Dockerfileis new — a Core youdocker run, on digest-pinned Ubuntu 24.04 with a sha256-verified Node 24 tarball, tini as PID 1, running ascore:1000. The Panel's image moves out of the repo root todeploy/panel.Dockerfileand swaps its runtime forgcr.io/distroless/nodejs24.deploy/dev/— the systemd fixture that existed so a tarball had somewhere to install — is deleted, and one referencedeploy/docker-compose.ymlbrings 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
--privilegedsystemd 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
Dockerfilebecomesdeploy/panel.Dockerfile. Pure move; every builder now names the file.deploy/core.Dockerfile— Ubuntu 24.04 digest-pinned, Node 24 as a sha256-verified nodejs.org tarball, native-addon toolchain in, Harnesses out.ACTANA_CONTAINER=1baked in, never sniffed from/.dockerenv. The lifecycle verbs Docker owns refuse and name their Docker equivalent.USER 65532:65532,/dataseeded byCOPY --chown.base-pins.ymlforARG NODE_VERSION, which no Dependabot config can see.deploy/dev/anddeploy/Caddyfiledeleted, all external references repointed.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.regosuppresseslinux-libc-dev(D7's clause — ~1200 kernel-header findings against a package the Core cannot not ship). The gate also scopes outusr/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-expansion5.0.7 against fixes at 5.0.8/5.0.9 andip-address10.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.trivyignoreand.trivyignore.yamlmatch 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_HOSTis actually set. Without that gate a hand-runactana daemonwould 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 theserverHostrecord 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 fromdigest_of("latest"), andgcr.io/distroless/nodejs24has 7,040 tags without a version number among them. Writing:latest@sha256:…would hunt for a tag newer thanlatestand 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, andbase-pins.test.mjsholds 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) andzlib1g(1). The two library findings are the Panel's own production dependencies, not base-image problems — both live undersrv/panel/node_modules/, which is this repository'spnpm installoutput and nothing the base contributes:postcss@babel/coreThose 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.25and@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.mdin the same commit as the work that found it:24.18.1is the value at the time of writing, not a decision. A weekly bot PR is no longer also an ADR amendment.How was this tested?
Verified locally on the merged tree:
packages/coretypecheckpackages/sharedtypecheckpackages/corevitestDocker 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:
better-sqlite3dlopens under distroless' glibcscripts/smoke-core-image.mjs— boots unprivileged,tiniat PID 1 and the daemon at PID 2 read out of/proc, refused verbs refuse, a real Panel pairs,docker restartkeeps the pairing,down -vends itscripts/smoke-panel-image.mjs—/dataownership, the absolute-path HEALTHCHECK, the migrated schema read back out of a running containerdocker compose upon the reference compose is not among them, and the earlier version of this description wrongly claimed it was. No workflow runsdocker composeat all;smoke-core-image.mjsdrivesdocker rundirectly and never readsdeploy/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 onlyimages-release.ymlpublishes on av*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
maintoo, and neither is this branch's:Dependency Audit— four dev-only highs, all.>eslint-plugin-react>minimatch>brace-expansion, identical tomainat88b03295. 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 tomain's: the Cursor leg fails withan 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,codexandcursor-clideclare nohomePathSuffixes). 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.ymlon 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:checkresolves all three pins live, and with each pin staled on purpose it names the exact digestdigest_of(<tag>)returns for both registries, which is the same call Dependabot makes.docs/ci-cd.mdsays where to look for the real PR.Type of change
Breaking changes & migration
deploy/dev/is gone. Anything drivingdocker compose -f deploy/dev/docker-compose.ymlstops working; usedeploy/docker-compose.yml. All eight in-repo references are repointed in the same commit.Dockerfileis gone, moved todeploy/panel.Dockerfile. A build that relied on the implied root path must name the file.panel-e2e-core-in-a-boxis deleted, along withstartContainerCoreande2e-panel-smoke.mjs --core-tarball.scripts/smoke-core-image.mjsreplaces 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.mjsstays for exactly that reason.X-Forwarded-Proto: https. None of this touches the core-link's mTLS (ADR 0002).AC_CORE_PUBLIC_HOSTchanges 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
README.md,DEPLOY.md,CONTRIBUTING.md,docs/ci-cd.md,docs/images/*) where relevantMerge 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