Version 27.1.0-rc.1: one workspace version, series and RC releases #223
No files matched your search
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "coordinator"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav-proto"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "directory"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "email"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "groupware"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http_proto"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap_proto"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap_proto"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nlp"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pop3"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "registry"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "scim-proto"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "scim"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "services"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "spam-filter"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "store"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trc"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "utils"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proc_macros"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tests"
|
||||
version = "0.16.27"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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()
|
||||
@@ -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:]))
|
||||
Reference in new issue
Block a user