Releasing without GitHub Actions¶
This doc covers building + publishing a release locally. For the fleet topology, the per-host rollout sequencing, and the post-rollout verification (and how a plain
applyself-bounces the changed agents as of 2.2.4 — so the upgrade takes effect without a separateagents restart all), see the canonical releasing.md. The one-command rollout isfleet-upgrade.sh.
When Actions is down (or you just want to release from srv where the apt repo already
runs), the whole pipeline runs locally. ci.yml only calls ci-local.sh, and
release.yml only calls release.sh + build-repo.sh + a docker build/push — so
release-local.sh reproduces the release job exactly.
Gating without Actions¶
There used to be a .githooks/pre-push hook that refused a push ci-local.sh had not
graded green, using a receipt file to avoid re-running the gate on an unchanged tree. It
was removed on 2026-08-23: its own test (test/pre_push_hook_test.sh) was supposed to
exercise the hook against a scratch bare remote, but the isolation leaked and it
force-pushed a wipe onto the real origin/main. Recovered from 59b0bdb7 with no data
loss, but a mechanism whose own test can force-push the real repo is worse than no
mechanism — nothing here re-implements it.
Run ./ci-local.sh before every push. There is no server-side equivalent to fall back on
and there will not be one while the repo is private on this plan: gh api
repos/muyajil/agentctl/rulesets and …/branches/main/protection both answer 403
"Upgrade to GitHub Pro or make this repository public", and Actions has been
billing-exhausted since 2026-07-08. So this is discipline, not enforcement — a push to
main or a branch with an open PR should get a full run; a branch with no PR yet can use
--changed.
CI_VIDEO=0 is a local iteration knob, not a release one. It drops the
video*/audio-mcp* shell suites — ~81% of the shell tier's time — on any invocation mode,
including a bare ./ci-local.sh. Unlike --changed it proves nothing: those suites are in
scope and a person excluded them. A run that uses it says so in the plan, in the tier, in a
banner and in its own final line, and can never print the unqualified blocking checks GREEN
verdict. Never cut a release from one.
One-time: a fresh signing key¶
The private signing key used to live only in the GH secret AGENTCTL_GPG_PRIVATE_KEY.
If it's not on the release host, generate a new one (RSA-4096, passphraseless so
reprepro/release.sh can sign in batch):
cat > /tmp/keygen <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Name-Real: agentctl release signing
Name-Email: agentctl@ajil.ch
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /tmp/keygen && rm -f /tmp/keygen
gpg --list-secret-keys --keyid-format=long agentctl@ajil.ch # note the key id
Save it to the GH secrets so Actions uses the SAME key when it's back (private key piped into gh — never printed):
gpg --armor --export-secret-keys <KEYID> | gh secret set AGENTCTL_GPG_PRIVATE_KEY --repo muyajil/agentctl
printf '%s' <KEYID> | gh secret set AGENTCTL_GPG_KEY --repo muyajil/agentctl
(As of 2026-07-07 the fresh key is 79319621519D327B / fpr
D18BB002681858801198419679319621519D327B, in noor's keyring on srv.)
Cut + release, all local¶
./cut-release.sh [patch|minor|major|X.Y.Z] # bump nfpm + tag + push (the git cut)
AGENTCTL_GPG_KEY=<KEYID> ./release-local.sh --ci # ci gate → build+sign+GH release → apt repo → GHCR push
release-local.sh:
1. release.sh --force — builds the .deb (docker), checksums, GPG-signs it, publishes the GitHub Release.
2. build-repo.sh — reprepro assembles the signed apt repo + exports the public keyring.
3. docker build + push ghcr.io/muyajil/agentctl-repo:{VER,latest}.
Prereqs on the release host: docker (perms + docker login ghcr.io with a PAT that
has write:packages), reprepro, gh (authed), the signing key's private half in the
gpg keyring.
Serve the new version¶
The repo host runs Watchtower → it auto-pulls :latest and restarts agentctl-repo.
Or by hand:
docker compose pull agentctl-repo && docker compose up -d agentctl-repo
Clients: re-trust the new key (only when the key changed)¶
The apt source is Signed-By: /usr/share/keyrings/agentctl-archive-keyring.gpg. The
container serves the new public key; each client re-fetches it, then upgrades:
curl -fsSL -u USER:PASS https://agentctl.srv.ajil.ch/agentctl-archive-keyring.gpg \
| sudo tee /usr/share/keyrings/agentctl-archive-keyring.gpg >/dev/null
sudo apt-get update && sudo apt-get install -y --only-upgrade agentctl
Until a client re-fetches the key, apt update fails signature verification against
the old key — expected, and the reason the key swap must be rolled to every host.