diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5305e24e0c..956c26b1a5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.9 +FROM golang:1.26.7@sha256:dc2521c2a906db43073b8b4d99f491b6341cf15610b6ebbab187c45153f9959e # Set bash as default shell for features SHELL ["/bin/bash", "-c"] diff --git a/.devcontainer/README.md b/.devcontainer/README.md index ed65a0eeb5..a68edbfe0b 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -5,8 +5,8 @@ This dev container provides a complete, reproducible development environment for ## What's Included ### Development Tools -- **Go 1.25.9** - Backend development -- **Node.js 20** - Frontend development +- **Go 1.26.7** - Backend development +- **Node.js 24** - Frontend development - **gopls v0.17.0** - Go language server - **Delve** - Go debugger - **inotify-tools** - File watching for hot reload @@ -178,7 +178,7 @@ If hooks fail, fix the issues and commit again. **Rebuilds are fast** (~30 seconds) thanks to: - Persistent Go build cache -- Persistent npm cache +- Persistent npm cache - Docker layer caching ## Troubleshooting @@ -225,7 +225,7 @@ Custom overrides: Create `.env.devcontainer` (gitignored) ## Resources - **VM Specs**: 8GB RAM, 30GB disk, 2 CPU cores -- **Base Image**: `golang:1.25.9` (Ubuntu-based) +- **Base Image**: `golang:1.26.7` (Ubuntu-based) - **Caches**: ~2-3GB for Go modules and build artifacts ## Tips & Tricks @@ -247,8 +247,8 @@ MacBook (VS Code) dev-containers VM (Proxmox) ↓ Docker Dev Container - ├── Go 1.25.9 + tools - ├── Node 20 + npm + ├── Go 1.26.7 + tools + ├── Node 24 + npm ├── Your code (/workspaces/pulse) ├── Hot reload watchers └── Running dev server diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ba3b8608e2..cd05ef15e6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,8 @@ "context": "." }, "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "20" + "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6": { + "version": "24" } }, "mounts": [ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c633f55d05..89929dd43b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -80,7 +80,9 @@ updates: - package-ecosystem: "docker" directories: - "/" + - "/.devcontainer" - "/deploy/provider-msp" + - "/tests/integration/mock-github-server" schedule: interval: "weekly" day: "tuesday" @@ -110,3 +112,33 @@ updates: - "version-update:semver-major" - "version-update:semver-minor" - "version-update:semver-patch" + + - package-ecosystem: "docker-compose" + directory: "/tests/integration" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:40" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" + # Keep the governed Alpine release line explicit while allowing automatic + # digest refreshes for the seed container used by release qualification. + ignore: + - dependency-name: "alpine" + update-types: + - "version-update:semver-major" + - "version-update:semver-minor" + - "version-update:semver-patch" + + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "04:50" + timezone: "Europe/London" + open-pull-requests-limit: 3 + labels: + - "dependencies" diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c92845d90f..e12db2f5e1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -118,7 +118,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: npm cache-dependency-path: frontend-modern/package-lock.json diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index db1ab6973b..6fb8349ca0 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -673,7 +673,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 1f18a4b312..774e78b806 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -90,7 +90,7 @@ jobs: - name: Set up Node.js for frontend governance uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: npm cache-dependency-path: repos/pulse/frontend-modern/package-lock.json diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml index 25a5c38e5b..3aaec26b5f 100644 --- a/.github/workflows/compile-release-payload.yml +++ b/.github/workflows/compile-release-payload.yml @@ -62,7 +62,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' - name: Verify compiler capacity shell: bash diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index d9f254075b..2766dc5299 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -310,7 +310,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' - name: Install dependencies run: npm --prefix frontend-modern ci @@ -343,7 +343,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' @@ -380,7 +380,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' @@ -455,7 +455,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' @@ -620,7 +620,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 92049867ee..e54374c140 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -241,7 +241,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index f78819fa04..6edf0dee7a 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -14,6 +14,25 @@ permissions: contents: read jobs: + container-lifecycle: + name: Container trust and support window + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Check governed image pins and lifecycle runway + run: >- + go test ./scripts/installtests + -run '^(TestGovernedContainerBaseSupportWindow|TestEveryDockerfilePinsExternalBases|TestNodeToolchainParity|TestIntegrationContainersUseGovernedImmutableBases|TestDockerBuildUsesCanonicalReleaseLdflags|TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary)$' + govulncheck: name: Go Vulnerability Scan (${{ matrix.directory }}) runs-on: ubuntu-24.04 @@ -65,7 +84,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' - name: Audit complete dependency graph working-directory: ${{ matrix.directory }} diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 28cfecffcd..0c7db03cdd 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: tests/integration/package-lock.json @@ -89,7 +89,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: tests/integration/package-lock.json @@ -242,7 +242,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: tests/integration/package-lock.json diff --git a/.github/workflows/unified-agent-native.yml b/.github/workflows/unified-agent-native.yml index 568af13bb6..10a7c661a4 100644 --- a/.github/workflows/unified-agent-native.yml +++ b/.github/workflows/unified-agent-native.yml @@ -106,7 +106,7 @@ jobs: if: ${{ !matrix.unix }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6df41b8172..ec159d107d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ and installer tooling. git clone https://github.com/rcourtman/Pulse.git cd Pulse -# Install Go 1.26 and Node.js 20 with your preferred package manager. +# Install Go 1.26 and Node.js 24 with your preferred package manager. # Install the repository and frontend dependencies exactly from their locks npm ci diff --git a/Dockerfile b/Dockerfile index fde773c38b..6944dac007 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG APPRISE_VERSION=1.12.0 # Build stage for frontend (must be built first for embedding) # Force amd64 platform to avoid slow QEMU emulation during multi-arch builds -FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder +FROM --platform=linux/amd64 node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf AS frontend-builder WORKDIR /app/frontend-modern @@ -213,7 +213,7 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ # Runtime image for the Docker agent (offered via --target agent_runtime) -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS agent_runtime +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS agent_runtime # Use TARGETARCH to select the correct binary for the build platform ARG TARGETARCH @@ -254,7 +254,7 @@ ENTRYPOINT ["/usr/local/bin/pulse-agent"] # Build the pinned Apprise CLI separately so release runtimes only retain the # Python interpreter and installed notification dependencies, not pip. -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS apprise-builder +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS apprise-builder ARG APPRISE_VERSION @@ -265,7 +265,7 @@ RUN apk --no-cache add python3 py3-pip && \ # Base operating-system surface shared by source-built and candidate-assembled # Pulse server images. -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-foundation +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS pulse-runtime-foundation ARG APPRISE_VERSION @@ -416,7 +416,7 @@ RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 && \ chown -R pulse:pulse /opt/pulse # Unified Agent image assembled from the same immutable candidate payload. -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS agent_runtime_prebuilt +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS agent_runtime_prebuilt ARG TARGETARCH ARG TARGETVARIANT diff --git a/deploy/provider-msp/Dockerfile.control-plane b/deploy/provider-msp/Dockerfile.control-plane index 2dcb8d3173..1d1066aa8e 100644 --- a/deploy/provider-msp/Dockerfile.control-plane +++ b/deploy/provider-msp/Dockerfile.control-plane @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder +FROM --platform=linux/amd64 node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf AS frontend-builder WORKDIR /app/frontend-modern COPY frontend-modern/package*.json ./ RUN --mount=type=cache,id=pulse-control-plane-npm-cache,target=/root/.npm \ @@ -51,7 +51,7 @@ RUN --mount=type=cache,id=pulse-control-plane-go-mod,target=/go/pkg/mod \ -trimpath \ -o /pulse-control-plane ./cmd/pulse-control-plane -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS control-plane-runtime-foundation +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS control-plane-runtime-foundation RUN apk add --no-cache ca-certificates docker-cli ENTRYPOINT ["pulse-control-plane"] diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 302396313f..f37d5dd9de 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5906,6 +5906,11 @@ recovery, and uninstall lifecycle. The dedicated ephemeral Windows job must also clear test-only `ProgramData\Pulse` state immediately before that proof; the lifecycle harness still refuses to proceed when the PulseAgent service is already installed. +The Windows native lifecycle job's generated install-command proof consumes +the governed frontend toolchain and must therefore select Node.js `24`, in +parity with the release and integration workflows. That build prerequisite +does not widen agent runtime or installer authority, but drift to another Node +major invalidates the native lifecycle job as release evidence. Normal release promotion requires platform-native identity for desktop agent binaries. macOS agents must be Developer ID signed, submitted successfully to diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 11b98169a5..f4f1e5cd5d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2793,6 +2793,17 @@ That same supply-chain boundary also owns the checked-in build roots themselves. `Dockerfile` must pin its Node, Go, and Alpine bases by immutable manifest-list digest so multi-arch release builds do not silently drift onto a different upstream filesystem just because a mutable tag was republished. +The governed frontend and container lines are Node.js `24` and Alpine `3.24`. +Every `actions/setup-node` release, governance, security, integration, and +native-agent job, the release-preflight worker, the developer container, and +the release frontend stages must stay on Node.js `24`; local integration setup +must reject a different major instead of treating newer non-LTS majors as +equivalent. Shipped and test Dockerfiles must pin every external base to a full +manifest-list digest, and checked-in integration Compose images must do the +same. Node.js `24` is normally supported through `2028-04-30` and Alpine +`3.24` through `2028-06-01`; a weekly repository check must fail once either +governed line has less than 180 days of normal upstream support remaining so a +replacement can be qualified before delivery depends on an unsupported line. The governed v6 release Go patch level is part of that same boundary: `go.mod`, `scripts/.go-version`, `scripts/install-go-toolchain.sh`, `scripts/build-release.sh`, the Go builder stages in `Dockerfile` and diff --git a/frontend-modern/public/docs/CONTRIBUTING.md b/frontend-modern/public/docs/CONTRIBUTING.md index 6df41b8172..ec159d107d 100644 --- a/frontend-modern/public/docs/CONTRIBUTING.md +++ b/frontend-modern/public/docs/CONTRIBUTING.md @@ -69,7 +69,7 @@ and installer tooling. git clone https://github.com/rcourtman/Pulse.git cd Pulse -# Install Go 1.26 and Node.js 20 with your preferred package manager. +# Install Go 1.26 and Node.js 24 with your preferred package manager. # Install the repository and frontend dependencies exactly from their locks npm ci diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index c8dbe733ab..4144de9cfb 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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 { diff --git a/scripts/installtests/container_dependency_contract_test.go b/scripts/installtests/container_dependency_contract_test.go new file mode 100644 index 0000000000..9645a3396e --- /dev/null +++ b/scripts/installtests/container_dependency_contract_test.go @@ -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) + } +} diff --git a/scripts/installtests/dependency_update_test.go b/scripts/installtests/dependency_update_test.go index 23dc97b0f5..ba77d0ebb3 100644 --- a/scripts/installtests/dependency_update_test.go +++ b/scripts/installtests/dependency_update_test.go @@ -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, + ) + } + } +} diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go index 2a962e6abd..0a89314d0b 100644 --- a/scripts/installtests/install_ps1_test.go +++ b/scripts/installtests/install_ps1_test.go @@ -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) } } } diff --git a/scripts/installtests/integration_container_test.go b/scripts/installtests/integration_container_test.go new file mode 100644 index 0000000000..32188ce075 --- /dev/null +++ b/scripts/installtests/integration_container_test.go @@ -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) +} diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 9cc5f8f89b..f3b23ed337 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -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) { diff --git a/scripts/release-preflight-worker.sh b/scripts/release-preflight-worker.sh index a431957855..905999162d 100755 --- a/scripts/release-preflight-worker.sh +++ b/scripts/release-preflight-worker.sh @@ -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 diff --git a/scripts/tests/test_dependabot_config.py b/scripts/tests/test_dependabot_config.py index 1ffbeb0459..63b990ae57 100644 --- a/scripts/tests/test_dependabot_config.py +++ b/scripts/tests/test_dependabot_config.py @@ -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"] diff --git a/tests/integration/QUICK_START.md b/tests/integration/QUICK_START.md index ab1244c7ea..98d26fb45c 100644 --- a/tests/integration/QUICK_START.md +++ b/tests/integration/QUICK_START.md @@ -5,7 +5,7 @@ This guide will help you get the update integration tests running quickly. ## Prerequisites - Docker and Docker Compose -- Node.js 18+ and npm +- Node.js 24 and npm - Go 1.25+ (for building mock server) ## Setup (One-time) diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index 4ffffbba00..4693778261 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -2,7 +2,7 @@ services: # Seed a deterministic bootstrap token for first-run setup E2E flows. # This is only used by the test stack and is safe to keep deterministic. seed-bootstrap-token: - image: alpine:3.20 + image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b container_name: pulse-test-seed-bootstrap-token environment: - PULSE_E2E_BOOTSTRAP_TOKEN=${PULSE_E2E_BOOTSTRAP_TOKEN:-0123456789abcdef0123456789abcdef0123456789abcdef} diff --git a/tests/integration/mock-github-server/Dockerfile b/tests/integration/mock-github-server/Dockerfile index bb1267d9a5..7ccc976005 100644 --- a/tests/integration/mock-github-server/Dockerfile +++ b/tests/integration/mock-github-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.7-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder WORKDIR /build COPY go.mod ./ @@ -7,7 +7,7 @@ RUN go mod download COPY main.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -o mock-github-server . -FROM alpine:3.22.1 +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b RUN apk --no-cache add ca-certificates WORKDIR /app diff --git a/tests/integration/scripts/managed-local-backend.test.mjs b/tests/integration/scripts/managed-local-backend.test.mjs index a8b9c7f97e..69ad01bb54 100644 --- a/tests/integration/scripts/managed-local-backend.test.mjs +++ b/tests/integration/scripts/managed-local-backend.test.mjs @@ -15,6 +15,18 @@ import { const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const integrationRoot = path.resolve(scriptsDir, '..'); +test('integration setup pins the governed Node.js major', async () => { + const [quickStart, setupScript] = await Promise.all([ + fs.readFile(path.join(integrationRoot, 'QUICK_START.md'), 'utf8'), + fs.readFile(path.join(scriptsDir, 'setup.sh'), 'utf8'), + ]); + + assert.match(quickStart, /- Node\.js 24 and npm/); + assert.match(setupScript, /if \[ "\$NODE_VERSION" -ne 24 \]; then/); + assert.match(setupScript, /Node\.js major version 24 is required/); + assert.doesNotMatch(setupScript, /Node\.js 24\+/); +}); + test('buildManagedLocalBackendState uses deterministic defaults', () => { const state = buildManagedLocalBackendState({}); assert.equal(state.repoRoot.endsWith('/repos/pulse'), true); diff --git a/tests/integration/scripts/setup.sh b/tests/integration/scripts/setup.sh index 1cb3a53a4f..429e18239d 100755 --- a/tests/integration/scripts/setup.sh +++ b/tests/integration/scripts/setup.sh @@ -33,13 +33,13 @@ echo "✅ Docker Compose is available" # Check Node.js if ! command -v node &> /dev/null; then - echo "❌ Node.js is not installed. Please install Node.js 18+ first." + echo "❌ Node.js is not installed. Please install Node.js 24 first." exit 1 fi NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) -if [ "$NODE_VERSION" -lt 18 ]; then - echo "❌ Node.js version 18 or higher is required (found: $(node -v))" +if [ "$NODE_VERSION" -ne 24 ]; then + echo "❌ Node.js major version 24 is required (found: $(node -v))" exit 1 fi echo "✅ Node.js $(node -v) is available"