From 3d94ac33643419fed88c71e5c9a3699c60d67ca6 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 17:21:56 +0100 Subject: [PATCH 1/2] feat(email): publish the staged Stalwart candidate image --- .../publish-private-mail-bootstrap.yaml | 77 ++++++++++++++----- docs/adr/email.md | 9 ++- .../private-mail-config/publication_test.go | 73 ++++++++++++------ 3 files changed, 116 insertions(+), 43 deletions(-) diff --git a/.github/workflows/publish-private-mail-bootstrap.yaml b/.github/workflows/publish-private-mail-bootstrap.yaml index 511dbda837..cecb2dc639 100644 --- a/.github/workflows/publish-private-mail-bootstrap.yaml +++ b/.github/workflows/publish-private-mail-bootstrap.yaml @@ -23,14 +23,27 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false -env: - IMAGE: ghcr.io/devantler-tech/platform-private-mail-bootstrap - IMAGE_TAG: v0.16.27-bootstrap-${{ github.sha }} - jobs: verify: name: Verify and build bootstrap without publishing runs-on: ubuntu-latest + strategy: + matrix: &bootstrap_profiles + include: + - runtime: "0.16.27" + server: v0.16.27@sha256:c429b311c93db5606598abc1c68225a85a6953183848ae01bee9c6a98885836c + cli: 1.0.13@sha256:9a1e07307d6f890a193322c4b737fc0868405f0f8486a19922cbf362f43884fd + image: ghcr.io/devantler-tech/platform-private-mail-bootstrap + - runtime: "1.0.3" + server: v1.0.3@sha256:73c684a84c24f5b4206ec266a8deb8986fe44a9f623997e07f084388d943c8bd + cli: 1.0.14@sha256:5b70732cb69cdab3ff0894c501544cf0bfa180fa75bcd0eeba4eebe04c1064e1 + image: ghcr.io/devantler-tech/platform-private-mail-bootstrap-candidate + env: &build_inputs + STALWART_RUNTIME: ${{ matrix.runtime }} + STALWART_SERVER_PIN: ${{ matrix.server }} + STALWART_CLI_PIN: ${{ matrix.cli }} + IMAGE: ${{ matrix.image }} + IMAGE_TAG: v${{ matrix.runtime }}-bootstrap-${{ github.sha }} permissions: contents: read steps: @@ -47,22 +60,54 @@ jobs: with: go-version-file: go.mod cache: false - - name: Test and build static initializer - run: | + - name: Run Email configuration tests + run: go test -race ./scripts/private-mail-config + - name: Build static initializer + run: &stage_bootstrap | set -euo pipefail - go test -race ./scripts/private-mail-config - mkdir -p "${RUNNER_TEMP}/private-mail-build" + build_dir="${RUNNER_TEMP}/private-mail-build" + dockerfile=scripts/private-mail-config/Dockerfile + mkdir -p "${build_dir}" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \ - -o "${RUNNER_TEMP}/private-mail-build/private-mail-bootstrap" ./scripts/private-mail-config - cp scripts/private-mail-config/Dockerfile "${RUNNER_TEMP}/private-mail-build/Dockerfile" - bash scripts/private-mail-config/stage-webui.sh "${RUNNER_TEMP}/private-mail-build" - docker buildx build --platform linux/amd64 "${RUNNER_TEMP}/private-mail-build" + -o "${build_dir}/private-mail-bootstrap" ./scripts/private-mail-config + awk -v server="${STALWART_SERVER_PIN}" -v cli="${STALWART_CLI_PIN}" ' + /^FROM ghcr\.io\/stalwartlabs\/stalwart:[^ ]+$/ { + print "FROM ghcr.io/stalwartlabs/stalwart:" server + server_count++ + next + } + /^FROM ghcr\.io\/stalwartlabs\/cli:[^ ]+ AS cli$/ { + print "FROM ghcr.io/stalwartlabs/cli:" cli " AS cli" + cli_count++ + next + } + { print } + END { if (server_count != 1 || cli_count != 1) exit 1 } + ' "${dockerfile}" > "${build_dir}/Dockerfile" + if [[ "${STALWART_RUNTIME}" == '0.16.27' ]]; then + cmp -s "${dockerfile}" "${build_dir}/Dockerfile" + fi + bash scripts/private-mail-config/stage-webui.sh "${build_dir}" "${STALWART_RUNTIME}" + - name: Build the selected image locally + run: docker buildx build --platform linux/amd64 --load --tag email-bootstrap-verify "${RUNNER_TEMP}/private-mail-build" + - name: Verify candidate server and CLI in the built image + if: matrix.runtime == '1.0.3' + run: | + set -euo pipefail + isolated=(--rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges) + server=$(docker run "${isolated[@]}" --entrypoint /usr/local/bin/stalwart email-bootstrap-verify --version) + cli=$(docker run "${isolated[@]}" --entrypoint /usr/local/bin/stalwart-cli email-bootstrap-verify --version) + test "${server}" = "${STALWART_RUNTIME}" + test "${cli}" = 'stalwart-cli 1.0.14' publish: name: Publish and sign immutable bootstrap if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: verify runs-on: ubuntu-latest + strategy: + matrix: *bootstrap_profiles + env: *build_inputs permissions: contents: read packages: write @@ -82,13 +127,7 @@ jobs: go-version-file: go.mod cache: false - name: Build static initializer - run: | - set -euo pipefail - mkdir -p "${RUNNER_TEMP}/private-mail-build" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \ - -o "${RUNNER_TEMP}/private-mail-build/private-mail-bootstrap" ./scripts/private-mail-config - cp scripts/private-mail-config/Dockerfile "${RUNNER_TEMP}/private-mail-build/Dockerfile" - bash scripts/private-mail-config/stage-webui.sh "${RUNNER_TEMP}/private-mail-build" + run: *stage_bootstrap - name: Set up Buildx container driver uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 with: diff --git a/docs/adr/email.md b/docs/adr/email.md index df20f00b0a..11aea572fe 100644 --- a/docs/adr/email.md +++ b/docs/adr/email.md @@ -296,8 +296,13 @@ bash scripts/private-mail-config/stage-webui.sh "$build_dir" 1.0.3 ``` The 1.0.3 profile supports the full OAuth scope needed for recovery administrator -sign-in through a registered Web Interface client. The default build and -serving image use the deployed baseline. +sign-in through a registered Web Interface client. The default container build +uses the deployed baseline. The publisher builds the 0.16.27 and 1.0.3 profiles +from their pinned server, CLI and Web Interface inputs. It checks the versions +inside the built 1.0.3 image without network access. Protected main publishes +the candidate to a separate image repository so the 0.16 recovery reader can +continue to require the legacy image repository. Selecting the candidate for +serving requires a separate deployment. Selecting a schema does not migrate a datastore. Stalwart 1.0 refuses stores created before 1.0. A coordinated upgrade requires the diff --git a/scripts/private-mail-config/publication_test.go b/scripts/private-mail-config/publication_test.go index b920ed222e..e92937933d 100644 --- a/scripts/private-mail-config/publication_test.go +++ b/scripts/private-mail-config/publication_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "gopkg.in/yaml.v3" @@ -44,12 +45,18 @@ func publicationFixture(t *testing.T) assetPublicationFixture { t.Fatal(err) } write(filepath.Join(assetDir, "webui-pins.json"), string(pins), 0644) + write(filepath.Join(assetDir, "webui-v1-pins.json"), `{ + "version": "2.0.4", + "url": "https://github.com/stalwartlabs/webui/releases/download/v2.0.4/webui.zip", + "sha256": "73dd270926cf548ff40a41a8f7a523667df1f2cd665ed680de45711a1c2d04b2", + "license_sha256": "a7c6bf8df7d8fb707af606599f275e0eb192cd28492c95593b0608ca966c8df1" +}`, 0644) write(filepath.Join(f.root, "asset-source"), "verified admin asset fixture\n", 0644) + write(filepath.Join(f.root, "asset-v1-source"), "verified 1.0 admin asset fixture\n", 0644) write(filepath.Join(f.root, "license-source"), "fixture license\n", 0644) write(filepath.Join(f.bin, "go"), `#!/usr/bin/env bash set -euo pipefail case "$1" in - test) exit 0 ;; build) output='' while [ "$#" -gt 0 ]; do @@ -60,11 +67,6 @@ case "$1" in ;; *) exit 64 ;; esac -`, 0755) - write(filepath.Join(f.bin, "docker"), `#!/usr/bin/env bash -set -euo pipefail -[ "$*" = "buildx build --platform linux/amd64 $RUNNER_TEMP/private-mail-build" ] -test -s "$RUNNER_TEMP/private-mail-build/private-mail-bootstrap" `, 0755) write(filepath.Join(f.bin, "curl"), `#!/usr/bin/env bash set -euo pipefail @@ -82,7 +84,9 @@ case "$output" in "$RUNNER_TEMP/"*) ;; *) exit 64 ;; esac case "$url" in https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip) if [ "${FAKE_DOWNLOAD_BAD:-}" = zip ]; then printf 'altered' >"$output"; else cp "$ASSET_FIXTURE_ROOT/asset-source" "$output"; fi ;; - https://raw.githubusercontent.com/stalwartlabs/webui/v1.0.11/LICENSES/AGPL-3.0-only.txt) + https://github.com/stalwartlabs/webui/releases/download/v2.0.4/webui.zip) + cp "$ASSET_FIXTURE_ROOT/asset-v1-source" "$output" ;; + https://raw.githubusercontent.com/stalwartlabs/webui/v1.0.11/LICENSES/AGPL-3.0-only.txt|https://raw.githubusercontent.com/stalwartlabs/webui/v2.0.4/LICENSES/AGPL-3.0-only.txt) if [ "${FAKE_DOWNLOAD_BAD:-}" = license ]; then printf 'altered' >"$output"; else cp "$ASSET_FIXTURE_ROOT/license-source" "$output"; fi ;; *) exit 65 ;; esac @@ -106,34 +110,59 @@ func TestBothPublicationPathsStageTheAdministrationArtifacts(t *testing.T) { } var workflow struct { Jobs map[string]struct { - Steps []struct{ Name, Run string } + Steps []struct{ Name, Run string } + Strategy struct { + Matrix struct { + Include []struct{ Runtime, Server, CLI, Image string } + } + } } } if yaml.Unmarshal(data, &workflow) != nil { t.Fatal("publication workflow could not be decoded") } - for _, tc := range []struct{ job, step string }{ - {"verify", "Test and build static initializer"}, {"publish", "Build static initializer"}, - } { - t.Run(tc.job, func(t *testing.T) { + for _, job := range []string{"verify", "publish"} { + t.Run(job, func(t *testing.T) { var script string - for _, step := range workflow.Jobs[tc.job].Steps { - if step.Name == tc.step { + for _, step := range workflow.Jobs[job].Steps { + if step.Name == "Build static initializer" { script = step.Run } } if script == "" { t.Fatal("publication producer is missing") } - f := publicationFixture(t) - if output, err := f.run(script); err != nil { - t.Fatalf("publication producer failed: %v: %s", err, output) + profiles := workflow.Jobs[job].Strategy.Matrix.Include + if len(profiles) != 2 { + t.Fatalf("publication must cover the deployed and candidate versions, got %d profiles", len(profiles)) } - for name, want := range map[string]string{"webui.zip": "verified admin asset fixture\n", "webui.LICENSE": "fixture license\n"} { - got, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", name)) - if err != nil || string(got) != want { - t.Fatalf("publication did not stage the required administration artifact %s", name) - } + for i, runtime := range []struct{ version, cli, asset, image string }{ + {"0.16.27", "1.0.13", "verified admin asset fixture\n", "ghcr.io/devantler-tech/platform-private-mail-bootstrap"}, + {"1.0.3", "1.0.14", "verified 1.0 admin asset fixture\n", "ghcr.io/devantler-tech/platform-private-mail-bootstrap-candidate"}, + } { + t.Run(runtime.version, func(t *testing.T) { + profile := profiles[i] + if profile.Runtime != runtime.version || !strings.HasPrefix(profile.Server, "v"+runtime.version+"@sha256:") || + !strings.HasPrefix(profile.CLI, runtime.cli+"@sha256:") || profile.Image != runtime.image { + t.Fatal("runtime must use the matching pinned server, CLI and publication repository") + } + f := publicationFixture(t) + if output, err := f.run(script, "STALWART_RUNTIME="+profile.Runtime, + "STALWART_SERVER_PIN="+profile.Server, "STALWART_CLI_PIN="+profile.CLI); err != nil { + t.Fatalf("publication producer failed: %v: %s", err, output) + } + buildRecipe, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", "Dockerfile")) + if err != nil || !strings.Contains(string(buildRecipe), "FROM ghcr.io/stalwartlabs/stalwart:"+profile.Server+"\n") || + !strings.Contains(string(buildRecipe), "FROM ghcr.io/stalwartlabs/cli:"+profile.CLI+" AS cli\n") { + t.Fatal("staged recipe does not select the required server and CLI") + } + for name, want := range map[string]string{"webui.zip": runtime.asset, "webui.LICENSE": "fixture license\n"} { + got, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", name)) + if err != nil || string(got) != want { + t.Fatalf("publication did not stage the selected administration artifact %s", name) + } + } + }) } }) } From 65796c2cca2aed09ead8ff834a100b14b8201c00 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 18:05:12 +0100 Subject: [PATCH 2/2] fix(email): allow the staged Stalwart image to run without capabilities --- .../workflows/publish-private-mail-bootstrap.yaml | 14 ++++++++++++-- scripts/private-mail-config/publication_test.go | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-private-mail-bootstrap.yaml b/.github/workflows/publish-private-mail-bootstrap.yaml index cecb2dc639..5630bbb723 100644 --- a/.github/workflows/publish-private-mail-bootstrap.yaml +++ b/.github/workflows/publish-private-mail-bootstrap.yaml @@ -70,7 +70,9 @@ jobs: mkdir -p "${build_dir}" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \ -o "${build_dir}/private-mail-bootstrap" ./scripts/private-mail-config - awk -v server="${STALWART_SERVER_PIN}" -v cli="${STALWART_CLI_PIN}" ' + # Platform listeners bind unprivileged ports. Linux refuses to exec + # the candidate with its upstream effective file capability and no capabilities. + awk -v runtime="${STALWART_RUNTIME}" -v server="${STALWART_SERVER_PIN}" -v cli="${STALWART_CLI_PIN}" ' /^FROM ghcr\.io\/stalwartlabs\/stalwart:[^ ]+$/ { print "FROM ghcr.io/stalwartlabs/stalwart:" server server_count++ @@ -81,8 +83,16 @@ jobs: cli_count++ next } + /^USER 0$/ && runtime == "1.0.3" && server_count == 1 { + print + print "RUN setcap -r /usr/local/bin/stalwart && capability=\"$(getcap /usr/local/bin/stalwart)\" && test -z \"$capability\"" + capability_count++ + next + } { print } - END { if (server_count != 1 || cli_count != 1) exit 1 } + END { + if (server_count != 1 || cli_count != 1 || (runtime == "1.0.3" && capability_count != 1)) exit 1 + } ' "${dockerfile}" > "${build_dir}/Dockerfile" if [[ "${STALWART_RUNTIME}" == '0.16.27' ]]; then cmp -s "${dockerfile}" "${build_dir}/Dockerfile" diff --git a/scripts/private-mail-config/publication_test.go b/scripts/private-mail-config/publication_test.go index e92937933d..005a5e0946 100644 --- a/scripts/private-mail-config/publication_test.go +++ b/scripts/private-mail-config/publication_test.go @@ -156,6 +156,10 @@ func TestBothPublicationPathsStageTheAdministrationArtifacts(t *testing.T) { !strings.Contains(string(buildRecipe), "FROM ghcr.io/stalwartlabs/cli:"+profile.CLI+" AS cli\n") { t.Fatal("staged recipe does not select the required server and CLI") } + stripsServerCapability := strings.Contains(string(buildRecipe), "\nUSER 0\nRUN setcap -r /usr/local/bin/stalwart") + if stripsServerCapability != (runtime.version == "1.0.3") { + t.Fatal("only the candidate recipe must remove Stalwart's privileged-port file capability as root") + } for name, want := range map[string]string{"webui.zip": runtime.asset, "webui.LICENSE": "fixture license\n"} { got, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", name)) if err != nil || string(got) != want {