Deploy ihasmail's newest release, recorded by its dated tag

The ihasmail default was a pin that went stale within days, and ihasmail
keeps ten releases' images, so an old default would in time stop pulling.
With no --ihasmail-image the tool now pulls :latest before asking, reads the
version the image carries, confirms the dated tag is the same image, and
writes that tag into compose.yaml (or the digest, if there is no such tag).
Stalwart and Caddy stay pinned. A weekly end-to-end run against the newest
release, three hours after ihasmail publishes, is what keeps it safe.
This commit is contained in:
2026-09-15 12:00:20 -07:00
parent c151df45d0
commit 408fc20d7b
10 changed files with 276 additions and 18 deletions
+37 -6
View File
@@ -22,16 +22,47 @@ import (
"strings"
)
// Versions this release was tested with, end to end. Stalwart is pinned
// because ihasmail validates against one Stalwart release at a time; the
// ihasmail tag is the newest release at the time; Caddy is pinned so that a
// redeploy months from now renders the same proxy.
// Stalwart is pinned to the release this version of the tool was tested with,
// because a Stalwart upgrade migrates its store with no way back and ihasmail
// validates against one Stalwart release at a time. Caddy is pinned so that a
// deploy months from now renders the same proxy.
//
// ihasmail is not pinned here. A pin went stale within days of each release,
// and ihasmail's image cleanup keeps ten releases, so an old default would in
// time stop pulling at all. The default is the newest release instead, looked
// up when the tool runs and written into compose.yaml as its dated tag -- see
// deploy.ResolveIhasmail -- so what a deployment runs is still recorded and
// nothing moves it afterwards.
const (
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
DefaultCaddyImage = "caddy:2.11.4"
IhasmailRepository = "ghcr.io/coffey-labs/ihasmail"
// NewestIhasmail is the default --ihasmail-image. Only full releases move
// this tag; prereleases never do.
NewestIhasmail = IhasmailRepository + ":latest"
)
// ihasmail's versions are the date of a commit and where it came from --
// 2026.9.13+pr344, or 2026.9.13+g1fa6578 for a commit that arrived without a
// pull request -- and its image tags are the same with the "+" as "-", since a
// Docker tag may not contain "+".
var ihasmailVersionRE = regexp.MustCompile(`^\d{4}\.\d{1,2}\.\d{1,2}\+(?:pr\d+|g[0-9a-f]{7,40})$`)
// IhasmailTag is the image tag an ihasmail version is published under. A
// version that is not a release's -- empty, or the 0.0.0 of a build nobody
// gave a version to -- has none.
func IhasmailTag(version string) (string, bool) {
if !ihasmailVersionRE.MatchString(version) {
return "", false
}
return strings.Replace(version, "+", "-", 1), true
}
// FollowsNewestIhasmail reports whether the plan still asks for the newest
// ihasmail release rather than a particular image.
func (p Plan) FollowsNewestIhasmail() bool { return p.IhasmailImage == NewestIhasmail }
// Stalwart's ACME order covers these next to the mail host, all under the mail
// domain: it is what its own DNS zone points at the mail host as CNAMEs, and
// Caddy has to answer for every one of them on port 80 or the order fails.
@@ -225,7 +256,7 @@ func (o Options) Validate() (Plan, error) {
}
p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage)
p.IhasmailImage = orDefault(o.IhasmailImage, NewestIhasmail)
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage)
for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
if !imageRE.MatchString(img) {
+38
View File
@@ -98,3 +98,41 @@ func TestEveryProblemAtOnce(t *testing.T) {
t.Errorf("got %d errors, want 3: %v", len(lines), err)
}
}
func TestIhasmailFollowsTheNewestReleaseUnlessNamed(t *testing.T) {
p, err := Options{Domain: "example.com"}.Validate()
if err != nil {
t.Fatal(err)
}
if !p.FollowsNewestIhasmail() || p.IhasmailImage != "ghcr.io/coffey-labs/ihasmail:latest" {
t.Errorf("default ihasmail image %q", p.IhasmailImage)
}
named, err := Options{Domain: "example.com", IhasmailImage: "ghcr.io/coffey-labs/ihasmail:2026.9.13-pr344"}.Validate()
if err != nil {
t.Fatal(err)
}
if named.FollowsNewestIhasmail() {
t.Error("a named image is treated as the newest release")
}
byDigest := "ghcr.io/coffey-labs/ihasmail@sha256:" + strings.Repeat("a", 64)
if _, err := (Options{Domain: "example.com", IhasmailImage: byDigest}).Validate(); err != nil {
t.Errorf("a by-digest image is refused: %v", err)
}
}
func TestIhasmailTag(t *testing.T) {
for version, want := range map[string]string{
"2026.9.13+pr344": "2026.9.13-pr344",
"2026.10.2+g1fa6578": "2026.10.2-g1fa6578",
"0.0.0": "",
"": "",
"2026.9.13": "",
"2026.9.13+pr344\n": "",
"latest": "",
} {
got, ok := IhasmailTag(version)
if got != want || ok != (want != "") {
t.Errorf("IhasmailTag(%q) = %q, %v; want %q", version, got, ok, want)
}
}
}
+44
View File
@@ -84,6 +84,50 @@ func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string,
return warnings, errors.Join(problems...)
}
// ResolveIhasmail turns "the newest ihasmail release" into the image that is
// the newest release right now, so compose.yaml records a version rather than
// a tag that moves. A plan that names its own image is returned as it is.
//
// The dated tag is taken from the version the image itself carries, and used
// only once the registry confirms that tag is the very same image; otherwise
// the image is pinned by digest, which is exact but says less to a person
// reading compose.yaml. Either way a later `docker compose pull` cannot move
// the deployment onto a release nobody chose.
func ResolveIhasmail(ctx context.Context, p config.Plan, log Log) (config.Plan, error) {
if !p.FollowsNewestIhasmail() {
return p, nil
}
log.Step("finding ihasmail's newest release")
if err := docker.Pull(ctx, config.NewestIhasmail); err != nil {
return p, fmt.Errorf("could not fetch ihasmail's newest release (%w); name an image with --ihasmail-image to use another", err)
}
newest, err := docker.ImageID(ctx, config.NewestIhasmail)
if err != nil {
return p, err
}
version, err := docker.ImageEnv(ctx, config.NewestIhasmail, "IHASMAIL_VERSION")
if err != nil {
return p, err
}
if tag, ok := config.IhasmailTag(version); ok {
dated := config.IhasmailRepository + ":" + tag
if docker.Pull(ctx, dated) == nil {
if id, err := docker.ImageID(ctx, dated); err == nil && id == newest {
p.IhasmailImage = dated
log.Info("ihasmail %s, recorded as %s", version, dated)
return p, nil
}
}
}
digest, err := docker.RepoDigest(ctx, config.NewestIhasmail, config.IhasmailRepository)
if err != nil {
return p, err
}
log.Warn("ihasmail's newest release (version %q) has no matching dated tag; recording it by digest", version)
p.IhasmailImage = digest
return p, nil
}
// portFree tries to bind an address. A permission error means an unprivileged
// user asking about a low port, which says nothing about whether Docker can
// have it, so it is not reported.
+48
View File
@@ -33,6 +33,54 @@ func Output(ctx context.Context, args ...string) (string, error) {
return strings.TrimSpace(stdout.String()), nil
}
// Pull pulls one image. Quiet, because it runs before the plan is confirmed
// and the step already says what it is fetching.
func Pull(ctx context.Context, image string) error {
_, err := Output(ctx, "pull", "--quiet", image)
return err
}
// ImageID is the local ID of an image, which is the same for two references
// only when they are the same image.
func ImageID(ctx context.Context, image string) (string, error) {
return Output(ctx, "image", "inspect", "--format", "{{.Id}}", image)
}
// ImageEnv is the value an image's configuration gives an environment
// variable, or "" when it sets none.
func ImageEnv(ctx context.Context, image, name string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
return envValue(out, name), nil
}
func envValue(env, name string) string {
for _, line := range strings.Split(env, "\n") {
if v, ok := strings.CutPrefix(line, name+"="); ok {
return v
}
}
return ""
}
// RepoDigest is an image's by-digest reference in one repository, e.g.
// ghcr.io/coffey-labs/ihasmail@sha256:..., which names exactly that image for
// as long as the registry keeps it.
func RepoDigest(ctx context.Context, image, repository string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
for _, d := range strings.Fields(out) {
if strings.HasPrefix(d, repository+"@") {
return d, nil
}
}
return "", fmt.Errorf("%s has no digest from %s", image, repository)
}
// Versions returns the engine and compose versions, which is also the check
// that both are installed and this user may use them.
func Versions(ctx context.Context) (engine, compose string, err error) {
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package docker
import "testing"
func TestEnvValue(t *testing.T) {
env := "PATH=/usr/local/bin:/usr/bin\nNODE_ENV=production\nIHASMAIL_VERSION=2026.9.13+pr344\nEMPTY=\n"
for name, want := range map[string]string{
"IHASMAIL_VERSION": "2026.9.13+pr344",
"NODE_ENV": "production",
"EMPTY": "",
"MISSING": "",
"IHASMAIL": "", // a prefix of a name is not the name
} {
if got := envValue(env, name); got != want {
t.Errorf("envValue(%q) = %q, want %q", name, got, want)
}
}
}
@@ -31,6 +31,8 @@ services:
ipv4_address: {{.Plan.StalwartIP}}
ihasmail:
# A fixed release: `docker compose pull` never moves it. To upgrade, change
# the tag here, then `docker compose pull && docker compose up -d`.
image: {{.Plan.IhasmailImage}}
restart: unless-stopped
depends_on: [stalwart]