Files
clawmates/.gitea/workflows/release.yml
T
Omar SobhandClaude Opus 5 bd1c970577
deploy / test (push) Failing after 2m56s
deploy / build (push) Skipped
ci: let the install rehearsal use a pre-built bundler
The rehearsal hardcoded `cargo build -p clawmates-bundler`, so it died with
"cargo: command not found" on the release runner — gw-04 builds Rust inside a
container and has no toolchain of its own. The release job had already built the
bundler two steps earlier, so it was also redundant work.

CLAWMATES_BUNDLER now short-circuits that build when it points at an executable,
falling back to cargo otherwise, so running the script by hand is unchanged.

Everything before this step already passed on the runner: images built, SBOMs
generated, bundle assembled and signed, and "bundle OK: 94 artifacts verified
offline" inside a --network none container.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:02:05 -07:00

200 lines
9.5 KiB
YAML

# Release: build the images both deploy targets share, assemble the SIGNED
# air-gapped bundle, verify it offline, rehearse the customer's install, and
# attach everything to the Gitea release for the tag.
#
# Moved from .github/workflows/ and rewritten for this forge. The old copy could
# never have run: `runs-on: ubuntu-latest` matches no runner here, and
# `softprops/action-gh-release` talks to GitHub's API, not Gitea's.
#
# The signing key is a repo secret (BUNDLE_SIGNING_KEY, hex ed25519 from
# `clawmates-bundler keygen`). The matching PUBLIC key is published out of band
# so customers can verify a bundle before `docker load`.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
bundle:
runs-on: gw04
steps:
- uses: actions/checkout@v4
- name: Version from tag
run: |
# workflow_dispatch has no tag; fall back to the short sha so a manual
# run produces a clearly-not-a-release version rather than an empty one.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
else
echo "VERSION=0.0.0-$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
fi
- name: Build images
run: |
set -eu
docker build -t "clawmates/server:$VERSION" -f images/server.Dockerfile .
docker build -t "clawmates/frontend:$VERSION" -f images/frontend.Dockerfile .
docker build -t "clawmates/broker:$VERSION" -f images/broker.Dockerfile .
docker build -t "clawmates/agent-base:$VERSION" images/agent-base
docker build -t "clawmates/agent-browser:$VERSION" images/agent-browser
docker pull -q postgres:16-alpine
docker pull -q tecnativa/docker-socket-proxy:0.3
# syft goes in the workspace, NOT /usr/local/bin. The host executor runs
# as root on the production gateway; a release should not leave binaries
# behind on it.
- name: SBOMs for every shipped image
run: |
set -eu
mkdir -p dist/sboms .tools
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b .tools
for image in server frontend broker agent-base agent-browser; do
./.tools/syft "clawmates/$image:$VERSION" -o spdx-json \
> "dist/sboms/$image.spdx.json"
done
- name: Save image tarballs
run: |
set -eu
mkdir -p dist/images
docker save "clawmates/server:$VERSION" -o dist/images/server.tar
docker save "clawmates/frontend:$VERSION" -o dist/images/frontend.tar
docker save "clawmates/broker:$VERSION" -o dist/images/broker.tar
docker save "clawmates/agent-base:$VERSION" -o dist/images/agent-base.tar
docker save "clawmates/agent-browser:$VERSION" -o dist/images/agent-browser.tar
docker save tecnativa/docker-socket-proxy:0.3 -o dist/images/socket-proxy.tar
docker save postgres:16-alpine -o dist/images/postgres.tar
du -sh dist/images
# gw-04 has no cargo, so the bundler builds in a container — same pattern
# and same cache volumes as deploy.yml. The forge credential is here
# because cargo resolves the whole workspace, which includes cm-brain's
# private clawhdf5 git dependency.
- name: Build bundler
run: |
docker run --rm \
-v "$PWD":/w -w /w \
-v cm-ci-cargo-registry:/usr/local/cargo/registry \
-v cm-ci-cargo-git:/usr/local/cargo/git \
-v cm-ci-target:/w/target \
-e SQLX_OFFLINE=true -e CARGO_NET_GIT_FETCH_WITH_CLI=true \
-e FORGE_TOKEN='${{ secrets.FORGE_TOKEN }}' \
rust:1.96-slim \
sh -c 'set -e
apt-get update -qq
apt-get install -y -qq pkg-config libssl-dev cmake git >/dev/null
git config --global url."https://oauth2:[email protected]/".insteadOf "https://git.redclaw.dev/"
cargo build --release -p clawmates-bundler
# Copy the binary OUT of the target volume and into the workspace.
# /w/target is a named docker volume, so anything left there is
# invisible to later steps running on the host — which is exactly
# how this failed the first time (exit 127, No such file).
mkdir -p /w/.tools
cp target/release/clawmates-bundler /w/.tools/clawmates-bundler'
test -x .tools/clawmates-bundler || { echo "bundler did not land in the workspace"; exit 1; }
- name: Assemble and sign the bundle
env:
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
run: |
set -eu
test -n "$BUNDLE_SIGNING_KEY" || { echo "BUNDLE_SIGNING_KEY is empty"; exit 1; }
umask 077
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
BUNDLER=.tools/clawmates-bundler
ARTIFACTS=""
for tar in dist/images/*.tar; do
ARTIFACTS="$ARTIFACTS $tar=images/$(basename "$tar")"
done
for migration in migrations/*.sql; do
ARTIFACTS="$ARTIFACTS $migration=migrations/$(basename "$migration")"
done
# shellcheck disable=SC2086
"$BUNDLER" assemble dist/bundle "$VERSION" /tmp/release.key \
deploy/compose/docker-compose.yml=compose/docker-compose.yml \
deploy/compose/clawmates.toml=compose/clawmates.toml \
deploy/compose/.env.example=compose/.env.example \
deploy/e2e/scenarios.toml=compose/scenarios.toml \
images/seccomp/agent-profile.json=seccomp/agent-profile.json \
deploy/airgapped/install.sh=install.sh \
"$BUNDLER"=bin/clawmates-bundler \
dist/sboms/server.spdx.json=sboms/server.spdx.json \
dist/sboms/frontend.spdx.json=sboms/frontend.spdx.json \
dist/sboms/agent-base.spdx.json=sboms/agent-base.spdx.json \
dist/sboms/agent-browser.spdx.json=sboms/agent-browser.spdx.json \
$ARTIFACTS
chmod +x dist/bundle/bin/clawmates-bundler dist/bundle/install.sh
rm -f /tmp/release.key
- name: Verify the bundle offline (public key only)
env:
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
run: |
set -eu
umask 077
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
.tools/clawmates-bundler pubkey /tmp/release.key dist/release.pub
rm -f /tmp/release.key
# The customer's exact procedure: the public half only, inside a
# NETWORK-DISABLED container, proving verification needs no internet.
docker run --rm --network none \
-v "$PWD/dist:/dist:ro" \
ubuntu:24.04 \
/dist/bundle/bin/clawmates-bundler verify /dist/bundle /dist/release.pub
- name: Tarball
run: tar -C dist -czf "clawmates-bundle-$VERSION.tgz" bundle
- name: Clean-room install rehearsal
run: |
set -eu
docker tag "clawmates/server:$VERSION" clawmates/server:latest
docker tag "clawmates/frontend:$VERSION" clawmates/frontend:latest
docker tag "clawmates/broker:$VERSION" clawmates/broker:latest
# Absolute path: the script cds around, and it needs the binary we
# already built rather than a cargo this host does not have.
CLAWMATES_BUNDLER="$PWD/.tools/clawmates-bundler" ./scripts/rehearse-install.sh
# Gitea's release API, not softprops/action-gh-release (GitHub-only).
# Create-or-reuse, so a re-run of the same tag updates instead of 409ing.
- name: Attach to the Gitea release
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -eu
API="https://git.redclaw.dev/api/v1/repos/$GITHUB_REPOSITORY/releases"
TAG="${GITHUB_REF_NAME}"
id=$(curl -sS -H "Authorization: token $FORGE_TOKEN" "$API/tags/$TAG" \
| sed -n 's/.*"id":[ ]*\([0-9]\+\).*/\1/p' | head -1)
if [ -z "$id" ]; then
id=$(curl -sS -X POST -H "Authorization: token $FORGE_TOKEN" \
-H 'content-type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Air-gapped bundle for $TAG. Verify with the published public key before docker load.\"}" \
"$API" | sed -n 's/.*"id":[ ]*\([0-9]\+\).*/\1/p' | head -1)
fi
test -n "$id" || { echo "could not create or find the release for $TAG"; exit 1; }
for f in "clawmates-bundle-$VERSION.tgz" dist/release.pub; do
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-F "attachment=@$f" \
"$API/$id/assets?name=$(basename "$f")")
echo " attached $(basename "$f") (HTTP $code)"
case "$code" in 20*) ;; *) echo "attach failed"; exit 1 ;; esac
done
# Release artifacts are GBs of image tarballs on the production gateway.
# Never `docker image prune -a` here: clawmates/agent-*:dev exist in no
# registry and are the source of the microVM rootfs files.
- name: Reclaim disk
if: always()
run: |
rm -rf dist .tools "clawmates-bundle-$VERSION.tgz" || true
for i in server frontend broker agent-base agent-browser; do
docker rmi "clawmates/$i:$VERSION" 2>/dev/null || true
done
df -h / | awk 'NR==2{print " disk free: "$4}'