From 55da1ed7a4cfb39b679c86d42b28951df07d2cef Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:29:22 +0530 Subject: [PATCH 1/6] build: add .dockerignore Keeps node_modules, build output and .git out of the build context. --- .dockerignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4d7e80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +dist_keycloak +build +storybook-static +.git +.github +.storybook +.vscode +.DS_Store +*.log From 398b5450b4980bc350a8182269c4d5f04cd89afe Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:29:22 +0530 Subject: [PATCH 2/6] build: bake the theme into a Keycloak 24 image Multi-stage: node+JDK+Maven builds the Keycloakify jar, then it is copied into quay.io/keycloak/keycloak:24.0.0 and registered with `kc.sh build`. Three things this settles: - The jar is copied *by name*. Keycloakify emits one jar per Keycloak version range; a glob would put several themes into providers/ and let Keycloak pick one. - `kc.sh build` is required. Providers register at build time, so a jar dropped into providers/ on a running server does nothing. Staging has no build step today, which is why it re-augments for ~16s on every start. - KC_DB / KC_HEALTH_ENABLED / KC_METRICS_ENABLED / KC_HTTP_RELATIVE_PATH are build-time options in KC 24. Setting them only in compose (as staging does) triggers that re-augmentation regardless. Baked in here to match. The image carries the source commit and the theme jar's sha256 as labels, so `docker inspect` answers which theme a box is running. --- Dockerfile | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b2953d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Stage 1 - build the Keycloakify theme jar. +# Keycloakify needs Node, a JDK and Maven >= 3.1.1 all on $PATH. +# --------------------------------------------------------------------------- +FROM node:20-bookworm AS theme-build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-17-jdk-headless maven \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# --ignore-scripts because the `postinstall` (keycloakify sync-extensions) reads +# src/, which is not copied yet. Deps land in their own cached layer this way, so +# a source-only change does not reinstall them. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY . . +RUN npx keycloakify sync-extensions +RUN npm run build-keycloak-theme + +# Keycloakify emits one jar per Keycloak version range. Keycloak 24 needs +# `keycloak-theme-for-kc-all-other-versions.jar` specifically -- copying them +# all into providers/ lets Keycloak pick a theme we did not intend. +RUN set -eux; \ + ls -l dist_keycloak; \ + test -f dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar; \ + sha256sum dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar \ + | cut -d' ' -f1 > /tmp/theme-jar-sha256; \ + cat /tmp/theme-jar-sha256 + +# --------------------------------------------------------------------------- +# Stage 2 - bake the theme into the Keycloak image. +# --------------------------------------------------------------------------- +FROM quay.io/keycloak/keycloak:24.0.0 + +COPY --from=theme-build \ + /app/dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar \ + /opt/keycloak/providers/ + +# These four are *build-time* options in Keycloak 24. Setting them only in +# compose `environment:` (as the staging box does today) forces a re-augmentation +# on every start. They must match what compose passes at runtime, or the server +# re-augments and the ~16s cost comes straight back. +ENV KC_DB=postgres \ + KC_HEALTH_ENABLED=true \ + KC_METRICS_ENABLED=true \ + KC_HTTP_RELATIVE_PATH=/auth + +# Providers are registered at build time. Without this the augmentation runs on +# every container start (the ~16s "Quarkus augmentation completed" in the logs) +# and a jar dropped into providers/ on a running server does nothing at all. +RUN /opt/keycloak/bin/kc.sh build + +ARG GIT_COMMIT_SHA=unknown +ARG THEME_JAR_SHA256=unknown + +# So that "which theme is this box running, and from which commit" is answered +# by one `docker inspect` rather than by comparing sha256 sums across servers. +LABEL org.opencontainers.image.revision="${GIT_COMMIT_SHA}" \ + org.opencontainers.image.source="https://github.com/CivicDataLab/DataSpaceKeycloakTheme" \ + in.civicdatalab.theme.jar.sha256="${THEME_JAR_SHA256}" From 4074eacac11741f48b3891ed48c63ca79388de9b Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:29:22 +0530 Subject: [PATCH 3/6] ci: add on-demand staging deploy workflow Builds the image in CI, pushes it to GHCR, and has the staging box pull it -- replacing the manual build-jar/scp/rebuild-on-the-box process that on 2026-08-27 left staging running a seven-month-stale theme copied from a retired server, with nothing logging an error. Mirrors deploy-backend.yml in DataSpaceBackend: digest-pinned image, health gate, automatic rollback to the previously-running image. workflow_dispatch only for now, taking a ref input. push-to-main is left disabled because main does not yet carry the theme work on fix/login-ui-alignment, so auto-deploying it would regress staging. --- .github/workflows/deploy-keycloak-staging.yml | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .github/workflows/deploy-keycloak-staging.yml diff --git a/.github/workflows/deploy-keycloak-staging.yml b/.github/workflows/deploy-keycloak-staging.yml new file mode 100644 index 0000000..bf73cc3 --- /dev/null +++ b/.github/workflows/deploy-keycloak-staging.yml @@ -0,0 +1,187 @@ +name: Deploy Keycloak theme to staging + +# Phase 1: on-demand only. `push: main` is deliberately NOT enabled yet -- +# `main` does not currently carry the theme work that is on +# `fix/login-ui-alignment`, so auto-deploying it would regress staging. +# Enable the push trigger once that branch is merged. +on: + workflow_dispatch: + inputs: + ref: + description: "Branch, tag or SHA to build and deploy" + type: string + required: true + default: fix/login-ui-alignment + +concurrency: + group: keycloak-staging-deploy + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DEPLOY_PATH: keycloak + COMPOSE_SERVICE: keycloak + CONTAINER_NAME: keycloak-staging + +jobs: + build: + name: Build & push image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + outputs: + image_ref: ${{ steps.push.outputs.image_ref }} + theme_jar_sha256: ${{ steps.jar.outputs.sha256 }} + steps: + - name: Checkout ${{ inputs.ref }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Resolve commit SHA + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build the theme stage on its own first so the jar's sha256 can be read + # out and baked into the final image as a label. This is the whole point: + # "which theme is this box running" becomes one `docker inspect`. + - name: Build theme stage and extract jar sha256 + id: jar + run: | + set -euo pipefail + docker buildx build \ + --platform linux/amd64 \ + --target theme-build \ + --cache-from type=gha \ + --cache-to type=gha,mode=max \ + --load -t theme-build:ci . + docker create --name themejar theme-build:ci > /dev/null + docker cp themejar:/app/dist_keycloak ./dist_keycloak + docker rm themejar > /dev/null + + JAR=dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar + test -f "$JAR" + SHA=$(sha256sum "$JAR" | cut -d' ' -f1) + SIZE=$(stat -c%s "$JAR") + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + + echo "### Theme jar" >> "$GITHUB_STEP_SUMMARY" + echo "- file: \`$(basename "$JAR")\`" >> "$GITHUB_STEP_SUMMARY" + echo "- sha256: \`$SHA\`" >> "$GITHUB_STEP_SUMMARY" + echo "- size: $SIZE bytes" >> "$GITHUB_STEP_SUMMARY" + ls -l dist_keycloak + + # Production's canonical jar is 2,351,205 bytes. A different build will + # never match byte-for-byte, but an order-of-magnitude difference means + # the build produced something other than this theme. + if [ "$SIZE" -lt 1500000 ] || [ "$SIZE" -gt 4000000 ]; then + echo "::error::theme jar is $SIZE bytes, far outside the expected ~2.3MB. Refusing to deploy." + exit 1 + fi + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=staging + type=raw,value=sha-${{ steps.commit.outputs.sha }} + + - name: Build and push + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT_SHA=${{ steps.commit.outputs.sha }} + THEME_JAR_SHA256=${{ steps.jar.outputs.sha256 }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Pin image by digest + id: push + run: | + echo "image_ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT" + + deploy: + name: Deploy to staging + needs: build + runs-on: ubuntu-latest + environment: keycloak-staging + timeout-minutes: 15 + + steps: + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.0.3 + env: + KEYCLOAK_IMAGE: ${{ needs.build.outputs.image_ref }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + GHCR_ACTOR: ${{ github.actor }} + DEPLOY_PATH: ${{ env.DEPLOY_PATH }} + COMPOSE_SERVICE: ${{ env.COMPOSE_SERVICE }} + CONTAINER_NAME: ${{ env.CONTAINER_NAME }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_PRIVATE_KEY }} + envs: KEYCLOAK_IMAGE,GHCR_TOKEN,GHCR_ACTOR,DEPLOY_PATH,COMPOSE_SERVICE,CONTAINER_NAME + command_timeout: 12m + script: | + set -euo pipefail + cd "$HOME/$DEPLOY_PATH" + + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_ACTOR" --password-stdin + + # Roll back to the exact image that is running now, not to a moving + # tag -- ":staging" will already point at the new build by then. + PREVIOUS_IMAGE=$(docker inspect --format='{{.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "") + echo "Currently running image id: ${PREVIOUS_IMAGE:-}" + + docker compose pull "$COMPOSE_SERVICE" + docker compose up -d --no-deps "$COMPOSE_SERVICE" + + echo "Waiting for Keycloak to become ready..." + for i in $(seq 1 30); do + if curl -sf --max-time 5 http://127.0.0.1:8004/auth/health/ready > /dev/null; then + echo "Ready." + docker inspect --format \ + 'commit={{index .Config.Labels "org.opencontainers.image.revision"}} theme_jar_sha256={{index .Config.Labels "in.civicdatalab.theme.jar.sha256"}}' \ + "$CONTAINER_NAME" + exit 0 + fi + sleep 6 + done + + echo "::error::Keycloak did not become ready after deploy -- rolling back." + if [ -n "$PREVIOUS_IMAGE" ]; then + KEYCLOAK_IMAGE="$PREVIOUS_IMAGE" docker compose up -d --no-deps "$COMPOSE_SERVICE" + for i in $(seq 1 30); do + if curl -sf --max-time 5 http://127.0.0.1:8004/auth/health/ready > /dev/null; then + echo "Rollback healthy." + exit 1 + fi + sleep 6 + done + echo "::error::Rollback image also failed to become ready -- needs manual intervention." + else + echo "::error::No previous image captured -- nothing to roll back to." + fi + exit 1 From b6758bf1d73e7dfe5286f4e5a093ef2173b491a5 Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:30:23 +0530 Subject: [PATCH 4/6] ci: pull from GHCR with the run's GITHUB_TOKEN, not a PAT The deploy job's own GITHUB_TOKEN can authenticate the box's `docker compose pull`, and it expires with the run. That drops the GHCR_TOKEN secret entirely and leaves no standing registry credential on the auth server; a trap logs out on every exit path. --- .github/workflows/deploy-keycloak-staging.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-keycloak-staging.yml b/.github/workflows/deploy-keycloak-staging.yml index bf73cc3..2a3cc1d 100644 --- a/.github/workflows/deploy-keycloak-staging.yml +++ b/.github/workflows/deploy-keycloak-staging.yml @@ -127,13 +127,19 @@ jobs: runs-on: ubuntu-latest environment: keycloak-staging timeout-minutes: 15 + permissions: + contents: read + packages: read steps: - name: Deploy over SSH uses: appleboy/ssh-action@v1.0.3 env: KEYCLOAK_IMAGE: ${{ needs.build.outputs.image_ref }} - GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + # This job's own GITHUB_TOKEN, not a long-lived PAT. It is valid only + # for the life of this run, so the box holds no standing registry + # credential -- and there is one fewer secret to rotate. + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} GHCR_ACTOR: ${{ github.actor }} DEPLOY_PATH: ${{ env.DEPLOY_PATH }} COMPOSE_SERVICE: ${{ env.COMPOSE_SERVICE }} @@ -149,6 +155,7 @@ jobs: cd "$HOME/$DEPLOY_PATH" echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_ACTOR" --password-stdin + trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT # Roll back to the exact image that is running now, not to a moving # tag -- ":staging" will already point at the new build by then. From 6889444570c677b38aa39a0d62066b74783e4c75 Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:31:13 +0530 Subject: [PATCH 5/6] ci: lowercase the GHCR image name github.repository is CivicDataLab/DataSpaceKeycloakTheme; Docker rejects uppercase in image references, so the digest-pinned image_ref would have been invalid on the box. --- .github/workflows/deploy-keycloak-staging.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-keycloak-staging.yml b/.github/workflows/deploy-keycloak-staging.yml index 2a3cc1d..9af4c66 100644 --- a/.github/workflows/deploy-keycloak-staging.yml +++ b/.github/workflows/deploy-keycloak-staging.yml @@ -19,7 +19,9 @@ concurrency: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + # Lowercased: github.repository is mixed-case and Docker rejects + # uppercase in image names. + IMAGE_NAME: civicdatalab/dataspacekeycloaktheme DEPLOY_PATH: keycloak COMPOSE_SERVICE: keycloak CONTAINER_NAME: keycloak-staging From 63c068344f78313e015939ab9790c95068be203b Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 27 Aug 2026 18:36:39 +0530 Subject: [PATCH 6/6] ci: deploy staging on push to dev dev becomes the integration branch for staging -- what is merged there is what staging runs. A push trigger also means the workflow does not need to sit on the default branch first, which workflow_dispatch would require. workflow_dispatch is kept for deploying an arbitrary ref on demand. --- .github/workflows/deploy-keycloak-staging.yml | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-keycloak-staging.yml b/.github/workflows/deploy-keycloak-staging.yml index 9af4c66..7a3a6ce 100644 --- a/.github/workflows/deploy-keycloak-staging.yml +++ b/.github/workflows/deploy-keycloak-staging.yml @@ -1,17 +1,19 @@ name: Deploy Keycloak theme to staging -# Phase 1: on-demand only. `push: main` is deliberately NOT enabled yet -- -# `main` does not currently carry the theme work that is on -# `fix/login-ui-alignment`, so auto-deploying it would regress staging. -# Enable the push trigger once that branch is merged. +# `dev` is the integration branch for staging: anything merged there is what +# staging should be running. `main` is deliberately not wired up -- it does not +# yet carry the theme work, so auto-deploying it would regress staging. on: + push: + branches: + - dev workflow_dispatch: inputs: ref: - description: "Branch, tag or SHA to build and deploy" + description: "Branch, tag or SHA to build and deploy (defaults to dev)" type: string - required: true - default: fix/login-ui-alignment + required: false + default: dev concurrency: group: keycloak-staging-deploy @@ -38,10 +40,12 @@ jobs: image_ref: ${{ steps.push.outputs.image_ref }} theme_jar_sha256: ${{ steps.jar.outputs.sha256 }} steps: - - name: Checkout ${{ inputs.ref }} + # On a push this is the pushed commit; on a manual run it is whatever + # ref was asked for. + - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.ref }} + ref: ${{ inputs.ref || github.sha }} - name: Resolve commit SHA id: commit