Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions .github/workflows/app-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,51 @@ 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
# 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
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:
Expand All @@ -35,15 +60,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 }}
43 changes: 34 additions & 9 deletions .github/workflows/app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -63,12 +80,18 @@ jobs:
manifest-file: .release-please-manifest.json

image:
needs: release-please
if: ${{ needs.release-please.outputs.released == 'true' }}
needs: [release-please, targets]
# 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
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:
Expand All @@ -82,19 +105,21 @@ 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 }}
type=raw,value=latest
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
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/app-targets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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.
#
# 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")"
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"
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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-<commit>` on every merge to `main`.
`app-release.yml` cuts a versioned release (via Release Please) and publishes
Expand Down