Skip to content

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 apply self-bounces the changed agents as of 2.2.4 — so the upgrade takes effect without a separate agents restart all), see the canonical releasing.md. The one-command rollout is fleet-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.