chore(ci): stop resurrecting the pulse-agent GHCR package

The agent_runtime verify-only build pointed its registry buildcache at
ghcr.io/<owner>/pulse-agent, a package no workflow publishes. A cache ref
creates the package it points at, so every release recreated it and the
repository's Packages sidebar advertised an empty pulse-agent entry that
reads like a pullable agent image sitting beside the real pulse,
pulse-control-plane and pulse-chart/pulse packages. The only release tag it
ever carried is a stray v6.0.0-rc.3.

Move the cache to ghcr.io/<owner>/pulse:agent-buildcache, alongside the
runtime stage's existing pulse:buildcache tag, so it lives in the package
that actually ships and shares backend-builder layers with the runtime
build.

TestAgentBuildCacheDoesNotResurrectPulseAgentPackage locks this in. It scans
every workflow for registry-qualified refs to the pulse-agent package,
collapsing ${{ }} expressions first so an interpolated owner cannot hide one
behind its spaces, and asserts both the new cache target and the chart's
published agent image default. Verified red on a restored buildcache ref and
red again on a ref hidden behind ${{ github.repository_owner }}.

Two stale claims corrected while here. The chart values comment said the old
agent.image default was never published, when it did carry v6.0.0-rc.3. The
deployment-installability contract called rcourtman/pulse the only image
publish-docker.yml pushes, when the same workflow also pushes the MSP
pulse-control-plane image. The contract now also forbids buildcache
references to the pulse-agent package, which is what let this regress.

Deleting the orphaned package itself remains a manual GHCR step.
This commit is contained in:
rcourtman 2026-08-06 10:48:05 +01:00
parent 5d120208b9
commit 7c9a572349
4 changed files with 111 additions and 8 deletions

View file

@ -429,8 +429,13 @@ jobs:
push: false
provenance: mode=max
sbom: true
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max
# Cache under the published pulse package, not a pulse-agent package.
# No workflow pushes a pulse-agent image, so a buildcache ref there
# resurrects an empty package in the repo's Packages sidebar that
# reads like a pullable agent image. Blobs are also shared with the
# runtime build, which uses the same backend-builder layers.
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache,mode=max
build-args: |
VERSION=${{ needs.prepare.outputs.tag }}
PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}

View file

@ -133,7 +133,8 @@ agent:
# Defaults to the main Pulse image; the runtime stage now ships an arch-
# resolved /usr/local/bin/pulse-agent symlink so the same image can serve
# both server and agent workloads. The previous default
# (ghcr.io/rcourtman/pulse-agent) was an image that was never published.
# (ghcr.io/rcourtman/pulse-agent) is not a maintained image: no release
# workflow pushes it, and the only tag it ever carried was v6.0.0-rc.3.
repository: rcourtman/pulse
tag: ""
pullPolicy: IfNotPresent

View file

@ -940,14 +940,22 @@ upgrade, update, release, or artifact-selection behavior.
from whatever branch GitHub happened to check out.
The chart's `agent.enabled=true` workload must point at an image that is
actually published. The default `agent.image.repository` must be the main
`rcourtman/pulse` image (which is the only image `publish-docker.yml`
pushes); the agent template must override the server ENTRYPOINT via
`rcourtman/pulse` image (the only agent-capable image `publish-docker.yml`
pushes; the same workflow also pushes the MSP `pulse-control-plane` image,
which is not an agent runtime); the agent template must override the server
ENTRYPOINT via
`agent.command` so the pod runs as a unified agent; and the runtime stage
of `Dockerfile` must ship an arch-resolved `/usr/local/bin/pulse-agent`
symlink that picks `pulse-agent-linux-{amd64,arm64,armv7}` per `TARGETARCH`
so a single command default works across multi-arch nodes. The
never-published `ghcr.io/rcourtman/pulse-agent` is forbidden as a chart
default. `scripts/validate-release.sh` must assert the
so a single command default works across multi-arch nodes. The unmaintained
`ghcr.io/rcourtman/pulse-agent` is forbidden as a chart default: no release
workflow publishes it, and the only release tag it ever carried is a stray
`v6.0.0-rc.3`. No workflow may reference that package at all, buildcache
refs included — a `cache-to` there recreates an empty package in the
repository's Packages sidebar that reads like a pullable agent image. The
`agent_runtime` build cache belongs at
`ghcr.io/<owner>/pulse:agent-buildcache`, alongside the runtime stage's own
`pulse:buildcache` tag. `scripts/validate-release.sh` must assert the
`/usr/local/bin/pulse-agent` symlink exists, points at one of the
supported Linux arch binaries, and is executable in the published image.
`create-release.yml` must trigger `publish-helm-chart.yml` via an explicit

View file

@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
@ -197,6 +198,94 @@ func shieldsBadgeMessage(value string) string {
return strings.ReplaceAll(value, "-", "--")
}
// TestAgentBuildCacheDoesNotResurrectPulseAgentPackage guards the repository's
// GHCR package list, which is a user-facing surface. A registry cache ref
// creates the package it points at, so pointing the agent_runtime build cache
// at ghcr.io/<owner>/pulse-agent recreated an empty package on every release.
// It then sat in the repo's Packages sidebar beside pulse, pulse-control-plane
// and pulse-chart/pulse, reading like a pullable agent image even though no
// workflow publishes it. Only images a release workflow actually pushes may
// own a package; the unified agent ships inside the main pulse image.
func TestAgentBuildCacheDoesNotResurrectPulseAgentPackage(t *testing.T) {
workflowDir := repoFile(".github", "workflows")
entries, err := os.ReadDir(workflowDir)
if err != nil {
t.Fatalf("read workflow dir: %v", err)
}
// Registry-qualified refs only: the release binaries are legitimately named
// pulse-agent-<os>-<arch> and must keep matching nothing here. Workflow
// expressions are collapsed first so an owner interpolated as
// ${{ github.repository_owner }} cannot hide the ref behind its spaces.
workflowExpr := regexp.MustCompile(`\$\{\{[^}]*\}\}`)
packageRef := regexp.MustCompile(`(?:ghcr\.io|docker\.io)/[^\s"']*/pulse-agent\b|(?:^|\s)rcourtman/pulse-agent\b`)
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || (!strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml")) {
continue
}
content, err := os.ReadFile(filepath.Join(workflowDir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for i, line := range strings.Split(string(content), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
if match := packageRef.FindString(workflowExpr.ReplaceAllString(line, "EXPR")); match != "" {
t.Fatalf("%s:%d targets the unpublished pulse-agent package (%q); no workflow may reference it, buildcache refs included", name, i+1, strings.TrimSpace(match))
}
}
}
release, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
if err != nil {
t.Fatalf("read create-release.yml: %v", err)
}
for _, required := range []string{
`cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache`,
`cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:agent-buildcache,mode=max`,
} {
if !strings.Contains(string(release), required) {
t.Fatalf("create-release.yml must cache the agent_runtime build under the published pulse package; missing %q", required)
}
}
values, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "values.yaml"))
if err != nil {
t.Fatalf("read values.yaml: %v", err)
}
agentBlock := topLevelYAMLBlock(t, string(values), "agent")
if !strings.Contains(agentBlock, "repository: rcourtman/pulse\n") {
t.Fatal("chart agent.image.repository must default to the published rcourtman/pulse image")
}
}
// topLevelYAMLBlock returns the lines of a top-level mapping key, from the key
// itself up to the next unindented key.
func topLevelYAMLBlock(t *testing.T, doc string, key string) string {
t.Helper()
lines := strings.Split(doc, "\n")
start := -1
for i, line := range lines {
if line == key+":" {
start = i
break
}
}
if start < 0 {
t.Fatalf("values.yaml missing top-level %q key", key)
}
for i := start + 1; i < len(lines); i++ {
line := lines[i]
if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "#") {
continue
}
return strings.Join(lines[start:i], "\n")
}
return strings.Join(lines[start:], "\n")
}
func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
if err != nil {