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