From e28ddb898731c4e88c0a703c1389dd747fbf07f5 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia <149591043+ttncode@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:36:11 +0700 Subject: [PATCH 1/2] feat: build one image per application, not one per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project generated with both a web and an api application published exactly one image: whichever the toolbox applied last. The other app passed CI and then vanished — never built, never pushed, never in the compose file a client runs. It was the shape the wizard offers first. app-build.yml and app-release.yml now take an `images` array and matrix over it. release-please and the release assets still run once, which is why the fan-out has to live here rather than in the generated call site: calling app-release.yml per application would cut a release per application. The singular image/context/dockerfile inputs stay, resolved into the same matrix by app-targets.yml, so a project generated before this keeps working at v1 with no edit. --- .github/workflows/app-build.yml | 43 +++++++++++++---- .github/workflows/app-release.yml | 40 ++++++++++++---- .github/workflows/app-targets.yml | 79 +++++++++++++++++++++++++++++++ README.md | 14 ++++-- 4 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/app-targets.yml diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 1796a81..a8462b4 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -2,26 +2,49 @@ name: App Build on: workflow_call: inputs: + images: + description: JSON array of {image, context, dockerfile}, one per application + type: string + required: false + default: "" image: - description: the ghcr repository, without a tag + description: the ghcr repository, without a tag — the single-target form type: string - required: true + required: false + default: "" context: type: string - required: true + required: false + default: "" dockerfile: description: path to the Dockerfile, relative to the checkout root type: string - required: true + required: false + default: "" permissions: {} jobs: + targets: + uses: ./.github/workflows/app-targets.yml + with: + images: ${{ inputs.images }} + image: ${{ inputs.image }} + context: ${{ inputs.context }} + dockerfile: ${{ inputs.dockerfile }} + build: + needs: targets runs-on: ubuntu-latest permissions: contents: read packages: write + strategy: + # One broken application must not hide the state of the others — the + # same reason the scaffold toolbox's own adapter matrix sets this. + fail-fast: false + matrix: + target: ${{ fromJson(needs.targets.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -35,15 +58,17 @@ jobs: - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ${{ inputs.image }} + images: ${{ matrix.target.image }} tags: | type=raw,value=main,enable={{is_default_branch}} type=sha,prefix=sha- - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} + context: ${{ matrix.target.context }} + file: ${{ matrix.target.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + # Scoped per image, so two applications built in the same run do not + # evict each other's layers out of one shared cache key. + cache-from: type=gha,scope=${{ matrix.target.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.target.image }} diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 90f802f..47078c7 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -2,16 +2,25 @@ name: App Release on: workflow_call: inputs: + images: + description: JSON array of {image, context, dockerfile}, one per application + type: string + required: false + default: "" image: + description: the ghcr repository, without a tag — the single-target form type: string - required: true + required: false + default: "" context: type: string - required: true + required: false + default: "" dockerfile: description: path to the Dockerfile, relative to the checkout root type: string - required: true + required: false + default: "" secrets: RELEASE_APP_ID: required: false @@ -21,6 +30,14 @@ on: permissions: {} jobs: + targets: + uses: ./.github/workflows/app-targets.yml + with: + images: ${{ inputs.images }} + image: ${{ inputs.image }} + context: ${{ inputs.context }} + dockerfile: ${{ inputs.dockerfile }} + release-please: runs-on: ubuntu-latest permissions: @@ -63,12 +80,17 @@ jobs: manifest-file: .release-please-manifest.json image: - needs: release-please + needs: [release-please, targets] if: ${{ needs.release-please.outputs.released == 'true' }} runs-on: ubuntu-latest permissions: contents: read packages: write + strategy: + # One broken application must not hide the state of the others. + fail-fast: false + matrix: + target: ${{ fromJson(needs.targets.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -82,7 +104,7 @@ jobs: - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ${{ inputs.image }} + images: ${{ matrix.target.image }} tags: | type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }} type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }} @@ -90,11 +112,13 @@ jobs: type=sha,prefix=sha- - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} + context: ${{ matrix.target.context }} + file: ${{ matrix.target.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha + # Scoped per image: app-build.yml populated this cache on the merge + # that is now being released, under the same key. + cache-from: type=gha,scope=${{ matrix.target.image }} assets: needs: release-please diff --git a/.github/workflows/app-targets.yml b/.github/workflows/app-targets.yml new file mode 100644 index 0000000..c35e2db --- /dev/null +++ b/.github/workflows/app-targets.yml @@ -0,0 +1,79 @@ +name: App Targets +# Resolves the set of images a project publishes into one matrix, so +# app-build.yml and app-release.yml agree on it by construction rather than by +# two copies of the same shell. +# +# Two input shapes, because a project generated before this existed still +# calls `v1` and must keep working: `images` is the JSON array a multi-app +# project passes, and `image`/`context`/`dockerfile` is the single target +# every project passed until now. Exactly one has to be given. +on: + workflow_call: + inputs: + images: + description: JSON array of {image, context, dockerfile}, one per application + type: string + required: false + default: "" + image: + description: the ghcr repository, without a tag — the single-target form + type: string + required: false + default: "" + context: + type: string + required: false + default: "" + dockerfile: + description: path to the Dockerfile, relative to the checkout root + type: string + required: false + default: "" + outputs: + matrix: + description: JSON array of {image, context, dockerfile} + value: ${{ jobs.resolve.outputs.matrix }} + +permissions: {} + +jobs: + resolve: + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + outputs: + matrix: ${{ steps.matrix.outputs.value }} + steps: + - id: matrix + env: + IMAGES: ${{ inputs.images }} + IMAGE: ${{ inputs.image }} + CONTEXT: ${{ inputs.context }} + DOCKERFILE: ${{ inputs.dockerfile }} + run: | + set -euo pipefail + + if [ -n "$IMAGES" ] && [ -n "$IMAGE" ]; then + echo "pass either images or image, not both" >&2 + exit 1 + fi + + if [ -n "$IMAGES" ]; then + # Validated here rather than left to fail inside `fromJson` in a + # strategy block, where the error names the expression and not the + # input that produced it. + value="$(jq -ce 'if type != "array" or length == 0 then error("images must be a non-empty array") else map( + if has("image") and has("context") and has("dockerfile") then . + else error("each entry needs image, context and dockerfile") + end) end' <<<"$IMAGES")" + elif [ -n "$IMAGE" ]; then + value="$(jq -cn --arg image "$IMAGE" --arg context "$CONTEXT" \ + --arg dockerfile "$DOCKERFILE" \ + '[{image: $image, context: $context, dockerfile: $dockerfile}]')" + else + echo "no image to build: pass images, or image with context and dockerfile" >&2 + exit 1 + fi + + echo "value=${value}" >> "$GITHUB_OUTPUT" + jq . <<<"$value" diff --git a/README.md b/README.md index 14c2c68..c5577c9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # .github Reusable GitHub Actions workflows for projects generated by -[`scaffold`](https://github.com/you/scaffold). Every generated project's own +[`scaffold`](https://github.com/ttncode/scaffold). Every generated project's own `.github/workflows/` holds five thin call sites; the actual jobs live here so one fix reaches every project instead of drifting per project (see `docs/decisions/0005-share-ci-through-reusable-workflows.md` in the scaffold @@ -13,9 +13,17 @@ toolbox repo for why). | --- | --- | --- | | `app-ci.yml` | `ci.yml` | `roots` — mise config roots to run `ci-unit` for, as a JSON array | | `app-security.yml` | `security.yml` | none | -| `app-build.yml` | `build.yml` | `image`, `context` | -| `app-release.yml` | `release.yml` | `image`, `context` | +| `app-build.yml` | `build.yml` | `images` — JSON array of `{image, context, dockerfile}`, one per application | +| `app-release.yml` | `release.yml` | `images`, same shape | | `app-docs.yml` | `docs.yml` | none | +| `app-targets.yml` | the two above | not called by a project directly | + +`app-build.yml` and `app-release.yml` also still accept the singular +`image`/`context`/`dockerfile` they took before `images` existed, so a project +generated earlier keeps working at `v1` unchanged. Give one shape or the +other, not both; `app-targets.yml` resolves whichever was given into the +matrix both workflows build from, so the two cannot disagree about what a +project publishes. `app-build.yml` publishes `main` and `sha-` on every merge to `main`. `app-release.yml` cuts a versioned release (via Release Please) and publishes From 0070ebf280de4c5197fb63c75bdfe03df107b4b3 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia <149591043+ttncode@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:19:39 +0700 Subject: [PATCH 2/2] fix: treat an empty images array as no application, not an error `scaffold new demo` with no adapter generates a project that publishes nothing. Under the previous validation its Build workflow failed on every push until somebody added an application. --- .github/workflows/app-build.yml | 2 ++ .github/workflows/app-release.yml | 3 ++- .github/workflows/app-targets.yml | 8 +++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index a8462b4..c2abbce 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -35,6 +35,8 @@ jobs: build: needs: targets + # An empty matrix is a project with no application yet, not a failure. + if: ${{ needs.targets.outputs.matrix != '[]' }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 47078c7..eac7339 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -81,7 +81,8 @@ jobs: image: needs: [release-please, targets] - if: ${{ needs.release-please.outputs.released == 'true' }} + # An empty matrix is a project with no application yet, not a failure. + if: ${{ needs.release-please.outputs.released == 'true' && needs.targets.outputs.matrix != '[]' }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/app-targets.yml b/.github/workflows/app-targets.yml index c35e2db..2620fe4 100644 --- a/.github/workflows/app-targets.yml +++ b/.github/workflows/app-targets.yml @@ -62,7 +62,13 @@ jobs: # Validated here rather than left to fail inside `fromJson` in a # strategy block, where the error names the expression and not the # input that produced it. - value="$(jq -ce 'if type != "array" or length == 0 then error("images must be a non-empty array") else map( + # + # An empty array is valid and means "this project has no + # application yet" — `scaffold new demo` with no adapter generates + # exactly that, and its Build workflow must stay green rather than + # fail on every push until somebody adds one. The caller skips the + # build job when the matrix comes back empty. + value="$(jq -ce 'if type != "array" then error("images must be an array") else map( if has("image") and has("context") and has("dockerfile") then . else error("each entry needs image, context and dockerfile") end) end' <<<"$IMAGES")"