diff --git a/.github/actions/deploy-twenty-app/action.yml b/.github/actions/deploy-twenty-app/action.yml index d2025d4758..81ad23fe33 100644 --- a/.github/actions/deploy-twenty-app/action.yml +++ b/.github/actions/deploy-twenty-app/action.yml @@ -40,7 +40,7 @@ runs: const fs = require('fs'), path = require('path'), os = require('os'); fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({ version: 1, - remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } } + remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY, accessToken: process.env.API_KEY } } }, null, 2)); " env: diff --git a/.github/actions/install-twenty-app/action.yml b/.github/actions/install-twenty-app/action.yml index 785cc578fc..a7f3479f55 100644 --- a/.github/actions/install-twenty-app/action.yml +++ b/.github/actions/install-twenty-app/action.yml @@ -40,7 +40,7 @@ runs: const fs = require('fs'), path = require('path'), os = require('os'); fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({ version: 1, - remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } } + remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY, accessToken: process.env.API_KEY } } }, null, 2)); " env: diff --git a/.github/actions/spawn-twenty-server/action.yml b/.github/actions/spawn-twenty-server/action.yml new file mode 100644 index 0000000000..58214c5c2c --- /dev/null +++ b/.github/actions/spawn-twenty-server/action.yml @@ -0,0 +1,124 @@ +name: Spawn Twenty Server +description: > + Provisions a running Twenty server and returns its URL + API key, from one of + two sources: + - dockerhub-latest: the latest published twentycrm/twenty-app-dev image + (delegates to spawn-twenty-app-dev-test, port 2021). + - local: a server built from the monorepo's current files (port 3000). It + brings up its own postgres + redis via docker, so it works inside any job + without declaring job-level services. + Both sources expose the same server-url / api-key contract so callers never + branch on how the server came up. + +inputs: + source: + description: 'Where the server comes from: "dockerhub-latest" or "local".' + required: false + default: 'local' + twenty-version: + description: 'Docker Hub image tag, only used when source is "dockerhub-latest".' + required: false + default: 'latest' + server-build-cache-key: + description: 'Cache key used to speed up the local server build (nx cache).' + required: false + default: 'server-build' + +outputs: + server-url: + description: 'URL where the Twenty server can be reached' + value: ${{ steps.resolve.outputs.server-url }} + api-key: + description: 'API key (or access token) for the seeded workspace' + value: ${{ steps.resolve.outputs.api-key }} + +runs: + using: 'composite' + steps: + - name: Spawn from Docker Hub image + id: dockerhub + if: inputs.source == 'dockerhub-latest' + uses: ./.github/actions/spawn-twenty-app-dev-test + with: + twenty-version: ${{ inputs.twenty-version }} + + - name: Start postgres and redis + if: inputs.source == 'local' + shell: bash + run: | + docker run -d --name twenty-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres:18 + docker run -d --name twenty-redis -p 6379:6379 redis + + echo "Waiting for postgres…" + for i in {1..30}; do + if PGPASSWORD=postgres pg_isready -h localhost -p 5432 -U postgres > /dev/null 2>&1; then + echo "Postgres ready" + break + fi + sleep 2 + if [ "$i" -eq 30 ]; then + echo "::error::Postgres did not become ready in time" + docker logs twenty-postgres 2>&1 | tail -40 + exit 1 + fi + done + + - name: Install dependencies + if: inputs.source == 'local' + uses: ./.github/actions/yarn-install + + - name: Restore server build cache + if: inputs.source == 'local' + uses: ./.github/actions/restore-cache + with: + key: ${{ inputs.server-build-cache-key }} + + - name: Build server from monorepo + if: inputs.source == 'local' + shell: bash + run: | + npx nx build twenty-shared + npx nx build twenty-server + + - name: Write server env and create databases + if: inputs.source == 'local' + shell: bash + run: | + npx nx reset:env:e2e-testing-server twenty-server + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";' + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";' + npx nx run twenty-server:database:reset + + - name: Start server + if: inputs.source == 'local' + shell: bash + run: nohup npx nx start:ci twenty-server & + + - name: Wait for server to be ready + if: inputs.source == 'local' + shell: bash + run: npx wait-on http://localhost:3000/healthz --timeout 180000 --interval 1000 + + - name: Resolve server url and api key + id: resolve + shell: bash + env: + SOURCE: ${{ inputs.source }} + DOCKERHUB_URL: ${{ steps.dockerhub.outputs.server-url }} + DOCKERHUB_KEY: ${{ steps.dockerhub.outputs.api-key }} + LOCAL_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik + run: | + if [ "$SOURCE" = "dockerhub-latest" ]; then + echo "server-url=$DOCKERHUB_URL" >> "$GITHUB_OUTPUT" + echo "api-key=$DOCKERHUB_KEY" >> "$GITHUB_OUTPUT" + elif [ "$SOURCE" = "local" ]; then + echo "server-url=http://localhost:3000" >> "$GITHUB_OUTPUT" + echo "api-key=$LOCAL_KEY" >> "$GITHUB_OUTPUT" + else + echo "::error::Unknown source '$SOURCE' (expected 'dockerhub-latest' or 'local')" + exit 1 + fi diff --git a/.github/actions/test-twenty-app/action.yml b/.github/actions/test-twenty-app/action.yml new file mode 100644 index 0000000000..be958b7c07 --- /dev/null +++ b/.github/actions/test-twenty-app/action.yml @@ -0,0 +1,85 @@ +name: Test Twenty App +description: > + Exercises a single Twenty app against an already-running server. In + "installation-and-integration-test" mode it runs the app's integration suite, + then deploys and installs the app. In "installation-only" mode it only deploys + and installs, which is enough to catch server-side install regressions without + paying for each app's full test suite. + +inputs: + api-url: + description: Base URL of the target Twenty instance + required: true + api-key: + description: API key or access token for the target workspace + required: true + app-path: + description: Path to the app directory (relative to repo root) + required: true + mode: + description: > + What to run: "installation-and-integration-test" (integration + deploy + + install), "integration-test-only" (integration suite only) or + "installation-only" (deploy + install only). + required: false + default: 'installation-and-integration-test' + +runs: + using: composite + steps: + - name: Validate mode + shell: bash + env: + MODE: ${{ inputs.mode }} + run: | + case "$MODE" in + installation-and-integration-test|integration-test-only|installation-only) ;; + *) + echo "::error::Unknown mode '$MODE' (expected 'installation-and-integration-test', 'integration-test-only' or 'installation-only')" + exit 1 + ;; + esac + + - name: Enable Corepack + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'integration-test-only' + shell: bash + run: corepack enable + + - name: Setup Node.js + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'integration-test-only' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '${{ inputs.app-path }}/.nvmrc' + cache: yarn + cache-dependency-path: '${{ inputs.app-path }}/yarn.lock' + + - name: Install dependencies + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'integration-test-only' + shell: bash + working-directory: ${{ inputs.app-path }} + run: yarn install --immutable + + - name: Integration tests + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'integration-test-only' + shell: bash + working-directory: ${{ inputs.app-path }} + env: + TWENTY_API_URL: ${{ inputs.api-url }} + TWENTY_API_KEY: ${{ inputs.api-key }} + run: yarn test + + - name: Deploy app + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'installation-only' + uses: ./.github/actions/deploy-twenty-app + with: + api-url: ${{ inputs.api-url }} + api-key: ${{ inputs.api-key }} + app-path: ${{ inputs.app-path }} + + - name: Install app + if: inputs.mode == 'installation-and-integration-test' || inputs.mode == 'installation-only' + uses: ./.github/actions/install-twenty-app + with: + api-url: ${{ inputs.api-url }} + api-key: ${{ inputs.api-key }} + app-path: ${{ inputs.app-path }} diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 7f7ed08dd4..af4add5fd1 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -485,6 +485,41 @@ jobs: with: skip: ${{ needs.upgrade-changed-files-check.outputs.any_changed != 'true' }} + discover-public-apps: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' + uses: ./.github/workflows/discover-apps.yaml + with: + scope: public + changed-only: false + + server-apps-install-smoke: + needs: [discover-public-apps, server-build] + timeout-minutes: 30 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: ${{ fromJSON(needs.discover-public-apps.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 10 + - name: Spawn Twenty server from monorepo + id: twenty + uses: ./.github/actions/spawn-twenty-server + with: + source: local + server-build-cache-key: ${{ env.SERVER_BUILD_CACHE_KEY }} + - name: Install app against server + uses: ./.github/actions/test-twenty-app + with: + api-url: ${{ steps.twenty.outputs.server-url }} + api-key: ${{ steps.twenty.outputs.api-key }} + app-path: ${{ matrix.app.path }} + mode: installation-only + ci-server-status-check: if: always() && !cancelled() timeout-minutes: 5 @@ -499,6 +534,8 @@ jobs: server-test, server-integration-test, cross-version-upgrade, + discover-public-apps, + server-apps-install-smoke, ] steps: - name: Fail job if any needs failed diff --git a/.github/workflows/ci-twenty-apps.yaml b/.github/workflows/ci-twenty-apps.yaml index 0f64eea223..c59efbbd1b 100644 --- a/.github/workflows/ci-twenty-apps.yaml +++ b/.github/workflows/ci-twenty-apps.yaml @@ -22,84 +22,11 @@ concurrency: jobs: discover: - timeout-minutes: 5 - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - has_apps: ${{ steps.set-matrix.outputs.has_apps }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Ensure no .github folder inside app folders - run: | - offenders=$(find packages/twenty-apps/internal packages/twenty-apps/public -mindepth 2 -maxdepth 2 -type d -name .github) - if [ -n "$offenders" ]; then - echo "::error::Apps must not define their own .github folder. Offenders:" - echo "$offenders" - exit 1 - fi - - - name: Detect changed files - id: changed-files - if: github.event_name != 'workflow_dispatch' - uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9 - with: - json: true - escape_json: false - files: | - packages/twenty-apps/internal/** - packages/twenty-apps/public/** - - - name: Build matrix of changed apps - id: set-matrix - env: - EVENT_NAME: ${{ github.event_name }} - CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - APPLICATION_INPUT: ${{ inputs.application }} - run: | - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - const roots = ['packages/twenty-apps/internal', 'packages/twenty-apps/public']; - const eventName = process.env.EVENT_NAME; - const changedFiles = JSON.parse(process.env.CHANGED_FILES || '[]'); - const changedApps = new Set(); - for (const file of changedFiles) { - const match = file.match(/^packages\/twenty-apps\/(?:internal|public)\/([^/]+)\//); - if (match) changedApps.add(match[1]); - } - const requestedApp = (process.env.APPLICATION_INPUT || '').trim(); - const matrix = roots - .filter((root) => fs.existsSync(root)) - .flatMap((root) => - fs.readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') - .map((entry) => entry.name) - .filter((name) => fs.existsSync(path.join(root, name, 'package.json'))) - .filter((name) => { - if (eventName === 'workflow_dispatch') { - return requestedApp ? name === requestedApp : true; - } - return changedApps.has(name); - }) - .map((name) => { - const appPath = path.join(root, name); - const scripts = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8')).scripts || {}; - return { - name, - path: appPath, - hasTypecheck: Boolean(scripts.typecheck), - hasUnit: Boolean(scripts['test:unit']), - hasIntegration: Boolean(scripts.test), - }; - }) - ); - fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\n`); - fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_apps=${matrix.length > 0}\n`); - NODE + uses: ./.github/workflows/discover-apps.yaml + with: + scope: internal-and-public + changed-only: true + application: ${{ inputs.application }} ci: needs: discover @@ -142,25 +69,44 @@ jobs: if: matrix.app.hasUnit run: yarn test:unit - - name: Spawn Twenty test instance + integration: + needs: discover + if: needs.discover.outputs.has_apps == 'true' + name: ${{ matrix.app.name }} (${{ matrix.server-source }}) + timeout-minutes: 30 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: ${{ fromJSON(needs.discover.outputs.matrix) }} + server-source: [dockerhub-latest, local] + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 10 + + - name: Spawn Twenty server id: twenty if: matrix.app.hasIntegration - uses: ./.github/actions/spawn-twenty-app-dev-test + uses: ./.github/actions/spawn-twenty-server with: - twenty-version: latest + source: ${{ matrix.server-source }} - - name: Integration tests + - name: Test app against server if: matrix.app.hasIntegration - run: yarn test - env: - TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }} - TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }} + uses: ./.github/actions/test-twenty-app + with: + api-url: ${{ steps.twenty.outputs.server-url }} + api-key: ${{ steps.twenty.outputs.api-key }} + app-path: ${{ matrix.app.path }} + mode: ${{ matrix.server-source == 'local' && 'installation-and-integration-test' || 'integration-test-only' }} ci-twenty-apps-status-check: if: always() && !cancelled() timeout-minutes: 5 runs-on: ubuntu-latest - needs: [discover, ci] + needs: [discover, ci, integration] steps: - name: Fail job if any needs failed if: contains(needs.*.result, 'failure') diff --git a/.github/workflows/discover-apps.yaml b/.github/workflows/discover-apps.yaml new file mode 100644 index 0000000000..8db9d9b9fc --- /dev/null +++ b/.github/workflows/discover-apps.yaml @@ -0,0 +1,119 @@ +name: Discover apps reusable workflow + +on: + workflow_call: + inputs: + scope: + description: 'App roots to scan: "public" or "internal-and-public".' + required: false + type: string + default: internal-and-public + changed-only: + description: 'Only include apps changed in this PR, else every app in scope.' + required: false + type: boolean + default: true + application: + description: 'workflow_dispatch: restrict to a single app folder name.' + required: false + type: string + default: '' + outputs: + matrix: + value: ${{ jobs.discover.outputs.matrix }} + has_apps: + value: ${{ jobs.discover.outputs.has_apps }} + +permissions: + contents: read + +jobs: + discover: + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + has_apps: ${{ steps.set-matrix.outputs.has_apps }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: ${{ inputs.changed-only && '0' || '1' }} + + - name: Ensure no .github folder inside app folders + run: | + offenders=$(find packages/twenty-apps/internal packages/twenty-apps/public -mindepth 2 -maxdepth 2 -type d -name .github) + if [ -n "$offenders" ]; then + echo "::error::Apps must not define their own .github folder. Offenders:" + echo "$offenders" + exit 1 + fi + + - name: Detect changed files + id: changed-files + if: inputs.changed-only && github.event_name != 'workflow_dispatch' + uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9 + with: + json: true + escape_json: false + files: | + packages/twenty-apps/internal/** + packages/twenty-apps/public/** + + - name: Build matrix of apps + id: set-matrix + env: + SCOPE: ${{ inputs.scope }} + CHANGED_ONLY: ${{ inputs.changed-only }} + EVENT_NAME: ${{ github.event_name }} + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + APPLICATION_INPUT: ${{ inputs.application }} + run: | + node <<'NODE' + const fs = require('fs'); + const path = require('path'); + const changedOnly = process.env.CHANGED_ONLY === 'true'; + const roots = process.env.SCOPE === 'public' + ? ['packages/twenty-apps/public'] + : ['packages/twenty-apps/internal', 'packages/twenty-apps/public']; + const eventName = process.env.EVENT_NAME; + const changedFiles = JSON.parse(process.env.CHANGED_FILES || '[]'); + const changedApps = new Set(); + for (const file of changedFiles) { + const match = file.match(/^packages\/twenty-apps\/(?:internal|public)\/([^/]+)\//); + if (match) changedApps.add(match[1]); + } + const requestedApp = (process.env.APPLICATION_INPUT || '').trim(); + const matrix = roots + .filter((root) => fs.existsSync(root)) + .flatMap((root) => + fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') + .map((entry) => entry.name) + .filter((name) => fs.existsSync(path.join(root, name, 'package.json'))) + .filter((name) => { + if (!changedOnly) return true; + if (eventName === 'workflow_dispatch') { + return requestedApp ? name === requestedApp : true; + } + return changedApps.has(name); + }) + .map((name) => { + const appPath = path.join(root, name); + const scripts = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8')).scripts || {}; + return { + name, + path: appPath, + hasTypecheck: Boolean(scripts.typecheck), + hasUnit: Boolean(scripts['test:unit']), + hasIntegration: Boolean(scripts.test), + }; + }) + ); + if (changedOnly && eventName === 'workflow_dispatch' && requestedApp && matrix.length === 0) { + console.error(`::error::Requested app "${requestedApp}" was not found under ${roots.join(', ')}`); + process.exit(1); + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_apps=${matrix.length > 0}\n`); + NODE diff --git a/packages/twenty-server/docs/UPGRADE_COMMANDS.md b/packages/twenty-server/docs/UPGRADE_COMMANDS.md index 77ad5e3b85..25fbfcd3ca 100644 --- a/packages/twenty-server/docs/UPGRADE_COMMANDS.md +++ b/packages/twenty-server/docs/UPGRADE_COMMANDS.md @@ -127,3 +127,5 @@ npx nx run twenty-server:database:migrate:generate --name --type fast --v It registers and boots (versions are validated against `TWENTY_ALL_VERSIONS`) but stays **dormant** — the sequence only runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current). It activates automatically when `nx version:bump` promotes the version to current. **Caveat:** `@WasRemovedInUpgrade` / `@WasIntroducedInUpgrade` are validated against the active sequence, so a decorator pointing at a still-dormant next-version command fails boot with `unknown-step-name`. For a deferred drop, keep the entity's `WasRemovedInUpgrade` type wrapper now and add the decorator only once the version is current. + +See the CI workflows for how upgrade commands are exercised in continuous integration.