From 51e078de5d60b69972c7398ccc58dd6f7e7f3377 Mon Sep 17 00:00:00 2001 From: Aperim Agent Date: Sat, 25 Apr 2026 18:29:08 +1000 Subject: [PATCH] feat(workflows): add ci-test, ci-build, and deploy-staging reusable workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three missing reusable workflow templates to complete the standard 5-stage CI/CD pipeline for all Aperim product repos: - ci-test.yml: pnpm test runner (Vitest/Jest), configurable test command, optional coverage run, per-repo timeout override - ci-build.yml: production build with optional pre/post hooks and artifact upload; handles token generation (build-pre-command) and bundle validation (build-post-command) without duplicating setup - deploy-staging.yml: build Docker image → push GHCR → kubectl rollout on DOKS staging namespace; tag defaults to short SHA, deployment/container names default to image-name to minimise per-repo config Repos call these via uses: aperim/.github/.github/workflows/.yml@main alongside the existing ci-quality.yml (lint+typecheck) and security-scan.yml. Refs: APE-1615 CI/CD standardisation Co-Authored-By: Paperclip --- .github/workflows/ci-build.yml | 108 +++++++++++++++++++ .github/workflows/ci-test.yml | 76 ++++++++++++++ .github/workflows/deploy-staging.yml | 150 +++++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 .github/workflows/ci-build.yml create mode 100644 .github/workflows/ci-test.yml create mode 100644 .github/workflows/deploy-staging.yml diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..6052251 --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,108 @@ +# CI Build — Reusable Workflow +# +# Runs the production build for Aperim Node.js/pnpm product repos. +# Calls aperim/.github/.github/actions/setup-node-pnpm for consistent setup. +# +# Usage in product repo: +# +# jobs: +# build: +# uses: aperim/.github/.github/workflows/ci-build.yml@main +# needs: [quality, test] +# with: +# runner: '["self-hosted","ubuntu-latest","aperim"]' +# build-command: 'pnpm build' +# +# For Turborepo monorepos that need token builds or custom pre-build steps, +# extend via build-pre-command / build-post-command inputs. + +name: CI Build + +on: + workflow_call: + inputs: + ref: + description: 'Git ref to checkout. Defaults to the triggering ref.' + required: false + type: string + default: '' + runner: + description: > + runs-on value as a JSON string. Use '"ubuntu-latest"' for GitHub-hosted + or '["self-hosted","ubuntu-latest","aperim"]' for Aperim self-hosted. + required: false + type: string + default: '"ubuntu-latest"' + node-version-file: + description: 'File containing Node.js version (e.g. .nvmrc).' + required: false + type: string + default: '.nvmrc' + build-pre-command: + description: 'Optional command to run before the main build (e.g. token/codegen generation). Set to empty string to skip.' + required: false + type: string + default: '' + build-command: + description: 'Command to run the production build.' + required: false + type: string + default: 'pnpm build' + build-post-command: + description: 'Optional command to run after the main build (e.g. bundle validation). Set to empty string to skip.' + required: false + type: string + default: '' + timeout-minutes: + description: 'Job timeout in minutes.' + required: false + type: number + default: 20 + upload-artifact: + description: 'Set to true to upload the build artifact.' + required: false + type: boolean + default: false + artifact-path: + description: 'Path to the build artifact to upload. Only used when upload-artifact is true.' + required: false + type: string + default: 'dist' + artifact-name: + description: 'Name for the uploaded artifact. Only used when upload-artifact is true.' + required: false + type: string + default: 'build' + secrets: + inherit: false + +jobs: + build: + name: Build + runs-on: ${{ fromJSON(inputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - name: Setup Node.js + pnpm + uses: aperim/.github/.github/actions/setup-node-pnpm@main + with: + ref: ${{ inputs.ref }} + node-version-file: ${{ inputs.node-version-file }} + + - name: Pre-build step + if: inputs.build-pre-command != '' + run: ${{ inputs.build-pre-command }} + + - name: Build + run: ${{ inputs.build-command }} + + - name: Post-build step + if: inputs.build-post-command != '' + run: ${{ inputs.build-post-command }} + + - name: Upload build artifact + if: inputs.upload-artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + retention-days: 7 diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml new file mode 100644 index 0000000..2c8761f --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,76 @@ +# CI Test — Reusable Workflow +# +# Runs pnpm tests (Vitest / Jest) for Aperim Node.js/pnpm product repos. +# Calls aperim/.github/.github/actions/setup-node-pnpm for consistent setup. +# +# Usage in product repo: +# +# jobs: +# test: +# uses: aperim/.github/.github/workflows/ci-test.yml@main +# with: +# runner: '["self-hosted","ubuntu-latest","aperim"]' +# test-command: 'pnpm test:run' +# +# For repos with DB service containers (e.g. aegis-command, budget), +# keep per-repo CI rather than wrapping this — service container setup +# is not expressible in reusable workflow inputs. + +name: CI Test + +on: + workflow_call: + inputs: + ref: + description: 'Git ref to checkout. Defaults to the triggering ref.' + required: false + type: string + default: '' + runner: + description: > + runs-on value as a JSON string. Use '"ubuntu-latest"' for GitHub-hosted + or '["self-hosted","ubuntu-latest","aperim"]' for Aperim self-hosted. + required: false + type: string + default: '"ubuntu-latest"' + node-version-file: + description: 'File containing Node.js version (e.g. .nvmrc).' + required: false + type: string + default: '.nvmrc' + test-command: + description: 'Command to run tests.' + required: false + type: string + default: 'pnpm test:run' + coverage-command: + description: 'Command to run tests with coverage. Set to empty string to skip.' + required: false + type: string + default: '' + timeout-minutes: + description: 'Job timeout in minutes.' + required: false + type: number + default: 30 + secrets: + inherit: false + +jobs: + test: + name: Test + runs-on: ${{ fromJSON(inputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - name: Setup Node.js + pnpm + uses: aperim/.github/.github/actions/setup-node-pnpm@main + with: + ref: ${{ inputs.ref }} + node-version-file: ${{ inputs.node-version-file }} + + - name: Run tests + run: ${{ inputs.test-command }} + + - name: Run tests with coverage + if: inputs.coverage-command != '' + run: ${{ inputs.coverage-command }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..d7aa7c5 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,150 @@ +# Deploy Staging — Reusable Workflow +# +# Builds a Docker image, pushes to GHCR, and rolls out to the Aperim DOKS +# (DigitalOcean Kubernetes, syd1) staging namespace via kubectl. +# +# Usage in product repo: +# +# jobs: +# deploy-staging: +# uses: aperim/.github/.github/workflows/deploy-staging.yml@main +# needs: [build] +# if: github.ref == 'refs/heads/main' +# with: +# image-name: itsacademic-api +# k8s-namespace: itsacademic-staging +# secrets: +# KUBECONFIG_STAGING: ${{ secrets.KUBECONFIG_STAGING }} +# +# Secrets required in the calling repo: +# KUBECONFIG_STAGING — base64-encoded kubeconfig for DOKS staging cluster. +# Generate with: kubectl config view --minify --raw | base64 + +name: Deploy Staging + +on: + workflow_call: + inputs: + ref: + description: 'Git ref to checkout. Defaults to the triggering ref.' + required: false + type: string + default: '' + runner: + description: > + runs-on value as a JSON string. Use '"ubuntu-latest"' for GitHub-hosted + or '["self-hosted","ubuntu-latest","aperim"]' for Aperim self-hosted. + required: false + type: string + default: '"ubuntu-latest"' + image-name: + description: 'Docker image name (without registry prefix). E.g. itsacademic-api.' + required: true + type: string + image-tag: + description: 'Tag to apply to the image. Defaults to the short SHA.' + required: false + type: string + default: '' + dockerfile: + description: 'Path to the Dockerfile.' + required: false + type: string + default: 'Dockerfile' + build-context: + description: 'Docker build context path.' + required: false + type: string + default: '.' + k8s-namespace: + description: 'Kubernetes namespace to deploy into.' + required: true + type: string + k8s-deployment: + description: 'Kubernetes deployment name to roll out. Defaults to image-name.' + required: false + type: string + default: '' + k8s-container: + description: 'Container name within the deployment. Defaults to image-name.' + required: false + type: string + default: '' + timeout-minutes: + description: 'Job timeout in minutes.' + required: false + type: number + default: 15 + secrets: + KUBECONFIG_STAGING: + description: 'Base64-encoded kubeconfig for the DOKS staging cluster.' + required: true + +env: + REGISTRY: ghcr.io + IMAGE_REPO: aperim + +jobs: + deploy-staging: + name: Deploy to Staging + runs-on: ${{ fromJSON(inputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + clean: true + + - name: Log in to GHCR + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image tag + id: tag + run: | + TAG="${{ inputs.image-tag }}" + if [ -z "$TAG" ]; then + TAG="${GITHUB_SHA::7}" + fi + echo "value=$TAG" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@1dc0e8c6c5ab11e66a12e3f37a2c7e80a36de8f8 # v6 + with: + context: ${{ inputs.build-context }} + file: ${{ inputs.dockerfile }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ inputs.image-name }}:${{ steps.tag.outputs.value }} + ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ inputs.image-name }}:staging-latest + + - name: Setup kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 --decode > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Install kubectl + uses: azure/setup-kubectl@776c3a21bd6bb6a5862c2dd87de47f06fbee855c # v4 + + - name: Roll out to staging + run: | + DEPLOYMENT="${{ inputs.k8s-deployment != '' && inputs.k8s-deployment || inputs.image-name }}" + CONTAINER="${{ inputs.k8s-container != '' && inputs.k8s-container || inputs.image-name }}" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ inputs.image-name }}:${{ steps.tag.outputs.value }}" + + kubectl set image deployment/"$DEPLOYMENT" \ + "$CONTAINER"="$IMAGE" \ + --namespace="${{ inputs.k8s-namespace }}" + + kubectl rollout status deployment/"$DEPLOYMENT" \ + --namespace="${{ inputs.k8s-namespace }}" \ + --timeout=5m