A release published an image and nothing else, so there was nothing for a host install to download -- the only way to get the binary was to pull the image and copy it out, which makes "install without Docker" depend on Docker. Each release now carries inbuxa-linux-amd64.tar.gz, inbuxa-linux-arm64.tar.gz and SHA256SUMS, named as stalwart-migrator's are. They are taken out of the image this pipeline just pushed rather than compiled again. A second Rust build per architecture is the slowest thing here, and it would leave two artifacts that are meant to be the same build and only probably are. Extracting makes that identity a fact: the binary in the tarball is the file the image runs. `docker create` starts nothing, so copying a file out of an arm64 image on an amd64 runner needs no emulation. One thing the extraction cannot carry: the image grants the binary cap_net_bind_service, and a tar archive does not keep that xattr. The release body says so, and says what to do instead -- setcap, or AmbientCapabilities in the unit -- because a server that cannot bind 25 and does not say why is a bad first hour. Checked by hand against v2026.9.23 before this landed: both architectures extract to the right ELF, and the amd64 binary runs on a bare Debian 13 with every library resolved and reports its own version.
226 lines
11 KiB
YAML
226 lines
11 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) 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`.\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
|