The v1 fallback I added a commit ago cannot work: deploy/compose/docker-compose.yml
uses v2-only syntax — a top-level `name:` and long-form
`env_file: {path, required}` — so docker-compose 1.29 rejects the file outright
("'name' does not match any of the regexes"). A fallback that always fails is
worse than no fallback, so the script now requires v2 and fails immediately with
what to do about it.
$COMPOSE overrides the detection. gw-04 is deliberately left WITHOUT a
`docker compose` plugin: installing one system-wide would flip the production
rolling deploy (clawmates-deploy.sh prefers v2 when present) off docker-compose
v1 as an invisible side effect of a release change. The runner gets a standalone
v2 binary at /opt/act-runner/bin/docker-compose and the workflow passes it in,
so prod keeps rolling exactly as it did.
Verified on gw-04: standalone v2.32.4 runs, and `docker compose` still resolves
to nothing, so clawmates-deploy.sh takes its v1 branch unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
207 lines
10 KiB
YAML
207 lines
10 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.
|
|
# COMPOSE points at a STANDALONE v2 binary. gw-04 intentionally has no
|
|
# `docker compose` plugin: installing one would switch the production
|
|
# rolling deploy (clawmates-deploy.sh) off docker-compose v1 as a side
|
|
# effect. The compose file needs v2 syntax, so the rehearsal gets its
|
|
# own copy rather than changing what prod rolls with.
|
|
CLAWMATES_BUNDLER="$PWD/.tools/clawmates-bundler" \
|
|
COMPOSE=/opt/act-runner/bin/docker-compose \
|
|
./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}'
|