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'.
139 lines
7.0 KiB
YAML
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
|