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
+41
View File
@@ -0,0 +1,41 @@
# The end-to-end test, every week, against ihasmail's newest release.
#
# A deploy with no --ihasmail-image takes whatever ihasmail release is newest
# when it runs, rather than one this repository pinned. That is only safe if
# something notices when a new release stops working with the Stalwart and
# Caddy versions pinned here -- this is that something. It runs on Mondays
# three hours after ihasmail's weekly release (09:00 UTC), so a release that
# breaks the one shot shows up the same day, before most people deploy it.
#
# e2e/public.sh needs nothing from the internet but images: Pebble stands in for
# Let's Encrypt and a DNS stub answers every name, and it publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the runner while it runs.
name: End-to-end
on:
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:
concurrency:
group: e2e
cancel-in-progress: false
permissions:
contents: read
jobs:
public:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Vet and test
run: |
go vet ./...
go test ./...
- name: Deploy and check a public stack
run: e2e/public.sh
+25 -10
View File
@@ -76,11 +76,14 @@ In order, in one run:
and usable, every port it needs is free, the deployment directory is new or
empty, no compose project of the same name exists, and the hostnames resolve.
It reports every problem at once, not the first.
2. **Shows you the plan and asks** before going ahead. `--yes` skips the
2. **Finds ihasmail's newest release, shows you the plan and asks** before
going ahead, so what you agree to is an exact ihasmail version. `--yes` skips the
question, and is required when there is no terminal to ask on.
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
`.env` holding a freshly generated `APP_SECRET`.
4. **Pulls the images**, pinned to versions tested together.
4. **Pulls the images**: Stalwart and Caddy at the versions this release of the
tool was tested with, and the ihasmail release it found, written into
`compose.yaml` by its dated tag so nothing moves it later.
5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose
password exists only in the tool's memory.
6. **Completes Stalwart's setup** through its API, the same setup its web UI
@@ -204,10 +207,12 @@ ihasmail-oneshot deploy --local --user alice
==> a local pair for example.test, in /home/you/ihasmail-example-test
ihasmail http://127.0.0.1:8080
Stalwart http://127.0.0.1:8081 (admin UI; no mail ports published)
images stalwartlabs/stalwart:v0.16.22, ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328
images stalwartlabs/stalwart:v0.16.22, ihasmail's newest release
mailboxes alice
==> preflight
docker 29.8.0, compose 5.5.1
==> finding ihasmail's newest release
ihasmail 2026.9.13+pr344, recorded as ghcr.io/coffey-labs/ihasmail:2026.9.13-pr344
deploy this? [y/N] y
==> writing /home/you/ihasmail-example-test
==> pulling images
@@ -219,7 +224,7 @@ deploy this? [y/N] y
ihasmail (172.31.253.11) exempt from Stalwart's auto-ban
restarting Stalwart to apply them
mailbox [email protected] created
signed in to ihasmail 2026.9.10+pr328 as [email protected]: linked
signed in to ihasmail 2026.9.13+pr344 as [email protected]: linked
==> done
webmail http://127.0.0.1:8080
Stalwart http://127.0.0.1:8081/admin
@@ -416,14 +421,17 @@ The containers restart by themselves after a crash or a reboot
### Upgrading
Change the image tag in `compose.yaml` and apply it:
Nothing upgrades on its own: every image in `compose.yaml` is a fixed version,
ihasmail included. To upgrade, change the image tag there and apply it:
```bash
docker compose pull && docker compose up -d
```
- **ihasmail** is safe to move to any newer release that supports your
Stalwart version. Its release notes say which.
Stalwart version. Its release notes say which. The newest is on
[ihasmail's releases](https://github.com/Coffey-Labs/ihasmail/releases); its
image tag is the version with `+` written as `-`, e.g. `2026.9.13-pr344`.
- **Stalwart**: read its upgrade notes before changing versions. Check that the
ihasmail version you run supports the new Stalwart release first, since
ihasmail validates against one Stalwart release at a time. Back up
@@ -477,7 +485,7 @@ ihasmail-oneshot deploy --local [flags]
| `--dir` | `./PROJECT` | Deployment directory to write. Must be new or empty |
| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes |
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image |
| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | ihasmail image |
| `--ihasmail-image` | the newest release | ihasmail image. By default the tool looks up ihasmail's newest release and writes it into `compose.yaml` by its dated tag; name an image to use a particular one |
| `--caddy-image` | `caddy:2.11.4` | Caddy image |
| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port, for reaching it without Caddy |
| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port. The tool configures Stalwart through it |
@@ -855,9 +863,16 @@ The code:
## Versions and releases
Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2`
added for another release the same day. Each release pins the Stalwart,
ihasmail and Caddy images it was tested with as its defaults. A newer release
of the tool generally means newer tested versions.
added for another release the same day. Each release pins the Stalwart and
Caddy images it was tested with as its defaults. A newer release of the tool
generally means newer tested versions of those.
ihasmail is the exception: a deploy takes its newest release, so a new
ihasmail needs no new release of this tool. What keeps that safe is the
end-to-end test, which runs every Monday against ihasmail's newest release, a
few hours after ihasmail publishes it. Stalwart is never taken this way — an
upgrade migrates its data with no way back, so its version only changes in a
release of this tool.
Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are
attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases).
+9 -1
View File
@@ -101,7 +101,7 @@ Flags:
fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)")
fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)")
fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image")
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.DefaultIhasmailImage, "ihasmail image")
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.NewestIhasmail, "ihasmail image; the default is the newest release, written into compose.yaml as its dated tag")
fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image")
fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port")
fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)")
@@ -132,6 +132,10 @@ Flags:
for _, w := range warnings {
log.Warn("%s", w)
}
// Before the question, so the answer is about the exact ihasmail release.
if plan, err = deploy.ResolveIhasmail(ctx, plan, log); err != nil {
return err
}
if !yes {
if err := confirm("deploy this?"); err != nil {
return err
@@ -162,7 +166,11 @@ func describe(p config.Plan, log deploy.Log) {
log.Info("ACME Let's Encrypt, contact %s", p.Email)
}
}
if p.FollowsNewestIhasmail() {
log.Info("images %s, ihasmail's newest release", p.StalwartImage)
} else {
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
}
if !p.Local {
log.Info(" %s", p.CaddyImage)
}
+10
View File
@@ -96,6 +96,16 @@ ok "deploy completed and signed in through ihasmail"
grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate"
ok "deploy reported Stalwart's certificate"
# No --ihasmail-image, so the deploy took the newest release -- and must have
# written it down as a dated tag, never the moving :latest.
image=$(sed -n 's/^ image: \(ghcr.io\/coffey-labs\/ihasmail[:@].*\)$/\1/p' "$DEPLOY/compose.yaml")
[[ "$image" =~ ^ghcr\.io/coffey-labs/ihasmail:[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}-(pr[0-9]+|g[0-9a-f]+)$ ]] \
|| die "compose.yaml does not pin ihasmail to a dated release: ${image:-none}"
ok "compose.yaml pins ihasmail to $image"
running=$(docker compose --project-directory "$DEPLOY" ps -q ihasmail | xargs docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^IHASMAIL_VERSION=//p')
[ "${running/+/-}" = "${image##*:}" ] || die "ihasmail runs $running, but compose.yaml says ${image##*:}"
ok "the running ihasmail is the release compose.yaml names ($running)"
# expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy
# obtains certificates for its names in parallel and in the background, so the
# first handshake for any one of them can come a few seconds after deploy.
+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]