Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ jobs:
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
cache-from: type=gha,scope=card
cache-to: type=gha,mode=max,scope=card
# Publish a single-platform manifest, not an OCI image index. Provenance
# attestations (on by default) wrap the image in an index with no
# top-level config, which the version check in already-deployed hubs
# (<=0.22.3) cannot parse — leaving their "Update available" button stuck.
# Keeping this false is what lets existing installs see the update button.
# See issue #45.
provenance: false

# Webproxy image: pure Caddy (xcaddy compiles the ratelimit plugin), so it
# depends on neither Go nor frontend — no `needs:`, runs in parallel from t=0.
Expand All @@ -108,6 +115,9 @@ jobs:
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
cache-from: type=gha,scope=webproxy
cache-to: type=gha,mode=max,scope=webproxy
# Single-platform manifest (no provenance index), for consistency with
# the card image. See issue #45.
provenance: false

release:
runs-on: ubuntu-latest
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push/PR to `main`:
- `go test -race -count=1 ./...`
- `govulncheck ./...`
- Frontend build (`npm ci && npm run build` in `docker/card/admin-ui/`)
- Docker image builds for both `card` and `webproxy`
- Docker image builds for both `card` and `webproxy` (via `docker/build-push-action@v6`)
- On push to `main` (not PRs): pushes images to Docker Hub as `latest`

Uses Go 1.25.11 with CGo enabled for sqlite3, Node 22 for frontend. Docker Hub push requires GitHub secrets `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`.

> **Keep `provenance: false` on the image build steps.** `docker/build-push-action` enables provenance attestations by default, which wraps the pushed image in an OCI image index (no top-level `config`). The version check in deployed hubs `<=0.22.3` only parses a single-platform manifest, so an index leaves their "Update available" button permanently stuck (issue #45). Newer hubs handle the index too (`web/update.go` follows it), but the published `latest` must stay a single manifest so already-deployed hubs can ever see the update that fixes them.

## Versioning

**Bump the version for every PR.** Bump `Version` in `docker/card/build/build.go` as part of each PR, following [semantic versioning](https://semver.org/): a new backward-compatible feature bumps MINOR (e.g. `0.19.9` → `0.20.0`), a bug fix or maintenance change bumps PATCH (e.g. `0.19.3` → `0.19.4`), and a breaking change bumps MAJOR. On merge to `main`, CI republishes `boltcard/card:latest` with `org.opencontainers.image.version` set to this string; the About-page update check only surfaces the "Update available" button on deployed hubs when that label exceeds their running version. A PR that changes the image without bumping the version therefore ships an update no one can see.
Expand Down Expand Up @@ -93,7 +95,7 @@ Entry point: `main.go` → opens SQLite DB → runs CLI or starts HTTP server on
- `phoenix/` — HTTP client for Phoenix Server API (invoices, payments, balance, channels). Uses basic auth from phoenix config (password cached at startup with `sync.Once`)
- `crypto/` — AES-CMAC authentication and AES decryption for Bolt Card NFC protocol
- `util/` — Error handling helpers (`CheckAndLog`), random hex generation, QR code encoding
- `build/` — Version string (currently "0.22.3"), date/time injected at build
- `build/` — Version string (currently "0.22.4"), date/time injected at build
- `web-content/` — Static assets under `public/`, SPA build output under `admin/spa/`

### Route Groups (`web/app.go`)
Expand Down
2 changes: 1 addition & 1 deletion docker/card/build/build.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package build

var Version string = "0.22.3"
var Version string = "0.22.4"
var Date string
var Time string
149 changes: 116 additions & 33 deletions docker/card/web/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ import (

const dockerHubImage = "boltcard/card"

// Registry manifest media types. Docker Hub serves the :latest tag as an OCI
// image index (buildx enables provenance attestations by default), so the Accept
// header must advertise the index/list types as well as the single-image types.
const (
mediaTypeOCIIndex = "application/vnd.oci.image.index.v1+json"
mediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
mediaTypeOCIManifest = "application/vnd.oci.image.manifest.v1+json"
mediaTypeManifestV2 = "application/vnd.docker.distribution.manifest.v2+json"
)

// CheckLatestVersion queries Docker Hub for the version label on the latest image.
func CheckLatestVersion() string {
client := &http.Client{Timeout: 15 * time.Second}
Expand Down Expand Up @@ -46,46 +56,19 @@ func CheckLatestVersion() string {
return ""
}

// 2. Fetch manifest to get config digest
manifestURL := fmt.Sprintf(
"https://registry-1.docker.io/v2/%s/manifests/latest",
dockerHubImage,
)
req, _ := http.NewRequest("GET", manifestURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.Token)
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")

resp2, err := client.Do(req)
// 2. Resolve the image config digest, following one image-index indirection
// if the tag points to a multi-arch / attestation index instead of a plain
// single-platform manifest.
configDigest, err := resolveConfigDigest(client, tokenResp.Token, "latest")
if err != nil {
log.Warn("CheckLatestVersion: manifest fetch failed: ", err)
return ""
}
defer resp2.Body.Close()

if resp2.StatusCode != 200 {
log.Warn("CheckLatestVersion: manifest status: ", resp2.StatusCode)
return ""
}

var manifest struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
if err := json.NewDecoder(resp2.Body).Decode(&manifest); err != nil {
log.Warn("CheckLatestVersion: manifest decode failed: ", err)
return ""
}

if manifest.Config.Digest == "" {
log.Warn("CheckLatestVersion: no config digest in manifest")
log.Warn("CheckLatestVersion: ", err)
return ""
}

// 3. Fetch config blob to read version label
blobURL := fmt.Sprintf(
"https://registry-1.docker.io/v2/%s/blobs/%s",
dockerHubImage, manifest.Config.Digest,
dockerHubImage, configDigest,
)
req2, _ := http.NewRequest("GET", blobURL, nil)
req2.Header.Set("Authorization", "Bearer "+tokenResp.Token)
Expand Down Expand Up @@ -127,6 +110,106 @@ func CheckLatestVersion() string {
return version
}

// resolveConfigDigest fetches the manifest for ref and returns the image config
// blob digest. When the tag resolves to an image index / manifest list it follows
// the linux/amd64 entry one level down to the single-platform image manifest that
// actually carries the config digest.
func resolveConfigDigest(client *http.Client, token, ref string) (string, error) {
// At most two hops: index -> image manifest -> config.
for range 2 {
body, err := fetchManifest(client, token, ref)
if err != nil {
return "", err
}
configDigest, childDigest, err := parseManifest(body)
if err != nil {
return "", err
}
if configDigest != "" {
return configDigest, nil
}
ref = childDigest // follow the index entry and parse the child manifest
}
return "", fmt.Errorf("image index nested too deeply")
}

// fetchManifest GETs a manifest by tag or digest, advertising both the index and
// single-image media types so the registry returns whichever the tag points to.
func fetchManifest(client *http.Client, token, ref string) ([]byte, error) {
manifestURL := fmt.Sprintf(
"https://registry-1.docker.io/v2/%s/manifests/%s",
dockerHubImage, ref,
)
req, _ := http.NewRequest("GET", manifestURL, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", strings.Join([]string{
mediaTypeOCIIndex, mediaTypeManifestList, mediaTypeOCIManifest, mediaTypeManifestV2,
}, ", "))

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("manifest fetch failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifest status: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}

// parseManifest inspects a registry manifest response. For a single-platform image
// manifest it returns the config blob digest. For an OCI image index / Docker
// manifest list it returns the digest of the image manifest to fetch next
// (childDigest), preferring linux/amd64 and falling back to any non-attestation
// platform. Exactly one of configDigest/childDigest is non-empty on success.
func parseManifest(body []byte) (configDigest, childDigest string, err error) {
var m struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
Manifests []struct {
Digest string `json:"digest"`
Annotations map[string]string `json:"annotations"`
Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
} `json:"platform"`
} `json:"manifests"`
}
if err := json.Unmarshal(body, &m); err != nil {
return "", "", fmt.Errorf("manifest decode failed: %w", err)
}

if len(m.Manifests) > 0 {
fallback := ""
for _, entry := range m.Manifests {
// Skip build attestation manifests (platform unknown/unknown).
if entry.Annotations["vnd.docker.reference.type"] == "attestation-manifest" {
continue
}
if entry.Platform.Architecture == "unknown" || entry.Platform.OS == "unknown" {
continue
}
if entry.Platform.OS == "linux" && entry.Platform.Architecture == "amd64" {
return "", entry.Digest, nil
}
if fallback == "" {
fallback = entry.Digest
}
}
if fallback != "" {
return "", fallback, nil
}
return "", "", fmt.Errorf("no usable platform manifest in image index")
}

if m.Config.Digest == "" {
return "", "", fmt.Errorf("no config digest in manifest")
}
return m.Config.Digest, "", nil
}

// CompareVersions returns 1 if latest > current, 0 if equal, -1 if latest < current.
func CompareVersions(current, latest string) int {
currentParts := strings.Split(current, ".")
Expand Down
103 changes: 103 additions & 0 deletions docker/card/web/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package web

import "testing"

// Real OCI image index served by Docker Hub for boltcard/card:latest since the
// CI switched to docker/build-push-action (buildx provenance attestations on by
// default). It has a manifests[] array and NO top-level config — the shape that
// broke CheckLatestVersion (issue #45).
const ociIndexManifest = `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:d9ddd6641c710953c4c6dc47a67e51b5f97b66fa1c15a2c87cd7b82349f4ab94",
"size": 1621,
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:7ea8e9054784c94adb225247df6bfae751f20e53918742f3d9e9caaab58a3007",
"size": 565,
"annotations": {
"vnd.docker.reference.digest": "sha256:d9ddd6641c710953c4c6dc47a67e51b5f97b66fa1c15a2c87cd7b82349f4ab94",
"vnd.docker.reference.type": "attestation-manifest"
},
"platform": { "architecture": "unknown", "os": "unknown" }
}
]
}`

// Single-platform image manifest (what the index entry points to, and what plain
// `docker push` produced before the CI change). It carries the config digest.
const plainImageManifest = `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:161fa579ed83ddbfaec540cd26c8b7a5c88143dc95b44d506cb91fe6b1710d4d",
"size": 2500
},
"layers": []
}`

func TestParseManifest_OCIIndexSelectsAmd64(t *testing.T) {
configDigest, childDigest, err := parseManifest([]byte(ociIndexManifest))
if err != nil {
t.Fatalf("parseManifest returned error: %v", err)
}
if configDigest != "" {
t.Errorf("expected no config digest for an index, got %q", configDigest)
}
want := "sha256:d9ddd6641c710953c4c6dc47a67e51b5f97b66fa1c15a2c87cd7b82349f4ab94"
if childDigest != want {
t.Errorf("expected amd64 child digest %q, got %q", want, childDigest)
}
}

func TestParseManifest_PlainImageReturnsConfigDigest(t *testing.T) {
configDigest, childDigest, err := parseManifest([]byte(plainImageManifest))
if err != nil {
t.Fatalf("parseManifest returned error: %v", err)
}
if childDigest != "" {
t.Errorf("expected no child digest for a plain manifest, got %q", childDigest)
}
want := "sha256:161fa579ed83ddbfaec540cd26c8b7a5c88143dc95b44d506cb91fe6b1710d4d"
if configDigest != want {
t.Errorf("expected config digest %q, got %q", want, configDigest)
}
}

// An index with no linux/amd64 entry should fall back to the first non-attestation
// platform manifest (e.g. an arm64-only build) rather than the attestation one.
func TestParseManifest_IndexFallsBackToNonAttestation(t *testing.T) {
const arm64Index = `{
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"digest": "sha256:aaaa",
"annotations": { "vnd.docker.reference.type": "attestation-manifest" },
"platform": { "architecture": "unknown", "os": "unknown" }
},
{
"digest": "sha256:bbbb",
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}`
_, childDigest, err := parseManifest([]byte(arm64Index))
if err != nil {
t.Fatalf("parseManifest returned error: %v", err)
}
if childDigest != "sha256:bbbb" {
t.Errorf("expected arm64 child digest sha256:bbbb, got %q", childDigest)
}
}

func TestParseManifest_NoConfigErrors(t *testing.T) {
if _, _, err := parseManifest([]byte(`{"schemaVersion":2}`)); err == nil {
t.Error("expected error for manifest with no config and no manifests array")
}
}
Loading