-
Notifications
You must be signed in to change notification settings - Fork 5
390 lines (359 loc) · 18.7 KB
/
Copy pathci.yml
File metadata and controls
390 lines (359 loc) · 18.7 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
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
name: CI
on:
push:
branches: [master]
paths-ignore:
- '**/*.md'
pull_request:
paths-ignore:
- '**/*.md'
# Collapse redundant runs per ref. Superseded PR runs are cancelled as soon as a
# new commit lands on the branch, but pushes to master are never cancelled
# mid-run: each master commit must finish its own build and push.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CST_VERSION: 1.22.1
DAVE_VERSION: 0.5.0
# Concurrency within a dave command type (dave >= 0.5.0 --jobs). Builds are
# I/O- and CPU-heavy so they get modest parallelism; structure tests are
# cheap (docker create + file assertions) so they fan out wider; integration
# tests each boot a database server, so keep them narrow to bound peak
# memory on the 4-vCPU runners.
DAVE_JOBS_BUILD: '3'
DAVE_JOBS_ST: '4'
DAVE_JOBS_TEST: '2'
jobs:
unit:
# Fast Python-level tests for the transform hooks. Runs first and gates the
# image build below, so a logic regression fails in seconds rather than
# after the multi-arch build. Pin to the interpreter the alpine:3.21 builder
# ships (python3 3.12) so tests exercise the same behavior as production.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install test dependencies
run: pip install -r test/requirements-dev.txt
- name: Run unit tests
run: python -m pytest test/unit -q
test:
needs: unit
strategy:
fail-fast: false
matrix:
# 5 contexts x 2 native runners = 10 shards. Sharding by context bounds
# the critical path at the slowest single context on one arch instead
# of every context serially on one runner, and each shard's dave run
# is further parallelized within a command type via --jobs.
context: [postgres, mysql, sqlite, cockroach, duckdb]
arch: [amd64, arm64]
include:
- arch: amd64
runner: ubuntu-latest
platform: linux/amd64
cst_arch: amd64
- arch: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
cst_arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
# Build only this runner's native platform; the buildcache refs are
# arch-scoped so the two arches never clobber each other's cache (the
# manifest templates keep the unsuffixed legacy ref as a read fallback).
PLATFORM: ${{ matrix.platform }}
CACHE_ARCH: -${{ matrix.arch }}
# True exactly when this run can read repo secrets: a push (on.push only
# fires for master) or a PR whose head branch lives in this repository.
# Fork PRs get no secrets by GitHub's own model, so for them this is
# false -- no login happens, CACHE_TO_SCOPE is never set, and the build
# renders no --cache-to at all: fork PRs stay import-only, exactly the
# behavior they have always had.
REGISTRY_AUTH: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Install dave
run: npm install -g dave@"$DAVE_VERSION"
- name: Install container-structure-test
run: |
curl -fsSL -o /tmp/cst \
"https://github.com/GoogleContainerTools/container-structure-test/releases/download/v${CST_VERSION}/container-structure-test-linux-${{ matrix.cst_arch }}"
chmod +x /tmp/cst
sudo mv /tmp/cst /usr/local/bin/container-structure-test
# With the default docker-container driver, `--load` round-trips every
# built image through a tarball export/import before it is runnable --
# pure overhead for images whose next stop is the local structure and
# integration tests. The docker driver builds straight into the daemon's
# image store, so the image is immediately runnable, but it only gets
# full BuildKit feature parity (registry cache-from/cache-to, proper
# multi-platform handling) when the daemon uses the containerd image
# store. Flip that on before buildx is configured; the jq merge
# preserves any daemon.json the runner image already ships.
- name: Enable containerd image store
run: |
sudo mkdir -p /etc/docker
[ -s /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json > /dev/null
jq '.features["containerd-snapshotter"] = true' /etc/docker/daemon.json > "$RUNNER_TEMP/daemon.json"
sudo mv "$RUNNER_TEMP/daemon.json" /etc/docker/daemon.json
sudo systemctl restart docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver: docker
# Master pushes need credentials to publish; same-repo PRs need them
# only so their cache export below can write to the registry.
- name: Log in to Docker Hub
if: env.REGISTRY_AUTH == 'true'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Opt in to importing the PR-scoped cache refs (buildcache-<tag>-<arch>-pr)
# during every build below, so a re-run attempt of this PR hits the cache
# its own earlier attempt exported. Import only: the matching EXPORT
# (CACHE_TO_SCOPE) is deliberately not set here but on the dedicated
# serialized step after the build -- a mode=max cache export is a heavy
# registry write, and with 10 shards building at -j 3 the fleet peaks at
# ~30 simultaneous writers, which trips Docker Hub's 429 rate limiting
# mid-build (the same arithmetic that forced `dave push` to -j 1). Gated
# on the same-repo condition above: on fork PRs the guard is false, the
# variable stays unset, and the manifest's ${CACHE_FROM_SCOPE:+...} guard
# erases the flag entirely.
- name: Import PR build cache
if: env.REGISTRY_AUTH == 'true' && github.event_name == 'pull_request'
run: echo "CACHE_FROM_SCOPE=pr" >> "$GITHUB_ENV"
# Last-known-good upstream fingerprints, carried across runs. When a live
# metadata fetch fails, bin/dataset-checksum falls back to the newest
# fingerprint recorded here instead of degrading to `unknown`; a single
# transient upstream blip would otherwise change the checksum baked into
# the build command, bust the EXTRACT layer cache, and force a full
# re-download of every dataset from the very upstream that just failed.
# Keyed per run+attempt because caches are immutable once written, and
# resolved through the context prefix so any earlier run's store is
# picked up -- cross-run fallback is the whole point of a last-known-good
# store. Fingerprints do not vary by arch, so arch stays out of the
# restore prefix and the two shards of a context read each other's
# entries; it is in the save key only because keys are reserved
# first-writer-wins and the two shards of one run would otherwise race
# to claim the same one.
- name: Restore checksum fallback store
uses: actions/cache/restore@v5
with:
path: ${{ runner.temp }}/checksum-fallback
key: checksum-lkg-${{ matrix.context }}-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: checksum-lkg-${{ matrix.context }}-
# Integration-test pass stamps from earlier attempts of this same commit.
# A stamp is keyed by image ID and holds the dataset list plus the exact
# bytes of every expected JSON, and is only written after a clean pass,
# so a hit means this exact image already satisfied these exact
# expectations. Re-running a failed shard re-imports the registry build
# cache, so tags untouched by the retry rebuild to a byte-identical image
# ID, hit their stamp, and skip booting the database container entirely;
# anything whose image or expectations actually changed misses by
# construction and is tested for real. Scoped to this SHA on purpose: the
# stamp contents would keep a broader restore correct on their own, but
# confining it to the commit keeps the cache small and the reasoning
# local. The restore-keys prefix (no attempt suffix) is what lets attempt
# N pick up the stamps attempt N-1 wrote.
- name: Restore integration test stamps
uses: actions/cache/restore@v5
with:
path: ${{ runner.temp }}/itest-stamps
key: itest-stamps-${{ matrix.context }}-${{ matrix.arch }}-${{ github.sha }}-${{ github.run_attempt }}
restore-keys: itest-stamps-${{ matrix.context }}-${{ matrix.arch }}-${{ github.sha }}-
# Every rendered build/push command embeds $(bin/dataset-checksum ...);
# uncached, that is one upstream metadata round-trip per tag, serialized
# ahead of any Docker work. Warm them all here concurrently so the
# per-tag calls become local cache reads. The cache dir is exported via
# GITHUB_ENV so every later dave step reads from the same warm cache
# (the runner context is not available in job-level env). The fallback
# dir is exported the same way, and must be in the environment before
# this first fetch runs: this step both consumes it (on failure) and
# refreshes it (on every clean fetch).
- name: Warm dataset checksums
env:
DATASET_CHECKSUM_CACHE: ${{ runner.temp }}/checksums
DATASET_CHECKSUM_FALLBACK: ${{ runner.temp }}/checksum-fallback
run: |
echo "DATASET_CHECKSUM_CACHE=$DATASET_CHECKSUM_CACHE" >> "$GITHUB_ENV"
echo "DATASET_CHECKSUM_FALLBACK=$DATASET_CHECKSUM_FALLBACK" >> "$GITHUB_ENV"
# Both persisted dirs exist even if the shard dies before the step
# that would first write them, so the always() cache saves below
# never warn about a missing path.
mkdir -p "$DATASET_CHECKSUM_FALLBACK" "${{ runner.temp }}/itest-stamps"
bin/dataset-checksums -c ${{ matrix.context }}
# The builds themselves must not be what pulls the bases: each context
# has only two or three distinct FROM images, but ~21 tags per shard
# each resolve them against Docker Hub independently, and 10 shards
# doing that from one account exhausts the shared hourly pull quota --
# once it is gone, no amount of minutes-scale backoff brings it back
# within the job timeout. So pull each distinct base exactly once here
# (a handful of pulls per shard, well inside the quota) and let every
# build resolve FROM from the local image store. Stage aliases (`FROM
# engine`) are names defined by an earlier `FROM ... AS`, not registry
# refs, and are skipped.
- name: Pre-pull base images
run: |
aliases="$(awk 'toupper($1)=="FROM" && toupper($3)=="AS" {print $4}' ${{ matrix.context }}/Dockerfile | tr '\n' ' ')"
images="$(awk 'toupper($1)=="FROM" {print $2}' ${{ matrix.context }}/Dockerfile | sort -u)"
for img in $images; do
case " $aliases " in *" $img "*) continue ;; esac
for attempt in 1 2 3 4 5; do
if docker pull --platform "$PLATFORM" "$img"; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "pull of $img failed after 5 attempts" >&2
exit 1
fi
delay=$((attempt * 60))
echo "pull of $img attempt $attempt failed; retrying in ${delay}s" >&2
sleep "$delay"
done
done
# Retried like the push below, and for the same reason: with the bases
# pre-pulled above the remaining registry traffic is buildcache manifest
# resolution, which can still catch a transient 429. A second
# `dave build` finds every already-built layer in the local store, so a
# retry redoes only the tags a failure actually killed.
- name: Build images
run: |
for attempt in 1 2 3; do
if dave build -c ${{ matrix.context }} -j "$DAVE_JOBS_BUILD"; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "build failed after 3 attempts" >&2
exit 1
fi
delay=$((attempt * 60))
echo "build attempt $attempt failed; retrying in ${delay}s" >&2
sleep "$delay"
done
# The PR cache export, moved out of the build itself (see the Import
# step above for the concurrency arithmetic). Re-running `dave build`
# with CACHE_TO_SCOPE set resolves every layer from the local store --
# the same full-cache-hit trick the Push step uses -- so this is
# upload-only, and -j 1 keeps the fleet at ~10 concurrent writers, the
# level the registry tolerates. continue-on-error because the cache is
# purely an optimization for the next attempt: a throttled export must
# never fail a shard whose images built and will be tested regardless.
- name: Export PR build cache
if: env.REGISTRY_AUTH == 'true' && github.event_name == 'pull_request'
continue-on-error: true
env:
CACHE_TO_SCOPE: pr
run: dave build -c ${{ matrix.context }} -j 1
- name: Test images (structure)
run: dave structure-test -c ${{ matrix.context }} -j "$DAVE_JOBS_ST"
- name: Test images (integration)
env:
DDS_TEST_CACHE: ${{ runner.temp }}/itest-stamps
run: dave test -c ${{ matrix.context }} -j "$DAVE_JOBS_TEST"
# Saved with `always()` so a shard that failed part-way still banks the
# stamps for the tags that did pass -- that partial credit is exactly
# what makes the next attempt cheap. The key carries the attempt number
# because a cache entry cannot be overwritten once written; the restore
# above finds it by prefix.
- name: Save integration test stamps
if: always()
uses: actions/cache/save@v5
with:
path: ${{ runner.temp }}/itest-stamps
key: itest-stamps-${{ matrix.context }}-${{ matrix.arch }}-${{ github.sha }}-${{ github.run_attempt }}
# Publish this shard's images natively as `<tag>-<arch>`; the merge job
# below joins the per-arch pairs into the real multi-arch tags. This
# `dave push` re-runs the same buildx build the Build step ran, against
# the same daemon on the same runner: under the docker driver every
# layer is already in the local image store, so the build resolves as a
# full cache hit and the step is effectively upload-only. The images
# being pushed are therefore bit-for-bit the ones the tests above
# validated.
- name: Push (native arch)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
env:
TAG_SUFFIX: -${{ matrix.arch }}
# -j 1: pushes from all matrix shards run simultaneously, so per-shard
# concurrency multiplies across 10 jobs. At -j 3 that is ~30 concurrent
# registry writers, which trips Docker Hub's 429 rate limiting; serial
# pushes per shard keep the fleet at ~10 and uploads are bandwidth-bound
# anyway. Raise only alongside a Docker Hub plan with higher limits.
# Even at ~10 writers a 429 still slips through occasionally, so retry
# up to three times with a lengthening pause: re-running `dave push`
# hits the same daemon whose image store already holds every layer, so
# the rebuild resolves as a full cache hit and the retry is
# upload-only and idempotent -- blobs that already landed are skipped
# by digest.
run: |
for attempt in 1 2 3; do
if dave push -c ${{ matrix.context }} -j 1; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "push failed after 3 attempts" >&2
exit 1
fi
delay=$((attempt * 60))
echo "push attempt $attempt failed; retrying in ${delay}s" >&2
sleep "$delay"
done
# The fallback store is refreshed by every clean fetch above, so save it
# at the very end of the job to bank whatever the run learned. `always()`
# matters here too: a shard that failed in build or test may still have
# completed a good metadata fetch, and that fingerprint is worth keeping
# for the next run.
- name: Save checksum fallback store
if: always()
uses: actions/cache/save@v5
with:
path: ${{ runner.temp }}/checksum-fallback
key: checksum-lkg-${{ matrix.context }}-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
# A per-arch push leaves `<tag>-amd64` / `<tag>-arm64` in the registry; this
# joins each pair into the real multi-arch `<tag>` (pass 1) and then points
# every alias tag -- retagFrom entries such as `latest` -- at its source
# (pass 2). Both are `imagetools create` calls: manifest-list writes against
# images already in the registry, so nothing is pulled, rebuilt or
# re-uploaded, which is why 15 minutes is generous.
merge:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Done-stamps persisted across re-run attempts of this commit, same idea
# as the integration-test stamps: `imagetools create` re-reads its
# sources and re-writes the target even for an already-merged tag, so
# without stamps a rate-limited merge can never converge -- every retry
# redoes all ~110 tags and dies at the same quota depth. With them, each
# retry only pays for the tags the previous attempt did not land.
- name: Restore merge stamps
uses: actions/cache/restore@v5
with:
path: ${{ runner.temp }}/merge-stamps
key: merge-stamps-${{ github.sha }}-${{ github.run_attempt }}
restore-keys: merge-stamps-${{ github.sha }}-
- name: Merge per-arch manifests
env:
MERGE_STAMP_DIR: ${{ runner.temp }}/merge-stamps
run: bin/merge-manifests
- name: Save merge stamps
if: always()
uses: actions/cache/save@v5
with:
path: ${{ runner.temp }}/merge-stamps
key: merge-stamps-${{ github.sha }}-${{ github.run_attempt }}