Files
jcoffey-dev 0d610ac3af Release workflows: series, patches and release candidates
tools/release/version.py reads the workspace version, maps it the way the
server does (the same test cases), checks a tag against it, lists a
version's image tags and bumps Cargo.toml with Cargo.lock.

publish (GitHub and Gitea): the tag must be v<shown version>, read from
Cargo.toml instead of branding.rs. A release moves :<version>, the
series tag and :latest; a release candidate moves :<version> and
:<series>-rc, never :latest, and its Release is a pre-release.

weekly-release: cuts the next patch of the current series (27.1 ->
27.1.1) and commits Cargo.toml and Cargo.lock together. While main is on
a release candidate it says so and stops: a series and its candidates
are cut by hand.

CI runs the helper's tests next to the fork tools'.
2026-10-28 08:38:45 -07:00

139 lines
7.0 KiB
YAML

# 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.
#
# 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
# 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 (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:
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, 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):
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")
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,
# 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)
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)
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)
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)
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
# 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 {shown}",
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
print(f"created release {rel['tag_name']}")
PY