diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index b30015a9da..73c5f83a8e 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -26,6 +26,7 @@ jobs: build: if: github.ref == 'refs/heads/main' name: ${{ matrix.name }} + timeout-minutes: 45 runs-on: ubuntu-24.04 permissions: contents: read @@ -111,8 +112,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} build-args: | SENTRY_ORG=${{ vars.SENTRY_ORG }} SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e918d66a9..826af76408 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: workflow_dispatch: + workflow_call: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -32,6 +33,7 @@ jobs: lint: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -55,6 +57,7 @@ jobs: i18n: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -80,6 +83,7 @@ jobs: openapi: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -105,6 +109,7 @@ jobs: typecheck: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -128,6 +133,7 @@ jobs: unit: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -149,7 +155,7 @@ jobs: - name: Test release security controls run: | docker pull nginx:1.29.5-alpine - node --test scripts/security/*.test.mjs + node --test scripts/security/*.test.mjs scripts/ci/*.test.mjs - name: Run unit tests env: @@ -159,6 +165,7 @@ jobs: build: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -180,7 +187,11 @@ jobs: - name: Build monorepo run: pnpm build + - name: Check site metadata and internal links + run: pnpm --filter @kaneo/site seo:check + integration: + timeout-minutes: 15 runs-on: ubuntu-24.04 services: postgres: @@ -226,6 +237,7 @@ jobs: docker-build: runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -233,12 +245,137 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - - name: Build kaneo image (smoke test) + - name: Build bundled image for runtime tests uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: context: . file: ./Dockerfile.kaneo platforms: linux/amd64 push: false - cache-from: type=gha - cache-to: type=gha,mode=max + load: true + tags: kaneo:ci + cache-from: type=gha,scope=kaneo + cache-to: type=gha,mode=max,scope=kaneo + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 10.32.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts --filter @kaneo/api... + - run: npm ci --ignore-scripts + working-directory: scripts/ui-review-bot + - run: scripts/ui-review-bot/node_modules/.bin/playwright install --with-deps chromium + - name: Start bundled image without Redis + run: docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml up -d --wait --wait-timeout 120 postgres minio app + - name: Browser regression and workspace isolation + run: node scripts/ci/browser.mjs http://127.0.0.1:55173 + - name: Verify realtime without Redis + run: node scripts/ci/realtime.mjs http://127.0.0.1:55173 + - name: Start two API instances with Redis + env: + KANEO_CI_REDIS_URL: redis://redis:6379 + run: docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile realtime up -d --wait --wait-timeout 120 app second redis + - name: Verify Redis fan-out + run: node scripts/ci/realtime.mjs http://127.0.0.1:55173 http://127.0.0.1:55174 + - name: Upgrade from the latest stable release + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/ci/upgrade.sh + - name: Save container logs + if: always() + run: | + mkdir -p .cache/ci-results + docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile realtime --profile upgrade logs --no-color > .cache/ci-results/containers.log + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: failure() + with: + name: runtime-failures + path: .cache/ci-results/ + include-hidden-files: true + retention-days: 7 + - name: Remove disposable test services and data + if: always() + run: docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile realtime --profile upgrade down -v + + workflows: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install verified actionlint + run: | + curl --fail --silent --show-error --location --max-time 60 \ + https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz \ + -o "$RUNNER_TEMP/actionlint.tar.gz" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint + - name: Lint workflows and embedded shell scripts + run: | + shellcheck --version + "$RUNNER_TEMP/actionlint" -color + shellcheck scripts/ci/*.sh + + storage: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 10.32.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Start disposable S3 storage + run: | + docker run -d --name kaneo-storage-ci \ + -p 127.0.0.1:59039:9000 \ + -e MINIO_ROOT_USER=local-test-access \ + -e MINIO_ROOT_PASSWORD=local-test-secret-only \ + -e MINIO_BROWSER=off -e MINIO_UPDATE=off \ + --entrypoint /opt/bitnami/minio/bin/minio \ + bitnamilegacy/minio@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 \ + server /tmp/minio --address :9000 + curl --fail --retry 30 --retry-all-errors --retry-delay 1 --max-time 5 \ + http://127.0.0.1:59039/minio/health/ready + - name: Verify signed uploads against real storage + env: + KANEO_STORAGE_TEST_ENDPOINT: http://127.0.0.1:59039 + run: pnpm --filter @kaneo/api exec vitest run --config vitest.storage.config.ts + - name: Storage logs + if: failure() + run: docker logs kaneo-storage-ci + - name: Remove disposable storage + if: always() + run: docker rm -f -v kaneo-storage-ci + + split-images: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + name: [api, web] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + - name: Build standalone image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: ./apps/${{ matrix.name }}/Dockerfile + platforms: linux/amd64 + push: false + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index ca6fad28ef..907b49190c 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "charts/kaneo/**" + - "scripts/security/check-helm-secrets.rb" - ".github/workflows/helm-chart.yml" - ".github/workflows/helm-validate.yml" workflow_call: diff --git a/.github/workflows/helm-validate.yml b/.github/workflows/helm-validate.yml index 5d44ae6093..f36daeb898 100644 --- a/.github/workflows/helm-validate.yml +++ b/.github/workflows/helm-validate.yml @@ -9,6 +9,7 @@ env: jobs: validate: name: ${{ matrix.name }} + timeout-minutes: 5 runs-on: ubuntu-24.04 permissions: contents: read @@ -61,3 +62,7 @@ jobs: - name: Render chart (${{ matrix.name }}) run: helm template kaneo "$CHART_PATH" --set kaneo.env.clientUrl=https://kaneo.example.com ${{ matrix.args }} + + - name: Check secret preservation and security settings + if: matrix.name == 'default' + run: ruby scripts/security/check-helm-secrets.rb diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 605015c0ff..88bc3a0732 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -17,59 +17,7 @@ jobs: verify: name: Verify nightly build if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - runs-on: ubuntu-24.04 - services: - postgres: - image: postgres:16 - env: - POSTGRES_DB: kaneo_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres -d kaneo_test" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - NODE_ENV: test - AUTH_SECRET: test-secret-with-at-least-32-chars - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/kaneo_test - KANEO_API_URL: http://localhost:1337 - KANEO_CLIENT_URL: http://localhost:5173 - DISABLE_GUEST_ACCESS: "false" - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - with: - version: 10.32.1 - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24.19.0 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run Biome - run: pnpm exec biome ci . - - - name: Run unit tests - run: pnpm test - - - name: Run API integration tests - run: pnpm test:integration - - - name: Build monorepo - run: pnpm build + uses: ./.github/workflows/ci.yml publish-images: name: Publish nightly images diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000000..035a0544f4 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,35 @@ +name: PR title + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + title: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + # Validate using the target branch's policy, not code supplied by the PR. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 10.32.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts --filter kaneo + - name: Validate Conventional Commit title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\n' "$PR_TITLE" | pnpm exec commitlint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc447527de..a883fcd2c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ permissions: {} jobs: plan: name: Plan + timeout-minutes: 15 runs-on: ubuntu-24.04 permissions: contents: write # semantic-release verifies push permission even on a dry run @@ -94,9 +95,31 @@ jobs: echo "" } >> "$GITHUB_STEP_SUMMARY" + verify-ci: + name: Verify CI for release source + needs: plan + if: ${{ !inputs.dry_run && needs.plan.outputs.version != '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 35 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + package-manager-cache: false + - name: Wait for successful main CI on this exact commit + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/ci/require-ci.mjs + images: name: Images - needs: plan + needs: [plan, verify-ci] if: ${{ !inputs.dry_run && needs.plan.outputs.version != '' }} permissions: contents: read @@ -120,6 +143,7 @@ jobs: name: Release needs: [plan, images, chart] if: ${{ !inputs.dry_run && needs.plan.outputs.version != '' }} + timeout-minutes: 15 runs-on: ubuntu-24.04 permissions: contents: write @@ -179,6 +203,7 @@ jobs: promote: name: Promote needs: [plan, release] + timeout-minutes: 15 runs-on: ubuntu-24.04 permissions: contents: read diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 0000000000..91e57d6508 --- /dev/null +++ b/scripts/ci/README.md @@ -0,0 +1,71 @@ +# CI checks + +`ci.yml` runs on PRs and main, and is reused by Nightly. It checks formatting, +translations, OpenAPI, types, unit/integration tests, site links, workflow syntax, +real S3 uploads, and all three Dockerfiles. The bundled image is loaded and run +against disposable PostgreSQL and MinIO services for browser, realtime and +upgrade tests. Runtime failures retain container logs, a browser screenshot and +a Playwright trace for seven days. + +The disposable S3 fixture pins Bitnami's archived MinIO 2025.7.23 image by its +multi-platform digest because the upstream public registries no longer serve +the previous image. It runs the MinIO binary directly with temporary test data. + +- `browser.mjs`: real sign-in, project/task creation, status changes, another + tab's realtime cache update, reload persistence and cross-workspace denial. +- `realtime.mjs`: authenticated clients receive the same mutation with no Redis, + and when connected to separate API instances sharing Redis. +- `upgrade.sh` / `upgrade.mjs`: resolve the latest stable GitHub release to an + image digest, seed it through public APIs, then replace it with the candidate + image on the same database. Verify account credentials, owner membership, + project/task fields, comments, private image bytes and subsequent writes. +- `require-ci.mjs`: release publication requires the latest CI run for the exact + main SHA to succeed. It accepts push and manual CI runs, waits up to 30 minutes, + and rejects failed, cancelled and skipped runs. If no push run exists (for + example a release commit containing `[skip ci]`), dispatch **CI** on main before + dispatching **Release**. Dry runs don't wait or publish. + +The separate PR title check uses the base branch's commitlint policy and reruns +when the title changes. Helm validation also executes the existing secret and +upgrade-rendering regression checks. Configure the new job checks as required in +GitHub if they should block merging; editing workflows alone does not change +branch protection. + +## Local runtime checks + +Use disposable services only. The HTTP helpers refuse non-loopback origins. +No root `.env`, production credentials or cloud storage are needed. You need +Docker, Node, pnpm, the API workspace dependencies and the existing browser +runner's dependencies (`npm ci --ignore-scripts --prefix scripts/ui-review-bot`). + +After building a candidate image tagged `kaneo:ci`: + +```sh +docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml up -d --wait postgres minio app +node scripts/ci/browser.mjs http://127.0.0.1:55173 +node scripts/ci/realtime.mjs http://127.0.0.1:55173 +KANEO_CI_REDIS_URL=redis://redis:6379 docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile realtime up -d --wait app second redis +node scripts/ci/realtime.mjs http://127.0.0.1:55173 http://127.0.0.1:55174 +GITHUB_REPOSITORY=usekaneo/kaneo GITHUB_REPOSITORY_OWNER=usekaneo bash scripts/ci/upgrade.sh +``` + +Install Chromium once using +`scripts/ui-review-bot/node_modules/.bin/playwright install chromium`. +Stop only this stack and remove its disposable data afterwards: + +```sh +docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile realtime --profile upgrade down -v +``` + +The stack reserves loopback ports 55173–55175, 59040, 59379 and 59432. It uses +fixed synthetic credentials and test databases; do not expose it to the network. + +For a local worktree on a copy-on-write filesystem, reuse the pnpm store without +copying dependency data: + +```sh +pnpm install --offline --frozen-lockfile --ignore-scripts --package-import-method=clone +``` + +This preserves independent workspace links. The logical `node_modules` size +still includes shared blocks; it is not the additional physical disk cost. diff --git a/scripts/ci/browser.mjs b/scripts/ci/browser.mjs new file mode 100644 index 0000000000..dbdeec0ab6 --- /dev/null +++ b/scripts/ci/browser.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { Client, localOrigin, password, ready } from "./http.mjs"; + +const require = createRequire( + new URL("../ui-review-bot/package.json", import.meta.url), +); +const { chromium } = require("playwright"); +const origin = localOrigin(process.argv[2]); +await ready(origin); +const owner = new Client(origin); +await owner.signup("browser-owner"); +const workspace = await owner.json("/api/auth/organization/create", "POST", { + name: "Browser regression", + slug: `browser-${Date.now()}`, +}); +const outsider = new Client(origin); +await outsider.signup("browser-outsider"); +const browser = await chromium.launch(); +const context = await browser.newContext({ + locale: "en-US", + viewport: { width: 1440, height: 1000 }, +}); +context.setDefaultTimeout(20_000); +await context.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, +}); +const failures = []; +context.on("page", (page) => { + page.on("pageerror", (error) => failures.push(error.message)); +}); +const page = await context.newPage(); +try { + await page.goto(`${origin}/auth/sign-in`); + await page.getByLabel("Email", { exact: true }).fill(owner.email); + await page.locator('input[name="password"]').fill(password); + await page.getByRole("button", { name: /^Sign In$/i }).click(); + await page.waitForURL("**/dashboard/**"); + await page.goto(`${origin}/dashboard/workspace/${workspace.id}`); + await page.getByRole("button", { name: "Add project", exact: true }).click(); + const projectDialog = page.getByRole("dialog", { + name: "Create a new project", + }); + await projectDialog + .getByPlaceholder("Project name", { exact: true }) + .fill("Browser regression project"); + const [projectResponse] = await Promise.all([ + page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/project" && + response.request().method() === "POST", + ), + projectDialog + .getByRole("button", { name: "Create Project", exact: true }) + .click(), + ]); + assert.ok(projectResponse.ok(), await projectResponse.text()); + const project = await projectResponse.json(); + await page.goto( + `${origin}/dashboard/workspace/${workspace.id}/project/${project.id}/board`, + ); + await page.getByTitle("Add task", { exact: true }).first().click(); + const taskDialog = page.getByRole("dialog"); + await taskDialog + .getByPlaceholder("Task title", { exact: true }) + .fill("Browser persistence task"); + const [taskResponse] = await Promise.all([ + page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/task/${project.id}` && + response.request().method() === "POST", + ), + taskDialog + .getByRole("button", { name: "Create Task", exact: true }) + .click(), + ]); + assert.ok(taskResponse.ok(), await taskResponse.text()); + const task = await taskResponse.json(); + await page.goto( + `${origin}/dashboard/workspace/${workspace.id}/project/${project.id}/task/${task.id}`, + ); + const observer = await context.newPage(); + // Observe connection readiness without replacing the transport or mocking messages. + await observer.addInitScript(() => { + const opened = new Set(); + window.__ciOpenSockets = opened; + window.WebSocket = new Proxy(window.WebSocket, { + construct(Target, args) { + const socket = new Target(...args); + socket.addEventListener("open", () => opened.add(socket.url)); + socket.addEventListener("close", () => opened.delete(socket.url)); + return socket; + }, + }); + }); + await observer.goto(page.url()); + await observer.waitForFunction( + (projectId) => + [...window.__ciOpenSockets].some( + (url) => new URL(url).pathname === `/api/ws/${projectId}`, + ), + project.id, + ); + await observer.getByRole("button", { name: "To Do", exact: true }).waitFor(); + await page.bringToFront(); + await page.getByRole("button", { name: "To Do", exact: true }).click(); + const [changedStatus] = await Promise.all([ + page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/task/status/${task.id}` && + response.request().method() === "PUT", + ), + page.getByRole("button", { name: /^In Progress/ }).click(), + ]); + assert.ok(changedStatus.ok()); + // The other tab must update through WebSocket-driven query invalidation, without a reload. + await observer + .getByRole("button", { name: "In Progress", exact: true }) + .waitFor(); + await observer.close(); + await page.reload(); + await page + .getByRole("button", { name: "In Progress", exact: true }) + .waitFor(); + const persisted = await owner.json(`/api/task/${task.id}`); + assert.equal(persisted.title, "Browser persistence task"); + assert.equal(persisted.status, "in-progress"); + for (const [path, options] of [ + [`/api/project/${project.id}`, {}], + [`/api/task/${task.id}`, {}], + [ + `/api/task/status/${task.id}`, + { method: "PUT", body: { status: "done" } }, + ], + ]) { + const response = await outsider.response(path, options); + assert.ok( + [403, 404].includes(response.status), + `Outsider accessed ${path}: ${response.status}`, + ); + } + assert.equal( + (await owner.json(`/api/task/${task.id}`)).status, + "in-progress", + ); + assert.deepEqual(failures, [], "Browser runtime errors"); + console.log( + "Browser sign-in, project/task creation, status persistence and workspace isolation passed", + ); +} catch (error) { + await mkdir(".cache/ci-results", { recursive: true }); + await page + .screenshot({ path: ".cache/ci-results/browser.png", fullPage: true }) + .catch(() => {}); + await context.tracing.stop({ path: ".cache/ci-results/browser-trace.zip" }); + throw error; +} finally { + await context.close(); + await browser.close(); +} diff --git a/scripts/ci/compose.yml b/scripts/ci/compose.yml new file mode 100644 index 0000000000..5c1eb9cd33 --- /dev/null +++ b/scripts/ci/compose.yml @@ -0,0 +1,98 @@ +# Disposable CI data only. Use a unique --project-name; never load the root .env. +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: kaneo_ci_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ci-only-password + ports: ["127.0.0.1:59432:5432"] + volumes: + - ./init-databases.sql:/docker-entrypoint-initdb.d/ci.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d kaneo_ci_test"] + interval: 2s + timeout: 5s + retries: 30 + redis: + image: redis:7-alpine + ports: ["127.0.0.1:59379:6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 30 + minio: + image: bitnamilegacy/minio@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 + entrypoint: /opt/bitnami/minio/bin/minio + command: server /tmp/minio --address :9000 + environment: + MINIO_ROOT_USER: local-test-access + MINIO_ROOT_PASSWORD: local-test-secret-only + MINIO_BROWSER: "off" + MINIO_UPDATE: "off" + ports: ["127.0.0.1:59040:9000"] + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9000/minio/health/ready"] + interval: 2s + timeout: 5s + retries: 30 + app: + image: ${KANEO_CI_IMAGE:-kaneo:ci} + environment: &app-env + NODE_ENV: production + DATABASE_URL: postgresql://postgres:ci-only-password@postgres:5432/kaneo_ci_test + AUTH_SECRET: ci-only-secret-with-at-least-32-characters + KANEO_CLIENT_URL: http://127.0.0.1:55173 + DISABLE_REGISTRATION: "false" + DISABLE_GUEST_ACCESS: "true" + DISABLE_PASSWORD_REGISTRATION: "false" + REDIS_URL: ${KANEO_CI_REDIS_URL:-} + S3_ENDPOINT: http://minio:9000 + S3_BUCKET: kaneo-ci-test + S3_ACCESS_KEY_ID: local-test-access + S3_SECRET_ACCESS_KEY: local-test-secret-only + S3_REGION: us-east-1 + S3_FORCE_PATH_STYLE: "true" + ports: ["127.0.0.1:55173:5173"] + depends_on: + postgres: {condition: service_healthy} + minio: {condition: service_healthy} + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5173/api/health"] + interval: 2s + timeout: 5s + retries: 60 + second: + image: ${KANEO_CI_IMAGE:-kaneo:ci} + profiles: [realtime] + environment: + <<: *app-env + KANEO_CLIENT_URL: http://127.0.0.1:55174 + REDIS_URL: redis://redis:6379 + ports: ["127.0.0.1:55174:5173"] + depends_on: + app: {condition: service_healthy} + redis: {condition: service_healthy} + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5173/api/health"] + interval: 2s + timeout: 5s + retries: 60 + upgrade: + image: ${KANEO_UPGRADE_IMAGE:-kaneo:ci} + profiles: [upgrade] + environment: + <<: *app-env + DATABASE_URL: postgresql://postgres:ci-only-password@postgres:5432/kaneo_upgrade_test + KANEO_CLIENT_URL: http://127.0.0.1:55175 + REDIS_URL: "" + ports: ["127.0.0.1:55175:5173"] + depends_on: + postgres: {condition: service_healthy} + minio: {condition: service_healthy} + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5173/api/health"] + interval: 2s + timeout: 5s + retries: 60 diff --git a/scripts/ci/http.mjs b/scripts/ci/http.mjs new file mode 100644 index 0000000000..ba4144beac --- /dev/null +++ b/scripts/ci/http.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { setTimeout } from "node:timers/promises"; + +export const password = "CI-only-password-937!"; +export function localOrigin(value) { + const url = new URL(value); + assert.equal(url.protocol, "http:"); + assert.equal( + url.hostname, + "127.0.0.1", + "Tests must use disposable loopback services", + ); + assert.equal(url.pathname, "/"); + return url.origin; +} +export async function ready(origin) { + localOrigin(origin); + for (let attempt = 0; attempt < 90; attempt++) { + try { + const response = await fetch(`${origin}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok && (await response.json()).status === "ok") return; + } catch {} + await setTimeout(1000); + } + throw new Error(`API did not become healthy at ${origin}`); +} +export class Client { + constructor(origin) { + this.origin = localOrigin(origin); + this.cookies = new Map(); + } + get cookie() { + return [...this.cookies] + .map(([name, value]) => `${name}=${value}`) + .join("; "); + } + async response(path, { method = "GET", body } = {}) { + assert.ok(path.startsWith("/api/")); + const response = await fetch(`${this.origin}${path}`, { + method, + headers: { + Origin: this.origin, + Cookie: this.cookie, + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + redirect: "error", + }); + for (const cookie of response.headers.getSetCookie()) { + const pair = cookie.split(";")[0]; + const at = pair.indexOf("="); + this.cookies.set(pair.slice(0, at), pair.slice(at + 1)); + } + return response; + } + async json(path, method = "GET", body = undefined) { + const response = await this.response(path, { method, body }); + assert.ok( + response.ok, + `${method} ${path}: ${response.status} ${await response.clone().text()}`, + ); + return response.json(); + } + async signup(label) { + this.email = `ci-${label}-${randomUUID()}@example.com`; + return this.json("/api/auth/sign-up/email", "POST", { + name: `CI ${label}`, + email: this.email, + password, + }); + } + async signin(email) { + return this.json("/api/auth/sign-in/email", "POST", { email, password }); + } +} +export async function seed(origin, label) { + const client = new Client(origin); + const account = await client.signup(label); + const workspace = await client.json("/api/auth/organization/create", "POST", { + name: `CI ${label}`, + slug: `ci-${randomUUID()}`, + }); + const project = await client.json("/api/project", "POST", { + name: `CI ${label}`, + workspaceId: workspace.id, + icon: "Folder", + slug: "CI", + }); + const task = await client.json(`/api/task/${project.id}`, "POST", { + title: `CI ${label} task`, + description: "Upgrade and realtime fixture", + priority: "medium", + status: "to-do", + }); + return { client, account, workspace, project, task }; +} diff --git a/scripts/ci/init-databases.sql b/scripts/ci/init-databases.sql new file mode 100644 index 0000000000..b5c1d80967 --- /dev/null +++ b/scripts/ci/init-databases.sql @@ -0,0 +1 @@ +CREATE DATABASE kaneo_upgrade_test; diff --git a/scripts/ci/realtime.mjs b/scripts/ci/realtime.mjs new file mode 100644 index 0000000000..0d827a0469 --- /dev/null +++ b/scripts/ci/realtime.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { Client, localOrigin, ready, seed } from "./http.mjs"; + +const require = createRequire( + new URL("../../apps/api/package.json", import.meta.url), +); +const WebSocket = createRequire(require.resolve("@hono/node-ws"))("ws"); +const origins = process.argv.slice(2).map(localOrigin); +assert.ok( + origins.length === 1 || origins.length === 2, + "Pass one no-Redis origin or two Redis-backed origins", +); +await Promise.all(origins.map(ready)); +const fixture = await seed(origins[0], "realtime"); +const sockets = []; +try { + // Two clients even without Redis; mutation initiator suppression must not hide updates from other windows. + for (const [index, origin] of (origins.length === 1 + ? [origins[0], origins[0]] + : origins + ).entries()) { + const client = new Client(origin); + await client.signin(fixture.client.email); + const socket = new WebSocket( + `${origin.replace("http:", "ws:")}/api/ws/${fixture.project.id}?windowId=ci-${index}`, + { + headers: { Cookie: client.cookie, Origin: origin }, + handshakeTimeout: 10_000, + }, + ); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + } + const received = sockets.map( + (socket) => + new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Task event did not reach every instance")), + 15_000, + ); + socket.on("message", (data) => { + const message = JSON.parse(String(data)); + if ( + message.type === "TASK_UPDATED" && + message.taskId === fixture.task.id + ) { + clearTimeout(timeout); + resolve(message); + } + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + }), + ); + // Attach rejection handlers before issuing the mutation. + const delivery = Promise.all(received); + await Promise.all([ + delivery, + fixture.client.json(`/api/task/status/${fixture.task.id}`, "PUT", { + status: "in-progress", + }), + ]); + assert.equal( + (await fixture.client.json(`/api/task/${fixture.task.id}`)).status, + "in-progress", + ); + console.log( + `Realtime passed with ${origins.length === 1 ? "no Redis" : "two API instances and Redis"}`, + ); +} finally { + for (const socket of sockets) socket.terminate(); +} diff --git a/scripts/ci/require-ci.mjs b/scripts/ci/require-ci.mjs new file mode 100644 index 0000000000..9513244404 --- /dev/null +++ b/scripts/ci/require-ci.mjs @@ -0,0 +1,60 @@ +// biome-ignore-all lint/suspicious/noUndeclaredEnvVars: Standalone GitHub Actions release guard. +import { execFileSync } from "node:child_process"; +import { setTimeout } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +export function assessRuns(runs, sha) { + const latest = runs + .filter( + (run) => + run.head_sha === sha && + run.head_branch === "main" && + ["push", "workflow_dispatch"].includes(run.event), + ) + .sort( + (a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt, + )[0]; + if (latest?.status !== "completed") return "pending"; + return latest.conclusion === "success" ? "success" : "failure"; +} + +async function main() { + const repo = process.env.GITHUB_REPOSITORY; + const sha = process.env.GITHUB_SHA; + if ( + !/^[\w.-]+\/[\w.-]+$/.test(repo ?? "") || + !/^[a-f0-9]{40}$/.test(sha ?? "") + ) { + throw new Error("Expected GitHub repository and full release source SHA"); + } + const deadline = Date.now() + 30 * 60_000; + while (Date.now() < deadline) { + const result = JSON.parse( + execFileSync( + "gh", + [ + "api", + `repos/${repo}/actions/workflows/ci.yml/runs?head_sha=${sha}&branch=main&per_page=100`, + ], + { encoding: "utf8", timeout: 30_000 }, + ), + ); + const state = assessRuns(result.workflow_runs, sha); + if (state === "success") { + console.log(`CI passed for release source ${sha}`); + return; + } + if (state === "failure") + throw new Error( + `Latest main CI run failed for ${sha}; fix or rerun CI before releasing`, + ); + console.log(`Waiting for main CI on ${sha}`); + await setTimeout(20_000); + } + throw new Error( + "Timed out waiting for successful CI; missing, skipped, or pending checks cannot authorize a release", + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + await main(); diff --git a/scripts/ci/require-ci.test.mjs b/scripts/ci/require-ci.test.mjs new file mode 100644 index 0000000000..0ccd9b7460 --- /dev/null +++ b/scripts/ci/require-ci.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assessRuns } from "./require-ci.mjs"; + +const sha = "a".repeat(40); +const passed = { + head_sha: sha, + head_branch: "main", + event: "push", + run_number: 1, + run_attempt: 1, + status: "completed", + conclusion: "success", +}; +test("release requires successful main push CI on the exact SHA", () => { + assert.equal(assessRuns([passed], sha), "success"); + assert.equal( + assessRuns([{ ...passed, event: "workflow_dispatch" }], sha), + "success", + ); + for (const override of [ + { head_sha: "b".repeat(40) }, + { event: "pull_request" }, + { head_branch: "other" }, + ]) { + assert.equal(assessRuns([{ ...passed, ...override }], sha), "pending"); + } + assert.equal(assessRuns([], sha), "pending"); + for (const conclusion of [ + "failure", + "cancelled", + "skipped", + "neutral", + "timed_out", + null, + ]) { + assert.equal(assessRuns([{ ...passed, conclusion }], sha), "failure"); + } +}); +test("a rerun or newer run supersedes an older successful result", () => { + for (const override of [{ run_attempt: 2 }, { run_number: 2 }]) { + const newer = { + ...passed, + ...override, + status: "in_progress", + conclusion: null, + }; + assert.equal(assessRuns([passed, newer], sha), "pending"); + assert.equal( + assessRuns( + [passed, { ...newer, status: "completed", conclusion: "failure" }], + sha, + ), + "failure", + ); + } +}); diff --git a/scripts/ci/upgrade.mjs b/scripts/ci/upgrade.mjs new file mode 100644 index 0000000000..2b9abb7061 --- /dev/null +++ b/scripts/ci/upgrade.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { request } from "node:http"; +import { createRequire } from "node:module"; +import { Client, localOrigin, ready, seed } from "./http.mjs"; + +const [mode, inputOrigin, stateFile] = process.argv.slice(2); +assert.ok( + ["seed", "verify"].includes(mode) && stateFile, + "Expected seed|verify ORIGIN STATE_FILE", +); +const origin = localOrigin(inputOrigin); +await ready(origin); +const bytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHAAAAABJRU5ErkJggg==", + "base64", +); +if (mode === "seed") { + const require = createRequire( + new URL("../../apps/api/package.json", import.meta.url), + ); + const { S3Client, CreateBucketCommand } = require("@aws-sdk/client-s3"); + const storage = new S3Client({ + endpoint: "http://127.0.0.1:59040", + region: "us-east-1", + forcePathStyle: true, + credentials: { + accessKeyId: "local-test-access", + secretAccessKey: "local-test-secret-only", + }, + }); + try { + await storage.send(new CreateBucketCommand({ Bucket: "kaneo-ci-test" })); + } catch (error) { + if (error.name !== "BucketAlreadyOwnedByYou") throw error; + } finally { + storage.destroy(); + } + const { client, account, workspace, project, task } = await seed( + origin, + "upgrade", + ); + const comment = await client.json(`/api/comment/${task.id}`, "POST", { + content: "Comment retained across upgrades", + }); + const input = { + filename: "upgrade.png", + contentType: "image/png", + size: bytes.length, + surface: "description", + }; + const upload = await client.json( + `/api/task/image-upload/${task.id}`, + "PUT", + input, + ); + const uploadUrl = new URL(upload.uploadUrl); + assert.ok( + ["minio:9000", "127.0.0.1:59040"].includes(uploadUrl.host), + "Only the disposable MinIO endpoint is allowed", + ); + // Reach the Compose service through its loopback port while preserving its signed Host header. + await new Promise((resolve, reject) => { + const req = request( + { + hostname: "127.0.0.1", + port: 59040, + path: uploadUrl.pathname + uploadUrl.search, + method: "PUT", + headers: { + ...upload.headers, + Host: uploadUrl.host, + "Content-Length": bytes.length, + }, + timeout: 15_000, + }, + (response) => { + response.resume(); + response.on("end", () => + response.statusCode === 200 + ? resolve() + : reject( + new Error(`Fixture upload failed: ${response.statusCode}`), + ), + ); + }, + ); + req.on("timeout", () => req.destroy(new Error("Upload timed out"))); + req.on("error", reject); + req.end(bytes); + }); + const asset = await client.json( + `/api/task/image-upload/${task.id}/finalize`, + "POST", + { ...input, key: upload.key }, + ); + await writeFile( + stateFile, + JSON.stringify({ + email: client.email, + userId: account.user.id, + workspace, + project, + task, + comment, + asset, + }), + { mode: 0o600 }, + ); + console.log( + "Previous release seeded with account, membership, project, task, comment and image", + ); +} else { + const fixture = JSON.parse(await readFile(stateFile, "utf8")); + const client = new Client(origin); + await client.signin(fixture.email); + const workspaces = await client.json("/api/auth/organization/list"); + assert.ok( + workspaces.some((workspace) => workspace.id === fixture.workspace.id), + ); + const members = await client.json( + `/api/workspace/${fixture.workspace.id}/members`, + ); + assert.ok( + members.some( + (member) => member.id === fixture.userId && member.role === "owner", + ), + ); + const project = await client.json(`/api/project/${fixture.project.id}`); + assert.equal(project.name, fixture.project.name); + const task = await client.json(`/api/task/${fixture.task.id}`); + for (const field of [ + "title", + "description", + "status", + "priority", + "number", + "projectId", + ]) + assert.equal( + task[field], + fixture.task[field], + `Task ${field} changed during upgrade`, + ); + const comments = await client.json(`/api/comment/${task.id}`); + assert.ok( + comments.some( + (comment) => + comment.id === fixture.comment.id && + comment.content === fixture.comment.content, + ), + ); + const assetUrl = new URL(fixture.asset.url, origin); + const image = await client.response(assetUrl.pathname); + assert.equal(image.status, 200); + assert.deepEqual(Buffer.from(await image.arrayBuffer()), bytes); + const outsider = new Client(origin); + await outsider.signup("upgrade-outsider"); + assert.ok( + [403, 404].includes((await outsider.response(assetUrl.pathname)).status), + "Private image became public after upgrade", + ); + await client.json(`/api/task/status/${task.id}`, "PUT", { + status: "in-progress", + }); + assert.equal( + (await client.json(`/api/task/${task.id}`)).status, + "in-progress", + ); + console.log( + "Upgrade preserved credentials, membership, task data, comments and private image bytes; writes still work", + ); +} diff --git a/scripts/ci/upgrade.sh b/scripts/ci/upgrade.sh new file mode 100644 index 0000000000..9cd2ac865a --- /dev/null +++ b/scripts/ci/upgrade.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The standard GitHub latest release excludes prereleases and package-only releases. +tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name) +version=${tag#v} +node scripts/security/validate-release-version.mjs "$version" --new-version +if [[ "$tag" != "v$version" ]]; then + echo "Expected a stable Kaneo vX.Y.Z release, got $tag" >&2 + exit 1 +fi +owner=$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]') +image="ghcr.io/$owner/kaneo:$version" +docker pull "$image" +# Record and use the pulled digest so a mutable tag cannot change the baseline mid-test. +export KANEO_UPGRADE_IMAGE +authoritative_ref=$(docker image inspect "$image" --format '{{index .RepoDigests 0}}') +KANEO_UPGRADE_IMAGE=$authoritative_ref +mkdir -p .cache/ci-results +printf 'Upgrade baseline: %s\n' "$KANEO_UPGRADE_IMAGE" | tee .cache/ci-results/upgrade-baseline.txt +compose=(docker compose --env-file /dev/null -p kaneo-ci -f scripts/ci/compose.yml --profile upgrade) +"${compose[@]}" up -d --wait --wait-timeout 120 upgrade +node scripts/ci/upgrade.mjs seed http://127.0.0.1:55175 .cache/ci-results/upgrade-state.json +"${compose[@]}" stop upgrade +export KANEO_UPGRADE_IMAGE=kaneo:ci +"${compose[@]}" up -d --no-deps --wait --wait-timeout 120 --force-recreate upgrade +node scripts/ci/upgrade.mjs verify http://127.0.0.1:55175 .cache/ci-results/upgrade-state.json diff --git a/tests/storage-integration/README.md b/tests/storage-integration/README.md index 5c09972137..0872208ee1 100644 --- a/tests/storage-integration/README.md +++ b/tests/storage-integration/README.md @@ -10,8 +10,9 @@ docker run --rm -d --name kaneo-storage-contract-test \ -e MINIO_ROOT_USER=local-test-access \ -e MINIO_ROOT_PASSWORD=local-test-secret-only \ -e MINIO_BROWSER=off -e MINIO_UPDATE=off \ - quay.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e \ - server /data --address :9000 + --entrypoint /opt/bitnami/minio/bin/minio \ + bitnamilegacy/minio@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 \ + server /tmp/minio --address :9000 ``` Once the service is ready, run: