diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3e4625b1..5524285b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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' }} diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index b234ded9..01ba5c3e 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -13,8 +13,8 @@ # * publish-amd64 pushes :-amd64 and :, a plain amd64 # image, as soon as its build is done. A deploy can start from it. # * publish-arm64 then builds arm64, pushes :-arm64, and replaces -# : with the two-platform index. :latest moves only here, so it -# never names an image without arm64. +# : 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. 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, 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 diff --git a/.gitea/workflows/spam-rules-watch.yml b/.gitea/workflows/spam-rules-watch.yml new file mode 100644 index 00000000..eb9ecdf6 --- /dev/null +++ b/.gitea/workflows/spam-rules-watch.yml @@ -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 diff --git a/.gitea/workflows/upstream-watch.yml b/.gitea/workflows/upstream-watch.yml deleted file mode 100644 index 8ae43c99..00000000 --- a/.gitea/workflows/upstream-watch.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/weekly-release.yml b/.gitea/workflows/weekly-release.yml index 382e199b..3096d248 100644 --- a/.gitea/workflows/weekly-release.yml +++ b/.gitea/workflows/weekly-release.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33da777d..9fd9d0ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. 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, 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 : 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 : and :latest. Built - # from the per-architecture tags rather than :, which by now is - # the amd64 image and would be read as such. + # Joins the two per-architecture tags into : and the series' + # moving tags (never :latest for a release candidate). Built from the + # per-architecture tags rather than :, 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" diff --git a/Cargo.lock b/Cargo.lock index c7403b09..70177deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 99524cdd..69931300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 39351a2b..b7ad28f2 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.16.27" +version.workspace = true edition = "2024" build = "build.rs" diff --git a/crates/common/src/config/telemetry.rs b/crates/common/src/config/telemetry.rs index 1aeaa96f..67e49722 100644 --- a/crates/common/src/config/telemetry.rs +++ b/crates/common/src/config/telemetry.rs @@ -602,10 +602,10 @@ impl Metrics { let metrics = bp.setting_infallible::().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 { diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index a204cd8b..d6908d64 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -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!(), ); } diff --git a/crates/common/src/manager/console.rs b/crates/common/src/manager/console.rs index ae8d11d2..bf7df84c 100644 --- a/crates/common/src/manager/console.rs +++ b/crates/common/src/manager/console.rs @@ -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). diff --git a/crates/common/src/telemetry/tracers/otel.rs b/crates/common/src/telemetry/tracers/otel.rs index af1a3c41..75541618 100644 --- a/crates/common/src/telemetry/tracers/otel.rs +++ b/crates/common/src/telemetry/tracers/otel.rs @@ -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); diff --git a/crates/coordinator/Cargo.toml b/crates/coordinator/Cargo.toml index 40bb9ca8..32826d5c 100644 --- a/crates/coordinator/Cargo.toml +++ b/crates/coordinator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coordinator" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index d976289d..5b7a3aeb 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav-proto" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml index 527f3aaf..08b0390b 100644 --- a/crates/dav/Cargo.toml +++ b/crates/dav/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 2f6e6781..1960b1fe 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index a5d32016..6d8d730b 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index 4ff56a8a..a9bc7178 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -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] diff --git a/crates/groupware/Cargo.toml b/crates/groupware/Cargo.toml index be680f04..573a12e6 100644 --- a/crates/groupware/Cargo.toml +++ b/crates/groupware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "groupware" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/http-proto/Cargo.toml b/crates/http-proto/Cargo.toml index 87af79ab..ec1f13ba 100644 --- a/crates/http-proto/Cargo.toml +++ b/crates/http-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http_proto" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index 32572d12..726f00f2 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/imap-proto/Cargo.toml b/crates/imap-proto/Cargo.toml index 4f3678d2..138f0fdb 100644 --- a/crates/imap-proto/Cargo.toml +++ b/crates/imap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap_proto" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index f7a36811..bb95bea5 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/jmap-proto/Cargo.toml b/crates/jmap-proto/Cargo.toml index f5ba95b2..c572f5a4 100644 --- a/crates/jmap-proto/Cargo.toml +++ b/crates/jmap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap_proto" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index aaa2d0ba..e26f1265 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -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!()), } } } diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index d565ffa1..16c4f4d8 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index ee56dcf0..38d7060d 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -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]] diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 817477e6..2f378fad 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/migration/Cargo.toml b/crates/migration/Cargo.toml index 30b37951..d384d192 100644 --- a/crates/migration/Cargo.toml +++ b/crates/migration/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migration" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index 63a6f9e6..8f4b3b19 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index cbdfe250..429d667d 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index a34fe4d8..4b52ea22 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "registry" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/scim-proto/Cargo.toml b/crates/scim-proto/Cargo.toml index cbe450d7..807a054b 100644 --- a/crates/scim-proto/Cargo.toml +++ b/crates/scim-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scim-proto" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/scim/Cargo.toml b/crates/scim/Cargo.toml index 17bd549d..95eef6b0 100644 --- a/crates/scim/Cargo.toml +++ b/crates/scim/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scim" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index a87eb9a7..17c52e64 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "services" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 30a4cfd8..bc62bb47 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -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] diff --git a/crates/spam-filter/Cargo.toml b/crates/spam-filter/Cargo.toml index 46fd6295..3a5561e7 100644 --- a/crates/spam-filter/Cargo.toml +++ b/crates/spam-filter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spam-filter" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 6ec01d98..566e2a78 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml index 1bb3b838..b0638398 100644 --- a/crates/trc/Cargo.toml +++ b/crates/trc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trc" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 4921f70e..853d344c 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -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" diff --git a/crates/types/src/branding.rs b/crates/types/src/branding.rs index 98f473e7..e00ac3d5 100644 --- a/crates/types/src/branding.rs +++ b/crates/types/src/branding.rs @@ -81,41 +81,41 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option { 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"); diff --git a/crates/types/src/semver.rs b/crates/types/src/semver.rs index e266f4a9..16ddac95 100644 --- a/crates/types/src/semver.rs +++ b/crates/types/src/semver.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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)); + } +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 7b8afc55..9449f512 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.16.27" +version.workspace = true edition = "2024" [dependencies] diff --git a/crates/utils/proc-macros/Cargo.toml b/crates/utils/proc-macros/Cargo.toml index 3d447c40..8517cc63 100644 --- a/crates/utils/proc-macros/Cargo.toml +++ b/crates/utils/proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proc_macros" -version = "0.16.27" +version.workspace = true edition = "2024" [lib] diff --git a/crates/utils/proc-macros/src/lib.rs b/crates/utils/proc-macros/src/lib.rs index 64ea8f1d..a570e475 100644 --- a/crates/utils/proc-macros/src/lib.rs +++ b/crates/utils/proc-macros/src/lib.rs @@ -2,12 +2,32 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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); diff --git a/crates/utils/proc-macros/src/version.rs b/crates/utils/proc-macros/src/version.rs new file mode 100644 index 00000000..c7c69558 --- /dev/null +++ b/crates/utils/proc-macros/src/version.rs @@ -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 { + let (core, pre) = match cargo.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (cargo, None), + }; + let parts = core.split('.').collect::>(); + 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::(), parts[1].parse::()); + 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}"); + } + } +} diff --git a/resources/spam-filter/README.md b/resources/spam-filter/README.md index d346d99c..08b471ad 100644 --- a/resources/spam-filter/README.md +++ b/resources/spam-filter/README.md @@ -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 diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 75ace813..b321b75e 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tests" -version = "0.16.27" +version.workspace = true edition = "2024" [features] diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index eee2748a..6b5b7ed5 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -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": {}, diff --git a/tools/release/tests/test_version.py b/tools/release/tests/test_version.py new file mode 100644 index 00000000..b928ae83 --- /dev/null +++ b/tools/release/tests/test_version.py @@ -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() diff --git a/tools/release/version.py b/tools/release/version.py new file mode 100644 index 00000000..37447fcd --- /dev/null +++ b/tools/release/version.py @@ -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, 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:]))