ci: publish the image on tags, port the weekly release #9
@@ -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<brand_version!>. 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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user