Repository navigation
244 lines (220 loc) · 9.42 KB
/
Copy pathdocker-publish.yml
File metadata and controls
244 lines (220 loc) · 9.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
name: Reusable Docker Publish
# Builds each image once per architecture on a runner of that architecture, pushes
# untagged digest-addressed manifests, then merges the digests into multi-platform
# manifest lists. Building linux/arm64 on an amd64 runner needs QEMU, which measured
# 5-6x slower than native on these images (npm ci 52.6s -> 333.2s, npm run frontend
# 90.4s -> 442.8s) and made the emulated leg ~84% of the build.
#
# Every image-publishing workflow calls this, so a fix here reaches all of them. The
# prefix-glob bug in #15446 was one artifact-name expression that would otherwise have
# needed correcting in five separate files.
on:
workflow_call:
inputs:
images:
description: >-
JSON array of objects with `target`, `file` and `image_name`, e.g.
[{"target":"node","file":"Dockerfile","image_name":"lc-dev"}]
required: true
type: string
tag_suffixes:
description: >-
Newline-separated tags applied to every image in every registry, e.g.
"abc1234\nlatest". Blank lines are ignored.
required: true
type: string
arches:
description: JSON array of architectures to build.
type: string
default: '["amd64","arm64"]'
checkout_ref:
description: Ref to build. Defaults to the ref that triggered the caller.
type: string
default: ''
build_branch:
description: Value for the BUILD_BRANCH build-arg and runtime env.
type: string
default: ''
build_timeout_minutes:
type: number
default: 90
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
permissions:
contents: read
packages: write
jobs:
build:
name: Build ${{ matrix.image.image_name }} (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
timeout-minutes: ${{ inputs.build_timeout_minutes }}
strategy:
fail-fast: false
matrix:
image: ${{ fromJSON(inputs.images) }}
arch: ${{ fromJSON(inputs.arches) }}
steps:
# Falls back to the caller's commit SHA rather than an empty ref, so every
# build leg of a run checks out the same immutable commit even if the
# branch advances mid-run.
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ inputs.checkout_ref || github.sha }}
# No QEMU: the runner is already the target architecture.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Prepare environment
run: cp .env.example .env
# Read from the checkout rather than github.sha, so a caller that builds a
# specific ref (main-image-workflow) stamps that ref's commit. Registry names
# must be lowercase, and the repository owner is not guaranteed to be.
- name: Compute build metadata
env:
BUILD_BRANCH_INPUT: ${{ inputs.build_branch }}
IMAGE_NAME: ${{ matrix.image.image_name }}
run: |
set -euo pipefail
printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"
printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
printf 'BUILD_BRANCH=%s\n' "$BUILD_BRANCH_INPUT" >> "$GITHUB_ENV"
printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
# The layer cache lives in GHCR beside the image: no 10GB Actions-cache cap,
# and readable from every branch and workflow, unlike type=gha which is
# branch-scoped. The ref is per-architecture so the platform jobs cannot
# clobber each other's cache manifest.
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.image.file }}
platforms: linux/${{ matrix.arch }}
target: ${{ matrix.image.target }}
cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache-${{ matrix.arch }},mode=max
outputs: type=image,"name=${{ env.GHCR_IMAGE }},docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image.image_name }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
BUILD_DATE=${{ env.BUILD_DATE }}
- name: Export digest
env:
BUILD_DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="$BUILD_DIGEST"
if [ -z "$digest" ]; then
echo "Build produced no digest." >&2
exit 1
fi
touch "/tmp/digests/${digest#sha256:}"
# The `-arch-` separator keeps one image's artifacts out of another's glob.
# Image names commonly prefix one another (`librechat` / `librechat-api`,
# `lc-dev` / `lc-dev-api`), and a bare `digests-<image>-*` pattern matches
# both, which is exactly the failure in #15446.
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image.image_name }}-arch-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# A full re-run (retry-docker-builds uses `rerun` when a job was
# cancelled) keeps the run id, and v4 refuses to upload over an existing
# artifact name. Overwrite instead of qualifying the name by
# run_attempt: `rerun-failed-jobs` re-runs the merge alone, which must
# still find the digests the earlier attempt's build legs uploaded.
overwrite: true
merge:
name: Merge manifests (${{ matrix.image.image_name }})
runs-on: ubuntu-latest
needs: build
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
image: ${{ fromJSON(inputs.images) }}
steps:
# Registry names must be lowercase, and the repository owner is not guaranteed to be.
- name: Resolve GHCR image name
env:
IMAGE_NAME: ${{ matrix.image.image_name }}
run: printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v7
with:
pattern: digests-${{ matrix.image.image_name }}-arch-*
merge-multiple: true
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# The digests are identical across registries (manifests are content-addressed),
# so the same set sources both manifest lists.
- name: Create manifest lists and push
working-directory: /tmp/digests
env:
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image.image_name }}
EXPECTED_ARCHES: ${{ join(fromJSON(inputs.arches), ' ') }}
TAG_SUFFIXES: ${{ inputs.tag_suffixes }}
run: |
set -euo pipefail
shopt -s nullglob
# One digest per architecture. Assert the exact count: too few would
# publish a manifest missing an architecture, too many means the artifact
# glob crossed images (#15446). The old guard only rejected an empty
# directory, which is why four digests sailed through.
expected=$(wc -w <<< "$EXPECTED_ARCHES")
digests=(*)
if [ "${#digests[@]}" -ne "$expected" ]; then
echo "Expected $expected platform digests ($EXPECTED_ARCHES), found ${#digests[@]}: ${digests[*]}" >&2
exit 1
fi
tag_args=()
while IFS= read -r suffix; do
[ -n "$suffix" ] || continue
tag_args+=(-t "IMAGE:${suffix}")
done <<< "$TAG_SUFFIXES"
if [ "${#tag_args[@]}" -eq 0 ]; then
echo "No tag suffixes supplied; refusing to publish an untagged manifest." >&2
exit 1
fi
echo "Merging ${#digests[@]} digests into $(( ${#tag_args[@]} / 2 )) tag(s) per registry"
for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do
docker buildx imagetools create \
"${tag_args[@]/IMAGE:/${image}:}" \
"${digests[@]/#/${image}@sha256:}"
done
- name: Inspect image
env:
TAG_SUFFIXES: ${{ inputs.tag_suffixes }}
run: |
set -euo pipefail
first_tag=$(grep -m1 -v '^[[:space:]]*$' <<< "$TAG_SUFFIXES")
docker buildx imagetools inspect "${GHCR_IMAGE}:${first_tag}"