feat(actions): add bump-stacks and deploy-stack composite actions

bump-stacks: invoked by stack-repo CI after a successful image push.
Clones libretech/gitops-sandbox, mutates stacks.yml to pin the new
short sha for the named stack, commits + pushes back. Idempotent —
exits 0 with a no-op notice when the sha is already pinned. Retries
up to 3× on non-fast-forward to absorb concurrent bumps.

deploy-stack: invoked by stack-repo deploy.yml (workflow_dispatch).
SSHes to netcup with a stack-scoped key, writes a .env.deploy file
pinning <STACK>_IMAGE, runs 'compose pull && compose up -d'. Writes
/srv/<stack>/.deployed for drift checks.
This commit is contained in:
2026-05-05 00:36:17 +02:00
parent b2109e7937
commit f1ac88259b
2 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
name: bump-stacks
description: |
Push the new short sha for a stack into libretech/gitops-sandbox/stacks.yml.
Idempotent: a no-op when the sha is already pinned.
inputs:
stack:
description: stack name (key under `stacks:` in stacks.yml)
required: true
sha:
description: short git sha (7 chars) of the image tag just pushed
required: true
bot_token:
description: PAT with write:repository on libretech/gitops-sandbox
required: true
run_number:
description: optional Gitea Actions run_number (informational only)
required: false
default: "0"
sandbox_repo:
description: orchestrator repo slug
required: false
default: libretech/gitops-sandbox
runs:
using: composite
steps:
- shell: bash
env:
STACK: ${{ inputs.stack }}
SHA: ${{ inputs.sha }}
RUN: ${{ inputs.run_number }}
BOT_TOKEN: ${{ inputs.bot_token }}
SANDBOX_REPO: ${{ inputs.sandbox_repo }}
run: |
set -euo pipefail
WORK=$(mktemp -d)
cd "$WORK"
git config --global user.name "gitops-bot"
git config --global user.email "gitops-bot@librete.ch"
REPO_URL="https://oauth2:${BOT_TOKEN}@git.librete.ch/${SANDBOX_REPO}.git"
# Retry up to 3 times on non-fast-forward to absorb concurrent bumps.
for attempt in 1 2 3; do
rm -rf clone
git clone --depth=2 "$REPO_URL" clone
cd clone
bun --version >/dev/null # baked into runner-image
# Bun-native edit: load yaml, mutate, write back. Preserves order
# because the `yaml` package keeps key positions on round-trip.
cat > /tmp/bump.js <<'JS'
import { readFileSync, writeFileSync } from "node:fs";
import { parseDocument } from "yaml";
const [, , stack, sha, run] = process.argv;
const doc = parseDocument(readFileSync("stacks.yml", "utf8"));
const stacks = doc.get("stacks");
if (!stacks?.has(stack)) {
console.error(`stack '${stack}' not in stacks.yml`);
process.exit(2);
}
const node = stacks.get(stack);
const oldSha = node.get("sha");
if (String(oldSha) === String(sha)) {
console.log(`no-op: ${stack} already at sha=${sha}`);
process.exit(10); // sentinel for caller
}
node.set("sha", sha);
node.set("run", Number(run));
writeFileSync("stacks.yml", doc.toString());
console.log(`bumped ${stack}: ${oldSha} → ${sha}`);
JS
# Install deps inside clone (cached after first run).
[ -f package.json ] || echo '{"type":"module","dependencies":{"yaml":"^2.6.1"}}' > package.json
bun install --frozen-lockfile 2>/dev/null || bun install
set +e
bun /tmp/bump.js "$STACK" "$SHA" "$RUN"
rc=$?
set -e
if [ "$rc" = "10" ]; then
echo "::notice::stacks.yml already at sha=$SHA, no commit"
exit 0
fi
[ "$rc" = "0" ] || exit "$rc"
git add stacks.yml
git commit -m "bump(${STACK}): sha=${SHA} run=${RUN}"
if git push origin main; then
echo "::notice::pushed bump for ${STACK} to ${SANDBOX_REPO}"
exit 0
fi
echo "push rejected, attempt ${attempt}/3"
cd ..
sleep $((attempt * 3))
done
echo "::error::could not push bump after 3 attempts"
exit 1

View File

@@ -0,0 +1,76 @@
name: deploy-stack
description: |
SSH to netcup, pull the pinned image, restart the stack via docker compose.
Each stack repo holds its own SSH key; the netcup `authorized_keys` line
is forced-command-locked to that stack's directory.
inputs:
stack:
description: stack name (cosmetic, used in deploy-marker file)
required: true
target:
description: absolute path on netcup, e.g. /srv/gitops-hello
required: true
image:
description: full image ref including tag, e.g. git.librete.ch/libretech/gitops-hello:sha-abc1234
required: true
ssh_key:
description: private SSH key (PEM, multi-line ok)
required: true
ssh_host:
description: netcup hostname, e.g. cloud.librete.ch
required: true
ssh_user:
description: netcup user, e.g. tengo
required: true
registry:
description: registry hostname for docker login
required: true
registry_user:
description: registry username
required: true
registry_pass:
description: registry PAT
required: true
runs:
using: composite
steps:
- shell: bash
env:
STACK: ${{ inputs.stack }}
TARGET: ${{ inputs.target }}
IMAGE: ${{ inputs.image }}
SSH_KEY: ${{ inputs.ssh_key }}
SSH_HOST: ${{ inputs.ssh_host }}
SSH_USER: ${{ inputs.ssh_user }}
REGISTRY: ${{ inputs.registry }}
REGISTRY_USER: ${{ inputs.registry_user }}
REGISTRY_PASS: ${{ inputs.registry_pass }}
run: |
set -euo pipefail
umask 077
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
SSH="ssh -i ~/.ssh/id_deploy -o BatchMode=yes ${SSH_USER}@${SSH_HOST}"
# The forced-command on netcup's side is parameterised by the
# original command, so we send a single-line script via stdin.
$SSH bash -s <<EOF
set -euo pipefail
cd "$TARGET"
echo "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
# Pin the image for compose via a stack-local .env file.
# HELLO_IMAGE / <STACK_UPPER>_IMAGE etc. — generic env name pattern.
STACK_UPPER=\$(echo "$STACK" | tr a-z A-Z)
printf '%s_IMAGE=%s\n' "\$STACK_UPPER" "$IMAGE" > .env.deploy
docker compose --env-file .env.deploy pull
docker compose --env-file .env.deploy up -d
# Write deploy marker — read by drift checks + observability.
printf '%s\n' "image=$IMAGE" "deployed_at=\$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .deployed
EOF
echo "::notice::deployed ${STACK}: ${IMAGE}"