# 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`.\n\n" "Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and " "`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this " "release's image for that architecture, so it is the same build. The image " "grants it `cap_net_bind_service`; a host install has to grant that itself " "(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.") 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 # The binaries for a host install, taken out of the image that was just # pushed rather than compiled again. # # Building them separately would mean a second Rust build per architecture # -- the slowest thing this pipeline does -- and would leave two artifacts # that are supposed to be the same build but only probably are. Extracting # them makes that identity a fact: the binary in the tarball is the file # the image runs. # # `docker create` does not start anything, so pulling an arm64 image on an # amd64 runner and copying a file out of it needs no emulation. binaries: needs: [version, publish, release] runs-on: docker container: image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli volumes: - /var/run/docker.sock:/var/run/docker.sock env: REGISTRY: ${{ vars.REGISTRY }} IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }} VERSION: ${{ needs.version.outputs.version }} TAG: ${{ github.ref_name }} REPO: ${{ github.repository }} PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: take the binaries out of the image run: | set -euo pipefail echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY" mkdir -p /out && cd /out for arch in amd64 arm64; do docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION" id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")" docker cp "$id:/usr/local/bin/inbuxa" "inbuxa" docker rm -f "$id" >/dev/null chmod 0755 inbuxa tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa rm inbuxa done sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS cat SHA256SUMS - name: attach them to the release run: | set -euo pipefail apk add --no-cache -q python3 python3 - <<'PY' import json, os, urllib.request, urllib.error, uuid, pathlib api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}" tok = {"Authorization": f"token {os.environ['TOKEN']}"} tag = os.environ["TAG"] def get(path): return json.load(urllib.request.urlopen(urllib.request.Request(api + path, headers=tok))) rel = get(f"/releases/tags/{tag}") assets = {a["name"]: a["id"] for a in get(f"/releases/{rel['id']}/assets")} for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz", "/out/SHA256SUMS"]: name = os.path.basename(path) # A re-run of a tag replaces its assets rather than leaving two # files with the same name and different contents. if name in assets: urllib.request.urlopen(urllib.request.Request( f"{api}/releases/{rel['id']}/assets/{assets[name]}", headers=tok, method="DELETE")) boundary = uuid.uuid4().hex body = b"".join([ f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n".encode(), b"Content-Type: application/octet-stream\r\n\r\n", pathlib.Path(path).read_bytes(), f"\r\n--{boundary}--\r\n".encode(), ]) req = urllib.request.Request( f"{api}/releases/{rel['id']}/assets?name={name}", data=body, method="POST", headers={**tok, "Content-Type": f"multipart/form-data; boundary={boundary}"}) urllib.request.urlopen(req) print("attached", name) PY - if: always() run: docker logout "$REGISTRY" || true