diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f63d2d..2bae888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index addae5a..cc8a76d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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`) diff --git a/docker/card/build/build.go b/docker/card/build/build.go index ece3781..414e2a1 100644 --- a/docker/card/build/build.go +++ b/docker/card/build/build.go @@ -1,5 +1,5 @@ package build -var Version string = "0.22.3" +var Version string = "0.22.4" var Date string var Time string diff --git a/docker/card/web/update.go b/docker/card/web/update.go index ad22758..86ec758 100644 --- a/docker/card/web/update.go +++ b/docker/card/web/update.go @@ -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} @@ -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) @@ -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, ".") diff --git a/docker/card/web/update_test.go b/docker/card/web/update_test.go new file mode 100644 index 0000000..d5611dc --- /dev/null +++ b/docker/card/web/update_test.go @@ -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") + } +}