Help and referral offer: /hilfe, /vermittlung, and an in-product link #56
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Self-host stack | |
| # Building the image proves it compiles. It does not prove that a self-hoster | |
| # can install it, that an update applies migrations and comes back up, or that | |
| # pinning the previous version rescues them when it does not. Those are the | |
| # failures that happen on someone else's server where we cannot reach, so they | |
| # are tested here against real containers and a real database. | |
| on: | |
| pull_request: | |
| # No push trigger. A merge re-tests the tree the PR just tested, and | |
| # release.yml's upgrade-gate covers the same migration path against the | |
| # published image rather than a locally built one, which is the stronger | |
| # check. What a push run added over the PR run was a second copy of the | |
| # same answer. | |
| # | |
| # Base images move underneath us (postgres, node, minio, the updater). A | |
| # weekly run catches a stack that broke without anybody touching the repo. | |
| schedule: | |
| - cron: "0 5 * * 1" | |
| concurrency: | |
| group: self-host-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| IMAGE: ghcr.io/nisd2/open-isms | |
| # Two builds differing only by APP_VERSION. That ARG is the last thing in | |
| # the Dockerfile, so the second build reuses the entire builder stage and | |
| # costs seconds — which is what makes testing a real container swap cheap. | |
| VERSION_A: 0.0.0-ci-a | |
| VERSION_B: 0.0.0-ci-b | |
| jobs: | |
| # Install, update, and roll back — the three things a self-hoster does. | |
| lifecycle: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| env: | |
| # Every compose command in this job sees the same file set. Mixing them | |
| # between steps is how you end up inspecting a different project than | |
| # the one you started. | |
| COMPOSE_FILE: compose.self-host.yml:compose.ci.yml | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Build the image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| load: true | |
| tags: ${{ env.IMAGE }}:ci-a | |
| build-args: APP_VERSION=${{ env.VERSION_A }} | |
| cache-from: type=gha,scope=ci-amd64 | |
| cache-to: type=gha,mode=max,scope=ci-amd64 | |
| - name: Build the "next release" image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| load: true | |
| tags: ${{ env.IMAGE }}:ci-b | |
| build-args: APP_VERSION=${{ env.VERSION_B }} | |
| cache-from: type=gha,scope=ci-amd64 | |
| # The published compose file is used as-is. The only addition is a label | |
| # telling the updater to use the local image rather than pulling from | |
| # ghcr.io, because the versions under test exist only on this runner. | |
| - name: Configure the stack | |
| run: | | |
| # Start from the file self-hosters are handed, so a broken template | |
| # fails here rather than on their first install. Values are then | |
| # replaced in place — appending a duplicate key would leave the | |
| # result depending on parse order, and the proxy profile in | |
| # particular must not be inherited (it would try to obtain a | |
| # certificate for example.com). | |
| cp .env.self-host.example .env | |
| set_env() { | |
| if grep -q "^$1=" .env; then | |
| sed -i "s|^$1=.*|$1=$2|" .env | |
| else | |
| echo "$1=$2" >> .env | |
| fi | |
| } | |
| set_env OPEN_ISMS_VERSION ci | |
| set_env COMPOSE_PROFILES minio,updater | |
| set_env POSTGRES_PASSWORD ci-postgres-password | |
| set_env AUTH_SECRET ci-auth-secret-not-real-0123456789abcdef | |
| set_env ERASURE_EMAIL_HASH_SALT ci-salt | |
| set_env AUTH_URL http://localhost:3026 | |
| set_env NEXT_PUBLIC_APP_URL http://localhost:3026 | |
| set_env AWS_S3_BUCKET evidence | |
| set_env AWS_ACCESS_KEY_ID openisms | |
| set_env AWS_SECRET_ACCESS_KEY ci-minio-secret | |
| set_env AWS_S3_ENDPOINT http://localhost:9000 | |
| set_env AWS_S3_INTERNAL_ENDPOINT http://minio:9000 | |
| set_env UPDATE_API_TOKEN ci-update-token | |
| set_env MINIO_KMS_KEY "$(openssl rand -base64 32)" | |
| echo "Profiles: $(grep '^COMPOSE_PROFILES=' .env)" | |
| cat > compose.ci.yml <<'YAMLEOF' | |
| services: | |
| app: | |
| # The versions under test exist only on this runner, so neither | |
| # compose nor the updater may go to ghcr.io looking for them. | |
| # Without this, changing the pinned tag makes compose try to | |
| # re-resolve the image remotely and the rollback step fails on | |
| # a registry error rather than on anything real. | |
| pull_policy: never | |
| labels: | |
| com.centurylinklabs.watchtower.no-pull: "true" | |
| YAMLEOF | |
| # `ci` is the tag the stack runs. Pointing it at a different image | |
| # later is exactly what a published release does to `stable`. | |
| docker tag "${IMAGE}:ci-a" "${IMAGE}:ci" | |
| - name: Install from scratch | |
| run: | | |
| docker compose up -d | |
| ./scripts/ci/wait-for-version.sh "${VERSION_A}" | |
| # The stack's security posture is a property of the shipped compose | |
| # file, so it is worth asserting rather than trusting. The updater holds | |
| # the Docker socket; if it ever gained a published port, anyone who | |
| # could reach it could replace the running application. | |
| - name: Nothing is exposed beyond loopback | |
| run: | | |
| docker compose ps --format '{{.Service}} {{.Ports}}' | |
| exposed=$(docker compose ps --format '{{.Service}} {{.Ports}}' \ | |
| | grep -E '0\.0\.0\.0:|:::' || true) | |
| if [ -n "${exposed}" ]; then | |
| echo "::error::A service publishes on all interfaces: ${exposed}" | |
| exit 1 | |
| fi | |
| if docker compose ps --format '{{.Service}} {{.Ports}}' | grep '^updater' | grep -q '\->'; then | |
| echo "::error::The updater must not publish a host port." | |
| exit 1 | |
| fi | |
| # A fresh install has to end up with every chain applied. An empty | |
| # bookkeeping table here would mean the app is serving against a schema | |
| # nobody migrated. | |
| - name: All three migration chains applied | |
| run: | | |
| for chain in grc isms saas; do | |
| count=$(docker compose exec -T postgres \ | |
| psql -U openisms -d openisms -tAc \ | |
| "SELECT count(*) FROM drizzle.__drizzle_migrations_${chain}") | |
| echo "${chain}: ${count} migration(s)" | |
| if [ "${count}" -lt 1 ]; then | |
| echo "::error::Chain ${chain} recorded no migrations." | |
| exit 1 | |
| fi | |
| done | |
| # Every restart re-runs the migrator. If it is not a no-op, each restart | |
| # would re-apply DDL and a container that restarts under load would | |
| # eventually break itself. | |
| - name: Restarting applies nothing | |
| run: | | |
| since=$(date -u +%Y-%m-%dT%H:%M:%S) | |
| docker compose restart app | |
| ./scripts/ci/wait-for-version.sh "${VERSION_A}" | |
| logs=$(docker compose logs app --since "${since}") | |
| echo "${logs}" | grep '\[migrate' || true | |
| if echo "${logs}" | grep -qE "applied [1-9][0-9]* migration"; then | |
| echo "::error::A restart applied migrations. The migrator is not idempotent." | |
| exit 1 | |
| fi | |
| # The update path a self-hoster actually clicks: the app asks the | |
| # updater over the internal network, the updater swaps the container, | |
| # and the new one migrates on boot before serving. | |
| - name: Update to the next version | |
| run: | | |
| docker tag "${IMAGE}:ci-b" "${IMAGE}:ci" | |
| # The app is both the caller and the container being replaced, so | |
| # this request dies partway through by design. | |
| docker compose exec -T app node -e " | |
| fetch('http://updater:8080/v1/update', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer ' + process.env.UPDATE_API_TOKEN }, | |
| }).then(r => console.log('updater responded', r.status)) | |
| .catch(e => console.log('connection ended:', e.message)); | |
| " || echo "exec ended with the container, as expected" | |
| ./scripts/ci/wait-for-version.sh "${VERSION_B}" | |
| echo "Updated ${VERSION_A} -> ${VERSION_B} via the updater API." | |
| - name: Rolling back to the pinned previous version | |
| run: | | |
| sed -i 's/^OPEN_ISMS_VERSION=.*/OPEN_ISMS_VERSION=ci-a/' .env | |
| docker compose up -d | |
| # The previous release has to run against the schema the update | |
| # left behind. This is the recovery path docs/updating.md promises. | |
| ./scripts/ci/wait-for-version.sh "${VERSION_A}" | |
| echo "Rolled back to ${VERSION_A} against the upgraded schema." | |
| - name: Container logs | |
| if: failure() | |
| run: | | |
| docker compose ps -a | |
| docker compose logs --tail 120 | |
| - name: Tear down | |
| if: always() | |
| run: docker compose down -v | |
| # Upgrading from the released code, not just from this branch to itself. | |
| # Skipped when a pull request changes nothing that can affect it. | |
| upgrade: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_USER: gate | |
| POSTGRES_PASSWORD: gate | |
| POSTGRES_DB: gate | |
| options: >- | |
| --health-cmd "pg_isready -U gate" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| ports: | |
| - 5432:5432 | |
| env: | |
| DB: postgres://gate:gate@localhost:5432/gate | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Does this change need the upgrade test | |
| id: scope | |
| run: | | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| echo "run=true" >> "$GITHUB_OUTPUT"; exit 0 | |
| fi | |
| changed=$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD \ | |
| | grep -E '(drizzle/|Dockerfile|compose|scripts/runtime-migrate|packages/grc-data-model/src)' || true) | |
| if [ -n "${changed}" ]; then | |
| echo "run=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Upgrade test running — this branch touches migrations, the image, or framework data." | |
| else | |
| echo "run=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Upgrade test skipped — nothing here can change the upgrade path." | |
| fi | |
| - uses: docker/setup-buildx-action@v4 | |
| if: steps.scope.outputs.run == 'true' | |
| # The version being upgraded FROM. Prefers the last release, because | |
| # that is what somebody is actually running; falls back to the base | |
| # branch until a release exists. | |
| - name: Build the previous version | |
| if: steps.scope.outputs.run == 'true' | |
| run: | | |
| previous=$(git tag --list 'v*' --sort=-v:refname | grep -v -- '-' | head -1) | |
| if [ -n "${previous}" ]; then | |
| ref="${previous}" | |
| elif [ "${{ github.event_name }}" = "pull_request" ]; then | |
| ref="origin/${{ github.base_ref }}" | |
| else | |
| ref="HEAD~1" | |
| fi | |
| echo "::notice::Upgrading from ${ref}." | |
| git worktree add /tmp/previous "${ref}" | |
| docker build -t openisms:previous /tmp/previous | |
| - name: Build this version | |
| if: steps.scope.outputs.run == 'true' | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| load: true | |
| tags: openisms:current | |
| cache-from: type=gha,scope=ci-amd64 | |
| - name: Migrate with the previous version | |
| if: steps.scope.outputs.run == 'true' | |
| run: | | |
| docker run --rm --network host -e DATABASE_URL="${DB}" \ | |
| openisms:previous node /app/scripts/runtime-migrate.mjs | |
| - name: Upgrade to this version | |
| if: steps.scope.outputs.run == 'true' | |
| run: | | |
| docker run --rm --network host -e DATABASE_URL="${DB}" \ | |
| openisms:current node /app/scripts/runtime-migrate.mjs | |
| - name: The upgrade is idempotent | |
| if: steps.scope.outputs.run == 'true' | |
| run: | | |
| output=$(docker run --rm --network host -e DATABASE_URL="${DB}" \ | |
| openisms:current node /app/scripts/runtime-migrate.mjs 2>&1) | |
| echo "${output}" | |
| if echo "${output}" | grep -qE "applied [1-9][0-9]* migration"; then | |
| echo "::error::Re-running the migrator applied migrations again." | |
| exit 1 | |
| fi | |
| # The rollback promise in docs/updating.md: after an upgrade, the | |
| # previous release still starts and serves against the newer schema. | |
| # | |
| # Worth being precise about what this does and does not prove. It | |
| # catches a migration that stops the old image booting at all — a | |
| # dropped table its startup path touches, an enum value it cannot | |
| # parse. It does not exercise application queries, because /api/health | |
| # only issues SELECT 1, so a column dropped from a page nobody loads | |
| # here would still pass. Full N-1 coverage needs e2e against the old | |
| # image; until then the expand/contract rule in | |
| # docs/migration-policy.md is what actually carries this guarantee. | |
| - name: The previous version still runs against the new schema | |
| if: steps.scope.outputs.run == 'true' | |
| run: | | |
| docker run --rm --network host -e DATABASE_URL="${DB}" \ | |
| openisms:previous node /app/scripts/runtime-migrate.mjs | |
| docker run -d --name rollback --network host \ | |
| -e DATABASE_URL="${DB}" \ | |
| -e AUTH_SECRET=ci-auth-secret-not-real-0123456789abcdef \ | |
| -e AUTH_URL=http://localhost:3000 \ | |
| -e NEXT_PUBLIC_APP_URL=http://localhost:3000 \ | |
| -e ERASURE_EMAIL_HASH_SALT=ci-salt \ | |
| openisms:previous | |
| for _ in $(seq 1 60); do | |
| if curl -fsS http://localhost:3000/api/health >/dev/null 2>&1; then | |
| echo "Previous release serves against the upgraded schema." | |
| exit 0 | |
| fi | |
| sleep 5 | |
| done | |
| echo "::error::The previous release could not serve against the new schema. See docs/migration-policy.md — a release must not break the one before it." | |
| docker logs rollback | |
| exit 1 | |
| # A backup nobody has restored is a belief, not a control — and this product | |
| # sells backup management as a NIS 2 measure, so the bar is higher here than | |
| # "the job ran without error". Writes a row and an object, takes a backup, | |
| # destroys every volume, restores, and asserts both halves came back. | |
| # | |
| # Needs no application image: the archive is Postgres plus the object store. | |
| backup-drill: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| env: | |
| COMPOSE_FILE: compose.self-host.yml | |
| PASSPHRASE: drill-passphrase-not-a-real-one | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Configure | |
| run: | | |
| cp .env.self-host.example .env | |
| set_env() { | |
| if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi | |
| } | |
| set_env COMPOSE_PROFILES minio,backup | |
| set_env POSTGRES_PASSWORD drill-password | |
| set_env AUTH_SECRET drill-auth-secret-0123456789abcdefgh | |
| set_env AWS_S3_BUCKET evidence | |
| set_env AWS_ACCESS_KEY_ID openisms | |
| set_env AWS_SECRET_ACCESS_KEY drill-minio-secret | |
| set_env MINIO_KMS_KEY "$(openssl rand -base64 32)" | |
| set_env BACKUP_PASSPHRASE "${PASSPHRASE}" | |
| - name: Write data that must survive | |
| run: | | |
| docker compose up -d postgres minio minio-init backup | |
| for _ in $(seq 1 30); do | |
| docker compose exec -T postgres pg_isready -U openisms -d openisms && break | |
| sleep 2 | |
| done | |
| docker compose exec -T postgres psql -U openisms -d openisms -c \ | |
| "CREATE TABLE drill (id int primary key, note text); | |
| INSERT INTO drill VALUES (1,'audit trail that must survive');" | |
| docker compose exec -T minio sh -c " | |
| echo 'this is a signed policy PDF' > /tmp/policy.txt | |
| mc alias set l http://localhost:9000 openisms drill-minio-secret >/dev/null | |
| mc cp /tmp/policy.txt l/evidence/policy.txt" | |
| - name: Take a backup | |
| run: docker compose exec -T backup backup | |
| - name: Destroy everything | |
| run: | | |
| archive=$(docker compose exec -T backup sh -c 'ls /archive' | tr -d '\r\n') | |
| echo "archive=${archive}" >> "$GITHUB_ENV" | |
| docker compose cp "backup:/archive/${archive}" "./${archive}" | |
| docker compose down -v | |
| test -z "$(docker volume ls -q --filter name=open-isms)" \ | |
| || { echo "::error::Volumes survived the teardown; the drill would prove nothing."; exit 1; } | |
| - name: Restore from the archive alone | |
| run: | | |
| gpg --decrypt --batch --passphrase "${PASSPHRASE}" "${archive}" > restore.tar.gz | |
| mkdir restored && tar -xzf restore.tar.gz -C restored | |
| test -f restored/backup/database/database.sql \ | |
| || { echo "::error::No database dump in the archive. The pre-backup command did not run."; exit 1; } | |
| # Objects go back before MinIO starts: it reads its data directory | |
| # at boot and will not notice files appearing underneath it. | |
| project=$(basename "$PWD") | |
| docker run --rm \ | |
| -v "${project}_minio-data":/data \ | |
| -v "$PWD/restored/backup/evidence":/src:ro \ | |
| busybox sh -c 'cp -a /src/. /data/' | |
| docker compose up -d postgres minio | |
| for _ in $(seq 1 30); do | |
| docker compose exec -T postgres pg_isready -U openisms -d openisms && break | |
| sleep 2 | |
| done | |
| docker compose exec -T postgres psql -U openisms -d openisms < restored/backup/database/database.sql | |
| - name: Both halves came back | |
| run: | | |
| note=$(docker compose exec -T postgres psql -U openisms -d openisms -tAc \ | |
| "SELECT note FROM drill WHERE id=1" | tr -d '\r') | |
| object=$(docker compose exec -T minio sh -c " | |
| mc alias set l http://localhost:9000 openisms drill-minio-secret >/dev/null 2>&1 | |
| mc cat l/evidence/policy.txt" | tr -d '\r') | |
| echo "database: ${note}" | |
| echo "object: ${object}" | |
| [ "${note}" = "audit trail that must survive" ] \ | |
| || { echo "::error::The database did not restore."; exit 1; } | |
| [ "${object}" = "this is a signed policy PDF" ] \ | |
| || { echo "::error::Evidence files did not restore. The database would point at documents that no longer exist."; exit 1; } | |
| - name: Tear down | |
| if: always() | |
| run: docker compose down -v |