Compare commits

8 Commits

Author SHA1 Message Date
mix 2f888dc7be ci: drop per-arch GHCR tags via GitHub Packages API
test / test (push) Has been cancelled
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-15 22:57:34 +03:00
mix 5598b62e4e release: 1.3.0
test / test (push) Has been cancelled
Close Unreleased for security, tests, docs, and release CI; pin compose and docs to 1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:51:29 +03:00
mix 32f5085c48 docs: record release CI workflow changes in [Unreleased]
The three post-1.2.5 CI commits that tie image publish to a published GitHub
Release and trim per-arch GHCR tags were missing from the changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:47:09 +03:00
mix 5601f73622 ci: consolidate release workflow and trim GHCR tags
test / test (push) Has been cancelled
Run release.yml directly on release:published instead of a dispatcher
workflow. Remove per-arch tags from GHCR after the manifest merge so only
X.Y.Z remains visible to operators.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:44:33 +03:00
mix e17b1680bd ci: dispatch release build from release-on-publish workflow
test / test (push) Has been cancelled
GitHub recorded release published at 16:44:21Z but no release.yml run
started. A thin release-on-publish workflow listens for publish and starts
release.yml via workflow_dispatch (always creates a run). release.yml now
checks out vX.Y.Z from the version input, not main HEAD.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:48:20 +03:00
mix 5b63da0e49 ci: trigger release workflow on published GitHub Release
test / test (push) Has been cancelled
Bare git tag pushes no longer start the image build; publishing a GitHub
Release does. workflow_dispatch still requires an explicit X.Y.Z version.
Docs explain draft vs published releases and that deleting a tag on GitHub
converts a published release back to draft.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:43:06 +03:00
mix e9aaed1c7b docs: reconcile [Unreleased] with full git history since 1.2.5
test / test (push) Has been cancelled
Expand the doc-alignment entry to cover guide RBAC and restore Resync
wording; trim the P2 entry of claims superseded by that pass while keeping
its /license route-table addition with a cross-reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:40:46 +03:00
mix 1bd71c10d0 docs: align operator docs with routes, sessions, and restore
test / test (push) Has been cancelled
Fix architecture route RBAC, restore Resync, and session/password wording;
correct guide restore/session/PTR claims and README port-587 text; point
the CSRF ADR at authz.go for global route gating.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:36:24 +03:00
8 changed files with 222 additions and 92 deletions
+48 -18
View File
@@ -1,13 +1,9 @@
name: release name: release
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
# Ordinary commits do not publish anything. A pushed tag matching vX.Y.Z does, # Ordinary commits and bare git tag pushes do not publish anything. A published
# and so does workflow_dispatch when it supplies a SemVer X.Y.Z version (or # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# runs on such a tag). A dispatch from main without that input must fail in # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# prepare — it must not publish ghcr.io/...:main. The version is the single
# source that goes into both the image tag and the panel binary's -ldflags
# version, so the two can never drift apart (the invariant restore's version
# check in spec 7.5.A depends on).
# #
# Native per-architecture builds (see docs/development.md), not qemu: # Native per-architecture builds (see docs/development.md), not qemu:
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate # running the full Postfix/OpenDKIM stack under emulation for the e2e gate
@@ -15,18 +11,17 @@ name: release
# tag on its own native runner; a merge job then combines them into the one # tag on its own native runner; a merge job then combines them into the one
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not # manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
# push-by-digest then test) is deliberate: it means the bytes that get tagged # push-by-digest then test) is deliberate: it means the bytes that get tagged
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering # are exactly the bytes that passed e2e. Per-arch tags are pushed only so
# in the registry as a side effect (harmless — the version tag's immutability, # imagetools can assemble the multi-arch manifest; merge removes them from GHCR
# spec 10.1, is about that tag, not these). # so operators see a single version tag (spec 10.1).
on: on:
push: release:
tags: types: [published]
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Image version as X.Y.Z (no v prefix). Required unless this run is already on a vX.Y.Z tag." description: "Image version as X.Y.Z (no v prefix)."
required: false required: true
type: string type: string
permissions: permissions:
@@ -43,15 +38,18 @@ jobs:
id: version id: version
env: env:
INPUT_VERSION: ${{ github.event.inputs.version }} INPUT_VERSION: ${{ github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
EVENT_NAME: ${{ github.event_name }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ "$EVENT_NAME" = "release" ]; then
raw="${RELEASE_TAG:-}"
else
raw="${INPUT_VERSION:-}" raw="${INPUT_VERSION:-}"
if [ -z "$raw" ] && [[ "${GITHUB_REF_NAME}" == v[0-9]*.[0-9]*.[0-9]* ]]; then
raw="${GITHUB_REF_NAME#v}"
fi fi
raw="${raw#v}" raw="${raw#v}"
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::release version is not SemVer X.Y.Z (ref is ${GITHUB_REF_NAME}; pass inputs.version on workflow_dispatch). Refusing to publish ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}" echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
exit 1 exit 1
fi fi
echo "version=${raw}" >> "$GITHUB_OUTPUT" echo "version=${raw}" >> "$GITHUB_OUTPUT"
@@ -69,6 +67,8 @@ jobs:
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
@@ -133,6 +133,10 @@ jobs:
needs: [prepare, build] needs: [prepare, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io - name: Log in to ghcr.io
@@ -172,3 +176,29 @@ jobs:
attempt=$((attempt + 1)) attempt=$((attempt + 1))
backoff=$((backoff * 2)) backoff=$((backoff * 2))
done done
- name: Remove per-arch tags from GHCR
# Side-effect tags for imagetools assembly only — not part of the public
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="${{ needs.prepare.outputs.version }}"
owner="${{ github.repository_owner }}"
pkg="${{ github.event.repository.name }}"
api="/users/${owner}/packages/container/${pkg}/versions"
for suffix in amd64 arm64; do
tag="${version}-${suffix}"
mapfile -t ids < <(gh api "$api" --paginate \
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
if [ "${#ids[@]}" -eq 0 ]; then
echo "no GHCR package version for tag ${tag}"
continue
fi
for id in "${ids[@]}"; do
echo "deleting GHCR package version ${id} (tag ${tag})"
gh api -X DELETE "${api}/${id}"
done
done
+46 -10
View File
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.3.0] - 2026-08-14
Security and quality after 1.2.5: domain-admin send-log authorization,
fail-closed sign-in and application delete, level-2 rate-limit race fix,
restore Resync, expanded tests, operator docs, release CI, and OFL for IBM
Plex. Upgrading from 1.2.x is a tag bump; no migration.
### Added ### Added
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
@@ -82,8 +89,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the same Resync on demand. The `internal/backup` package comment now matches the same Resync on demand. The `internal/backup` package comment now matches
this behaviour. this behaviour.
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The merge job had called
`docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed
with a warning and the side-effect tags stayed in the registry.
### Changed ### Changed
- docs: operator and as-built docs aligned with the code after a full
pass — [architecture.md](docs/architecture.md) route table now marks
**global** routes (404 for domain administrators) and documents the
one-time restore Resync in Persistence; session/password and restore-session
wording corrected in [guide.md](docs/guide.md) and architecture (own-password
change vs admin reset, no "logout everywhere", immediate session restore on
the next request, PTR cache ≈1 min, decrypt has no version check, restore
Resync on first boot, domain add/delete/import and `POST /reload` global-only,
Settings DMARC global-only); [README.md](README.md) port-587 and quick-start
volume wording fixed; [security.md](docs/security.md) CSRF ADR points at
`authz.go` for route gating. No behaviour change.
- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
administration**, and **Domain administration** — DNS setup, operations, administration**, and **Domain administration** — DNS setup, operations,
rate limiting, and backup sections follow the instance/domain boundary rate limiting, and backup sections follow the instance/domain boundary
@@ -109,14 +133,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
`internal/legal/legal.go` (the value the panel footer actually injects), not `internal/legal/legal.go` (the value the panel footer actually injects), not
`layout.html`. `release.yml` refuses a `workflow_dispatch` whose version is `layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
not SemVer `X.Y.Z`, so a run from `main` cannot publish `ghcr.io/...:main`. packages were dropped so the tree is consistent; AGPL-3.0 does not require
Per-file `SPDX-License-Identifier` headers on the two command packages were them ([development.md](docs/development.md) § External libraries). Deleted the
dropped so the tree is consistent; AGPL-3.0 does not require them
([development.md](docs/development.md) § External libraries). Deleted the
completed `docs/plans/logrotate-mode.md` (history in git and completed `docs/plans/logrotate-mode.md` (history in git and
[1.2.3](#123---2026-08-12)). [1.2.3](#123---2026-08-12)).
- ci: the release image is published only for a **published** GitHub Release
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
bare git tag push no longer starts the build. `release.yml` listens for
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest,
then removes the per-arch tags from GHCR via the GitHub Packages API so
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what
`deploy/docker-compose.yml` pins). A dispatch whose version input is missing
or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md)
documents draft vs published releases, why deleting a release tag converts
it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release
tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags`
workflow).
- test: the authorization and sign-in surfaces that had no tests now have them. - test: the authorization and sign-in surfaces that had no tests now have them.
The login limiter is covered for its ceiling, its per-address scope, the reset The login limiter is covered for its ceiling, its per-address scope, the reset
at the end of a window and the sweep that keeps finished buckets out of at the end of a window and the sweep that keeps finished buckets out of
@@ -178,11 +214,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
check defends against, and gives a new revisit trigger. Dropped the check defends against, and gives a new revisit trigger. Dropped the
unimplemented "or argon2" alternative for the password hash. unimplemented "or argon2" alternative for the password hash.
[guide.md](docs/guide.md) documents the Users page and the two roles, [guide.md](docs/guide.md) documents the Users page and the two roles,
the Settings page's default DMARC report address, level-2 rate limiting's level-2 rate limiting's fail-open behaviour, and that a domain-admin can
fail-open behaviour, that restoring an older backup can resurrect sessions, export working SASL passwords for domains assigned to them.
and that a domain-admin can export working SASL passwords for domains [architecture.md](docs/architecture.md) gains `/license` and the
assigned to them. [architecture.md](docs/architecture.md)'s route table now `/account``/settings` redirect in the route table (later expanded for
lists `/license` and the `/account``/settings` redirect. Corrected stale RBAC in the doc-alignment pass above). Corrected stale
`admin.dmarc_report_email` references in `admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and [roadmap.md](docs/roadmap.md) and
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's [docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
+5 -5
View File
@@ -26,7 +26,7 @@ send log and DNS checks in the panel, encrypted backups.
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Full-server backup and single-domain export/import (optional password encryption) - Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; data in a `./data` bind mount - Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
## Documentation ## Documentation
@@ -96,7 +96,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.2.5 ghcr.io/mixeme/selfpost:1.3.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
@@ -136,9 +136,9 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file maps **465** (always) and **587** (when The compose file always publishes **465** and **587**; Postfix listens on 587
`SUBMISSION_ENABLE=true`); bump the pinned image tag deliberately when only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables). variables](docs/guide.md#environment-variables).
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.2.5 image: ghcr.io/mixeme/selfpost:1.3.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+29 -19
View File
@@ -154,7 +154,10 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. The table below is a summary — HTMX fragment endpoints unless noted. Routes marked **global** return **404** for domain administrators
(`requireGlobal()` in
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`, (`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`. `/system-log/body`, …) and every POST variant live in `web.go`.
@@ -165,17 +168,17 @@ unless noted. The table below is a summary — HTMX fragment endpoints
| `/setup/*` | One-time admin bootstrap | | `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth | | `/login`, `/logout` | Session auth |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | | `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/import` | Domain import (`POST`; form on the Backup page) | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/deliveries` | Send log with filters | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue` | Postfix queue view | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/system-log` | `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Admin username/password and DMARC report address | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | Panel user CRUD (global admin only) | | `/users`, `/users/*` | **Global.** Panel user CRUD |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
@@ -194,13 +197,16 @@ holds the cookie works after process restart, redeploy, or full backup restore.
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change** — all other sessions are deleted; the current session stays - **Password change on `/settings`** — changing your own password deletes
active ([internal/store/sessions.go](../internal/store/sessions.go), every other session for that user; the current session stays active
([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)). [handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
Restoring an **older** backup also restores session rows: a session invalidated Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still has the after that backup was taken can become valid again if the browser still holds
cookie and idle timeout has not expired. the cookie and the restored row's `expires_at` has not passed.
--- ---
@@ -295,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of **Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on `/data` tree, minus `log/`, the setup token and any `tls/`; version check on
restore. Stopped-container `tar` of `./data` is safe (see guide). restore. On the first successful boot after restore, the panel runs one
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, so drift between the extracted archive
and the database is healed before mail flows (same step as `POST /reload` on
demand). Stopped-container `tar` of `./data` is safe (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password → ([internal/secretfile](../internal/secretfile/secretfile.go)): password →
+56 -14
View File
@@ -191,11 +191,12 @@ tag / push only on explicit request (see `release.yml`).
### Release image ### Release image
The release image is published **only** for a SemVer version `X.Y.Z`: a pushed The release image is published **only** for a SemVer version `X.Y.Z`: a
tag `vX.Y.Z`, or a `workflow_dispatch` that supplies that version (or runs on **published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
such a tag). Ordinary commits, and a dispatch from `main` without a version that supplies that version. Pushing a git tag alone does not publish. Ordinary
input, do not publish. The version is the single source that drives the image commits, and a dispatch from `main` without a version input, do not publish.
tag and `-ldflags` in the binaries so they cannot drift apart. The version is the single source that drives the image tag and `-ldflags` in
the binaries so they cannot drift apart.
**Steps (on explicit request):** **Steps (on explicit request):**
@@ -203,8 +204,42 @@ tag and `-ldflags` in the binaries so they cannot drift apart.
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
local-trial image references) in the **same** release commit. local-trial image references) in the **same** release commit.
2. Create and push git tag `vX.Y.Z` on that commit. 2. Create and push git tag `vX.Y.Z` on that commit.
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, 3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`. 4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
page lists only **published** releases. A draft is visible to maintainers only —
it looks like “no releases” to everyone else. CI does not create or publish the
GitHub Release; you do that in the UI. Deleting a releases git tag on GitHub
(or re-pushing tags while cleaning the registry) converts a published release
back into a **draft** — that matches “I published three times and it keeps
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
GHCR package versions, not the git tag.
Push workflow and source changes to **github.com/mixeme/selfpost** before
publishing — Actions reads that repo, not Gitea.
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
GitHub, two things follow:
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
prune remote tags that are absent on Gitea (or re-push with `--force` /
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
Release back to draft. Mirror **branches and new tags forward**; do not
delete release tags on the GitHub side. GHCR cleanup is package versions in
the UI — not `git push github --delete` and not tag prune on the mirror.
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
still points at a commit whose `release.yml` has `on: push: tags` and no
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
`release.yml` on **Publish release** (`release: published`), so mirroring
those tags alone does not start the image build.
Safe mirror: push tags to GitHub without deleting existing ones; keep release
tags on Gitea; publish the GitHub Release on github.com after the mirror has
the tag.
Ordinary commits **do not** publish an image. The compose pin and the git tag Ordinary commits **do not** publish an image. The compose pin and the git tag
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
@@ -290,26 +325,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
`gofmt -l``go vet ./...``go test ./...` (main module, no e2e). `gofmt -l``go vet ./...``go test ./...` (main module, no e2e).
### `release.yml` — push of tag `vX.Y.Z`, or `workflow_dispatch` with SemVer ### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
`prepare` takes the version from the tag (`v1.2.5``1.2.5`) or from the Publishing a GitHub Release runs `release.yml` directly (`release: published`,
`workflow_dispatch` `version` input. A dispatch whose ref is not a `vX.Y.Z` same pattern as gosentry / imap-scrub). You can also run it manually via
tag and whose input is missing or not `X.Y.Z` fails in `prepare` — it must `workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
not publish `ghcr.io/...:main`. run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
`prepare` takes the version from `github.event.release.tag_name` on a release
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
is missing or not `X.Y.Z` fails in `prepare`.
``` ```
prepare (version from tag or workflow_dispatch input) release: published
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm] → build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from prepare) → docker build --load (VERSION from prepare)
→ e2e (test/e2e) → e2e (test/e2e)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64 → push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge → merge
→ docker buildx imagetools create → unified manifest X.Y.Z → docker buildx imagetools create → unified manifest X.Y.Z
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
``` ```
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
emulation for e2e is impractical. E2e first, then push — the registry receives emulation for e2e is impractical. E2e first, then push — the registry receives
the bytes that passed the gate. the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
tagged in GHCR; per-arch names exist briefly during the merge job.
A failed e2e **blocks** image publication. A failed e2e **blocks** image publication.
+35 -23
View File
@@ -165,7 +165,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.2.5`. Intermediate deliberately never `:latest`. The current pin is `1.3.0`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
@@ -329,9 +329,9 @@ the panel shows after manual edits under `/data`.
### Settings ### Settings
`/settings` changes the administrator username and/or password, and the `/settings` changes the signed-in user's username and/or password. **Global
panel-wide default DMARC report address (`rua=`) offered when a domain administrators** also set the panel-wide default DMARC report address (`rua=`)
doesn't set its own — see offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
are separate and are not changed here. are separate and are not changed here.
@@ -344,8 +344,9 @@ There are two roles:
including Users, Backup, Status, Mail queue, and System log. including Users, Backup, Status, Mail queue, and System log.
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; `/users`, `/backup`, `/status`, `/mail-queue`, and Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/system-log` are not reachable (404). A domain-admin can *export* the `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
reachable (404). A domain-admin can *export* the
domains assigned to them — see domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain). [Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
@@ -360,8 +361,10 @@ seven days) with no absolute lifetime cap — an admin who keeps using the panel
stays signed in indefinitely. HTMX polling on the monitoring screens stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does (Deliveries, Mail queue, System log, and the Status health fragment) does
**not** count as activity, so an auto-refreshing tab left open will not keep a **not** count as activity, so an auto-refreshing tab left open will not keep a
session alive forever. Changing the password signs out every other session but session alive forever. Changing **your own** password on `/settings` signs out
leaves the current browser signed in. every other session for that user but leaves the current browser signed in.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
### Upgrading ### Upgrading
@@ -389,8 +392,8 @@ from whoever assigns the IP (hosting provider's panel/support), not from
your own DNS zone. your own DNS zone.
The [Status](#status) page verifies the server's hostname against this The [Status](#status) page verifies the server's hostname against this
record (forward-confirmed reverse DNS). Results are cached for a few record (forward-confirmed reverse DNS). Results are cached for about one
minutes; use *Re-check* right after publishing a record. minute; use *Re-check* right after publishing a record.
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
@@ -427,7 +430,11 @@ starting a container of the **exact same image version** that created it —
SelfPost refuses to start otherwise and tells you which tag to use. On the SelfPost refuses to start otherwise and tells you which tag to use. On the
first successful start after restore, `manifest.json` from the archive is first successful start after restore, `manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is **deleted** — it guards only that one boot, so a later in-place upgrade is
not blocked. This is why the compose file pins a fixed tag rather than not blocked. On that same first boot the panel also runs one **Resync**
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both
daemons are reloaded, healing any drift between the extracted files and the
database (the Status page's *Reload configuration* button runs the same step
on demand). This is why the compose file pins a fixed tag rather than
`:latest`: without a known version, there'd be no way to tell which image `:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)). tag](#fixed-image-tag)).
@@ -459,7 +466,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.: the panel exits with a message naming the tag to use, e.g.:
``` ```
backup: this backup was created by SelfPost 1.2.3 but this image is 1.2.5 — restore into the matching image (selfpost:1.2.3) backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3)
``` ```
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
@@ -475,8 +482,8 @@ belong to the old IP/host and have to be reissued for the new one; nothing in
the backup carries them. the backup carries them.
**Restoring an encrypted (`.spbk`) backup** needs a running container to **Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — an empty first-boot container works, any matching or decrypt it first — any container with the `selfpost-backup` CLI works; decryption
newer version, since decryption doesn't touch `/data`. Start one normally does not read `/data` and performs no version check. Start one normally
(step 5, but on an empty `/data` you haven't unpacked yet), then: (step 5, but on an empty `/data` you haven't unpacked yet), then:
```sh ```sh
@@ -488,11 +495,14 @@ resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password export](#encrypting-a-backup-or-export) for the decrypt command's password
options. options.
Restoring an archive taken **before** you invalidated a session (password Restoring an archive taken **before** a session row was removed can bring
change, logout everywhere) can bring that session back: session rows travel that session back: session rows travel with the backup, and a browser that
with the backup, and a browser that still holds the matching cookie is still holds the matching cookie is signed in again on the next request if the
logged in again once the idle timeout allows it. If a restore might do this, restored row's idle expiry has not passed. `POST /logout` removes only the
changing every user's password afterwards clears it out. current session; there is no "logout everywhere". Changing your own password
on `/settings` deletes your other sessions, but a global administrator
resetting another user's password on `/users` does not invalidate that user's
existing sessions.
**Alternative: archive `./data` while stopped.** If the service can be taken **Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
@@ -559,12 +569,14 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Domains page ### Domains page
`/domains` adds sending domains, and shows each domain's DKIM TXT value, `/domains` lists sending domains and hosts the add-domain form (**global
SPF/DMARC checks, and SASL applications. Per-domain rate limits (level 2) and administrator only**). Domain administrators see only domains assigned to
trusted-IP application overrides are configured here — see [Rate limiting — them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application
overrides are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain* level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads writes a single-domain archive; *Import a domain* on the Backup page reads
one back in — see [Exporting and importing a single one back in (**global administrator only**) — see [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain). domain](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC) ### Domain-level DNS (SPF, DKIM, DMARC)
+1 -1
View File
@@ -198,7 +198,7 @@ domain-admin's browser sending a request still needs that domain-admin's own
cookie, so a token would not add a boundary between roles that the cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`, authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route [internal/web/auth/principal.go](../internal/web/auth/principal.go); route
gating in [internal/web/auth/middleware.go](../internal/web/auth/middleware.go)) gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
don't already enforce. The remaining case is an external site making a don't already enforce. The remaining case is an external site making a
logged-in user's browser send a request, which the origin check covers without logged-in user's browser send a request, which the origin check covers without
touching a single template. touching a single template.