Two release builds side by side on one machine each take twice as long, and production only needs amd64. publish-amd64 now pushes :<version> as soon as the amd64 build is done; publish-arm64 builds arm64 afterwards, then replaces :<version> with the two-platform index and moves :latest. Both jobs use one named BuildKit builder whose container outlives the job, so the dependency layer (cargo chef cook) is reused until the dependencies change. The release is created after amd64; the binaries are attached once arm64 is in.
280 lines
13 KiB
YAML
280 lines
13 KiB
YAML
# 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), built by two jobs on
|
|
# the image-build runner rather than one buildx run for both. 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 -- but two release builds (LTO, one codegen unit) side by side on one
|
|
# machine each take twice as long. Production runs amd64, so amd64 goes first
|
|
# and on its own:
|
|
# * publish-amd64 pushes :<version>-amd64 and :<version>, a plain amd64
|
|
# image, as soon as its build is done. A deploy can start from it.
|
|
# * publish-arm64 then builds arm64, pushes :<version>-arm64, and replaces
|
|
# :<version> with the two-platform index. :latest moves only here, so it
|
|
# never names an image without arm64.
|
|
#
|
|
# Both jobs use one BuildKit builder, `gitea-builder`, whose container
|
|
# (buildx_buildkit_gitea-builder0) and state volume stay on the runner's host
|
|
# between jobs: a job container's `buildx create` finds the existing container
|
|
# and reuses it and its cache. The dependency build (`cargo chef cook`) is
|
|
# keyed on the recipe, which only a dependency change alters, so a release
|
|
# normally compiles just the workspace. Removing that container or its volume
|
|
# costs the next release a cold build, nothing more. The planner and dependency
|
|
# layers for the build platform are shared, so arm64 also reuses what amd64
|
|
# just did where it can.
|
|
#
|
|
# 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-amd64:
|
|
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 buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
|
# Attestations off, as before: they add manifests of their own, and the
|
|
# index should hold the two images and nothing else.
|
|
- run: |
|
|
docker buildx build \
|
|
--platform linux/amd64 \
|
|
--provenance=false --sbom=false \
|
|
--tag "$IMAGE:$VERSION-amd64" \
|
|
--tag "$IMAGE:$VERSION" \
|
|
--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
|
|
|
|
publish-arm64:
|
|
needs: [version, publish-amd64]
|
|
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: |
|
|
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
|
|
# The index is built from the two per-architecture tags rather than from
|
|
# :<version>, which by now is the amd64 image and would be read as such.
|
|
- run: |
|
|
docker buildx build \
|
|
--platform linux/arm64 \
|
|
--provenance=false --sbom=false \
|
|
--tag "$IMAGE:$VERSION-arm64" \
|
|
--push .
|
|
docker buildx imagetools create \
|
|
--tag "$IMAGE:$VERSION" \
|
|
--tag "$IMAGE:latest" \
|
|
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
|
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
|
- 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 once the amd64 image exists so its pull instructions
|
|
# work; arm64 and the binaries follow.
|
|
release:
|
|
needs: [version, publish-amd64]
|
|
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`. "
|
|
"amd64 is published first; arm64 is added to the same tag when its build "
|
|
"finishes, and `:latest` moves then.\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-arm64, 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
|