diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 00000000..dbc122d4 --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,36 @@ +name: Setup action +description: Reusable action to setup node and install packages + +runs: + using: "composite" + steps: + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: pnpm/action-setup@v3 + name: Install pnpm + id: pnpm-install + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + shell: bash + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: pnpm-store-${{ hashFiles('**/package.json') }} + restore-keys: | + pnpm-store- + + - name: Install dependencies + run: pnpm install + shell: bash diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000..3befeeec --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,101 @@ +name: build +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +on: + pull_request: + types: + - closed + - opened + - reopened + - synchronize +jobs: + build: + name: Build + runs-on: ubuntu-latest + outputs: + affectedPackages: ${{ steps.get-affected-packages.outputs.packages }} + if: "!(github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == false)" + steps: + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Get affected packages + id: get-affected-packages + run: echo "packages=$(pnpm exec foundation-get-affected-project-chunks test:js origin/${{github.base_ref}} 4)" >> $GITHUB_OUTPUT + + - name: Compile source files + run: pnpm run build + + - name: Lint affected source files + run: pnpm run lint --filter=[origin/${{github.base_ref}}] + + - name: Get current git commit hash + id: get-git-commit-hash + run: | + echo "gitCommitHash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + id: ts-build-cache + with: + path: | + ./packages/container/libraries/* + ./packages/foundation/tools/* + key: ts-build-${{ steps.get-git-commit-hash.outputs.gitCommitHash }} + restore-keys: | + ts-build-${{ steps.get-git-commit-hash.outputs.gitCommitHash }} + + test-package: + name: Test package + needs: build + runs-on: ubuntu-latest + if: ${{ needs.build.outputs.affectedPackages != '[]' }} + strategy: + matrix: + packages: ${{fromJSON(needs.build.outputs.affectedPackages)}} + steps: + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Get current git commit hash + id: get-git-commit-hash + run: | + echo "gitCommitHash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + id: ts-build-cache + with: + path: | + ./packages/container/libraries/* + ./packages/foundation/tools/* + key: ts-build-${{ steps.get-git-commit-hash.outputs.gitCommitHash }} + restore-keys: | + ts-build-${{ steps.get-git-commit-hash.outputs.gitCommitHash }} + + - name: Build on cache miss + run: pnpm run build --filter ${{ join(matrix.packages, ' --filter ') }} + if: ${{ !steps.ts-build-cache.outputs.cache-hit }} + + - name: Launch Unit Tests + run: pnpm run test:unit:js --filter ${{ join(matrix.packages, ' --filter ') }} --only + + - name: Launch Integration Tests + run: pnpm run test:integration:js --filter ${{ join(matrix.packages, ' --filter ') }} --only + + done: + name: Done + needs: + - test-package + runs-on: ubuntu-latest + steps: + - run: 'echo "Done!"' diff --git a/package.json b/package.json index b9841d21..97c6f128 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@inversifyjs/foundation-prettier-config": "workspace:*", "@inversifyjs/foundation-eslint-config": "workspace:*", "@inversifyjs/foundation-jest-config": "workspace:*", + "@inversifyjs/foundation-scripts": "workspace:*", "@inversifyjs/foundation-typescript-config": "workspace:*", "husky": "9.1.6", "lint-staged": "15.2.10", diff --git a/packages/foundation/tools/scripts/.gitignore b/packages/foundation/tools/scripts/.gitignore new file mode 100644 index 00000000..44a0d434 --- /dev/null +++ b/packages/foundation/tools/scripts/.gitignore @@ -0,0 +1,2 @@ +# npm installed packages +/node_modules/ diff --git a/packages/foundation/tools/scripts/.lintstagedrc.json b/packages/foundation/tools/scripts/.lintstagedrc.json new file mode 100644 index 00000000..6105c241 --- /dev/null +++ b/packages/foundation/tools/scripts/.lintstagedrc.json @@ -0,0 +1,5 @@ +{ + "*.js": [ + "prettier --write" + ] +} diff --git a/packages/foundation/tools/scripts/bin/getAffectedProjectsChunks.js b/packages/foundation/tools/scripts/bin/getAffectedProjectsChunks.js new file mode 100755 index 00000000..a8fd4001 --- /dev/null +++ b/packages/foundation/tools/scripts/bin/getAffectedProjectsChunks.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +import process from 'node:process'; +import { promisifiedExec } from '../src/promisifiedExec.js'; + +/** + * @param {Array} elements Elements + * @param {number} chunks chunks amount + * @returns {Array.} + */ +function buildChunks(elements, chunks) { + const chunkSize = Math.ceil(elements.length / chunks); + + const chunksArray = []; + + for (let i = 0; i < elements.length; i += chunkSize) { + chunksArray.push(elements.slice(i, i + chunkSize)); + } + + return chunksArray; +} + +const TURBOREPO_ROOT_PACKAGE_NAME = '//'; +const TURBOREPO_TASK_NOT_FOUND_MAGIC_STRING = '\u003cNONEXISTENT\u003e'; + +const taskToDryRun = process.argv[2]; +const baseRef = process.argv[3]; +const chunks = parseInt(process.argv[4]); + +let execCommand = `pnpm exec turbo run ${taskToDryRun} --dry=json`; + +if (baseRef !== undefined) { + execCommand += ` --filter ...[${baseRef}]`; +} + +const stringifiedDryRun = (await promisifiedExec(execCommand)).trim(); + +const dryRunResult = JSON.parse(stringifiedDryRun); + +/** @type {Array.} */ +const dryResultPackageNames = dryRunResult.packages.filter( + (packageName) => packageName !== TURBOREPO_ROOT_PACKAGE_NAME, +); + +const tasks = dryRunResult.tasks.filter((task) => + dryResultPackageNames.some( + (packageName) => + task.taskId === `${packageName}#${taskToDryRun}` && + !task.command.includes(TURBOREPO_TASK_NOT_FOUND_MAGIC_STRING), + ), +); + +/** @type {Array.} */ +const packageNames = tasks.map((task) => task.package); + +const packageNameChunks = buildChunks(packageNames, chunks).filter( + (chunk) => chunk.length > 0, +); + +process.stdout.write(JSON.stringify(packageNameChunks)); diff --git a/packages/foundation/tools/scripts/bin/writeCommonJsPackageJson.js b/packages/foundation/tools/scripts/bin/writeCommonJsPackageJson.js new file mode 100755 index 00000000..c0414f38 --- /dev/null +++ b/packages/foundation/tools/scripts/bin/writeCommonJsPackageJson.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import { argv } from 'node:process'; +import path from 'node:path'; +import { writeFile } from 'node:fs/promises'; + +/** + * @param {string} path + * @returns {Promise} + */ +async function pathExists(path) { + try { + await fs.access(path); + return true; + } catch (_err) { + return false; + } +} + +const directory = argv[2]; + +if (directory === undefined) { + throw new Error('Expected a path'); +} + +const directoryExists = await pathExists(directory); + +if (!directoryExists) { + throw new Error(`Path ${directory} not found`); +} + +const filePath = path.join(directory, 'package.json'); + +const packageJsonFileContent = JSON.stringify( + { + type: 'commonjs', + }, + undefined, + 2, +); + +await writeFile(filePath, packageJsonFileContent); diff --git a/packages/foundation/tools/scripts/bin/writeEsmPackageJson.js b/packages/foundation/tools/scripts/bin/writeEsmPackageJson.js new file mode 100755 index 00000000..2941aab5 --- /dev/null +++ b/packages/foundation/tools/scripts/bin/writeEsmPackageJson.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import { argv } from 'node:process'; +import path from 'node:path'; +import { writeFile } from 'node:fs/promises'; + +/** + * @param {string} path + * @returns {Promise} + */ +async function pathExists(path) { + try { + await fs.access(path); + return true; + } catch (_err) { + return false; + } +} + +const directory = argv[2]; + +if (directory === undefined) { + throw new Error('Expected a path'); +} + +const directoryExists = await pathExists(directory); + +if (!directoryExists) { + throw new Error(`Path ${directory} not found`); +} + +const filePath = path.join(directory, 'package.json'); + +const packageJsonFileContent = JSON.stringify( + { + type: 'module', + }, + undefined, + 2, +); + +await writeFile(filePath, packageJsonFileContent); diff --git a/packages/foundation/tools/scripts/package.json b/packages/foundation/tools/scripts/package.json new file mode 100644 index 00000000..4abc1c8b --- /dev/null +++ b/packages/foundation/tools/scripts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@inversifyjs/foundation-scripts", + "private": true, + "description": "Common scripts for monorepo packages", + "repository": { + "type": "git", + "url": "git+https://github.com/notaphplover/one-game.git" + }, + "author": "Multiple authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/notaphplover/one-game/issues" + }, + "type": "module", + "homepage": "https://github.com/notaphplover/one-game#readme", + "bin": { + "foundation-get-affected-project-chunks": "./bin/getAffectedProjectsChunks.js", + "foundation-ts-package-cjs": "./bin/writeCommonJsPackageJson.js", + "foundation-ts-package-esm": "./bin/writeEsmPackageJson.js" + } +} diff --git a/packages/foundation/tools/scripts/prettier.config.mjs b/packages/foundation/tools/scripts/prettier.config.mjs new file mode 100644 index 00000000..70361db5 --- /dev/null +++ b/packages/foundation/tools/scripts/prettier.config.mjs @@ -0,0 +1,3 @@ +import config from '@inversifyjs/foundation-prettier-config'; + +export default config; diff --git a/packages/foundation/tools/scripts/src/promisifiedExec.js b/packages/foundation/tools/scripts/src/promisifiedExec.js new file mode 100644 index 00000000..6413ac80 --- /dev/null +++ b/packages/foundation/tools/scripts/src/promisifiedExec.js @@ -0,0 +1,42 @@ +import { exec } from 'node:child_process'; + +/** + * @param {string} command command + * @param {PromisifiedExecOptions} options true to pipe standard input output and error streams + * @returns {Promise} + */ +export async function promisifiedExec(command, options) { + options = { + cwd: options?.cwd, + interactive: options?.interactive ?? false, + }; + + return new Promise((resolve, reject) => { + const childProcess = exec( + command, + { + cwd: options.cwd, + }, + (error, stdout) => { + if (error === null) { + resolve(stdout); + } else { + reject(error); + } + }, + ); + + if (options.interactive) { + childProcess.stdout.pipe(process.stdout); + childProcess.stderr.pipe(process.stderr); + + process.stdin.pipe(childProcess.stdin); + } + }); +} + +/** + * @typedef {Object} PromisifiedExecOptions + * @property {boolean} interactive + * @property {string} cwd + */