Keep release toolchains within support

This commit is contained in:
pulse-triage[bot] 2026-08-30 06:40:25 +01:00
parent 366fe3f293
commit 0d32dac16e
32 changed files with 359 additions and 58 deletions

View file

@ -389,7 +389,7 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
"COPY --from=release_payload /amd64/bin/pulse /opt/pulse/bin/pulse-linux-amd64",
"COPY --from=release_payload /arm64/bin/pulse /opt/pulse/bin/pulse-linux-arm64",
"! -name '*.sig' ! -name '*.sshsig' -exec chmod 755 {} +",
"FROM alpine:3.20@sha256:",
"FROM alpine:3.24@sha256:",
"AS agent_runtime_prebuilt",
} {
if !strings.Contains(dockerfile, needle) {
@ -1443,7 +1443,7 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
}
dockerfile := string(dockerfileBytes)
dockerRequired := []string{
`FROM --platform=linux/amd64 node:20-alpine@sha256:`,
`FROM --platform=linux/amd64 node:24-alpine@sha256:`,
`FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`,
`FROM backend-builder AS release-assets-builder`,
`AS agent_runtime`,
@ -1478,10 +1478,10 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
t.Fatalf("Dockerfile missing canonical release ldflags usage: %s", needle)
}
}
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 node:20-alpine@sha256:`, ` AS frontend-builder`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 node:24-alpine@sha256:`, ` AS frontend-builder`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM --platform=linux/amd64 golang:1.26.7-alpine@sha256:`, ` AS backend-builder`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.20@sha256:`, ` AS agent_runtime`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.20@sha256:`, ` AS pulse-runtime-foundation`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ` AS agent_runtime`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ` AS pulse-runtime-foundation`)
hostedStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS hosted_runtime`)
runtimeStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS runtime`)
if hostedStart == -1 || runtimeStart == -1 || hostedStart > runtimeStart {
@ -1491,10 +1491,10 @@ func TestDockerBuildUsesCanonicalReleaseLdflags(t *testing.T) {
if strings.Contains(hostedStage, "rendered-installers") || strings.Contains(hostedStage, "/opt/pulse/bin") {
t.Fatalf("hosted_runtime target must not depend on installer rendering or embedded agent artifacts:\n%s", hostedStage)
}
if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:20-alpine AS frontend-builder`) ||
if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:24-alpine AS frontend-builder`) ||
strings.Contains(dockerfile, `FROM --platform=linux/amd64 golang:1.26.7-alpine AS backend-builder`) ||
strings.Contains(dockerfile, `FROM alpine:3.20 AS agent_runtime`) ||
strings.Contains(dockerfile, `FROM alpine:3.20 AS pulse-runtime-base`) {
strings.Contains(dockerfile, `FROM alpine:3.24 AS agent_runtime`) ||
strings.Contains(dockerfile, `FROM alpine:3.24 AS pulse-runtime-base`) {
t.Fatal("Dockerfile base images must be pinned by immutable @sha256 digests")
}
if builds, cleanBuilds := strings.Count(dockerfile, " go build \\"), strings.Count(dockerfile, "-buildvcs=false"); builds != cleanBuilds {

View file

@ -0,0 +1,113 @@
package installtests
import (
"bufio"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
var externalImageDigest = regexp.MustCompile(`@sha256:[0-9a-f]{64}$`)
func TestEveryDockerfilePinsExternalBases(t *testing.T) {
err := filepath.WalkDir(repoFile("."), func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == "node_modules" || entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasPrefix(entry.Name(), "Dockerfile") {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
stages := make(map[string]bool)
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
fields := strings.Fields(scanner.Text())
if len(fields) < 2 || !strings.EqualFold(fields[0], "FROM") {
continue
}
imageIndex := 1
if strings.HasPrefix(fields[imageIndex], "--platform=") {
imageIndex++
}
if imageIndex >= len(fields) {
t.Fatalf("%s:%d has malformed FROM instruction", path, lineNumber)
}
image := fields[imageIndex]
if !stages[image] && !externalImageDigest.MatchString(image) {
t.Fatalf("%s:%d external base image %q is not pinned to a full sha256 digest", path, lineNumber, image)
}
for index := imageIndex + 1; index+1 < len(fields); index++ {
if strings.EqualFold(fields[index], "AS") {
stages[fields[index+1]] = true
break
}
}
}
return scanner.Err()
})
if err != nil {
t.Fatalf("audit Dockerfiles: %v", err)
}
}
func TestNodeToolchainParity(t *testing.T) {
workflowPaths, err := filepath.Glob(repoFile(".github", "workflows", "*.yml"))
if err != nil {
t.Fatalf("list workflows: %v", err)
}
versionPattern := regexp.MustCompile(`node-version:\s*['"]?([^'"\s#]+)`)
setupCount := 0
versionCount := 0
for _, path := range workflowPaths {
contentBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
content := string(contentBytes)
setupCount += strings.Count(content, "actions/setup-node@")
for _, match := range versionPattern.FindAllStringSubmatch(content, -1) {
versionCount++
if match[1] != nodeToolchainLine {
t.Fatalf("%s selects Node.js %s; all workflows must use governed line %s", path, match[1], nodeToolchainLine)
}
}
}
if setupCount == 0 || versionCount != setupCount {
t.Fatalf("every setup-node step must select the governed line: setup=%d versions=%d", setupCount, versionCount)
}
workerBytes, err := os.ReadFile(repoFile("scripts", "release-preflight-worker.sh"))
if err != nil {
t.Fatalf("read release preflight worker: %v", err)
}
if !strings.Contains(string(workerBytes), `!= "`+nodeToolchainLine+`"`) {
t.Fatalf("release preflight worker must enforce Node.js %s parity", nodeToolchainLine)
}
devcontainerBytes, err := os.ReadFile(repoFile(".devcontainer", "devcontainer.json"))
if err != nil {
t.Fatalf("read devcontainer config: %v", err)
}
devcontainer := string(devcontainerBytes)
if !strings.Contains(devcontainer, `"version": "`+nodeToolchainLine+`"`) ||
!regexp.MustCompile(`ghcr\.io/devcontainers/features/node@sha256:[0-9a-f]{64}`).MatchString(devcontainer) {
t.Fatalf("devcontainer must use Node.js %s from a digest-pinned feature", nodeToolchainLine)
}
}

View file

@ -3,6 +3,15 @@ package installtests
import (
"regexp"
"testing"
"time"
)
const (
alpineRuntimeLine = "3.24"
alpineRuntimeSupportEnd = "2028-06-01"
nodeToolchainLine = "24"
nodeToolchainSupportEnd = "2028-04-30"
containerSupportLead = 180 * 24 * time.Hour
)
// assertDigestPinnedDockerStage allows automated digest refreshes while still
@ -21,3 +30,31 @@ func assertDigestPinnedDockerStage(t *testing.T, dockerfile, prefix, suffix stri
)
}
}
// TestGovernedContainerBaseSupportWindow turns the upstream lifecycle date
// into an advance operational signal. Digest automation keeps a selected line
// patched, but it cannot move a deliberately governed major/minor tag.
func TestGovernedContainerBaseSupportWindow(t *testing.T) {
for _, policy := range []struct {
name string
line string
supportEnd string
}{
{name: "Alpine", line: alpineRuntimeLine, supportEnd: alpineRuntimeSupportEnd},
{name: "Node.js", line: nodeToolchainLine, supportEnd: nodeToolchainSupportEnd},
} {
supportEnd, err := time.Parse("2006-01-02", policy.supportEnd)
if err != nil {
t.Fatalf("parse %s %s support end: %v", policy.name, policy.line, err)
}
remaining := time.Until(supportEnd)
if remaining < containerSupportLead {
t.Fatalf(
"%s %s has less than 180 days of normal support remaining (support ends %s); select and qualify a newer line",
policy.name,
policy.line,
policy.supportEnd,
)
}
}
}

View file

@ -110,6 +110,7 @@ func TestNativeWindowsSelfTestDoesNotPreseedLifecycleState(t *testing.T) {
workflow := string(content)
required := []string{
`node-version: '24'`,
`$selfTestStateDir = Join-Path $env:RUNNER_TEMP 'pulse-agent-self-test'`,
`$selfTestLogFile = Join-Path $selfTestStateDir 'pulse-agent.log'`,
`--self-test --state-dir $selfTestStateDir --log-file $selfTestLogFile`,
@ -119,7 +120,7 @@ func TestNativeWindowsSelfTestDoesNotPreseedLifecycleState(t *testing.T) {
}
for _, needle := range required {
if !strings.Contains(workflow, needle) {
t.Fatalf("native Windows self-test must keep lifecycle state isolated: %s", needle)
t.Fatalf("native Windows workflow must keep its governed lifecycle proof intact: %s", needle)
}
}
}

View file

@ -0,0 +1,35 @@
package installtests
import (
"os"
"regexp"
"strings"
"testing"
)
func TestIntegrationContainersUseGovernedImmutableBases(t *testing.T) {
dockerfileBytes, err := os.ReadFile(repoFile("tests", "integration", "mock-github-server", "Dockerfile"))
if err != nil {
t.Fatalf("read mock GitHub Dockerfile: %v", err)
}
dockerfile := string(dockerfileBytes)
assertDigestPinnedDockerStage(t, dockerfile, `FROM golang:1.26.7-alpine@sha256:`, ` AS builder`)
assertDigestPinnedDockerStage(t, dockerfile, `FROM alpine:3.24@sha256:`, ``)
composeBytes, err := os.ReadFile(repoFile("tests", "integration", "docker-compose.test.yml"))
if err != nil {
t.Fatalf("read integration compose file: %v", err)
}
compose := string(composeBytes)
if !regexpDigestPinnedImage(`alpine:3.24`, compose) {
t.Fatal("integration seed image must use the governed Alpine line pinned by a full immutable digest")
}
if strings.Contains(dockerfile, "golang:1.23-alpine") || strings.Contains(compose, "image: alpine:3.20\n") {
t.Fatal("integration containers must not restore retired toolchain or runtime lines")
}
}
func regexpDigestPinnedImage(image string, content string) bool {
pattern := regexp.MustCompile(`(?m)^\s*image:\s*` + regexp.QuoteMeta(image) + `@sha256:[0-9a-f]{64}\s*$`)
return pattern.MatchString(content)
}

View file

@ -303,11 +303,11 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.
text := string(dockerfileBytes)
assertContainsAll(t, text,
"# syntax=docker/dockerfile:1.7",
"FROM --platform=linux/amd64 node:20-alpine@sha256:",
"FROM --platform=linux/amd64 node:24-alpine@sha256:",
"npm ci",
"npm run build",
"FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:",
"FROM alpine:3.20@sha256:",
"FROM alpine:3.24@sha256:",
"ARG PULSE_LICENSE_PUBLIC_KEY_SHA256",
"ARG TARGETOS",
"ARG TARGETARCH",
@ -332,9 +332,9 @@ func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.
"FROM alpine:3.21",
"CGO_ENABLED=0 go build -o /pulse-control-plane ./cmd/pulse-control-plane",
)
assertDigestPinnedDockerStage(t, text, `FROM --platform=linux/amd64 node:20-alpine@sha256:`, ` AS frontend-builder`)
assertDigestPinnedDockerStage(t, text, `FROM --platform=linux/amd64 node:24-alpine@sha256:`, ` AS frontend-builder`)
assertDigestPinnedDockerStage(t, text, `FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:`, ` AS builder`)
assertDigestPinnedDockerStage(t, text, `FROM alpine:3.20@sha256:`, ` AS control-plane-runtime-foundation`)
assertDigestPinnedDockerStage(t, text, `FROM alpine:3.24@sha256:`, ` AS control-plane-runtime-foundation`)
}
func assertContainsAll(t *testing.T, text string, required ...string) {

View file

@ -49,8 +49,8 @@ if ! docker compose version >/dev/null 2>&1; then
echo "Error: Docker Compose v2 is required on the worker." >&2
exit 3
fi
if [ "$(node -p "process.versions.node.split('.')[0]")" != "20" ]; then
echo "Error: the worker must use Node.js 20 to match the release workflows." >&2
if [ "$(node -p "process.versions.node.split('.')[0]")" != "24" ]; then
echo "Error: the worker must use Node.js 24 to match the release workflows." >&2
exit 3
fi

View file

@ -24,6 +24,18 @@ def manifest_directories(filename: str) -> list[str]:
return sorted(directories)
def dockerfile_directories() -> list[str]:
"""Return every directory containing a Docker build manifest."""
directories = set()
for manifest in ROOT.rglob("Dockerfile*"):
relative = manifest.relative_to(ROOT)
if "node_modules" in relative.parts:
continue
parent = relative.parent.as_posix()
directories.add("/" if parent == "." else f"/{parent}")
return sorted(directories)
class DependabotConfigTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@ -34,7 +46,15 @@ class DependabotConfigTest(unittest.TestCase):
def test_covers_every_shipped_dependency_ecosystem(self) -> None:
self.assertEqual(
set(self.updates), {"github-actions", "gomod", "npm", "docker"}
set(self.updates),
{
"github-actions",
"gomod",
"npm",
"docker",
"docker-compose",
"devcontainers",
},
)
self.assertEqual(self.updates["github-actions"]["directory"], "/")
self.assertEqual(
@ -46,9 +66,14 @@ class DependabotConfigTest(unittest.TestCase):
manifest_directories("package-lock.json"),
)
self.assertEqual(
self.updates["docker"]["directories"],
["/", "/deploy/provider-msp"],
sorted(self.updates["docker"]["directories"]),
dockerfile_directories(),
)
self.assertEqual(
self.updates["docker-compose"]["directory"],
"/tests/integration",
)
self.assertEqual(self.updates["devcontainers"]["directory"], "/")
def test_updates_are_weekly_staggered_and_bounded(self) -> None:
self.assertEqual(self.config["version"], 2)
@ -100,6 +125,17 @@ class DependabotConfigTest(unittest.TestCase):
self.assertEqual(set(ignored), {"node", "golang", "alpine"})
self.assertTrue(all(types == all_semver for types in ignored.values()))
compose_ignored = self.updates["docker-compose"]["ignore"]
self.assertEqual(
compose_ignored,
[
{
"dependency-name": "alpine",
"update-types": sorted(all_semver),
}
],
)
def test_weekly_scan_covers_the_same_lockfiles(self) -> None:
workflow = yaml.safe_load(SECURITY_SCAN.read_text(encoding="utf-8"))
jobs = workflow["jobs"]