Skip to content

Commit 3225dd0

Browse files
Lutherwavesclaude
andcommitted
fix(ci): make the daemon release complete, and correct two false claims
Review found that the previous commit asserted two things that are not true, and that this workflow could still publish a wrong or half-finished release. **The kept image references are not public.** That commit justified keeping `blox-sandbox` and `blox-browser` on the grounds that they "name public images in this repository's own organization". They do not: an anonymous ghcr token request is refused for both, while `openblox-sandbox` — the image this repo actually builds and publishes — is granted. So the de-identification pass kept two private references on a rationale that a one-command check refutes. The sandbox examples now use the real public image; the browser example uses a neutral placeholder, because no reference browser image is published. **The release could still be partial.** Three ways, all now closed: - `fail-fast` was left at its default, so a failing amd64 leg cancels arm64 and the release ends up holding one architecture. That is the same partially-published failure this workflow exists to end, only harder to notice than shipping nothing at all. - `gh release upload` needs the release object, which it does not create. A hand-pushed tag has none, and on the normal path go-semantic-release creates the tag ref and the release in separate calls — this job's trigger fires on the first. The build usually outlasts that gap; "usually" is the wrong property here. It now waits for the object and fails loudly if it never appears, rather than creating one and racing the sibling leg. - The verify step ran `--version` and threw the output away, so it proved the flag exists and nothing about the stamp. If `-X main.version` ever stops landing, the binary prints `dev` and the step still passes — publishing an asset that claims to be someone's local build, which is exactly what the `dev` default is for. It now asserts the value equals the tag. Also: attach the systemd unit and example config, since the workflow header claimed to ship a unit and did not; key the concurrency group on the tag rather than the triggering ref, which had it backwards (dispatches for different tags serialized, while a dispatch and a tag push for the same tag did not); document the install in docs/security.md, which described deploying a daemon whose binary had no stated source; and drop the comment in release.yml claiming there are no binaries to ship, which the previous commit diagnosed as the cause of the assetless release and then left in place. `TestVersionDefaultsToDev` pins the invariant the `version` doc comment argues for, so stamping in-tree fails a test instead of shipping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dcf059b commit 3225dd0

8 files changed

Lines changed: 122 additions & 24 deletions

File tree

.github/workflows/publish-daemon.yml

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ name: Publish daemon
55
# openbloxd is installed on a host, not pulled as an image: it holds the Docker
66
# socket so the containerized callers do not have to, and running it in a
77
# container with that socket mounted would put the privilege straight back where
8-
# this daemon exists to take it from. So the artifact is a binary and a systemd
9-
# unit, which is what deploy/openbloxd.service expects at
10-
# /usr/local/bin/openbloxd.
8+
# this daemon exists to take it from. So the artifact is a binary, which is what
9+
# deploy/openbloxd.service expects at /usr/local/bin/openbloxd, plus the unit and
10+
# example config so an operator gets all three versioned together.
1111
#
1212
# Tag-triggered, matching publish-image.yml: release.yml cuts the tag from
1313
# conventional commits once CI is green on main, and both publish jobs hang off
@@ -31,7 +31,11 @@ permissions:
3131
contents: read
3232

3333
concurrency:
34-
group: publish-daemon-${{ github.ref }}
34+
# Key on the tag being published, not the triggering ref. Every
35+
# workflow_dispatch shares refs/heads/main, so github.ref would serialize
36+
# unrelated tags while letting a dispatch and a tag push for the SAME tag run
37+
# concurrently — exactly backwards.
38+
group: publish-daemon-${{ inputs.tag || github.ref_name }}
3539
cancel-in-progress: false
3640

3741
defaults:
@@ -48,6 +52,11 @@ jobs:
4852
# contents. Scoped to this job rather than the workflow.
4953
contents: write
5054
strategy:
55+
# Never cancel the sibling arch. A cancelled leg leaves the release holding
56+
# one architecture and silently missing the other — the same
57+
# partially-published failure this workflow exists to end, just harder to
58+
# spot than shipping nothing.
59+
fail-fast: false
5160
matrix:
5261
goarch: [amd64, arm64]
5362
steps:
@@ -95,29 +104,83 @@ jobs:
95104
-o "openbloxd-linux-${GOARCH}" ./cmd/openbloxd
96105
sha256sum "openbloxd-linux-${GOARCH}" > "openbloxd-linux-${GOARCH}.sha256"
97106
98-
- name: Verify the binary runs
107+
- name: Verify the binary runs and reports its version
99108
# A release asset that cannot execute is worse than a missing one: the
100-
# install step succeeds and the daemon fails at boot on the host. Only
101-
# meaningful for the native arch; the cross-built one is checked by file
102-
# type instead.
109+
# install step succeeds and the daemon fails at boot on the host.
110+
#
111+
# Assert the STAMP, not merely that the flag runs. If -X main.version
112+
# ever stops landing — the var is renamed, moved out of main, or the
113+
# ldflags get dropped in a refactor — the binary prints "dev", and
114+
# `--version` alone would still exit 0. A release asset claiming to be
115+
# someone's local build is precisely what the "dev" default exists to
116+
# make visible, so let it fail here instead.
117+
#
118+
# The arm64 leg cannot execute on this runner, so it checks the
119+
# architecture only. That is weaker, and deliberately not dressed up as
120+
# more: the stamping logic is arch-independent, so amd64 covers it.
103121
env:
104122
GOARCH: ${{ matrix.goarch }}
123+
TAG: ${{ steps.tag.outputs.tag }}
105124
run: |
106125
set -euo pipefail
107126
if [ "$GOARCH" = "amd64" ]; then
108-
./"openbloxd-linux-${GOARCH}" --version
127+
got="$(./"openbloxd-linux-${GOARCH}" --version)"
128+
if [ "$got" != "$TAG" ]; then
129+
echo "version stamp is '$got', expected '$TAG'" >&2
130+
exit 1
131+
fi
109132
else
110133
file "openbloxd-linux-${GOARCH}" | grep -q 'ARM aarch64'
111134
fi
112135
113136
- name: Attach to release
114137
env:
115138
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
139+
GH_REPO: ${{ github.repository }}
116140
TAG: ${{ steps.tag.outputs.tag }}
117141
GOARCH: ${{ matrix.goarch }}
118142
run: |
119143
set -euo pipefail
144+
145+
# `gh release upload` needs the release OBJECT, not just the tag ref,
146+
# and does not create one. Two ways that bites: a hand-pushed tag has
147+
# no release at all, and on the normal path go-semantic-release creates
148+
# the tag ref and the release as separate API calls — this job's trigger
149+
# fires on the first. The build usually outlasts that gap, but "usually"
150+
# is the wrong property for the release path.
151+
#
152+
# Wait rather than create: creating it here would race the sibling
153+
# matrix leg for who creates it.
154+
for _ in $(seq 1 12); do
155+
if gh release view "$TAG" >/dev/null 2>&1; then
156+
break
157+
fi
158+
sleep 10
159+
done
160+
if ! gh release view "$TAG" >/dev/null 2>&1; then
161+
echo "no release object for tag '$TAG' after 120s — refusing to guess" >&2
162+
exit 1
163+
fi
164+
165+
# The two legs upload disjoint filenames, so they cannot collide;
166+
# --clobber is here to make a re-dispatch idempotent rather than an
167+
# error on an asset that already exists.
120168
gh release upload "$TAG" \
121169
"openbloxd-linux-${GOARCH}" \
122170
"openbloxd-linux-${GOARCH}.sha256" \
123171
--clobber
172+
173+
- name: Attach the unit and example config
174+
# One arch only — these are arch-independent, and both legs uploading
175+
# them would have the two races --clobber cannot help with.
176+
if: matrix.goarch == 'amd64'
177+
env:
178+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179+
GH_REPO: ${{ github.repository }}
180+
TAG: ${{ steps.tag.outputs.tag }}
181+
run: |
182+
set -euo pipefail
183+
gh release upload "$TAG" \
184+
deploy/openbloxd.service \
185+
deploy/openbloxd.example.yaml \
186+
--clobber

.github/workflows/release.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ name: Release
22

33
# Tags a semver release from conventional commits once CI is green on main.
44
#
5-
# For a Go module a tag *is* the release — there is no registry to publish to,
6-
# so this stays useful while the repository is private: consumers can require a
7-
# real version instead of a commit pseudo-version. No goreleaser hook yet; there
8-
# are no binaries to ship until cmd/openbloxd exists.
5+
# For a Go module a tag *is* the release for library consumers — there is no
6+
# registry to publish to, so they can require a real version instead of a commit
7+
# pseudo-version.
98
#
10-
# The tag is not inert, though: publish-image.yml watches `v*` and builds the
11-
# matching sandbox image from it. That makes the token this job pushes with load
12-
# bearing — see the note on it below.
9+
# The tag is not inert, and this job does not do the publishing. Two workflows
10+
# watch `v*` and hang off the tag rather than off each other:
11+
# publish-image.yml builds the sandbox image
12+
# publish-daemon.yml builds and attaches the openbloxd binaries
13+
# That makes the token this job pushes with load bearing — see the note on it
14+
# below. It also means a tag cut without those workflows firing is a release that
15+
# ships nothing, which is how v0.3.0 came to have no assets.
1316
on:
1417
workflow_run:
1518
workflows: [CI]

cmd/openbloxd/main_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,14 @@ func TestServeShutsDownOnContextCancel(t *testing.T) {
7171
t.Fatal("serve() did not return after ctx cancellation")
7272
}
7373
}
74+
75+
// TestVersionDefaultsToDev pins the invariant the `version` doc comment argues
76+
// for: an in-tree build must never look like a release. The only thing allowed to
77+
// change this value is -ldflags at release time, so if a refactor ever stamps it
78+
// in the tree — or someone edits the default to a plausible-looking number —
79+
// this fails rather than letting a local build claim a version it does not have.
80+
func TestVersionDefaultsToDev(t *testing.T) {
81+
if version != "dev" {
82+
t.Errorf("version = %q in an unstamped build, want %q", version, "dev")
83+
}
84+
}

deploy/openbloxd.example.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ profiles:
1313
code-exec:
1414
# Pin a digest. A tag can be repointed by whoever controls the registry,
1515
# and the image is the sandbox's entire userland.
16-
image: ghcr.io/blox-eng/blox-sandbox@sha256:CHANGEME
16+
image: ghcr.io/blox-eng/openblox-sandbox@sha256:CHANGEME
1717
runtime: runsc # gVisor. Anything else trades away the isolation.
1818
egress: none # No interface at all: no resolver, no route.
1919
user: "1000:1000"
@@ -27,7 +27,8 @@ profiles:
2727
max_timeout: 10m
2828

2929
browser:
30-
image: ghcr.io/blox-eng/blox-browser@sha256:CHANGEME
30+
# No reference browser image is published; supply your own.
31+
image: ghcr.io/example/headless-browser@sha256:CHANGEME
3132
runtime: runsc
3233
egress: none
3334
user: "1000:1000"

docs/security.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ else on the box has to. A caller talks to it over a Unix socket instead, using
108108
`pkg/brokerclient`, which implements the same `Backend` interface `pkg/docker` does —
109109
switching to it is a constructor change, not a rewrite.
110110

111+
**Install it on the host, not as a container.** Running `openbloxd` in a container with
112+
`docker.sock` mounted puts the privilege straight back where the daemon exists to take
113+
it from. Each release attaches the binary for `amd64` and `arm64`, alongside the systemd
114+
unit and an example config:
115+
116+
```sh
117+
# Pick your arch; verify before trusting it.
118+
curl -fsSLO https://github.com/blox-eng/openblox/releases/latest/download/openbloxd-linux-amd64
119+
curl -fsSLO https://github.com/blox-eng/openblox/releases/latest/download/openbloxd-linux-amd64.sha256
120+
sha256sum -c openbloxd-linux-amd64.sha256
121+
122+
sudo install -m 0755 openbloxd-linux-amd64 /usr/local/bin/openbloxd
123+
openbloxd --version # must print the release tag, not "dev"
124+
```
125+
126+
`--version` reporting `dev` means the binary is somebody's local build rather than a
127+
release asset. That distinction matters because the client and daemon have to agree on
128+
the wire format, so "what is actually running on this host" needs an answer you can
129+
trust.
130+
111131
**Profiles are the whole policy surface.** Every setting that could weaken isolation —
112132
image, runtime, egress, resource caps, lifetime — is chosen by profile name in the
113133
daemon's config file (`deploy/openbloxd.example.yaml`), not by the request. `Create`

pkg/docker/image_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
func TestIsDigestPinned(t *testing.T) {
1515
pinned := []string{
1616
"alpine@sha256:abc123",
17-
"ghcr.io/blox-eng/blox-sandbox@sha256:deadbeef",
17+
"ghcr.io/blox-eng/openblox-sandbox@sha256:deadbeef",
1818
"registry.example.test:5000/team/img@sha256:0011",
1919
}
2020
for _, ref := range pinned {
@@ -26,7 +26,7 @@ func TestIsDigestPinned(t *testing.T) {
2626
loose := []string{
2727
"alpine",
2828
"alpine:3.20",
29-
"ghcr.io/blox-eng/blox-sandbox:0.2.0",
29+
"ghcr.io/blox-eng/openblox-sandbox:0.2.0",
3030
"registry.example.test:5000/team/img",
3131
// A port in the host is not a digest, and neither is an empty one.
3232
"example.test:5000/img@",

plans/2026-08-15-openbloxd-policy-broker.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,7 +1887,7 @@ profiles:
18871887
code-exec:
18881888
# Pin a digest. A tag can be repointed by whoever controls the registry,
18891889
# and the image is the sandbox's entire userland.
1890-
image: ghcr.io/blox-eng/blox-sandbox@sha256:CHANGEME
1890+
image: ghcr.io/blox-eng/openblox-sandbox@sha256:CHANGEME
18911891
runtime: runsc # gVisor. Anything else trades away the isolation.
18921892
egress: none # No interface at all: no resolver, no route.
18931893
user: "1000:1000"
@@ -1901,7 +1901,7 @@ profiles:
19011901
max_timeout: 10m
19021902

19031903
browser:
1904-
image: ghcr.io/blox-eng/blox-browser@sha256:CHANGEME
1904+
image: ghcr.io/example/headless-browser@sha256:CHANGEME
19051905
runtime: runsc
19061906
egress: none
19071907
user: "1000:1000"

specs/2026-08-15-openbloxd-policy-broker-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ loudly rather than degrading quietly. That is the correct failure.
118118
socket: /run/openbloxd/openbloxd.sock # 0660, group openbloxd
119119
profiles:
120120
code-exec:
121-
image: ghcr.io/blox-eng/blox-sandbox@sha256:…
121+
image: ghcr.io/blox-eng/openblox-sandbox@sha256:…
122122
runtime: runsc
123123
egress: none
124124
user: "1000:1000"
@@ -134,7 +134,7 @@ profiles:
134134
username:
135135
password:
136136
browser:
137-
image: ghcr.io/blox-eng/blox-browser@sha256:…
137+
image: ghcr.io/example/headless-browser@sha256:…
138138
runtime: runsc
139139
egress: none
140140
user: "1000:1000"

0 commit comments

Comments
 (0)