From 8d509ab9e51d7caa11700e8ba093e42e71511334 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Mon, 4 May 2026 16:50:16 +0300 Subject: [PATCH 01/10] ci: support app image validation builds --- .github/workflows/bake-and-push.yml | 33 +++++++++++++++++++---------- .github/workflows/techlabblog.yml | 12 ++++------- .github/workflows/trustlab.yml | 6 +++--- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/.github/workflows/bake-and-push.yml b/.github/workflows/bake-and-push.yml index 203208281..2fd64c665 100644 --- a/.github/workflows/bake-and-push.yml +++ b/.github/workflows/bake-and-push.yml @@ -4,7 +4,7 @@ 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/techlabblog.yml b/.github/workflows/techlabblog.yml index 7ded93094..3a8250cd5 100644 --- a/.github/workflows/techlabblog.yml +++ b/.github/workflows/techlabblog.yml @@ -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,14 +66,11 @@ 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: @@ -84,9 +79,10 @@ jobs: uses: ./.github/workflows/bake-and-push.yml with: target: techlabblog - target_os: "['ubuntu-24.04-arm']" + target_os: '["ubuntu-24.04-arm"]' base_tag: ${{ vars.UI_BASE_TAG }} tag: ${{ github.sha }} + push: true set: | techlabblog.args.SENTRY_DSN=${{ vars.TECHLABBLOG_SENTRY_DSN }} techlabblog.args.SENTRY_ENVIRONMENT=production diff --git a/.github/workflows/trustlab.yml b/.github/workflows/trustlab.yml index 2c402e0f0..e1d07ea20 100644 --- a/.github/workflows/trustlab.yml +++ b/.github/workflows/trustlab.yml @@ -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 @@ -58,9 +57,10 @@ jobs: uses: ./.github/workflows/bake-and-push.yml with: target: trustlab - target_os: "['ubuntu-24.04-arm']" + 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) || '' }} From b37e5f32e9d6112187cd1e3ff53f9db37ec5e0e7 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Mon, 4 May 2026 16:50:54 +0300 Subject: [PATCH 02/10] ci: add affected apps PR build workflow --- .github/workflows/apps-pr-build.yml | 127 ++++++++++++++++++ scripts/pr-build-targets.mjs | 196 ++++++++++++++++++++++++++++ scripts/pr-build-targets.test.mjs | 98 ++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 .github/workflows/apps-pr-build.yml create mode 100644 scripts/pr-build-targets.mjs create mode 100644 scripts/pr-build-targets.test.mjs diff --git a/.github/workflows/apps-pr-build.yml b/.github/workflows/apps-pr-build.yml new file mode 100644 index 000000000..e973b852b --- /dev/null +++ b/.github/workflows/apps-pr-build.yml @@ -0,0 +1,127 @@ +name: Apps 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/apps-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: + has_targets: ${{ steps.targets.outputs.has_targets }} + matrix: ${{ steps.targets.outputs.matrix }} + trusted: ${{ steps.trust.outputs.trusted }} + 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: Build app matrix + id: targets + shell: bash + run: | + if [[ "${{ steps.trust.outputs.trusted }}" != "true" ]]; then + echo "App PR builds are skipped for fork or untrusted PRs." + echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + matrix="$( + node scripts/pr-build-targets.mjs \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" + )" + + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + if [[ "${matrix}" == '{"include":[]}' ]]; then + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_targets=true" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.target }} image + needs: affected + if: ${{ needs.affected.outputs.has_targets == 'true' }} + strategy: + fail-fast: false + max-parallel: 2 + matrix: ${{ fromJSON(needs.affected.outputs.matrix) }} + permissions: + actions: read + contents: read + uses: ./.github/workflows/bake-and-push.yml + with: + target: ${{ matrix.target }} + target_os: '["ubuntu-24.04-arm"]' + base_tag: ${{ vars.UI_BASE_TAG }} + tag: pr-${{ github.event.pull_request.number }}-${{ github.sha }} + push: false + set: | + ${{ matrix.target == 'techlabblog' && format('techlabblog.args.SENTRY_DSN={0}', vars.TECHLABBLOG_SENTRY_DSN) || '' }} + ${{ matrix.target == 'techlabblog' && 'techlabblog.args.SENTRY_ENVIRONMENT=ci' || '' }} + secrets: + DATABASE_URL: ${{ secrets.TRUSTLAB_MONGO_URL }} + PAYLOAD_SECRET: ${{ secrets.TRUSTLAB_PAYLOAD_SECRET }} diff --git a/scripts/pr-build-targets.mjs b/scripts/pr-build-targets.mjs new file mode 100644 index 000000000..558047bb4 --- /dev/null +++ b/scripts/pr-build-targets.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +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. +// - Docker and workflow files live outside Turbo's workspace graph, so path rules +// below cover those direct and global image-build inputs explicitly. +export const BUILD_TARGET_CONFIG = { + techlabblog: { + paths: [".github/workflows/techlabblog.yml", "docker/apps/techlabblog/"], + }, + trustlab: { + paths: [ + ".github/workflows/trustlab.yml", + "docker/apps/trustlab/", + "scripts/revalidate.mjs", + ], + }, +}; + +export const BUILD_TARGETS = Object.keys(BUILD_TARGET_CONFIG); + +const GLOBAL_BUILD_FILES = new Set([ + ".github/workflows/bake-and-push.yml", + ".github/workflows/apps-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", + 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 === "--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; +} + +export function createMatrix(targets) { + return { + include: targets.map((target) => ({ target })), + }; +} + +export function uniqueBuildTargets(targets) { + const targetSet = new Set(targets); + return BUILD_TARGETS.filter((target) => targetSet.has(target)); +} + +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 uniqueBuildTargets( + changedFiles.flatMap((file) => { + return Object.entries(BUILD_TARGET_CONFIG) + .filter(([, config]) => + config.paths.some((path) => matchesPath(file, path)), + ) + .map(([app]) => app); + }), + ); +} + +function matchesPath(file, path) { + return path.endsWith("/") ? file.startsWith(path) : file === path; +} + +export function parseTurboTargets(turboOutput) { + const lines = turboOutput.split("\n"); + const jsonLineIndex = lines.findIndex((line) => + line.trimStart().startsWith("{"), + ); + if (jsonLineIndex === -1) { + throw new Error("Turbo output did not include JSON"); + } + + const data = JSON.parse(lines.slice(jsonLineIndex).join("\n")); + const packages = data.packages?.items ?? []; + return uniqueBuildTargets( + packages + .filter((pkg) => pkg.path?.startsWith("apps/")) + .map((pkg) => pkg.name), + ); +} + +export function turboAffectedTargets(base, head) { + const turboOutput = run( + "pnpm", + ["exec", "turbo", "ls", "--affected", "--output=json"], + { + env: { + ...process.env, + TURBO_SCM_BASE: base, + TURBO_SCM_HEAD: head, + }, + }, + ); + return parseTurboTargets(turboOutput); +} + +export function resolveBuildTargets({ changedFiles, turboTargets }) { + if (hasGlobalBuildChange(changedFiles)) { + return BUILD_TARGETS; + } + + return uniqueBuildTargets([ + ...directBuildTargets(changedFiles), + ...turboTargets, + ]); +} + +export function affectedBuildTargets({ base, head }) { + const changedFiles = changedFilesFromGit(base, head); + const globalBuildChange = hasGlobalBuildChange(changedFiles); + console.error(`Changed files:\n${changedFiles.join("\n") || "(none)"}`); + + if (globalBuildChange) { + console.error("Global build input changed; building all app targets."); + return BUILD_TARGETS; + } + + return resolveBuildTargets({ + changedFiles, + turboTargets: turboAffectedTargets(base, head), + }); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const targets = affectedBuildTargets(args); + process.stdout.write(`${JSON.stringify(createMatrix(targets))}\n`); +} + +const invokedPath = + process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedPath) { + try { + main(); + } catch (error) { + console.error(error.message); + 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..bb4bf78aa --- /dev/null +++ b/scripts/pr-build-targets.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + BUILD_TARGETS, + createMatrix, + directBuildTargets, + hasGlobalBuildChange, + parseTurboTargets, + resolveBuildTargets, + uniqueBuildTargets, +} from "./pr-build-targets.mjs"; + +describe("pr-build-targets", () => { + it("creates a GitHub Actions matrix", () => { + assert.deepEqual(createMatrix(["techlabblog", "trustlab"]), { + include: [{ target: "techlabblog" }, { target: "trustlab" }], + }); + }); + + it("detects global build inputs that require all app 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 Docker and workflow paths to build targets", () => { + assert.deepEqual( + directBuildTargets([ + "docker/apps/techlabblog/Dockerfile", + ".github/workflows/trustlab.yml", + "scripts/revalidate.mjs", + ]), + ["techlabblog", "trustlab"], + ); + }); + + it("filters Turbo package output to migrated apps under apps/", () => { + const turboOutput = `warning before json +{ + "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(parseTurboTargets(turboOutput), [ + "techlabblog", + "trustlab", + ]); + }); + + it("deduplicates and keeps build target ordering", () => { + assert.deepEqual( + uniqueBuildTargets(["trustlab", "pesayetu", "techlabblog", "trustlab"]), + BUILD_TARGETS, + ); + }); + + it("resolves all app targets when global build inputs change", () => { + assert.deepEqual( + resolveBuildTargets({ + changedFiles: ["docker-bake.hcl"], + turboTargets: [], + }), + BUILD_TARGETS, + ); + }); + + it("resolves direct path and Turbo targets into a stable deduped target list", () => { + assert.deepEqual( + resolveBuildTargets({ + changedFiles: ["docker/apps/trustlab/Dockerfile"], + turboTargets: ["techlabblog", "trustlab"], + }), + BUILD_TARGETS, + ); + }); + + it("resolves an empty matrix when no app build targets are affected", () => { + assert.deepEqual( + resolveBuildTargets({ + changedFiles: ["docs/readme.md"], + turboTargets: [], + }), + [], + ); + }); +}); From 32341553794bc0b5c9bc17e7fa4ca059223596a8 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Mon, 4 May 2026 16:51:19 +0300 Subject: [PATCH 03/10] test: include script tests in CI --- .github/workflows/ci.yml | 13 +++++++------ package.json | 4 +++- turbo.json | 3 --- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 916206732..91f3106c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,6 @@ jobs: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} strategy: matrix: - node-version: [24] os: [ubuntu-latest] steps: - name: Checkout @@ -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/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/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"] }, From 818919e9dc5131198e60ddb14a3143d10c3ffc47 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 09:44:40 +0300 Subject: [PATCH 04/10] ci: use app-specific build workflows --- .../{bake-and-push.yml => _bake-and-push.yml} | 2 +- .github/workflows/_build-techlabblog.yml | 60 ++++++++++++++++ .github/workflows/_build-trustlab.yml | 59 ++++++++++++++++ .../{apps-pr-build.yml => pr-build.yml} | 70 ++++++++++--------- .github/workflows/techlabblog.yml | 11 ++- .github/workflows/trustlab.yml | 8 +-- scripts/pr-build-targets.mjs | 42 +++++++++-- scripts/pr-build-targets.test.mjs | 22 ++++-- 8 files changed, 219 insertions(+), 55 deletions(-) rename .github/workflows/{bake-and-push.yml => _bake-and-push.yml} (99%) create mode 100644 .github/workflows/_build-techlabblog.yml create mode 100644 .github/workflows/_build-trustlab.yml rename .github/workflows/{apps-pr-build.yml => pr-build.yml} (63%) diff --git a/.github/workflows/bake-and-push.yml b/.github/workflows/_bake-and-push.yml similarity index 99% rename from .github/workflows/bake-and-push.yml rename to .github/workflows/_bake-and-push.yml index 2fd64c665..747d05366 100644 --- a/.github/workflows/bake-and-push.yml +++ b/.github/workflows/_bake-and-push.yml @@ -1,4 +1,4 @@ -name: Bake and Push +name: Reusable | Bake and Push permissions: contents: read diff --git a/.github/workflows/_build-techlabblog.yml b/.github/workflows/_build-techlabblog.yml new file mode 100644 index 000000000..aa9b641ab --- /dev/null +++ b/.github/workflows/_build-techlabblog.yml @@ -0,0 +1,60 @@ +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 }} + 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/apps-pr-build.yml b/.github/workflows/pr-build.yml similarity index 63% rename from .github/workflows/apps-pr-build.yml rename to .github/workflows/pr-build.yml index e973b852b..09da8f9c3 100644 --- a/.github/workflows/apps-pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -1,4 +1,4 @@ -name: Apps PR Build +name: PR Build on: pull_request: @@ -17,8 +17,10 @@ on: - "scripts/revalidate.mjs" - "scripts/pr-build-targets.mjs" - "scripts/pr-build-targets.test.mjs" - - ".github/workflows/bake-and-push.yml" - - ".github/workflows/apps-pr-build.yml" + - ".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" @@ -36,8 +38,8 @@ jobs: runs-on: ubuntu-24.04 outputs: has_targets: ${{ steps.targets.outputs.has_targets }} - matrix: ${{ steps.targets.outputs.matrix }} - trusted: ${{ steps.trust.outputs.trusted }} + techlabblog: ${{ steps.targets.outputs.techlabblog }} + trustlab: ${{ steps.targets.outputs.trustlab }} steps: - name: Check PR trust boundary id: trust @@ -76,52 +78,56 @@ jobs: if: ${{ steps.trust.outputs.trusted == 'true' }} run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build app matrix + - 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 'matrix={"include":[]}' >> "$GITHUB_OUTPUT" echo "has_targets=false" >> "$GITHUB_OUTPUT" + echo "techlabblog=false" >> "$GITHUB_OUTPUT" + echo "trustlab=false" >> "$GITHUB_OUTPUT" exit 0 fi - matrix="$( - node scripts/pr-build-targets.mjs \ - --base "${{ github.event.pull_request.base.sha }}" \ - --head "${{ github.event.pull_request.head.sha }}" - )" + node scripts/pr-build-targets.mjs \ + --github-output \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" - echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" - if [[ "${matrix}" == '{"include":[]}' ]]; then - echo "has_targets=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "has_targets=true" >> "$GITHUB_OUTPUT" + build-techlabblog: + name: Build techlabblog image + needs: affected + if: ${{ needs.affected.outputs.techlabblog == 'true' }} + 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: - name: Build ${{ matrix.target }} image + build-trustlab: + name: Build trustlab image needs: affected - if: ${{ needs.affected.outputs.has_targets == 'true' }} - strategy: - fail-fast: false - max-parallel: 2 - matrix: ${{ fromJSON(needs.affected.outputs.matrix) }} + if: ${{ needs.affected.outputs.trustlab == 'true' }} permissions: actions: read contents: read - uses: ./.github/workflows/bake-and-push.yml + uses: ./.github/workflows/_build-trustlab.yml with: - target: ${{ matrix.target }} - target_os: '["ubuntu-24.04-arm"]' base_tag: ${{ vars.UI_BASE_TAG }} tag: pr-${{ github.event.pull_request.number }}-${{ github.sha }} push: false - set: | - ${{ matrix.target == 'techlabblog' && format('techlabblog.args.SENTRY_DSN={0}', vars.TECHLABBLOG_SENTRY_DSN) || '' }} - ${{ matrix.target == 'techlabblog' && 'techlabblog.args.SENTRY_ENVIRONMENT=ci' || '' }} 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 3a8250cd5..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 @@ -76,16 +76,13 @@ jobs: 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 e1d07ea20..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 @@ -54,10 +54,8 @@ 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 diff --git a/scripts/pr-build-targets.mjs b/scripts/pr-build-targets.mjs index 558047bb4..d156c8f20 100644 --- a/scripts/pr-build-targets.mjs +++ b/scripts/pr-build-targets.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; // Policy: @@ -25,8 +26,10 @@ export const BUILD_TARGET_CONFIG = { export const BUILD_TARGETS = Object.keys(BUILD_TARGET_CONFIG); const GLOBAL_BUILD_FILES = new Set([ - ".github/workflows/bake-and-push.yml", - ".github/workflows/apps-pr-build.yml", + ".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", @@ -38,6 +41,7 @@ const GLOBAL_BUILD_FILES = new Set([ function parseArgs(argv) { const args = { base: "origin/main", + githubOutput: false, head: "HEAD", }; @@ -45,6 +49,8 @@ function parseArgs(argv) { 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 { @@ -79,12 +85,33 @@ function run(command, args, options = {}) { return result.stdout; } -export function createMatrix(targets) { +export function createOutputs(targets) { + const targetSet = new Set(targets); return { - include: targets.map((target) => ({ target })), + has_targets: targets.length > 0, + targets, + ...Object.fromEntries( + BUILD_TARGETS.map((target) => [target, targetSet.has(target)]), + ), }; } +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 uniqueBuildTargets(targets) { const targetSet = new Set(targets); return BUILD_TARGETS.filter((target) => targetSet.has(target)); @@ -181,7 +208,12 @@ export function affectedBuildTargets({ base, head }) { function main() { const args = parseArgs(process.argv.slice(2)); const targets = affectedBuildTargets(args); - process.stdout.write(`${JSON.stringify(createMatrix(targets))}\n`); + const outputs = createOutputs(targets); + + if (args.githubOutput) { + writeGitHubOutputs(outputs, process.env.GITHUB_OUTPUT); + } + process.stdout.write(`${JSON.stringify(outputs)}\n`); } const invokedPath = diff --git a/scripts/pr-build-targets.test.mjs b/scripts/pr-build-targets.test.mjs index bb4bf78aa..238430384 100644 --- a/scripts/pr-build-targets.test.mjs +++ b/scripts/pr-build-targets.test.mjs @@ -3,7 +3,7 @@ import { describe, it } from "node:test"; import { BUILD_TARGETS, - createMatrix, + createOutputs, directBuildTargets, hasGlobalBuildChange, parseTurboTargets, @@ -12,9 +12,21 @@ import { } from "./pr-build-targets.mjs"; describe("pr-build-targets", () => { - it("creates a GitHub Actions matrix", () => { - assert.deepEqual(createMatrix(["techlabblog", "trustlab"]), { - include: [{ target: "techlabblog" }, { target: "trustlab" }], + it("creates GitHub Actions outputs", () => { + assert.deepEqual(createOutputs(["techlabblog", "trustlab"]), { + has_targets: true, + targets: ["techlabblog", "trustlab"], + techlabblog: true, + trustlab: true, + }); + }); + + it("creates empty GitHub Actions outputs", () => { + assert.deepEqual(createOutputs([]), { + has_targets: false, + targets: [], + techlabblog: false, + trustlab: false, }); }); @@ -86,7 +98,7 @@ describe("pr-build-targets", () => { ); }); - it("resolves an empty matrix when no app build targets are affected", () => { + it("resolves an empty target list when no app build targets are affected", () => { assert.deepEqual( resolveBuildTargets({ changedFiles: ["docs/readme.md"], From b5cd9dc7bda795606da8880c6147d41b1dd08e12 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 09:58:01 +0300 Subject: [PATCH 05/10] ci: document shared TechLab build config --- .github/workflows/_build-techlabblog.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/_build-techlabblog.yml b/.github/workflows/_build-techlabblog.yml index aa9b641ab..67d5d3870 100644 --- a/.github/workflows/_build-techlabblog.yml +++ b/.github/workflows/_build-techlabblog.yml @@ -49,6 +49,7 @@ jobs: tag: ${{ inputs.tag }} push: ${{ inputs.push }} set: | + # Stable app-level config lives here so PR and main builds share one build contract. techlabblog.args.SENTRY_DSN=${{ vars.TECHLABBLOG_SENTRY_DSN }} techlabblog.args.SENTRY_ENVIRONMENT=${{ inputs.sentry_environment }} ${{ inputs.set }} From b572a486522055db59e4d8b1d26159b1c4609da5 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 10:04:15 +0300 Subject: [PATCH 06/10] ci: fix TechLab bake overrides --- .github/workflows/_build-techlabblog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/_build-techlabblog.yml b/.github/workflows/_build-techlabblog.yml index 67d5d3870..1d0a4e6b9 100644 --- a/.github/workflows/_build-techlabblog.yml +++ b/.github/workflows/_build-techlabblog.yml @@ -48,8 +48,8 @@ jobs: 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: | - # Stable app-level config lives here so PR and main builds share one build contract. techlabblog.args.SENTRY_DSN=${{ vars.TECHLABBLOG_SENTRY_DSN }} techlabblog.args.SENTRY_ENVIRONMENT=${{ inputs.sentry_environment }} ${{ inputs.set }} From a113f825ed5f8a561357af09b00fb0804b7465c3 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 12:59:32 +0300 Subject: [PATCH 07/10] ci: simplify PR build target detection --- .github/workflows/pr-build.yml | 12 ++-- scripts/pr-build-targets.mjs | 102 +++++++++++------------------- scripts/pr-build-targets.test.mjs | 92 +++++---------------------- 3 files changed, 57 insertions(+), 149 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 09da8f9c3..5a4bf3146 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -37,9 +37,7 @@ jobs: name: Detect affected apps runs-on: ubuntu-24.04 outputs: - has_targets: ${{ steps.targets.outputs.has_targets }} - techlabblog: ${{ steps.targets.outputs.techlabblog }} - trustlab: ${{ steps.targets.outputs.trustlab }} + targets: ${{ steps.targets.outputs.targets }} steps: - name: Check PR trust boundary id: trust @@ -84,9 +82,7 @@ jobs: run: | if [[ "${{ steps.trust.outputs.trusted }}" != "true" ]]; then echo "App PR builds are skipped for fork or untrusted PRs." - echo "has_targets=false" >> "$GITHUB_OUTPUT" - echo "techlabblog=false" >> "$GITHUB_OUTPUT" - echo "trustlab=false" >> "$GITHUB_OUTPUT" + echo "targets=[]" >> "$GITHUB_OUTPUT" exit 0 fi @@ -98,7 +94,7 @@ jobs: build-techlabblog: name: Build techlabblog image needs: affected - if: ${{ needs.affected.outputs.techlabblog == 'true' }} + if: ${{ contains(fromJSON(needs.affected.outputs.targets), 'techlabblog') }} permissions: actions: read contents: read @@ -116,7 +112,7 @@ jobs: build-trustlab: name: Build trustlab image needs: affected - if: ${{ needs.affected.outputs.trustlab == 'true' }} + if: ${{ contains(fromJSON(needs.affected.outputs.targets), 'trustlab') }} permissions: actions: read contents: read diff --git a/scripts/pr-build-targets.mjs b/scripts/pr-build-targets.mjs index d156c8f20..66d71d579 100644 --- a/scripts/pr-build-targets.mjs +++ b/scripts/pr-build-targets.mjs @@ -8,14 +8,17 @@ import { fileURLToPath } from "node:url"; // - 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. -// - Docker and workflow files live outside Turbo's workspace graph, so path rules -// below cover those direct and global image-build inputs explicitly. +// - Non-workspace build inputs are handled explicitly because Turbo cannot map +// them through the workspace graph. export const BUILD_TARGET_CONFIG = { techlabblog: { - paths: [".github/workflows/techlabblog.yml", "docker/apps/techlabblog/"], + directBuildInputPaths: [ + ".github/workflows/techlabblog.yml", + "docker/apps/techlabblog/", + ], }, trustlab: { - paths: [ + directBuildInputPaths: [ ".github/workflows/trustlab.yml", "docker/apps/trustlab/", "scripts/revalidate.mjs", @@ -23,8 +26,6 @@ export const BUILD_TARGET_CONFIG = { }, }; -export const BUILD_TARGETS = Object.keys(BUILD_TARGET_CONFIG); - const GLOBAL_BUILD_FILES = new Set([ ".github/workflows/_bake-and-push.yml", ".github/workflows/_build-techlabblog.yml", @@ -85,17 +86,6 @@ function run(command, args, options = {}) { return result.stdout; } -export function createOutputs(targets) { - const targetSet = new Set(targets); - return { - has_targets: targets.length > 0, - targets, - ...Object.fromEntries( - BUILD_TARGETS.map((target) => [target, targetSet.has(target)]), - ), - }; -} - function writeGitHubOutputs(outputs, outputFile) { if (!outputFile) { throw new Error("GITHUB_OUTPUT is required when using --github-output"); @@ -112,11 +102,6 @@ function writeGitHubOutputs(outputs, outputFile) { appendFileSync(outputFile, `${output}\n`); } -export function uniqueBuildTargets(targets) { - const targetSet = new Set(targets); - return BUILD_TARGETS.filter((target) => targetSet.has(target)); -} - 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}`]) @@ -130,22 +115,21 @@ export function hasGlobalBuildChange(changedFiles) { } export function directBuildTargets(changedFiles) { - return uniqueBuildTargets( - changedFiles.flatMap((file) => { - return Object.entries(BUILD_TARGET_CONFIG) - .filter(([, config]) => - config.paths.some((path) => matchesPath(file, path)), - ) - .map(([app]) => app); - }), - ); + 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 parseTurboTargets(turboOutput) { +export function parseTurboBuildTargets(turboOutput) { const lines = turboOutput.split("\n"); const jsonLineIndex = lines.findIndex((line) => line.trimStart().startsWith("{"), @@ -156,14 +140,16 @@ export function parseTurboTargets(turboOutput) { const data = JSON.parse(lines.slice(jsonLineIndex).join("\n")); const packages = data.packages?.items ?? []; - return uniqueBuildTargets( - packages - .filter((pkg) => pkg.path?.startsWith("apps/")) - .map((pkg) => pkg.name), - ); + return packages + .filter( + (pkg) => + pkg.path?.startsWith("apps/") && + Object.hasOwn(BUILD_TARGET_CONFIG, pkg.name), + ) + .map((pkg) => pkg.name); } -export function turboAffectedTargets(base, head) { +export function turboBuildTargets(base, head) { const turboOutput = run( "pnpm", ["exec", "turbo", "ls", "--affected", "--output=json"], @@ -175,40 +161,26 @@ export function turboAffectedTargets(base, head) { }, }, ); - return parseTurboTargets(turboOutput); + return parseTurboBuildTargets(turboOutput); } -export function resolveBuildTargets({ changedFiles, turboTargets }) { - if (hasGlobalBuildChange(changedFiles)) { - return BUILD_TARGETS; - } - - return uniqueBuildTargets([ - ...directBuildTargets(changedFiles), - ...turboTargets, - ]); -} - -export function affectedBuildTargets({ base, head }) { +export function buildTargets({ base, head }) { const changedFiles = changedFilesFromGit(base, head); - const globalBuildChange = hasGlobalBuildChange(changedFiles); - console.error(`Changed files:\n${changedFiles.join("\n") || "(none)"}`); - - if (globalBuildChange) { - console.error("Global build input changed; building all app targets."); - return BUILD_TARGETS; + if (hasGlobalBuildChange(changedFiles)) { + return Object.keys(BUILD_TARGET_CONFIG).sort(); } - - return resolveBuildTargets({ - changedFiles, - turboTargets: turboAffectedTargets(base, head), - }); + return [ + ...new Set([ + ...directBuildTargets(changedFiles), + ...turboBuildTargets(base, head), + ]), + ].sort(); } function main() { const args = parseArgs(process.argv.slice(2)); - const targets = affectedBuildTargets(args); - const outputs = createOutputs(targets); + const targets = buildTargets(args); + const outputs = { targets }; if (args.githubOutput) { writeGitHubOutputs(outputs, process.env.GITHUB_OUTPUT); @@ -222,7 +194,7 @@ if (invokedPath) { try { main(); } catch (error) { - console.error(error.message); + 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 index 238430384..c7bcf873c 100644 --- a/scripts/pr-build-targets.test.mjs +++ b/scripts/pr-build-targets.test.mjs @@ -2,35 +2,13 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { - BUILD_TARGETS, - createOutputs, directBuildTargets, hasGlobalBuildChange, - parseTurboTargets, - resolveBuildTargets, - uniqueBuildTargets, + parseTurboBuildTargets, } from "./pr-build-targets.mjs"; describe("pr-build-targets", () => { - it("creates GitHub Actions outputs", () => { - assert.deepEqual(createOutputs(["techlabblog", "trustlab"]), { - has_targets: true, - targets: ["techlabblog", "trustlab"], - techlabblog: true, - trustlab: true, - }); - }); - - it("creates empty GitHub Actions outputs", () => { - assert.deepEqual(createOutputs([]), { - has_targets: false, - targets: [], - techlabblog: false, - trustlab: false, - }); - }); - - it("detects global build inputs that require all app 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); @@ -41,70 +19,32 @@ describe("pr-build-targets", () => { ); }); - it("maps direct Docker and workflow paths to build targets", () => { + it("maps direct build input paths to candidate build targets", () => { assert.deepEqual( directBuildTargets([ "docker/apps/techlabblog/Dockerfile", ".github/workflows/trustlab.yml", - "scripts/revalidate.mjs", ]), ["techlabblog", "trustlab"], ); }); - it("filters Turbo package output to migrated apps under apps/", () => { + it("filters Turbo package output to build targets", () => { const turboOutput = `warning before json -{ - "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(parseTurboTargets(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("deduplicates and keeps build target ordering", () => { - assert.deepEqual( - uniqueBuildTargets(["trustlab", "pesayetu", "techlabblog", "trustlab"]), - BUILD_TARGETS, - ); - }); - - it("resolves all app targets when global build inputs change", () => { - assert.deepEqual( - resolveBuildTargets({ - changedFiles: ["docker-bake.hcl"], - turboTargets: [], - }), - BUILD_TARGETS, - ); - }); - - it("resolves direct path and Turbo targets into a stable deduped target list", () => { - assert.deepEqual( - resolveBuildTargets({ - changedFiles: ["docker/apps/trustlab/Dockerfile"], - turboTargets: ["techlabblog", "trustlab"], - }), - BUILD_TARGETS, - ); - }); - - it("resolves an empty target list when no app build targets are affected", () => { - assert.deepEqual( - resolveBuildTargets({ - changedFiles: ["docs/readme.md"], - turboTargets: [], - }), - [], - ); - }); }); From 05cdae13e5868280e78cb182ea0570fa58b2de3e Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 13:33:15 +0300 Subject: [PATCH 08/10] test: harden Turbo target parsing --- scripts/pr-build-targets.mjs | 23 ++++++++++++++--------- scripts/pr-build-targets.test.mjs | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/scripts/pr-build-targets.mjs b/scripts/pr-build-targets.mjs index 66d71d579..413fe7d5f 100644 --- a/scripts/pr-build-targets.mjs +++ b/scripts/pr-build-targets.mjs @@ -130,15 +130,7 @@ function matchesPath(file, path) { } export function parseTurboBuildTargets(turboOutput) { - const lines = turboOutput.split("\n"); - const jsonLineIndex = lines.findIndex((line) => - line.trimStart().startsWith("{"), - ); - if (jsonLineIndex === -1) { - throw new Error("Turbo output did not include JSON"); - } - - const data = JSON.parse(lines.slice(jsonLineIndex).join("\n")); + const data = parseTurboJson(turboOutput); const packages = data.packages?.items ?? []; return packages .filter( @@ -149,6 +141,19 @@ export function parseTurboBuildTargets(turboOutput) { .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", diff --git a/scripts/pr-build-targets.test.mjs b/scripts/pr-build-targets.test.mjs index c7bcf873c..e60ccce5b 100644 --- a/scripts/pr-build-targets.test.mjs +++ b/scripts/pr-build-targets.test.mjs @@ -30,7 +30,7 @@ describe("pr-build-targets", () => { }); it("filters Turbo package output to build targets", () => { - const turboOutput = `warning before json + const turboOutput = ` { "packages": { "items": [ @@ -47,4 +47,25 @@ describe("pr-build-targets", () => { "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"]); + }); }); From 5145afc81c62c883faabc48fa05b983465be9e3e Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 13:34:51 +0300 Subject: [PATCH 09/10] ci: bump action versions --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91f3106c0..a39d2e00b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,13 +27,13 @@ jobs: 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 @@ -45,7 +45,7 @@ jobs: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} From 74729f124fbdcedbf710d88e643b89c720d15bc8 Mon Sep 17 00:00:00 2001 From: kilemensi Date: Tue, 5 May 2026 13:37:04 +0300 Subject: [PATCH 10/10] ci: fix actions/cache version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39d2e00b..25bcab2cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Setup pnpm cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}