Version 27.1.0-rc.1: one workspace version, series and RC releases #223

Merged
jcoffey-dev merged 4 commits from release/version-27.1-rc.1 into main 2026-10-28 16:11:34 +00:00
52 changed files with 722 additions and 316 deletions

No files matched your search

+4
View File
@@ -56,6 +56,10 @@ jobs:
run: python3 tools/fork/expr-schema.py --check
- if: always()
run: python3 -m unittest discover -s tools/fork/tests
# The release workflows' version helper and its mapping, the same cases
# as the server's display_version macro.
- if: always()
run: python3 -m unittest discover -s tools/release/tests
build:
if: ${{ vars.BUILD_ON != 'github' }}
+32 -25
View File
@@ -13,8 +13,8 @@
# * publish-amd64 pushes :<version>-amd64 and :<version>, a plain amd64
# image, as soon as its build is done. A deploy can start from it.
# * publish-arm64 then builds arm64, pushes :<version>-arm64, and replaces
# :<version> with the two-platform index. :latest moves only here, so it
# never names an image without arm64.
# :<version> with the two-platform index. The series tags (and :latest, for a
# release) move only here, so they never name an image without arm64.
#
# Both jobs use one BuildKit builder, `gitea-builder`, whose container
# (buildx_buildkit_gitea-builder0) and state volume stay on the runner's host
@@ -27,18 +27,20 @@
# just did where it can.
#
# Two guards before anything is pushed:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be v<version>, the workspace version in Cargo.toml as tags
# name it (tools/release/version.py: 27.1.0 is v27.1, 27.1.0-rc.1 is
# v27.1-rc.1). The server reports that same version, so a tag beside an
# unbumped Cargo.toml would publish an image that says otherwise.
# * the tag must be on main or on a release/* branch, so an image never
# describes code that was never reviewed onto one of them. A release/*
# branch carries a hotfix: it starts at an earlier release tag, takes
# fixes through pull requests into it, and is tagged there, so production
# can get a fix without everything that has landed on main since.
#
# :latest moves with every published tag: tags are cut by the weekly release
# (or by hand for a real release); there are no prerelease tags here.
# Each version also moves its series' tags (tools/release/version.py
# --image-tags): a release 27.1.N moves :27.1 and :latest; a release candidate
# 27.1-rc.N moves :27.1-rc and never :latest, and its Release is a
# pre-release.
#
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry.
@@ -64,6 +66,8 @@ jobs:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
outputs:
version: ${{ steps.v.outputs.version }}
tags: ${{ steps.v.outputs.tags }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
# Full history: the ancestry check cannot be answered from a shallow
# clone. The checkout also fetches every branch as origin/*.
@@ -76,16 +80,9 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
V="$(python3 tools/release/version.py --tag "$TAG")"
tags="$(python3 tools/release/version.py --image-tags | tr '\n' ' ')"
if python3 tools/release/version.py --prerelease; then pre=true; else pre=false; fi
commit="$(git rev-parse "${TAG}^{commit}")"
on=""
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
@@ -94,7 +91,9 @@ jobs:
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
echo "$TAG is on $on"
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
echo "tags=${tags% }" >> "$GITHUB_OUTPUT"
echo "prerelease=$pre" >> "$GITHUB_OUTPUT"
echo "version $V; image tags: ${tags% }; prerelease: $pre"
publish-amd64:
if: ${{ vars.BUILD_ON != 'github' }}
@@ -150,6 +149,7 @@ jobs:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
TAGS: ${{ needs.version.outputs.tags }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
@@ -165,9 +165,9 @@ jobs:
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION-arm64" \
--push .
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
args=""
for t in $TAGS; do args="$args --tag $IMAGE:$t"; done
docker buildx imagetools create $args \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
- if: always()
@@ -222,6 +222,8 @@ jobs:
env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
TAGS: ${{ needs.version.outputs.tags }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REGISTRY: ${{ vars.REGISTRY }}
@@ -237,15 +239,20 @@ jobs:
except urllib.error.HTTPError as e:
if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`. "
also = " ".join(f"`:{t}`" for t in os.environ["TAGS"].split()[1:])
pre = os.environ["PRERELEASE"] == "true"
body = (("Release candidate for the next series, to try before it ships. "
"`:latest` stays on the current release.\n\n" if pre else "")
+ f"Container image: `{image}` (linux/amd64, linux/arm64); also {also}. "
"amd64 is published first; arm64 is added to the same tag when its build "
"finishes, and `:latest` moves then.\n\n"
"finishes, and the series tags move then.\n\n"
"Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and "
"`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this "
"release's image for that architecture, so it is the same build. The image "
"grants it `cap_net_bind_service`; a host install has to grant that itself "
"(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.")
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body,
"prerelease": pre}).encode()
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
print(f"created release {r['tag_name']}")
PY
+83
View File
@@ -0,0 +1,83 @@
# Watch spam-filter, whose rules the server bundles (resources/spam-filter/),
# and open an issue when it publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main, so it waits in the tracker until
# someone takes it in as resources/spam-filter/README.md describes.
#
# Reads metadata only: the releases list from GitHub's API and spam_rules.rs
# from Gitea's. Drafts and pre-releases are ignored. An issue is opened once
# per release: an existing one with the same title, open or closed, stops a
# second.
#
# This was upstream-watch, which also opened an issue for each new release of
# the server the fork was built on. 0.16.27 was the last import (2026-10-27),
# so that half is gone.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: spam-rules-watch
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
concurrency:
group: spam-rules-watch
cancel-in-progress: false
jobs:
spam-rules-watch:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- shell: bash
run: |
python3 - <<'PY'
import base64, json, os, re, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, url, body=None, token=os.environ["TOKEN"]):
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-spam-rules-watch"}
if token:
headers["Authorization"] = f"token {token}"
req = urllib.request.Request(url, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
def key(tag):
return tuple(int(x) for x in SEMVER.match(tag).groups())
# The spam filter rules bundled with the server (resources/spam-filter/):
# an issue when spam-filter publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main.
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
text = base64.b64decode(src["content"]).decode()
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
if not m:
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
bundled = "v" + m.group(1)
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(bundled)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
latest = newer[-1]
tag = latest["tag_name"]
title = f"Update the bundled spam rules to {tag}"
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
if title in existing:
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
f"The server bundles {bundled}.\n\n"
"Update it as resources/spam-filter/README.md describes: take the rules file "
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
"and run the antispam test.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"spam rules {tag}: opened #{issue['number']}.")
PY
-122
View File
@@ -1,122 +0,0 @@
# Watch upstream for releases the fork hasn't imported yet, and open an issue
# for each one so it waits in the tracker until someone strips it in.
#
# Reads metadata only -- the releases list from GitHub's API and the head of
# this repo's `upstream` branch from Gitea's. Nothing of upstream's is fetched,
# so none of its history (which carries the Enterprise code) can land here.
# Importing is still by hand: tools/fork/strip.py onto `upstream`, then merge,
# as docs/spec/SPEC.md §2.2 and §2.2a describe.
#
# The imported base is the tag in the `upstream` branch's head commit subject
# ("Import upstream v0.16.22, stripped"). Drafts and pre-releases are ignored.
# An issue is opened once per release: an existing one with the same title,
# open or closed, stops a second.
#
# It also watches spam-filter, whose rules the server bundles
# (resources/spam-filter/), and opens an issue for a newer release.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: upstream-watch
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
concurrency:
group: upstream-watch
cancel-in-progress: false
jobs:
upstream-watch:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- shell: bash
run: |
python3 - <<'PY'
import json, os, re, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, url, body=None, token=os.environ["TOKEN"]):
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-upstream-watch"}
if token:
headers["Authorization"] = f"token {token}"
req = urllib.request.Request(url, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
def key(tag):
return tuple(int(x) for x in SEMVER.match(tag).groups())
subject = call("GET", f"{api}/branches/upstream")["commit"]["message"].splitlines()[0]
m = re.search(r"\bupstream (v\d+\.\d+\.\d+)\b", subject)
if not m:
print(f"Can't read the imported base from the upstream branch: {subject!r}", file=sys.stderr); sys.exit(1)
base = m.group(1)
# Unauthenticated: a public repo, once a day, well inside the limit.
rels = call("GET", "https://api.github.com/repos/stalwartlabs/stalwart/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(base)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: {base} is the newest upstream release.")
# Titles and bodies stay free of the upstream project's name, as the
# rest of the fork's user-visible text does.
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=Import+upstream&limit=50")}
for r in newer:
tag = r["tag_name"]
title = f"Import upstream {tag}"
if title in existing:
print(f"{tag}: issue already exists."); continue
body = (f"Upstream published {tag} on {r['published_at'][:10]}. "
f"The fork's imported base is {base}.\n\n"
"Import it as tools/fork/README.md describes:\n\n"
"```bash\n"
"git -C \"$UPSTREAM_CLONE\" fetch --tags\n"
f"tools/fork/strip.py --upstream \"$UPSTREAM_CLONE\" --ref {tag} --out /tmp/strip-{tag}\n"
"```\n\n"
"Commit the stripped tree to `upstream` with the strip report in the message, "
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"{tag}: opened #{issue['number']}.")
# The spam filter rules bundled with the server (resources/spam-filter/):
# an issue when spam-filter publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main.
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
import base64
text = base64.b64decode(src["content"]).decode()
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
if not m:
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
bundled = "v" + m.group(1)
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(bundled)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
latest = newer[-1]
tag = latest["tag_name"]
title = f"Update the bundled spam rules to {tag}"
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
if title in existing:
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
f"The server bundles {bundled}.\n\n"
"Update it as resources/spam-filter/README.md describes: take the rules file "
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
"and run the antispam test.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"spam rules {tag}: opened #{issue['number']}.")
PY
+34 -31
View File
@@ -4,12 +4,14 @@
# :latest to an identical build, spends a version number, and notifies
# everybody about nothing.
#
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
# second release on one day. It lives in crates/types/src/branding.rs
# (brand_version!), deliberately not in Cargo.toml so upstream's version bumps
# merge without conflicts. The bump is committed to main and the tag names that
# commit, so the tree a tag points at reports the version the tag claims --
# publish.yml refuses a tag that doesn't.
# Since 27.1 the version is a series and its patches: the weekly release cuts
# the next patch of the current series (27.1 -> 27.1.1 -> 27.1.2). A new series
# and its release candidates (27.1-rc.N) are cut by hand, so while main is on a
# release candidate this job says so and stops. The version is the workspace
# version in Cargo.toml (tools/release/version.py reads and bumps it, with
# Cargo.lock). The bump is committed to main and the tag names that commit, so
# the tree a tag points at reports the version the tag claims -- publish.yml
# refuses a tag that doesn't.
#
# Mondays 10:07 UTC, last of the three INBUXA releases: Admin and the webmail
# release ahead of the server they talk to. Run it by hand with
@@ -20,11 +22,11 @@
# RELEASE_TOKEN (jcoffey-dev, write:repository, allowed to push to main):
# * a tag Gitea creates for the job's own token raises no event, and the
# tag must start publish.yml;
# * the bump is committed through the contents API. Gitea has no "only if
# the branch is still at X" guard, so the job checks main's head right
# before writing and refuses if it moved since the commit it counted from;
# run it again. (The API does refuse if the file itself changed, via its
# blob sha.)
# * the bump (Cargo.toml and Cargo.lock, one commit) goes through the
# contents API. Gitea has no "only if the branch is still at X" guard, so
# the job checks main's head right before writing and refuses if it moved
# since the commit it counted from; run it again. (The API does refuse if
# a file itself changed, via its blob sha.)
name: weekly-release
on:
@@ -63,7 +65,9 @@ jobs:
- shell: bash
run: |
python3 - <<'PY'
import base64, datetime, json, os, re, subprocess, sys, urllib.request
import base64, json, os, subprocess, sys, urllib.request
sys.path.insert(0, "tools/release")
import version as v
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, path, token, body=None):
@@ -81,6 +85,12 @@ jobs:
return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0
sha = git("rev-parse", "HEAD")
current = v.cargo_version()
version = v.next_patch(current)
if version is None:
print(f"main is on release candidate {v.display(current)}: release candidates "
"are cut by hand, so there's no weekly release."); sys.exit(0)
# The newest published release, or empty on a project that has never
# had one -- in which case everything counts as new. A release can
# outlive its tag; falling back to the whole history over-counts,
@@ -92,12 +102,10 @@ jobs:
if count == 0:
print(f"Nothing to release: no commits since {previous}."); sys.exit(0)
d = datetime.datetime.now(datetime.timezone.utc)
today = f"{d.year}.{d.month}.{d.day}"
version, n = today, 2
while has_tag(f"v{version}"):
version, n = f"{today}.{n}", n + 1
tag = f"v{version}"
shown = v.display(version)
tag = f"v{shown}"
if has_tag(tag):
print(f"{tag} exists but main still says {v.display(current)}; fix main first.", file=sys.stderr); sys.exit(1)
print(f"Releasing {tag} -- {count} commit(s) since {previous or 'the beginning'}, from {sha}.")
if os.environ["DRY_RUN"] == "1":
print(f"Dry run (RELEASE_LIVE='{os.environ.get('RELEASE_LIVE', '')}'): stopping here."); sys.exit(0)
@@ -106,22 +114,17 @@ jobs:
if not token:
print("RELEASE_TOKEN secret is not set on this repository", file=sys.stderr); sys.exit(1)
# Scoped to the macro body rather than replacing the first quoted
# string in the file, and asserted to have matched exactly once:
# branding.rs holds other string literals.
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
out, hits = re.subn(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")',
lambda m: m.group(1) + version + m.group(2), src, count=1)
assert hits == 1, f"brand_version! not found in {path}"
files = {str(p.relative_to(v.ROOT)): text for p, text in v.set_version(version).items()}
head = call("GET", "/branches/main", token)["commit"]["id"]
if head != sha:
print(f"main moved from {sha} to {head} since this run counted; run it again.", file=sys.stderr); sys.exit(1)
blob = call("GET", f"/contents/{path}?ref={sha}", token)["sha"]
bump = call("PUT", f"/contents/{path}", token, {
"branch": "main", "message": f"Version {version}", "sha": blob,
"content": base64.b64encode(out.encode()).decode()})["commit"]["sha"]
changes = [{"operation": "update", "path": path,
"sha": call("GET", f"/contents/{path}?ref={sha}", token)["sha"],
"content": base64.b64encode(text.encode()).decode()}
for path, text in files.items()]
bump = call("POST", "/contents", token, {
"branch": "main", "message": f"Version {shown}", "files": changes})["commit"]["sha"]
print(f"committed the bump as {bump}")
# Notes bounded to what is new: one line per change on main's
@@ -129,7 +132,7 @@ jobs:
# is an ordinary push, so publish.yml builds and pushes the image.
notes = git("log", "--first-parent", "--format=- %s", rng)
rel = call("POST", "/releases", token, {
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {version}",
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {shown}",
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
print(f"created release {rel['tag_name']}")
PY
+46 -26
View File
@@ -99,6 +99,10 @@ jobs:
run: python3 tools/fork/expr-schema.py --check
- if: always()
run: python3 -m unittest discover -s tools/fork/tests
# The release workflows' version helper and its mapping, the same cases
# as the server's display_version macro.
- if: always()
run: python3 -m unittest discover -s tools/release/tests
# The build, and that every test target compiles. The suites are not run:
# they need a store, fixed ports and containers (docs/spec/
@@ -164,10 +168,10 @@ jobs:
# --------------------------------------------------------------- tags ------
# Two guards before anything is pushed, the same as Gitea's publish.yml:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be v<version>, the workspace version in Cargo.toml as
# tags name it (tools/release/version.py: 27.1.0 is v27.1, 27.1.0-rc.1
# is v27.1-rc.1). The server reports that same version, so a tag beside
# an unbumped Cargo.toml would publish an image that says otherwise.
# * the tag must be on main or on a release/* branch, so an image never
# describes code that was never reviewed onto one of them. A release/*
# branch carries a hotfix cut from an earlier release tag.
@@ -176,6 +180,10 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
# Space-separated: the version, then the series' moving tags. A release
# candidate gets 27.1-rc.N and 27.1-rc; a release 27.1.N, 27.1 and latest.
tags: ${{ steps.v.outputs.tags }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
# Full history, and every branch as origin/*: the ancestry check cannot
# be answered from a shallow clone.
@@ -187,16 +195,9 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
V="$(python3 tools/release/version.py --tag "$TAG")"
tags="$(python3 tools/release/version.py --image-tags | tr '\n' ' ')"
if python3 tools/release/version.py --prerelease; then pre=true; else pre=false; fi
commit="$(git rev-parse "${TAG}^{commit}")"
on=""
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
@@ -205,6 +206,9 @@ jobs:
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
echo "$TAG is on $on"
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "tags=${tags% }" >> "$GITHUB_OUTPUT"
echo "prerelease=$pre" >> "$GITHUB_OUTPUT"
echo "image tags: ${tags% }; prerelease: $pre"
# Each architecture on its own native runner, side by side. The Dockerfile
# cross-compiles from the build platform, and on the self-hosted runners one
@@ -212,8 +216,8 @@ jobs:
# each natively (the builder stage picks the matching target, and the
# aarch64 toolchain it installs exists on arm64 too), and the small final
# stage needs no QEMU. amd64 also moves :<version> as soon as it is done, so
# a production deploy can start from it; :latest waits for the index below,
# so it never names an image without arm64.
# a production deploy can start from it; the series tags and :latest wait
# for the index below, so they never name an image without arm64.
publish:
needs: [version]
runs-on: ${{ matrix.runner }}
@@ -265,14 +269,16 @@ jobs:
${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }}
# Joins the two per-architecture tags into :<version> and :latest. Built
# from the per-architecture tags rather than :<version>, which by now is
# the amd64 image and would be read as such.
# Joins the two per-architecture tags into :<version> and the series'
# moving tags (never :latest for a release candidate). Built from the
# per-architecture tags rather than :<version>, which by now is the amd64
# image and would be read as such.
index:
needs: [version, publish]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
TAGS: ${{ needs.version.outputs.tags }}
steps:
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
@@ -282,9 +288,10 @@ jobs:
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- run: |
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
set -euo pipefail
args=()
for t in $TAGS; do args+=(--tag "$IMAGE:$t"); done
docker buildx imagetools create "${args[@]}" \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
@@ -308,6 +315,8 @@ jobs:
- env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
TAGS: ${{ needs.version.outputs.tags }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY: ${{ vars.REGISTRY }}
run: |
@@ -317,11 +326,18 @@ jobs:
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`.
also="$(for t in ${TAGS#"$VERSION"}; do printf ' `:%s`' "$t"; done)"
body="Container image: \`$image\` (linux/amd64, linux/arm64); also$also.
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
if [ "$PRERELEASE" = true ]; then
body="Release candidate for the next series, to try before it ships. \`:latest\` stays on the current release.
$body"
fi
jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body}' |
--argjson pre "$PRERELEASE" \
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' |
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
--data @- "$api/releases" | jq -r '"created release " + .tag_name'
@@ -382,7 +398,8 @@ jobs:
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# authoritative one, to ghcr.io under the same tags (:latest only for a
# release, never a release candidate). It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
@@ -398,13 +415,16 @@ jobs:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.version }}
TAGS: ${{ needs.version.outputs.tags }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
args=()
for t in $TAGS; do args+=(-t "$dst:$t"); done
docker buildx imagetools create "${args[@]}" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
Generated
+51 -42
View File
@@ -1298,7 +1298,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"aes-gcm-siv",
"ahash",
@@ -1484,7 +1484,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "coordinator"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"async-nats",
"futures",
@@ -1886,7 +1886,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dav"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"calcard",
"chrono",
@@ -1909,7 +1909,7 @@ dependencies = [
[[package]]
name = "dav-proto"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"calcard",
"chrono",
@@ -2122,7 +2122,7 @@ dependencies = [
[[package]]
name = "directory"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"argon2 0.6.0",
@@ -2363,7 +2363,7 @@ dependencies = [
[[package]]
name = "email"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"aes 0.9.3",
"aes-gcm 0.11.1",
@@ -3009,7 +3009,7 @@ dependencies = [
[[package]]
name = "groupware"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"calcard",
@@ -3290,7 +3290,17 @@ dependencies = [
[[package]]
name = "http"
version = "0.16.27"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http"
version = "27.1.0-rc.1"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -3331,16 +3341,6 @@ dependencies = [
"utils",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
@@ -3386,7 +3386,7 @@ dependencies = [
[[package]]
name = "http_proto"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"compact_str",
@@ -3873,7 +3873,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "imap"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"common",
@@ -3898,7 +3898,7 @@ dependencies = [
[[package]]
name = "imap_proto"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -3913,7 +3913,7 @@ dependencies = [
[[package]]
name = "inbuxa"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"coordinator",
@@ -3921,7 +3921,7 @@ dependencies = [
"directory",
"email",
"groupware",
"http 0.16.27",
"http 27.1.0-rc.1",
"http_proto",
"imap",
"jmap",
@@ -3946,7 +3946,7 @@ dependencies = [
[[package]]
name = "inbuxa-features"
version = "0.16.22"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"aho-corasick",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "jmap"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "jmap_proto"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"calcard",
@@ -4802,7 +4802,7 @@ dependencies = [
[[package]]
name = "managesieve"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"compact_str",
@@ -4937,7 +4937,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
[[package]]
name = "migration"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"email",
@@ -5187,7 +5187,7 @@ dependencies = [
[[package]]
name = "nlp"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"hashify",
@@ -6015,7 +6015,7 @@ dependencies = [
[[package]]
name = "pop3"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"directory",
@@ -6208,6 +6208,14 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proc_macros"
version = "27.1.0-rc.1"
dependencies = [
"quote",
"syn 3.0.9",
]
[[package]]
name = "prometheus"
version = "0.14.0"
@@ -6837,7 +6845,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "registry"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"hashify",
@@ -7394,7 +7402,7 @@ dependencies = [
[[package]]
name = "scim"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -7420,7 +7428,7 @@ dependencies = [
[[package]]
name = "scim-proto"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"hashify",
"serde",
@@ -7764,7 +7772,7 @@ dependencies = [
[[package]]
name = "services"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"aes-gcm 0.11.1",
"aho-corasick",
@@ -8079,7 +8087,7 @@ checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "smtp"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -8170,7 +8178,7 @@ dependencies = [
[[package]]
name = "spam-filter"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"common",
"compact_str",
@@ -8304,7 +8312,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "store"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"arc-swap",
@@ -8564,7 +8572,7 @@ dependencies = [
[[package]]
name = "tests"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"aws-lc-rs",
@@ -8586,7 +8594,7 @@ dependencies = [
"form_urlencoded",
"futures",
"groupware",
"http 0.16.27",
"http 27.1.0-rc.1",
"http_proto",
"hyper",
"hyper-util",
@@ -9179,7 +9187,7 @@ dependencies = [
[[package]]
name = "trc"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -9288,12 +9296,13 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "types"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"blake3",
"compact_str",
"hashify",
"jmap-tools",
"proc_macros",
"rkyv",
"serde",
"trc",
@@ -9457,7 +9466,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utils"
version = "0.16.27"
version = "27.1.0-rc.1"
dependencies = [
"ahash",
"arcstr",
+7
View File
@@ -35,6 +35,13 @@ members = [
"tests",
]
# inbuxa: the one place the version is set. Every member inherits it, and
# brand_version! shows it the way tags and images name it: 27.1.0 is "27.1",
# 27.1.3 is "27.1.3", 27.1.0-rc.1 is "27.1-rc.1". tools/release/version.py
# reads and bumps it.
[workspace.package]
version = "27.1.0-rc.1"
[workspace.lints.clippy]
result_unit_err = "allow"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.16.27"
version.workspace = true
edition = "2024"
build = "build.rs"
+2 -2
View File
@@ -602,10 +602,10 @@ impl Metrics {
let metrics = bp.setting_infallible::<structs::Metrics>().await;
let resource = Resource::builder()
.with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version!()))
.build();
let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!())
.with_version(types::brand_version!())
.build();
Metrics {
+5 -5
View File
@@ -42,7 +42,7 @@ pub struct IpcReceivers {
const HELP: &str = concat!(
types::brand_server!(),
" ",
types::brand_version_full!(),
types::brand_version!(),
r#"
Usage: inbuxa [OPTIONS]
@@ -97,7 +97,7 @@ impl BootManager {
std::process::exit(0);
}
("version" | "V", _) => {
println!("{}", types::brand_version_full!());
println!("{}", types::brand_version!());
std::process::exit(0);
}
("config" | "c", Some(value)) => {
@@ -173,20 +173,20 @@ impl BootManager {
Hostname = bootstrap.registry.local_hostname().to_string(),
Details =
"No configuration file was found. Port 8080 is open for initial setup.",
Version = types::brand_version_full!(),
Version = types::brand_version!(),
);
} else if bootstrap.registry.is_recovery_mode() {
trc::event!(
Server(trc::ServerEvent::RecoveryMode),
Details = "Port 8080 is open for troubleshooting and recovery.",
Hostname = bootstrap.registry.local_hostname().to_string(),
Version = types::brand_version_full!(),
Version = types::brand_version!(),
);
} else {
trc::event!(
Server(trc::ServerEvent::Startup),
Hostname = bootstrap.registry.local_hostname().to_string(),
Version = types::brand_version_full!(),
Version = types::brand_version!(),
);
}
+1 -2
View File
@@ -8,7 +8,6 @@
use base64::Engine;
use base64::engine::general_purpose;
use std::env;
use std::io::{self, Write};
use store::write::{AnyClass, AnyKey, BatchBuilder, ValueClass};
use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store};
@@ -16,7 +15,7 @@ use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX,
const HELP: &str = concat!(
types::brand_server!(),
" ",
types::brand_version_full!(),
types::brand_version!(),
r#" Data Store CLI
Enter commands (type 'help' for available commands).
+2 -2
View File
@@ -30,11 +30,11 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
tokio::spawn(async move {
let resource = Resource::builder()
.with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version!()))
.build();
let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!())
.with_version(types::brand_version!())
.build();
otel.log_exporter.set_resource(&resource);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coordinator"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav-proto"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "email"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -2,7 +2,7 @@
name = "inbuxa-features"
description = "inbuxa's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
license = "AGPL-3.0-only"
version = "0.16.22"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "groupware"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http_proto"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap_proto"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap_proto"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -474,7 +474,7 @@ impl Session {
impl Default for SieveSessionCapabilities {
fn default() -> Self {
Self {
implementation: concat!(types::brand!(), " ", types::brand_version_full!()),
implementation: concat!(types::brand!(), " ", types::brand_version!()),
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only"
version = "0.16.27"
version.workspace = true
edition = "2024"
[[bin]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "migration"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pop3"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "registry"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim-proto"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "services"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -6,7 +6,7 @@ homepage = "https://inbuxa.org"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "spam-filter"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trc"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+2 -1
View File
@@ -1,10 +1,11 @@
[package]
name = "types"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
utils = { path = "../utils" }
proc_macros = { path = "../utils/proc-macros" }
trc = { path = "../trc" }
jmap-tools = { version = "0.1" }
hashify = "0.2"
+24 -24
View File
@@ -81,41 +81,41 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
is_set(&legacy).then_some(legacy)
}
/// INBUXA's own version, dated like the rest of its family: `YYYY.M.D`, with
/// a letter or `.N` suffix for a second release on one day. It's set here and
/// not in Cargo.toml, so upstream's version bumps merge without conflicts.
/// inbuxa's version as tags, images and `--version` name it: `27.1` for a
/// series, `27.1.3` for its patches, `27.1-rc.1` for a release candidate. It
/// comes from the one version in the workspace Cargo.toml (`27.1.0`, `27.1.3`,
/// `27.1.0-rc.1`), so a release changes that and nothing else.
#[macro_export]
macro_rules! brand_version {
() => {
"2026.10.28"
$crate::branding::__display_version!()
};
}
/// The version with the Stalwart release it's built on, e.g.
/// `2026.9.20 (Stalwart 0.16.22)`. The base comes from Cargo, which follows
/// upstream, so it's always the base actually compiled in. It matters because
/// Stalwart's data upgrades are one-way. Once INBUXA stops tracking upstream,
/// this becomes just the version.
#[macro_export]
macro_rules! brand_version_full {
// The upstream crate version, without naming the upstream project: this
// string is user-visible (--version, the startup banner, the console,
// telemetry and the JMAP session's "implementation" field), and the name
// belongs only in copyright notices and the lineage line.
() => {
concat!(
$crate::brand_version!(),
" (upstream ",
env!("CARGO_PKG_VERSION"),
")"
)
};
}
#[doc(hidden)]
pub use proc_macros::display_version as __display_version;
#[cfg(test)]
mod tests {
use super::{LEGACY_ENV_PREFIX, legacy_setting};
#[test]
fn the_version_is_cargos_without_the_series_zero() {
let cargo = env!("CARGO_PKG_VERSION");
let shown = crate::brand_version!();
assert_eq!(
cargo,
match shown.split_once('-') {
Some((series, pre)) => format!("{series}.0-{pre}"),
None if shown.matches('.').count() == 1 => format!("{shown}.0"),
None => shown.to_string(),
}
);
// Usable where a literal is needed.
let banner: &str = concat!("v", crate::brand_version!());
assert_eq!(banner, format!("v{shown}"));
}
#[test]
fn an_upstream_setting_is_named_for_renaming() {
let old = format!("{LEGACY_ENV_PREFIX}_RECOVERY_ADMIN");
+27 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use std::fmt::Display;
@@ -67,7 +69,15 @@ impl TryFrom<&str> for Semver {
let mut parts = value.splitn(3, '.');
let major = parts.next().ok_or(())?.parse().map_err(|_| ())?;
let minor = parts.next().ok_or(())?.parse().map_err(|_| ())?;
let patch = parts.next().ok_or(())?.parse().map_err(|_| ())?;
// inbuxa: a pre-release or build suffix (27.1.0-rc.1) is not part of
// the number; without this, Semver::current() panics on an RC.
let patch = parts.next().ok_or(())?;
let patch = patch
.split(['-', '+'])
.next()
.unwrap_or_default()
.parse()
.map_err(|_| ())?;
Ok(Semver::new(major, minor, patch))
}
}
@@ -78,3 +88,19 @@ impl Display for Semver {
write!(f, "{major}.{minor}.{patch}")
}
}
#[cfg(test)]
mod tests {
use super::Semver;
#[test]
fn parses_release_candidates() {
assert_eq!(Semver::try_from("27.1.0-rc.1"), Ok(Semver::new(27, 1, 0)));
assert_eq!(Semver::try_from("27.1.3"), Ok(Semver::new(27, 1, 3)));
assert_eq!(Semver::try_from("27.1.3+g1234"), Ok(Semver::new(27, 1, 3)));
assert_eq!(Semver::try_from("27.1"), Err(()));
assert_eq!(Semver::try_from("27.1.x"), Err(()));
assert!(Semver::current().is_valid());
assert!(Semver::new(2026, 10, 28) > Semver::new(27, 1, 0));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "utils"
version = "0.16.27"
version.workspace = true
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proc_macros"
version = "0.16.27"
version.workspace = true
edition = "2024"
[lib]
+20
View File
@@ -2,12 +2,32 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input};
// inbuxa: the displayed version (see version.rs).
mod version;
/// The workspace version as tags and images name it, as a string literal, so
/// it can go into `concat!`. Read from the crate being compiled, which
/// inherits the one version in the workspace Cargo.toml.
#[proc_macro]
pub fn display_version(_input: TokenStream) -> TokenStream {
let cargo = std::env::var("CARGO_PKG_VERSION").unwrap_or_default();
match version::display(&cargo) {
Ok(shown) => quote!(#shown).into(),
Err(err) => {
let err = format!("workspace version: {err}");
quote!(compile_error!(#err)).into()
}
}
}
#[proc_macro_derive(EnumMethods)]
pub fn enum_id(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
+86
View File
@@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa's version as people see it, worked out from the Cargo one.
//!
//! Cargo needs three numbers (`27.1.0`, `27.1.0-rc.1`); tags, images and
//! `--version` name a series without its `.0` (`27.1`, `27.1-rc.1`).
//! tools/release/version.py does the same mapping for the release workflows,
//! and its tests use the same cases as the ones here.
/// `27.1.0` -> `27.1`, `27.1.3` -> `27.1.3`, `27.1.0-rc.1` -> `27.1-rc.1`.
/// Anything else is refused, so a version nobody planned for fails the build
/// rather than reaching a tag.
pub fn display(cargo: &str) -> Result<String, String> {
let (core, pre) = match cargo.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
None => (cargo, None),
};
let parts = core.split('.').collect::<Vec<_>>();
let is_number = |part: &str| {
!part.is_empty()
&& part.bytes().all(|b| b.is_ascii_digit())
&& (part == "0" || !part.starts_with('0'))
};
if parts.len() != 3 || !parts.iter().all(|part| is_number(part)) {
return Err(format!("{cargo:?} is not YY.M.N"));
}
// A two-digit year and a month: 2026.10.28 is the old dated scheme.
let (year, month) = (parts[0].parse::<u32>(), parts[1].parse::<u32>());
if !matches!((year, month), (Ok(10..=99), Ok(1..=12))) {
return Err(format!(
"{cargo:?}: the series is a two-digit year and a month, as in 27.1"
));
}
let series = format!("{}.{}", parts[0], parts[1]);
match pre {
None if parts[2] == "0" => Ok(series),
None => Ok(format!("{series}.{}", parts[2])),
Some(pre)
if parts[2] == "0"
&& pre
.strip_prefix("rc.")
.is_some_and(|n| is_number(n) && n != "0") =>
{
Ok(format!("{series}-{pre}"))
}
Some(_) => Err(format!(
"{cargo:?}: the only pre-release is a series' rc.N, as in 27.1.0-rc.1"
)),
}
}
#[cfg(test)]
mod tests {
use super::display;
#[test]
fn display_versions() {
for (cargo, shown) in [
("27.1.0", "27.1"),
("27.1.3", "27.1.3"),
("27.1.0-rc.1", "27.1-rc.1"),
("27.7.0-rc.12", "27.7-rc.12"),
("28.1.10", "28.1.10"),
] {
assert_eq!(display(cargo).as_deref(), Ok(shown), "{cargo}");
}
for cargo in [
"27.1",
"27.1.0.1",
"27.1.1-rc.1",
"27.1.0-rc.0",
"27.1.0-beta.1",
"27.01.0",
"2026.10.28.1",
"2026.11.2",
"27.13.0",
"",
] {
assert!(display(cargo).is_err(), "{cargo}");
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ high-confidence spam categories, −3.0 for legitimate), which match
## Updating
The `upstream-watch` workflow opens an issue when spam-filter publishes a
The `spam-rules-watch` workflow opens an issue when spam-filter publishes a
newer release. To take it:
1. Download `spam-filter-rules.json.gz` from that release, pinned by tag
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tests"
version = "0.16.27"
version.workspace = true
edition = "2024"
[features]
+1 -1
View File
@@ -56,7 +56,7 @@ pub async fn test(test: &TestServer) {
"urn:ietf:params:jmap:submission": {},
"urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:sieve": {
"implementation": concat!(types::brand!(), " ", types::brand_version_full!())
"implementation": concat!(types::brand!(), " ", types::brand_version!())
},
"urn:ietf:params:jmap:blob": {},
"urn:ietf:params:jmap:quota": {},
+104
View File
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
# SPDX-License-Identifier: AGPL-3.0-only
"""Tests for tools/release/version.py: python3 -m unittest discover -s tools/release/tests"""
import importlib.util
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("version", os.path.join(HERE, "..", "version.py"))
version = importlib.util.module_from_spec(spec)
spec.loader.exec_module(version)
class Display(unittest.TestCase):
# The same cases as crates/utils/proc-macros/src/version.rs: the server
# and the workflows must name a version the same way.
def test_shown(self):
for cargo, shown in [
("27.1.0", "27.1"),
("27.1.3", "27.1.3"),
("27.1.0-rc.1", "27.1-rc.1"),
("27.7.0-rc.12", "27.7-rc.12"),
("28.1.10", "28.1.10"),
]:
self.assertEqual(version.display(cargo), shown, cargo)
def test_refused(self):
for cargo in ["27.1", "27.1.0.1", "27.1.1-rc.1", "27.1.0-rc.0", "27.1.0-beta.1",
"27.01.0", "2026.10.28.1", "2026.11.2", "27.13.0", ""]:
with self.assertRaises(ValueError, msg=cargo):
version.display(cargo)
def test_image_tags(self):
self.assertEqual(version.image_tags("27.1.0-rc.2"), ["27.1-rc.2", "27.1-rc"])
self.assertEqual(version.image_tags("27.1.0"), ["27.1", "latest"])
self.assertEqual(version.image_tags("27.1.4"), ["27.1.4", "27.1", "latest"])
def test_weekly(self):
self.assertIsNone(version.next_patch("27.1.0-rc.3"))
self.assertEqual(version.next_patch("27.1.0"), "27.1.1")
self.assertEqual(version.next_patch("27.1.9"), "27.1.10")
class SetVersion(unittest.TestCase):
"""--set on a copy of this repository's Cargo.toml, Cargo.lock and member
manifests: a round trip gives the files back unchanged, and Cargo accepts
the bumped lockfile as it is."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
src = version.ROOT
shutil.copy(src / "Cargo.toml", self.tmp / "Cargo.toml")
shutil.copy(src / "Cargo.lock", self.tmp / "Cargo.lock")
for member in version.tomllib.loads((src / "Cargo.toml").read_text())["workspace"]["members"]:
(self.tmp / member).mkdir(parents=True, exist_ok=True)
shutil.copy(src / member / "Cargo.toml", self.tmp / member / "Cargo.toml")
def tearDown(self):
shutil.rmtree(self.tmp)
def write(self, files):
for path, text in files.items():
path.write_text(text)
def test_round_trip(self):
before = {p: (self.tmp / p).read_text() for p in ("Cargo.toml", "Cargo.lock")}
old = version.cargo_version(self.tmp)
self.write(version.set_version("27.7.0-rc.4", self.tmp))
self.assertEqual(version.cargo_version(self.tmp), "27.7.0-rc.4")
lock = (self.tmp / "Cargo.lock").read_text()
self.assertEqual(lock.count('version = "27.7.0-rc.4"'), len(version.members(self.tmp)))
self.write(version.set_version(old, self.tmp))
for p, text in before.items():
self.assertEqual((self.tmp / p).read_text(), text, p)
def test_refuses_a_bad_version(self):
with self.assertRaises(ValueError):
version.set_version("2026.11.2", self.tmp)
@unittest.skipUnless(shutil.which("cargo"), "no cargo here")
def test_cargo_accepts_it(self):
# In the real tree, since cargo needs every member's sources. Offline,
# so it needs the crates already in cargo's cache: CI's fork-checks
# runner has none, so it skips there (the build job uses --locked).
root = version.ROOT
metadata = ["cargo", "metadata", "--locked", "--offline", "--format-version", "1"]
if subprocess.run(metadata, cwd=root, capture_output=True).returncode != 0:
self.skipTest("cargo can't resolve this tree offline here")
files = version.set_version("27.1.0-rc.9", root)
saved = {p: p.read_text() for p in files}
try:
self.write(files)
run = subprocess.run(metadata, cwd=root, capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stderr)
finally:
self.write(saved)
if __name__ == "__main__":
unittest.main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
# SPDX-License-Identifier: AGPL-3.0-only
"""inbuxa's version, for the release workflows and for cutting a release by hand.
The one version is [workspace.package] in Cargo.toml: 27.1.0 for a series,
27.1.3 for its patches, 27.1.0-rc.1 for a release candidate. Tags, images and
`--version` name it without the series' .0: v27.1, v27.1.3, v27.1-rc.1. The
server works that out with the display_version proc macro
(crates/utils/proc-macros/src/version.rs); display() here is the same mapping,
tested with the same cases.
version.py the displayed version (27.1-rc.1)
version.py --cargo the Cargo version (27.1.0-rc.1)
version.py --tag TAG check that TAG is v<displayed>, then print it
version.py --image-tags the image tags for this version, one per line
version.py --prerelease exit 0 for a release candidate, 1 otherwise
version.py --set VERSION set the version (Cargo form) in Cargo.toml and
Cargo.lock: a release candidate, or a series
bump, is cut this way by hand
Standard library only: the weekly release runs it in a plain python image.
"""
import re
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
NUMBER = r"(?:0|[1-9][0-9]*)"
CARGO = re.compile(rf"^({NUMBER})\.({NUMBER})\.({NUMBER})(?:-rc\.([1-9][0-9]*))?$")
def parse(cargo):
"""(major, minor, patch, rc or None), or ValueError for anything that
isn't YY.M.N or YY.M.0-rc.N."""
m = CARGO.match(cargo)
if not m or (m.group(4) and m.group(3) != "0"):
raise ValueError(f"{cargo!r} is not YY.M.N or YY.M.0-rc.N")
major, minor, patch = (int(m.group(i)) for i in (1, 2, 3))
# A two-digit year and a month: 2026.10.28 is the old dated scheme.
if not (10 <= major <= 99 and 1 <= minor <= 12):
raise ValueError(f"{cargo!r}: the series is a two-digit year and a month, as in 27.1")
return major, minor, patch, int(m.group(4)) if m.group(4) else None
def display(cargo):
major, minor, patch, rc = parse(cargo)
series = f"{major}.{minor}"
if rc is not None:
return f"{series}-rc.{rc}"
return series if patch == 0 else f"{series}.{patch}"
def is_prerelease(cargo):
return parse(cargo)[3] is not None
def image_tags(cargo):
"""A release candidate gets its own tag and the series' moving -rc tag,
never :latest. A release gets its own tag, the series tag and :latest."""
major, minor, _, rc = parse(cargo)
series = f"{major}.{minor}"
if rc is not None:
return [display(cargo), f"{series}-rc"]
return list(dict.fromkeys([display(cargo), series, "latest"]))
def next_patch(cargo):
"""What the weekly release cuts next: the next patch of a released
series, or None during release candidates, which are cut by hand."""
major, minor, patch, rc = parse(cargo)
return None if rc is not None else f"{major}.{minor}.{patch + 1}"
def members(root=ROOT):
workspace = tomllib.loads((root / "Cargo.toml").read_text())["workspace"]
names = set()
for member in workspace["members"]:
names.add(tomllib.loads((root / member / "Cargo.toml").read_text())["package"]["name"])
# Path dependencies inside the workspace are members too (proc_macros).
for lock in tomllib.loads((root / "Cargo.lock").read_text())["package"]:
if "source" not in lock and lock["name"] in names | {"proc_macros"}:
names.add(lock["name"])
return names
def cargo_version(root=ROOT):
return tomllib.loads((root / "Cargo.toml").read_text())["workspace"]["package"]["version"]
def set_version(new, root=ROOT):
"""Rewrite the version in Cargo.toml and every workspace entry of
Cargo.lock, as `cargo update -w` would, without needing cargo. Returns
{path: new text} so a caller can write the files or send them elsewhere."""
parse(new)
old = cargo_version(root)
names = members(root)
toml_path = root / "Cargo.toml"
toml = toml_path.read_text()
section = re.search(r"(?ms)^\[workspace\.package\]\n.*?(?=^\[|\Z)", toml)
body, hits = re.subn(r'(?m)^version = "[^"]*"$', f'version = "{new}"', section.group(0), count=1)
if hits != 1:
raise ValueError("no version line in [workspace.package]")
toml = toml[: section.start()] + body + toml[section.end() :]
lock_path = root / "Cargo.lock"
lock = lock_path.read_text()
bumped = 0
def package(m):
nonlocal bumped
block = m.group(0)
name = re.search(r'(?m)^name = "([^"]+)"$', block).group(1)
if name in names and "\nsource = " not in block:
block, hits = re.subn(rf'(?m)^version = "{re.escape(old)}"$', f'version = "{new}"', block, count=1)
bumped += hits
return block
lock = re.sub(r"(?s)\[\[package\]\]\n.*?(?=\n\n|\Z)", package, lock)
# A member that shares its name with a registry crate (http) is named
# with its version in dependency lists.
for name in names:
lock = lock.replace(f'"{name} {old}"', f'"{name} {new}"')
if bumped != len(names):
raise ValueError(f"bumped {bumped} of {len(names)} workspace packages in Cargo.lock")
return {toml_path: toml, lock_path: lock}
def main(argv):
cargo = cargo_version()
if not argv:
print(display(cargo))
elif argv == ["--cargo"]:
print(cargo)
elif argv[0] == "--tag" and len(argv) == 2:
if argv[1] != f"v{display(cargo)}":
print(f"Tag {argv[1]} names a commit whose version is {display(cargo)} (Cargo {cargo}).", file=sys.stderr)
print("Refusing to publish an image that would report the wrong version.", file=sys.stderr)
return 1
print(display(cargo))
elif argv == ["--image-tags"]:
print("\n".join(image_tags(cargo)))
elif argv == ["--prerelease"]:
return 0 if is_prerelease(cargo) else 1
elif argv[0] == "--set" and len(argv) == 2:
for path, text in set_version(argv[1]).items():
path.write_text(text)
print(f"{cargo} -> {argv[1]} ({display(argv[1])})")
else:
print(__doc__, file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))