From 4320c87692a97ec04464f7440dbccddbd9757548 Mon Sep 17 00:00:00 2001 From: David Julian Albers Date: Mon, 29 Jun 2026 17:23:53 +0200 Subject: [PATCH 1/3] Add reusable app CI + deploy verb (the paved road) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app-ci.yml: reusable workflow_call (lint→typecheck→test→build→push image to the caller's GHCR namespace, tagged by SHA) - deploy.yml: reusable workflow mounting the deploy verb on the push event - scripts/deploy.sh: the stateless deploy verb (PLEXUS.md §5) — ssh → pull → mise run migrate → up → poll /healthz → rollback; hand-runnable, no state Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/app-ci.yml | 83 ++++++++++++++++++++++++++++++++++++ .github/workflows/deploy.yml | 69 ++++++++++++++++++++++++++++++ scripts/deploy.sh | 78 +++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 .github/workflows/app-ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100755 scripts/deploy.sh diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml new file mode 100644 index 0000000..87ae3d9 --- /dev/null +++ b/.github/workflows/app-ci.yml @@ -0,0 +1,83 @@ +name: app-ci + +# Reusable CI for Plexus apps (PLEXUS.md §5): lint → typecheck → test → build & +# push a container image to the caller's GHCR namespace, tagged with the git SHA. +# Apps reference it in ~5 lines: +# +# jobs: +# ci: +# uses: plexus-ms/library/.github/workflows/app-ci.yml@v1 +# with: { app: plexus-website } + +on: + workflow_call: + inputs: + app: + description: Workspace app name (pnpm filter + mise dir under apps/). + required: true + type: string + image-name: + description: Image name under the caller's GHCR org. Defaults to `app`. + required: false + type: string + default: '' + dockerfile: + description: Dockerfile path. Defaults to apps//Dockerfile. + required: false + type: string + default: '' + push: + description: Build and push the image (set false for PRs). + required: false + type: boolean + default: true + outputs: + image: + description: Full image ref that was built (name:sha). + value: ${{ jobs.image.outputs.image }} + +permissions: + contents: read + packages: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v4 + - run: pnpm install --frozen-lockfile + - run: mise run lint + - run: mise run '//...:typecheck' + - run: mise run '//...:test' + + image: + needs: check + if: ${{ inputs.push }} + runs-on: ubuntu-latest + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v7 + - id: meta + # Image lives under the CALLER's org so the repo's own GITHUB_TOKEN can + # push it (no cross-org PAT). Tenant app → tenant-org registry. + run: | + name='${{ inputs.image-name }}' + [ -n "$name" ] || name='${{ inputs.app }}' + owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]') + echo "image=ghcr.io/$owner/$name:${{ github.sha }}" >> "$GITHUB_OUTPUT" + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + file: ${{ inputs.dockerfile != '' && inputs.dockerfile || format('apps/{0}/Dockerfile', inputs.app) }} + push: true + tags: ${{ steps.meta.outputs.image }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7ebc600 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: deploy + +# Reusable deploy for Plexus apps (PLEXUS.md §5). Mounts the stateless deploy verb +# (scripts/deploy.sh) on the push event; the tenant supplies the host + SSH key. +# Apps reference it after app-ci: +# +# deploy: +# needs: ci +# if: github.ref == 'refs/heads/main' +# uses: plexus-ms/library/.github/workflows/deploy.yml@v1 +# with: +# app: plexus-website +# image: ${{ needs.ci.outputs.image }} +# host: deploy@178.105.240.160 +# secrets: +# ssh-key: ${{ secrets.DEPLOY_SSH_KEY }} + +on: + workflow_call: + inputs: + app: + required: true + type: string + image: + description: Full image ref to deploy (name:tag), e.g. app-ci's output. + required: true + type: string + host: + description: SSH target, e.g. deploy@1.2.3.4. + required: true + type: string + health-url: + description: Healthcheck URL polled on the host. Defaults to the §4 path. + required: false + type: string + default: '' + library-ref: + description: Tag/branch of plexus-ms/library providing the deploy verb. + required: false + type: string + default: v1 + secrets: + ssh-key: + description: Private key authorized as the deploy user on the host. + required: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Fetch the deploy verb from the library + uses: actions/checkout@v7 + with: + repository: plexus-ms/library + ref: ${{ inputs.library-ref }} + - name: Load SSH key + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + eval "$(ssh-agent -s)" + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" + echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" + printf '%s\n' "${{ secrets.ssh-key }}" | ssh-add - + - name: Deploy + env: + PLEXUS_HEALTH_URL: ${{ inputs.health-url }} + run: bash scripts/deploy.sh '${{ inputs.host }}' '${{ inputs.app }}' '${{ inputs.image }}' diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5a41aec --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Plexus deploy verb — PLEXUS.md §5. A stateless procedure, not a system: +# +# ssh → docker compose pull → mise run migrate → docker compose up -d +# → poll /healthz → on failure: re-up the previous image, exit non-zero +# +# State lives in git (compose.yml + the app's mise.toml, placed on the host by the +# tenant's Ansible) and in the registry (the image). "Which version is live" is the +# running container's image, queried from `docker` (reality) — never a file we own. +# No persistent state, no daemon, no UI, no reconciliation loop. +# +# Degradation test — hand-runnable with no extra machinery: +# ./deploy.sh deploy@1.2.3.4 plexus-website ghcr.io/org/plexus-website: +# +set -euo pipefail + +HOST="${1:?usage: deploy.sh }" +APP="${2:?usage: deploy.sh }" +IMAGE="${3:?usage: deploy.sh }" + +# Overridable knobs (sane defaults for the stateless-app profile). +APP_DIR="${PLEXUS_APP_DIR:-/opt/plexus/$APP}" +HEALTH_URL="${PLEXUS_HEALTH_URL:-http://127.0.0.1:3000/healthz}" +RETRIES="${PLEXUS_HEALTH_RETRIES:-30}" + +echo "→ deploying $IMAGE" +echo " host: $HOST" +echo " dir: $APP_DIR" +echo " health: $HEALTH_URL" + +# Everything below runs on the host. Args are passed positionally (no fragile +# remote-env forwarding); the heredoc is quoted so it is not expanded locally. +ssh -o StrictHostKeyChecking=accept-new "$HOST" bash -seuo pipefail -- \ + "$APP_DIR" "$IMAGE" "$HEALTH_URL" "$RETRIES" <<'REMOTE' +APP_DIR="$1"; IMAGE="$2"; HEALTH_URL="$3"; RETRIES="$4" +cd "$APP_DIR" + +# Reality is the source of truth: read the currently-live image for rollback. +PREV_IMAGE="" +CID="$(docker compose ps -q web 2>/dev/null || true)" +[ -n "$CID" ] && PREV_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$CID" 2>/dev/null || true)" +echo " previous: ${PREV_IMAGE:-}" + +up() { # $1 = image ref + echo "IMAGE=$1" > .env # reproducibility cache for a manual `docker compose up` + IMAGE="$1" docker compose pull web + IMAGE="$1" docker compose up -d web +} + +healthy() { + for _ in $(seq 1 "$RETRIES"); do + curl -fsS -o /dev/null "$HEALTH_URL" && return 0 + sleep 2 + done + return 1 +} + +# Pull → migrate (idempotent; a no-op for stateless apps) → up. +echo "IMAGE=$IMAGE" > .env +IMAGE="$IMAGE" docker compose pull web +mise trust . >/dev/null 2>&1 || true +mise run migrate +IMAGE="$IMAGE" docker compose up -d web + +if healthy; then + echo "✓ $IMAGE is live and healthy" + exit 0 +fi + +echo "✗ healthcheck failed after $((RETRIES * 2))s" +if [ -n "$PREV_IMAGE" ] && [ "$PREV_IMAGE" != "$IMAGE" ]; then + echo "↩ rolling back to $PREV_IMAGE" + up "$PREV_IMAGE" + if healthy; then echo "✓ rolled back to $PREV_IMAGE"; else echo "✗ rollback also unhealthy — manual intervention needed"; fi +fi +exit 1 +REMOTE From bc8cc5c49fea3448134e1309d7c5970caba2d5ed Mon Sep 17 00:00:00 2001 From: David Julian Albers Date: Wed, 8 Jul 2026 10:17:49 +0200 Subject: [PATCH 2/3] refactor: reusable ci now lives at github.com/plexus-ms/ci.git --- .github/workflows/app-ci.yml | 83 ------------------------------------ .github/workflows/deploy.yml | 69 ------------------------------ 2 files changed, 152 deletions(-) delete mode 100644 .github/workflows/app-ci.yml delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml deleted file mode 100644 index 87ae3d9..0000000 --- a/.github/workflows/app-ci.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: app-ci - -# Reusable CI for Plexus apps (PLEXUS.md §5): lint → typecheck → test → build & -# push a container image to the caller's GHCR namespace, tagged with the git SHA. -# Apps reference it in ~5 lines: -# -# jobs: -# ci: -# uses: plexus-ms/library/.github/workflows/app-ci.yml@v1 -# with: { app: plexus-website } - -on: - workflow_call: - inputs: - app: - description: Workspace app name (pnpm filter + mise dir under apps/). - required: true - type: string - image-name: - description: Image name under the caller's GHCR org. Defaults to `app`. - required: false - type: string - default: '' - dockerfile: - description: Dockerfile path. Defaults to apps//Dockerfile. - required: false - type: string - default: '' - push: - description: Build and push the image (set false for PRs). - required: false - type: boolean - default: true - outputs: - image: - description: Full image ref that was built (name:sha). - value: ${{ jobs.image.outputs.image }} - -permissions: - contents: read - packages: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: jdx/mise-action@v4 - - run: pnpm install --frozen-lockfile - - run: mise run lint - - run: mise run '//...:typecheck' - - run: mise run '//...:test' - - image: - needs: check - if: ${{ inputs.push }} - runs-on: ubuntu-latest - outputs: - image: ${{ steps.meta.outputs.image }} - steps: - - uses: actions/checkout@v7 - - id: meta - # Image lives under the CALLER's org so the repo's own GITHUB_TOKEN can - # push it (no cross-org PAT). Tenant app → tenant-org registry. - run: | - name='${{ inputs.image-name }}' - [ -n "$name" ] || name='${{ inputs.app }}' - owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]') - echo "image=ghcr.io/$owner/$name:${{ github.sha }}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 - with: - context: . - file: ${{ inputs.dockerfile != '' && inputs.dockerfile || format('apps/{0}/Dockerfile', inputs.app) }} - push: true - tags: ${{ steps.meta.outputs.image }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 7ebc600..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: deploy - -# Reusable deploy for Plexus apps (PLEXUS.md §5). Mounts the stateless deploy verb -# (scripts/deploy.sh) on the push event; the tenant supplies the host + SSH key. -# Apps reference it after app-ci: -# -# deploy: -# needs: ci -# if: github.ref == 'refs/heads/main' -# uses: plexus-ms/library/.github/workflows/deploy.yml@v1 -# with: -# app: plexus-website -# image: ${{ needs.ci.outputs.image }} -# host: deploy@178.105.240.160 -# secrets: -# ssh-key: ${{ secrets.DEPLOY_SSH_KEY }} - -on: - workflow_call: - inputs: - app: - required: true - type: string - image: - description: Full image ref to deploy (name:tag), e.g. app-ci's output. - required: true - type: string - host: - description: SSH target, e.g. deploy@1.2.3.4. - required: true - type: string - health-url: - description: Healthcheck URL polled on the host. Defaults to the §4 path. - required: false - type: string - default: '' - library-ref: - description: Tag/branch of plexus-ms/library providing the deploy verb. - required: false - type: string - default: v1 - secrets: - ssh-key: - description: Private key authorized as the deploy user on the host. - required: true - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Fetch the deploy verb from the library - uses: actions/checkout@v7 - with: - repository: plexus-ms/library - ref: ${{ inputs.library-ref }} - - name: Load SSH key - run: | - mkdir -p ~/.ssh && chmod 700 ~/.ssh - eval "$(ssh-agent -s)" - echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" - echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" - printf '%s\n' "${{ secrets.ssh-key }}" | ssh-add - - - name: Deploy - env: - PLEXUS_HEALTH_URL: ${{ inputs.health-url }} - run: bash scripts/deploy.sh '${{ inputs.host }}' '${{ inputs.app }}' '${{ inputs.image }}' From a66beff89f87747e81459209942f3f0cab7a1029 Mon Sep 17 00:00:00 2001 From: David Julian Albers Date: Wed, 8 Jul 2026 10:18:15 +0200 Subject: [PATCH 3/3] chore: standardize biome task --- .github/workflows/ci.yml | 4 ++-- hk.pkl | 8 ++++---- mise.toml | 2 +- scripts/deploy.sh | 6 ++++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0a8b8..4419a67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: - uses: actions/checkout@v7 - uses: jdx/mise-action@v4 - run: pnpm install --frozen-lockfile - - run: mise //:check - - run: mise //:verify + - run: mise :biome + - run: mise :verify - run: mise //...:build changeset: diff --git a/hk.pkl b/hk.pkl index c037025..c3d1171 100644 --- a/hk.pkl +++ b/hk.pkl @@ -5,17 +5,17 @@ hooks { fix = true stash = true steps { - ["check"] { + ["biome"] { glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.css", "**/*.json") - check = "mise //:check {{ files }}" - fix = "mise //:check --write {{ files }}" + check = "mise :biome {{ files }}" + fix = "mise :biome --write {{ files }}" } } } ["pre-push"] { steps { ["verify"] { - check = "mise //:verify" + check = "mise :verify" } } } diff --git a/mise.toml b/mise.toml index f4c7132..3f6323e 100644 --- a/mise.toml +++ b/mise.toml @@ -13,7 +13,7 @@ pnpm = "11" [hooks] postinstall = "hk install --mise" -[tasks.check] +[tasks.biome] description = "Lint and format with Biome" usage = ''' flag "--write" help="Apply safe fixes and formatting in place" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5a41aec..079e49f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,7 +2,7 @@ # # Plexus deploy verb — PLEXUS.md §5. A stateless procedure, not a system: # -# ssh → docker compose pull → mise run migrate → docker compose up -d +# ssh → docker compose pull → mise migrate → docker compose up -d # → poll /healthz → on failure: re-up the previous image, exit non-zero # # State lives in git (compose.yml + the app's mise.toml, placed on the host by the @@ -59,8 +59,10 @@ healthy() { # Pull → migrate (idempotent; a no-op for stateless apps) → up. echo "IMAGE=$IMAGE" > .env IMAGE="$IMAGE" docker compose pull web +# Bare `mise migrate` (not `:migrate`): the app dir on the host holds a +# standalone mise.toml, and monorepo path syntax requires a monorepo root. mise trust . >/dev/null 2>&1 || true -mise run migrate +mise migrate IMAGE="$IMAGE" docker compose up -d web if healthy; then