From f1ac88259b8db673c6b6c9da777eb16f30edc4ca Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 5 May 2026 00:36:17 +0200 Subject: [PATCH] feat(actions): add bump-stacks and deploy-stack composite actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 _IMAGE, runs 'compose pull && compose up -d'. Writes /srv//.deployed for drift checks. --- .gitea/actions/bump-stacks/action.yml | 100 +++++++++++++++++++++++++ .gitea/actions/deploy-stack/action.yml | 76 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 .gitea/actions/bump-stacks/action.yml create mode 100644 .gitea/actions/deploy-stack/action.yml diff --git a/.gitea/actions/bump-stacks/action.yml b/.gitea/actions/bump-stacks/action.yml new file mode 100644 index 0000000..88b1bd9 --- /dev/null +++ b/.gitea/actions/bump-stacks/action.yml @@ -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 diff --git a/.gitea/actions/deploy-stack/action.yml b/.gitea/actions/deploy-stack/action.yml new file mode 100644 index 0000000..6461785 --- /dev/null +++ b/.gitea/actions/deploy-stack/action.yml @@ -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 <_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}"