diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..05ef77d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +# Publish a release when a version tag is pushed: test, check for known +# vulnerabilities, build the Linux binaries, attach them with their checksums. +# +# Tags are the date of the commit, as ihasmail's are: v2026.9.15, and +# v2026.9.15.1 for a second release on the same day. +name: Release + +on: + push: + tags: ["v*"] + # For a tag whose run never started. GitHub has queued and then orphaned + # runs before, and a pushed tag has no other way to trigger this again. + workflow_dispatch: + inputs: + tag: + description: "Existing tag to release, e.g. v2026.9.15" + required: true + type: string + +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Check the tag names a commit on main + run: | + set -euo pipefail + git fetch --quiet origin main + git merge-base --is-ancestor "$(git rev-parse "$TAG^{commit}")" origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + + - name: Vet and test + run: | + go vet ./... + go test ./... + + - name: Known vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + - name: Build + run: | + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist + + - name: Publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d14b298..20c4777 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ go build ./... go test ./... ``` -Requires Go 1.26 or newer. The tool is Go standard library only — no external +Requires Go 1.26.8 or newer. The tool is Go standard library only — no external dependencies — and nothing third-party is vendored. To try a command from a checkout without installing it: @@ -22,6 +22,25 @@ sudo go run ./cmd/stalwart-migrate preflight [docs/rehearsal.md](docs/rehearsal.md) explains why even read-only commands need write access to the checkpoint directory. +## Releases + +Releases are tagged by date, like ihasmail's: `v2026.9.15`, with `.1`, `.2` +added for another release the same day. Binaries for `linux/amd64` and +`linux/arm64` and a `SHA256SUMS` file are attached to every +[release](https://github.com/Coffey-Labs/stalwart-migrator/releases). + +Every release is built by the [release workflow](.github/workflows/release.yml) +from a tagged commit on `main`, after the tests and a known-vulnerabilities +check pass. The archives are reproducible: `scripts/build-release.sh` builds the +same bytes from the same commit, so a release can be checked before tagging: + +```sh +scripts/build-release.sh v2026.9.15 dist +``` + +If a pushed tag's run never starts, run the workflow by hand from the Actions +tab and give it the tag. + ## Why not a shell script Stalwart's 0.15 → 0.16 boundary is not a drop-in binary swap: settings move, diff --git a/README.md b/README.md index 70e3611..886cbd5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # stalwart-migrator +[![Latest release](https://img.shields.io/github/v/release/Coffey-Labs/stalwart-migrator?sort=date)](https://github.com/Coffey-Labs/stalwart-migrator/releases/latest) [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](LICENSE) [![Docs: docs.ihasmail.org](https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9)](https://docs.ihasmail.org/install/stalwart-migrator/) @@ -30,17 +31,17 @@ it needs. | 🛟 **[Recovery](docs/recovery.md)** | Why recovery is your snapshot, what the tool keeps, never booting recovery mode again | | 📊 **[Status](docs/status.md)** | Command and package state, validation, field reports | | ⚙️ **[Architecture](ARCHITECTURE.md)** | The design: phases, checkpoints, and the reasoning behind them | -| 🔧 **[Contributing](CONTRIBUTING.md)** | Building, testing, and where the design is written down | +| 🔧 **[Contributing](CONTRIBUTING.md)** | Building, testing, releases, and where the design is written down | ## Requirements -- Stalwart **0.15.5**, as a systemd service or a single Docker container +- Linux (amd64 or arm64) with Stalwart **0.15.5**, as a systemd service or a + single Docker container - root on the mail server - `python3`, for Stalwart's own `migrate_v016.py` - `stalwart-cli` **1.0.2 or later**, a separate download from the server - an administrator account **in Stalwart's directory** — not the `fallback-admin` from `config.toml`, which does not survive the migration -- Go **1.26 or newer**, to build ## Fix these on the server first @@ -55,14 +56,21 @@ Both stop a migration, and `preflight` refuses on both: Details: [Known Stalwart problems](docs/known-stalwart-problems.md). -## Build +## Install ```sh -git clone https://github.com/Coffey-Labs/stalwart-migrator.git -cd stalwart-migrator -go build -o stalwart-migrate ./cmd/stalwart-migrate +ARCH=amd64 # or arm64 +curl -fsSLO https://github.com/Coffey-Labs/stalwart-migrator/releases/latest/download/stalwart-migrate-linux-$ARCH.tar.gz +curl -fsSLO https://github.com/Coffey-Labs/stalwart-migrator/releases/latest/download/SHA256SUMS +sha256sum --ignore-missing -c SHA256SUMS +tar -xzf stalwart-migrate-linux-$ARCH.tar.gz +sudo install -m 0755 stalwart-migrate /usr/local/bin/ +stalwart-migrate version ``` +Or build from source with Go 1.26.8 or newer: +`go build -o stalwart-migrate ./cmd/stalwart-migrate`. + ## Use Give the admin password with `--admin-password` or @@ -70,20 +78,20 @@ Give the admin password with `--admin-password` or ```sh # 1. Read-only checks and a migration plan -sudo ./stalwart-migrate preflight --admin-url https://mail.example.com --admin-user admin@example.com +sudo stalwart-migrate preflight --admin-url https://mail.example.com --admin-user admin@example.com # 2. Read-only: convert your settings and report what won't carry over -sudo ./stalwart-migrate rehearse --admin-url https://mail.example.com --admin-user admin@example.com +sudo stalwart-migrate rehearse --admin-url https://mail.example.com --admin-user admin@example.com # 3. Rehearse the real migration on a clone of the server (strongly recommended) # 4. Migrate, once you have a snapshot you have checked you can restore -sudo ./stalwart-migrate run --admin-url https://mail.example.com --admin-user admin@example.com \ +sudo stalwart-migrate run --admin-url https://mail.example.com --admin-user admin@example.com \ --recovery-point-confirmed --yes # Afterwards -sudo ./stalwart-migrate status # which steps completed -sudo ./stalwart-migrate report # what validation found +sudo stalwart-migrate status # which steps completed +sudo stalwart-migrate report # what validation found ``` A Docker container also needs `--container-path-unproven` and `--target-image`; diff --git a/cmd/stalwart-migrate/main.go b/cmd/stalwart-migrate/main.go index 0adf713..4481ed8 100644 --- a/cmd/stalwart-migrate/main.go +++ b/cmd/stalwart-migrate/main.go @@ -13,6 +13,9 @@ import ( "os" ) +// version is set at build time: -ldflags "-X main.version=...". +var version = "dev" + func main() { if len(os.Args) < 2 { usage() @@ -33,6 +36,8 @@ func main() { err = runStatus(os.Args[2:]) case "report": err = runReport(os.Args[2:]) + case "version", "--version": + fmt.Println("stalwart-migrate", version) default: usage() os.Exit(1) @@ -53,5 +58,6 @@ commands: run perform the migration (needs --yes and --recovery-point-confirmed) tenants show which tenant owns which domain, and what blocks a migration (read-only) status show the state of an in-progress or completed run - report print the validation report for a run`) + report print the validation report for a run + version print the version of this binary`) } diff --git a/go.mod b/go.mod index 5804fca..2d6fdac 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Coffey-Labs/stalwart-migrator -go 1.26.5 +go 1.26.8 diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..e9c0cc8 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Build the release archives: one per architecture, plus SHA256SUMS. +# +# Usage: scripts/build-release.sh VERSION [OUTDIR] +# scripts/build-release.sh v2026.9.15 dist +# +# The release workflow runs exactly this, so a release can be reproduced -- or +# checked before tagging -- on any machine with Go. Archive names carry no +# version, so .../releases/latest/download/ always means the newest. +set -euo pipefail + +VERSION="${1:?usage: $0 VERSION [OUTDIR]}" +OUT="${2:-dist}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +rm -rf "$OUT" && mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +# Linux only: the tool runs as root on the mail server it upgrades, which is a +# systemd service or a Docker container there. +for arch in amd64 arm64; do + name="stalwart-migrate-linux-$arch" + mkdir -p "$STAGE/$name" + echo "==> building $name ($VERSION)" + (cd "$ROOT" && CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath \ + -ldflags "-s -w -X main.version=$VERSION" -o "$STAGE/$name/stalwart-migrate" ./cmd/stalwart-migrate) + cp "$ROOT/LICENSE" "$ROOT/README.md" "$STAGE/$name/" + # Fixed owner and time, so the same commit gives the same archive. + tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH:-0}" \ + -C "$STAGE/$name" -czf "$OUT/$name.tar.gz" stalwart-migrate LICENSE README.md +done + +(cd "$OUT" && sha256sum ./*.tar.gz | sed 's| \./| |' > SHA256SUMS) +echo "==> $OUT:" +(cd "$OUT" && cat SHA256SUMS)