# 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