From 29bcfecb80565312cdca9dfb3fd7a5fc2941b473 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 22:45:05 -0700 Subject: [PATCH 1/5] ci: add Gitea Actions workflow ported from .gitlab-ci.yml --- .gitea/workflows/ci.yml | 42 +++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 43 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..21e859b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off +# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once +# this directory exists; .github/workflows stays as it was for GitHub. +# +# Every job runs in an image pinned by digest (tag in the trailing comment), +# and the only action used is coffey-labs/actions/checkout pinned by SHA. The +# instance resolves short `uses:` against itself, never GitHub, so nothing +# unreviewed can be pulled in. +# +# Not ported, as on GitLab: publish.yml and release.yml still need doing. +name: ci + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: docker + container: + image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm + # Both kept inside the workspace (a per-job volume on the project disk), + # deliberately not on /tmp, which on this host is a tmpfs that a Rust + # build of this size has filled before. There is no cache between runs + # here -- the runner's cache server is off -- so every build is cold. + env: + CARGO_INCREMENTAL: "0" + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + - run: | + echo "CARGO_HOME=$GITHUB_WORKSPACE/.cargo" >> "$GITHUB_ENV" + echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/target" >> "$GITHUB_ENV" + - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null + - run: cargo build -p inbuxa --locked + # --no-run: the workflow compiled every test target without running them, + # which catches a test that no longer builds without paying for the suite. + - run: cargo test --workspace --locked --no-run diff --git a/.gitignore b/.gitignore index c85ed4c..44de0f4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ run.sh !.gitattributes !.github !.gitlab-ci.yml +!.gitea CLAUDE.md # The cutover rehearsal writes its fixture and state here. From 1ee2e2a6a3dae9cb1e76d34b8554cebd4478c280 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 07:59:35 -0700 Subject: [PATCH 2/5] ci: persistent Cargo cache, run on either runner The build now mounts the named volume inbuxa-server-cargo at /cache and keeps CARGO_HOME and CARGO_TARGET_DIR there, so a push reuses the compiled dependency tree (RocksDB included) instead of rebuilding it from scratch. Both runners allow that one volume; each host keeps its own copy. With the cache in place the job moves to runs-on: light, so it can run on host2 as well. Cargo's parallelism now follows the job's CPU cap rather than the host's core count, and the target dir is dropped past 25 GB. --- .gitea/workflows/ci.yml | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 21e859b..b0f07db 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,22 +21,40 @@ concurrency: jobs: build: - runs-on: docker + # Either runner (host1 or host2): the build needs no docker socket. + runs-on: light container: image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm - # Both kept inside the workspace (a per-job volume on the project disk), - # deliberately not on /tmp, which on this host is a tmpfs that a Rust - # build of this size has filled before. There is no cache between runs - # here -- the runner's cache server is off -- so every build is cold. + # A named volume per host that outlives the job: Cargo's registry/git + # cache and the target dir. Without it every run recompiled RocksDB and + # the rest of the dependency tree from scratch. Each runner allows this + # one volume in its valid_volumes; each host keeps its own copy. + volumes: + - inbuxa-server-cargo:/cache env: + CARGO_HOME: /cache/cargo-home + CARGO_TARGET_DIR: /cache/target + # Dependencies are reused whole; incremental data for the workspace + # crates would only bloat a shared target dir. CARGO_INCREMENTAL: "0" steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + # Cargo sizes its parallelism from the host's core count, not the job's + # CPU cap (2 on host2, 4 on host1); a C++ build of RocksDB at 8-way + # parallelism inside 6 GB gets OOM-killed. Match jobs to the cap. - run: | - echo "CARGO_HOME=$GITHUB_WORKSPACE/.cargo" >> "$GITHUB_ENV" - echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/target" >> "$GITHUB_ENV" + jobs=$(awk '$1 != "max" { printf "%d", $1 / $2 }' /sys/fs/cgroup/cpu.max 2>/dev/null) + echo "CARGO_BUILD_JOBS=${jobs:-$(nproc)}" >> "$GITHUB_ENV" + echo "cargo jobs: ${jobs:-$(nproc)}; cache: $(du -sh /cache 2>/dev/null | cut -f1)" - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null - run: cargo build -p inbuxa --locked # --no-run: the workflow compiled every test target without running them, # which catches a test that no longer builds without paying for the suite. - run: cargo test --workspace --locked --no-run + # Keep the cache from growing without bound: past 25 GB the target dir + # is dropped and the next build starts cold. The download cache stays. + - if: always() + run: | + used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1) + echo "target dir: ${used:-0} GB" + if [ "${used:-0}" -gt 25 ]; then rm -rf /cache/target && echo "over 25 GB: target dir cleared"; fi From b37d2526608af006977b38726d109023a7af712a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 08:45:15 -0700 Subject: [PATCH 3/5] ci: raise the Cargo target-dir limit to 60 GB The dev and test profiles together already take ~22 GB after one cold build, so the 25 GB limit would have wiped a warm cache within a build or two. --- .gitea/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b0f07db..dce400e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -51,10 +51,12 @@ jobs: # --no-run: the workflow compiled every test target without running them, # which catches a test that no longer builds without paying for the suite. - run: cargo test --workspace --locked --no-run - # Keep the cache from growing without bound: past 25 GB the target dir + # Keep the cache from growing without bound: past 60 GB the target dir # is dropped and the next build starts cold. The download cache stays. + # Two builds (dev + test profiles) already fill ~22 GB, so the limit + # has to sit well above that or it would wipe a warm cache every run. - if: always() run: | used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1) echo "target dir: ${used:-0} GB" - if [ "${used:-0}" -gt 25 ]; then rm -rf /cache/target && echo "over 25 GB: target dir cleared"; fi + if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi From 7d2c2d2322200deb15df7c5c6b1042fdb2cba631 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 09:07:15 -0700 Subject: [PATCH 4/5] Point links at git.coffeylabs.org after the move from GitHub GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo, release, raw-file and clone links now go to Gitea at git.coffeylabs.org, container images to registry.coffeylabs.org, and GitLab-style /-/blob paths to Gitea's /src/branch form. Go module paths are identifiers and stay as they are; links to GitHub issues and pull requests are left as history. --- SECURITY.md | 4 ++-- install.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 6d7e6c1..81f028e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,8 +35,8 @@ to Stalwart Labs with credit to you, and you'll be told that has happened. This repository is the mail server. The web front ends have their own: -- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin) -- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa) +- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin) +- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa) Upstream's own security documents are kept in `.github-upstream/` for reference. They describe Stalwart Labs' process, not this project's. diff --git a/install.sh b/install.sh index d57a165..2158891 100644 --- a/install.sh +++ b/install.sh @@ -21,6 +21,6 @@ echo >&2 echo "A server started with no configuration comes up in bootstrap mode;" >&2 echo "INBUXA Admin's setup wizard completes first boot over JMAP." >&2 echo >&2 -echo "Releases: https://github.com/inbuxa/inbuxa-server/releases" >&2 +echo "Releases: https://git.coffeylabs.org/inbuxa/inbuxa-server/releases" >&2 echo "Docs: https://docs.inbuxa.org/install/fresh/" >&2 exit 1 From 8846a280f14a0c349741f51b958107994ecd7caf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 09:56:32 -0700 Subject: [PATCH 5/5] ci: publish the image on tags, port the weekly release publish.yml replaces .github/workflows/publish.yml: on a v* tag it checks the tag equals v and is on main, builds the linux/amd64+arm64 image in one buildx run (the Dockerfile already cross-compiles, so only its final stage goes through QEMU), pushes : and :latest to the registry, links the package, and creates the tag's release if it has none. weekly-release.yml ports .github/workflows/release.yml: bump brand_version! through the contents API, then create the release and so the tag, which starts publish.yml. It only dry-runs until RELEASE_LIVE=1 and a RELEASE_TOKEN secret exist. --- .gitea/workflows/publish.yml | 138 ++++++++++++++++++++++++++++ .gitea/workflows/weekly-release.yml | 135 +++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/weekly-release.yml diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..73edeba --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,138 @@ +# Publish the container image, ported from .github/workflows/publish.yml when +# the project moved to the self-hosted Gitea (2026-09-22). Starts on a v* tag, +# whether a person pushed it or weekly-release.yml created it through the +# releases API. +# +# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in +# one buildx run on host1 instead of one native runner per architecture: the +# Dockerfile's builder stage runs on the build platform and cross-compiles +# with an aarch64 linker, so only the small final stage (apt, setcap) goes +# through QEMU for arm64. No digest-joining job is needed. +# +# 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 on main, so an image never describes code that was never +# reviewed onto the default branch. +# +# :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. +# +# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's +# own token is refused by the container registry. +name: publish + +on: + push: + tags: ['v*'] + +jobs: + version: + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + outputs: + version: ${{ steps.v.outputs.version }} + steps: + # Full history: the ancestry check cannot be answered from a shallow + # clone. The checkout also fetches every branch as origin/*. + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + with: + fetch-depth: 0 + - id: v + shell: bash + env: + 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 + git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \ + || { echo "$TAG is not on main" >&2; exit 1; } + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "version $V" + + publish: + needs: [version] + runs-on: docker + container: + image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + env: + DOCKER_BUILDKIT: "1" + REGISTRY: ${{ vars.REGISTRY }} + IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }} + VERSION: ${{ needs.version.outputs.version }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + - run: | + test -n "$REGISTRY" && test -n "$VERSION" + test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; } + echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY" + docker run --privileged --rm tonistiigi/binfmt --install arm64 + docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder + # Attestations off, as before: they add manifests of their own to the + # index, and the index should hold the two images and nothing else. + - run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --provenance=false --sbom=false \ + --tag "$IMAGE:$VERSION" \ + --tag "$IMAGE:latest" \ + --push . + docker buildx imagetools inspect "$IMAGE:$VERSION" + # Gitea keeps a container package on its owner; linking it shows it on + # the repository's Packages tab. Idempotent. + - run: | + apk add --no-cache -q curl + curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \ + "$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \ + || echo "package already linked (or link refused); not fatal" + - if: always() + run: docker logout "$REGISTRY" || true + + # The weekly release creates its Release (and so the tag) first; a tag + # pushed by hand has none. Either way the tag ends up with exactly one + # Release, created after the image exists so its pull instructions work. + release: + needs: [version, publish] + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + steps: + - shell: bash + env: + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.version }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + REGISTRY: ${{ vars.REGISTRY }} + run: | + python3 - <<'PY' + import json, os, urllib.request, urllib.error + api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}" + h = {"Authorization": f"token {os.environ['TOKEN']}", "Content-Type": "application/json"} + tag, version = os.environ["TAG"], os.environ["VERSION"] + try: + urllib.request.urlopen(urllib.request.Request(f"{api}/releases/tags/{tag}", headers=h)) + print(f"{tag} already has a release"); raise SystemExit + 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`." + data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).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/weekly-release.yml b/.gitea/workflows/weekly-release.yml new file mode 100644 index 0000000..382e199 --- /dev/null +++ b/.gitea/workflows/weekly-release.yml @@ -0,0 +1,135 @@ +# Weekly release, ported from .github/workflows/release.yml when the project +# moved to the self-hosted Gitea (2026-09-22): cut a release once a week, but +# only if there is something in it. A release with nothing in it moves +# :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. +# +# 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 +# workflow_dispatch; dry_run defaults to true. +# +# NOT LIVE YET: this only ever dry-runs unless the Actions variable +# RELEASE_LIVE is '1' (repo or org). Going live also needs a repo secret +# 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.) +name: weekly-release + +on: + schedule: + - cron: '7 10 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: Show the decision and stop + type: boolean + default: true + +# One at a time: two overlapping runs would race to write the same version and +# create the same tag. +concurrency: + group: weekly-release + cancel-in-progress: false + +jobs: + weekly-release: + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + env: + READ_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # Live only with RELEASE_LIVE=1 AND either the schedule or a manual run + # with dry_run unticked. + DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }} + RELEASE_LIVE: ${{ vars.RELEASE_LIVE }} + REPO: ${{ github.repository }} + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + with: + fetch-depth: 0 + - shell: bash + run: | + python3 - <<'PY' + import base64, datetime, json, os, re, subprocess, sys, urllib.request + + api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}" + def call(method, path, token, body=None): + req = urllib.request.Request(api + path, method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={"Authorization": f"token {token}", "Content-Type": "application/json"}) + with urllib.request.urlopen(req) as r: + return json.load(r) + def git(*a): + return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout.strip() + def has_tag(t): + # show-ref matches an exact ref; rev-parse --verify on this git + # can read some tag names as describe output and "find" a tag + # that isn't there. + return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0 + + sha = git("rev-parse", "HEAD") + # 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, + # which cuts a release that was due anyway. + rels = call("GET", "/releases?draft=false&pre-release=false&limit=1", os.environ["READ_TOKEN"]) + previous = rels[0]["tag_name"] if rels else "" + rng = f"{previous}..HEAD" if previous and has_tag(previous) else "HEAD" + count = int(git("rev-list", "--count", rng)) + 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}" + 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) + + token = os.environ.get("RELEASE_TOKEN", "") + 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}" + + 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"] + print(f"committed the bump as {bump}") + + # Notes bounded to what is new: one line per change on main's + # first-parent history. Creating the release creates the tag, which + # 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}", + "body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"}) + print(f"created release {rel['tag_name']}") + PY