diff --git a/.github/workflows/bake-and-push.yml b/.github/workflows/_bake-and-push.yml similarity index 77% rename from .github/workflows/bake-and-push.yml rename to .github/workflows/_bake-and-push.yml index 203208281..747d05366 100644 --- a/.github/workflows/bake-and-push.yml +++ b/.github/workflows/_bake-and-push.yml @@ -1,10 +1,10 @@ -name: Bake and Push +name: Reusable | Bake and Push permissions: contents: read actions: read -# Reusable workflow for building and pushing a bake target. +# Reusable workflow for building and optionally pushing a bake target. # Replaces build-docker-image.yml for apps migrated to docker-bake.hcl. # # Callers pass app-specific build args via the `set` input using GitHub Variables @@ -25,12 +25,17 @@ on: required: true type: string description: > - JSON string. Target OS (e.g. ubuntu-24.04-arm or - ['ubuntu-24.04-arm', 'ubuntu-24.04']) of the image + JSON string passed to runs-on via fromJSON, e.g. + ["ubuntu-24.04-arm"]. tag: required: true type: string - description: "Image tag to push (e.g. git SHA or semver)" + description: "Image tag to build or push (e.g. git SHA or semver)" + push: + required: false + type: boolean + default: true + description: "Whether to push the built image to the registry" base_tag: required: false type: string @@ -38,9 +43,8 @@ on: description: > Pre-built base image tag (BASE_TAG). When set, pulls ui-builder-base and ui-runner-base from the registry instead of building them inline. - Use this in CI to avoid rebuilding base images on every app push. - Omit (or leave empty) to build base images inline — useful when - testing base image changes locally via act or in build-base-images.yml. + Required for CI app builds so they reuse published base images rather + than rebuilding base images inline. set: required: false type: string @@ -56,9 +60,9 @@ on: DATABASE_URL: required: false DOCKER_HUB_USERNAME: - required: true + required: false DOCKER_HUB_ACCESS_TOKEN: - required: true + required: false PAYLOAD_SECRET: required: false SENTRY_AUTH_TOKEN: @@ -82,14 +86,21 @@ jobs: id: meta run: echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Validate base image tag + if: ${{ inputs.base_tag == '' }} + run: | + echo "::error::base_tag is required for CI app builds. Build and publish base images, then set vars.UI_BASE_TAG." + exit 1 + - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 + if: ${{ inputs.push }} with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Build and push + - name: Build with Docker bake uses: docker/bake-action@v7 env: TAG: ${{ inputs.tag }} @@ -104,7 +115,7 @@ jobs: with: files: docker-bake.hcl targets: ${{ inputs.target }} - push: true + push: ${{ inputs.push }} set: | *.cache-from=type=gha,scope=${{ inputs.target }} *.cache-to=type=gha,mode=max,scope=${{ inputs.target }} diff --git a/.github/workflows/_build-techlabblog.yml b/.github/workflows/_build-techlabblog.yml new file mode 100644 index 000000000..1d0a4e6b9 --- /dev/null +++ b/.github/workflows/_build-techlabblog.yml @@ -0,0 +1,61 @@ +name: Reusable Build | TechLab Blog + +on: + workflow_call: + inputs: + tag: + required: true + type: string + description: "Image tag to build or push" + push: + required: true + type: boolean + description: "Whether to push the built image to the registry" + base_tag: + required: true + type: string + description: "Published base image tag to build from" + sentry_environment: + required: true + type: string + description: "Sentry environment baked into the app" + set: + required: false + type: string + default: "" + description: "Additional bake --set overrides" + secrets: + DOCKER_HUB_USERNAME: + required: false + DOCKER_HUB_ACCESS_TOKEN: + required: false + SENTRY_AUTH_TOKEN: + required: false + SENTRY_ORG: + required: false + SENTRY_PROJECT: + required: false + +jobs: + build: + permissions: + actions: read + contents: read + uses: ./.github/workflows/_bake-and-push.yml + with: + target: techlabblog + target_os: '["ubuntu-24.04-arm"]' + base_tag: ${{ inputs.base_tag }} + tag: ${{ inputs.tag }} + push: ${{ inputs.push }} + # Stable app-level config lives here so PR and main builds share one build contract. + set: | + techlabblog.args.SENTRY_DSN=${{ vars.TECHLABBLOG_SENTRY_DSN }} + techlabblog.args.SENTRY_ENVIRONMENT=${{ inputs.sentry_environment }} + ${{ inputs.set }} + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/_build-trustlab.yml b/.github/workflows/_build-trustlab.yml new file mode 100644 index 000000000..c6fadd496 --- /dev/null +++ b/.github/workflows/_build-trustlab.yml @@ -0,0 +1,59 @@ +name: Reusable Build | TrustLab + +on: + workflow_call: + inputs: + tag: + required: true + type: string + description: "Image tag to build or push" + push: + required: true + type: boolean + description: "Whether to push the built image to the registry" + base_tag: + required: true + type: string + description: "Published base image tag to build from" + set: + required: false + type: string + default: "" + description: "Additional bake --set overrides" + secrets: + DATABASE_URL: + required: false + DOCKER_HUB_USERNAME: + required: false + DOCKER_HUB_ACCESS_TOKEN: + required: false + PAYLOAD_SECRET: + required: false + SENTRY_AUTH_TOKEN: + required: false + SENTRY_ORG: + required: false + SENTRY_PROJECT: + required: false + +jobs: + build: + permissions: + actions: read + contents: read + uses: ./.github/workflows/_bake-and-push.yml + with: + target: trustlab + target_os: '["ubuntu-24.04-arm"]' + base_tag: ${{ inputs.base_tag }} + tag: ${{ inputs.tag }} + push: ${{ inputs.push }} + set: ${{ inputs.set }} + secrets: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 916206732..25bcab2cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,17 +24,16 @@ jobs: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} strategy: matrix: - node-version: [24] os: [ubuntu-latest] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 2 # https://github.com/pnpm/action-setup#use-cache-to-reduce-installation-time - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 id: pnpm-install with: run_install: false @@ -46,7 +45,7 @@ jobs: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -56,9 +55,9 @@ jobs: # Looks like to use pnpm cache, setup-node must run after pnpm/action-setup # https://github.com/actions/setup-node/blob/main/docs/advanced-usage.md#caching-packages-data - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version-file: "package.json" cache: "pnpm" - name: Confirm pnpm version @@ -67,16 +66,18 @@ jobs: - name: Install dependencies run: pnpm install + # root task + # https://turborepo.dev/docs/guides/tools/oxc#create-root-tasks-1 - name: Format - run: pnpm format:check + run: pnpm exec turbo format:check - name: Lint run: pnpm lint:check # Standard linux runners for public repositories have 4 vCPUs and 16GB of RAM # see: https://docs.github.com/en/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories - - name: Jest - run: pnpm jest:ci + - name: Test + run: pnpm test:ci # TODO: Re-enable build in a dedicated CI build cleanup PR. The current # build surface needs app-scoped env isolation for Payload/Next apps. diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 000000000..5a4bf3146 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,129 @@ +name: PR Build + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "apps/techlabblog/**" + - "apps/trustlab/**" + - "docker/apps/techlabblog/**" + - "docker/apps/trustlab/**" + - "docker-bake.hcl" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "packages/**" + - "scripts/revalidate.mjs" + - "scripts/pr-build-targets.mjs" + - "scripts/pr-build-targets.test.mjs" + - ".github/workflows/_bake-and-push.yml" + - ".github/workflows/_build-techlabblog.yml" + - ".github/workflows/_build-trustlab.yml" + - ".github/workflows/pr-build.yml" + - ".github/workflows/techlabblog.yml" + - ".github/workflows/trustlab.yml" + +permissions: + actions: read + contents: read + +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label }}" + cancel-in-progress: true + +jobs: + affected: + name: Detect affected apps + runs-on: ubuntu-24.04 + outputs: + targets: ${{ steps.targets.outputs.targets }} + steps: + - name: Check PR trust boundary + id: trust + shell: bash + run: | + trusted=false + if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then + case "${{ github.event.pull_request.author_association }}" in + OWNER|MEMBER|COLLABORATOR) + trusted=true + ;; + esac + fi + echo "trusted=${trusted}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v6 + if: ${{ steps.trust.outputs.trusted == 'true' }} + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Install pnpm + if: ${{ steps.trust.outputs.trusted == 'true' }} + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install Node.js + if: ${{ steps.trust.outputs.trusted == 'true' }} + uses: actions/setup-node@v6 + with: + node-version-file: "package.json" + cache: "pnpm" + + - name: Install dependencies + if: ${{ steps.trust.outputs.trusted == 'true' }} + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Detect app build targets + id: targets + shell: bash + run: | + if [[ "${{ steps.trust.outputs.trusted }}" != "true" ]]; then + echo "App PR builds are skipped for fork or untrusted PRs." + echo "targets=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + node scripts/pr-build-targets.mjs \ + --github-output \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" + + build-techlabblog: + name: Build techlabblog image + needs: affected + if: ${{ contains(fromJSON(needs.affected.outputs.targets), 'techlabblog') }} + permissions: + actions: read + contents: read + uses: ./.github/workflows/_build-techlabblog.yml + with: + base_tag: ${{ vars.UI_BASE_TAG }} + tag: pr-${{ github.event.pull_request.number }}-${{ github.sha }} + push: false + sentry_environment: ci + secrets: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.TECHLABBLOG_SENTRY_PROJECT }} + + build-trustlab: + name: Build trustlab image + needs: affected + if: ${{ contains(fromJSON(needs.affected.outputs.targets), 'trustlab') }} + permissions: + actions: read + contents: read + uses: ./.github/workflows/_build-trustlab.yml + with: + base_tag: ${{ vars.UI_BASE_TAG }} + tag: pr-${{ github.event.pull_request.number }}-${{ github.sha }} + push: false + secrets: + DATABASE_URL: ${{ secrets.TRUSTLAB_MONGO_URL }} + PAYLOAD_SECRET: ${{ secrets.TRUSTLAB_PAYLOAD_SECRET }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.TRUSTLAB_SENTRY_PROJECT }} diff --git a/.github/workflows/techlabblog.yml b/.github/workflows/techlabblog.yml index 7ded93094..4745d34d5 100644 --- a/.github/workflows/techlabblog.yml +++ b/.github/workflows/techlabblog.yml @@ -7,10 +7,10 @@ on: paths: - "apps/techlabblog/**" - "docker/apps/techlabblog/**" - - "docker/base.Dockerfile" - "docker-bake.hcl" + - ".github/workflows/_bake-and-push.yml" + - ".github/workflows/_build-techlabblog.yml" - ".github/workflows/techlabblog.yml" - - ".github/workflows/bake-and-push.yml" permissions: actions: read @@ -30,9 +30,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [24] os: [ubuntu-24.04] - target_os: [ubuntu-24.04-arm] permissions: contents: read outputs: @@ -47,7 +45,7 @@ jobs: # https://github.com/EndBug/version-check#github-workflow - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version-file: "package.json" - name: Check if version is bumped id: check @@ -68,28 +66,23 @@ jobs: # codeforafrica/techlabblog:latest — version bump only (mutable, for convenience) # # Required GitHub Variables: - # TECHLABBLOG_SENTRY_DSN + # TECHLABBLOG_SENTRY_DSN, UI_BASE_TAG # # Required GitHub Secrets (for Sentry source map upload during build): # SENTRY_AUTH_TOKEN, SENTRY_ORG, TECHLABBLOG_SENTRY_PROJECT # - # TODO: Set BASE_TAG below to a published base image version (e.g. v3) once - # base images are built and pushed via build-base-images.yml. Until then, - # base images are built inline (slower but correct). build: needs: version-check permissions: actions: read contents: read - uses: ./.github/workflows/bake-and-push.yml + uses: ./.github/workflows/_build-techlabblog.yml with: - target: techlabblog - target_os: "['ubuntu-24.04-arm']" base_tag: ${{ vars.UI_BASE_TAG }} tag: ${{ github.sha }} + push: true + sentry_environment: production set: | - techlabblog.args.SENTRY_DSN=${{ vars.TECHLABBLOG_SENTRY_DSN }} - techlabblog.args.SENTRY_ENVIRONMENT=production ${{ needs.version-check.outputs.changed == 'true' && format('techlabblog.tags=codeforafrica/techlabblog:{0}', github.sha) || '' }} ${{ needs.version-check.outputs.changed == 'true' && format('techlabblog.tags=codeforafrica/techlabblog:{0}', needs.version-check.outputs.version) || '' }} ${{ needs.version-check.outputs.changed == 'true' && 'techlabblog.tags=codeforafrica/techlabblog:latest' || '' }} diff --git a/.github/workflows/trustlab.yml b/.github/workflows/trustlab.yml index 2c402e0f0..07cd4cc0a 100644 --- a/.github/workflows/trustlab.yml +++ b/.github/workflows/trustlab.yml @@ -7,11 +7,11 @@ on: paths: - "apps/trustlab/**" - "docker/apps/trustlab/**" - - "docker/base.Dockerfile" - "docker-bake.hcl" - "scripts/revalidate.mjs" + - ".github/workflows/_bake-and-push.yml" + - ".github/workflows/_build-trustlab.yml" - ".github/workflows/trustlab.yml" - - ".github/workflows/bake-and-push.yml" permissions: actions: read @@ -26,7 +26,6 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [24] os: [ubuntu-24.04] permissions: contents: read @@ -40,7 +39,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version-file: "package.json" - name: Check if version is bumped id: check @@ -55,12 +54,11 @@ jobs: permissions: actions: read contents: read - uses: ./.github/workflows/bake-and-push.yml + uses: ./.github/workflows/_build-trustlab.yml with: - target: trustlab - target_os: "['ubuntu-24.04-arm']" base_tag: ${{ vars.UI_BASE_TAG }} tag: ${{ github.sha }} + push: true set: | ${{ needs.version-check.outputs.changed == 'true' && format('trustlab.tags=codeforafrica/trustlab:{0}', github.sha) || '' }} ${{ needs.version-check.outputs.changed == 'true' && format('trustlab.tags=codeforafrica/trustlab:{0}', needs.version-check.outputs.version) || '' }} diff --git a/package.json b/package.json index 08e9e5a04..2f4a7f101 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "jest:coverage": "turbo run jest -- --coverage", "jest:debug": "turbo run jest --concurrency=1 -- --runInBand --detectOpenHandles", "playwright": "turbo run playwright", - "test": "turbo run jest playwright --parallel", + "test": "pnpm test:scripts && pnpm jest", + "test:ci": "pnpm test:scripts && pnpm jest:ci", + "test:scripts": "node --test scripts/*.test.mjs", "clean": "turbo run clean && rm -rf node_modules", "format:check": "oxfmt --check .", "format": "oxfmt .", diff --git a/scripts/pr-build-targets.mjs b/scripts/pr-build-targets.mjs new file mode 100644 index 000000000..413fe7d5f --- /dev/null +++ b/scripts/pr-build-targets.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Policy: +// - Turbo owns workspace impact detection, including package-to-app dependents. +// - Only workspaces under apps/ can become PR build targets. +// - Only Docker-migrated apps are buildable by this workflow today. +// - Non-workspace build inputs are handled explicitly because Turbo cannot map +// them through the workspace graph. +export const BUILD_TARGET_CONFIG = { + techlabblog: { + directBuildInputPaths: [ + ".github/workflows/techlabblog.yml", + "docker/apps/techlabblog/", + ], + }, + trustlab: { + directBuildInputPaths: [ + ".github/workflows/trustlab.yml", + "docker/apps/trustlab/", + "scripts/revalidate.mjs", + ], + }, +}; + +const GLOBAL_BUILD_FILES = new Set([ + ".github/workflows/_bake-and-push.yml", + ".github/workflows/_build-techlabblog.yml", + ".github/workflows/_build-trustlab.yml", + ".github/workflows/pr-build.yml", + "scripts/pr-build-targets.mjs", + "docker-bake.hcl", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "turbo.json", +]); + +function parseArgs(argv) { + const args = { + base: "origin/main", + githubOutput: false, + head: "HEAD", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--base") { + args.base = readOptionValue(argv, ++i, arg); + } else if (arg === "--github-output") { + args.githubOutput = true; + } else if (arg === "--head") { + args.head = readOptionValue(argv, ++i, arg); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +function readOptionValue(argv, index, option) { + const value = argv[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${option}`); + } + return value; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + + if (result.status !== 0) { + throw new Error( + [`Command failed: ${command} ${args.join(" ")}`, result.stderr.trim()] + .filter(Boolean) + .join("\n"), + ); + } + return result.stdout; +} + +function writeGitHubOutputs(outputs, outputFile) { + if (!outputFile) { + throw new Error("GITHUB_OUTPUT is required when using --github-output"); + } + + const output = Object.entries(outputs) + .map(([key, value]) => { + const normalizedValue = Array.isArray(value) + ? JSON.stringify(value) + : String(value); + return `${key}=${normalizedValue}`; + }) + .join("\n"); + appendFileSync(outputFile, `${output}\n`); +} + +export function changedFilesFromGit(base, head) { + // Triple-dot matches PR semantics: files changed on head since the merge base. + return run("git", ["diff", "--name-only", `${base}...${head}`]) + .split("\n") + .map((file) => file.trim()) + .filter(Boolean); +} + +export function hasGlobalBuildChange(changedFiles) { + return changedFiles.some((file) => GLOBAL_BUILD_FILES.has(file)); +} + +export function directBuildTargets(changedFiles) { + return changedFiles.flatMap((file) => { + return Object.entries(BUILD_TARGET_CONFIG) + .filter(([, config]) => + config.directBuildInputPaths.some((path) => matchesPath(file, path)), + ) + .map(([app]) => app); + }); +} + +function matchesPath(file, path) { + // Paths ending in "/" match all children. + return path.endsWith("/") ? file.startsWith(path) : file === path; +} + +export function parseTurboBuildTargets(turboOutput) { + const data = parseTurboJson(turboOutput); + const packages = data.packages?.items ?? []; + return packages + .filter( + (pkg) => + pkg.path?.startsWith("apps/") && + Object.hasOwn(BUILD_TARGET_CONFIG, pkg.name), + ) + .map((pkg) => pkg.name); +} + +function parseTurboJson(turboOutput) { + const start = turboOutput.indexOf("{"); + if (start === -1) { + throw new Error("Turbo output did not include JSON"); + } + const end = turboOutput.lastIndexOf("}"); + if (end < start) { + throw new Error("Turbo JSON was incomplete"); + } + + return JSON.parse(turboOutput.slice(start, end + 1)); +} + +export function turboBuildTargets(base, head) { + const turboOutput = run( + "pnpm", + ["exec", "turbo", "ls", "--affected", "--output=json"], + { + env: { + ...process.env, + TURBO_SCM_BASE: base, + TURBO_SCM_HEAD: head, + }, + }, + ); + return parseTurboBuildTargets(turboOutput); +} + +export function buildTargets({ base, head }) { + const changedFiles = changedFilesFromGit(base, head); + if (hasGlobalBuildChange(changedFiles)) { + return Object.keys(BUILD_TARGET_CONFIG).sort(); + } + return [ + ...new Set([ + ...directBuildTargets(changedFiles), + ...turboBuildTargets(base, head), + ]), + ].sort(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const targets = buildTargets(args); + const outputs = { targets }; + + if (args.githubOutput) { + writeGitHubOutputs(outputs, process.env.GITHUB_OUTPUT); + } + process.stdout.write(`${JSON.stringify(outputs)}\n`); +} + +const invokedPath = + process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedPath) { + try { + main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/pr-build-targets.test.mjs b/scripts/pr-build-targets.test.mjs new file mode 100644 index 000000000..e60ccce5b --- /dev/null +++ b/scripts/pr-build-targets.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + directBuildTargets, + hasGlobalBuildChange, + parseTurboBuildTargets, +} from "./pr-build-targets.mjs"; + +describe("pr-build-targets", () => { + it("detects global build inputs that require all build targets", () => { + assert.equal(hasGlobalBuildChange(["docker-bake.hcl"]), true); + assert.equal(hasGlobalBuildChange(["scripts/pr-build-targets.mjs"]), true); + assert.equal(hasGlobalBuildChange(["pnpm-lock.yaml"]), true); + assert.equal(hasGlobalBuildChange(["turbo.json"]), true); + assert.equal( + hasGlobalBuildChange(["packages/commons-ui-core/index.js"]), + false, + ); + }); + + it("maps direct build input paths to candidate build targets", () => { + assert.deepEqual( + directBuildTargets([ + "docker/apps/techlabblog/Dockerfile", + ".github/workflows/trustlab.yml", + ]), + ["techlabblog", "trustlab"], + ); + }); + + it("filters Turbo package output to build targets", () => { + const turboOutput = ` + { + "packages": { + "items": [ + { "name": "techlabblog", "path": "apps/techlabblog" }, + { "name": "commons-ui-core", "path": "packages/commons-ui-core" }, + { "name": "pesayetu", "path": "apps/pesayetu" }, + { "name": "trustlab", "path": "apps/trustlab" } + ] + } + }`; + + assert.deepEqual(parseTurboBuildTargets(turboOutput), [ + "techlabblog", + "trustlab", + ]); + }); + + it("ignores Turbo output before and after JSON when on different lines", () => { + const turboOutput = `warning before json + { + "packages": { + "items": [ + { "name": "techlabblog", "path": "apps/techlabblog" } + ] + } + } + status after json`; + + assert.deepEqual(parseTurboBuildTargets(turboOutput), ["techlabblog"]); + }); + + it("ignores Turbo output before and after JSON when on the same line", () => { + const turboOutput = + 'warning {"packages":{"items":[{"name":"trustlab","path":"apps/trustlab"}]}} status'; + + assert.deepEqual(parseTurboBuildTargets(turboOutput), ["trustlab"]); + }); +}); diff --git a/turbo.json b/turbo.json index a387b9828..7f38749a0 100644 --- a/turbo.json +++ b/turbo.json @@ -24,9 +24,6 @@ "playwright": { "dependsOn": ["build"] }, - "test": { - "dependsOn": ["build"] - }, "lint:check": { "dependsOn": ["^lint:check"] },