forked from blooop/devlaunch
-
Notifications
You must be signed in to change notification settings - Fork 0
210 lines (201 loc) · 11.3 KB
/
Copy pathdevcontainer-prebuild.yml
File metadata and controls
210 lines (201 loc) · 11.3 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
name: Devcontainer prebuild
# Builds this repository's devcontainer and publishes it to GHCR, so that opening
# a workspace pulls an image instead of building one.
#
# The mechanism is devpod's, not a convention of ours. `devpod up` -- which is
# what every `dl` launch runs -- hashes the devcontainer build config together
# with the build context and looks for `<repository>:devpod-<hash>` in the
# repositories named by `customizations.devpod.prebuildRepository`
# (.devcontainer/devcontainer.json). If that exact tag is there it uses the image
# directly, features included, and builds nothing. So the thing this workflow has
# to get right is the *tag*, which is why the build runs through `devpod build`
# rather than `docker buildx` or `devcontainers/ci`: those would push a
# well-formed image under a tag devpod never asks for, which looks like success
# and changes nothing.
#
# `devpod build` is also idempotent about it. It computes the hash, finds the
# prebuild already present, and returns without rebuilding or repushing -- so a
# re-run, or a dispatch against a commit whose `.devcontainer/` is unchanged,
# costs a registry lookup.
#
# The architecture is hashed too, which is why this is a matrix. `arch + config +
# dockerfile + contextHash` is the string devpod hashes
# (pkg/devcontainer/config/prebuild.go), so amd64 and arm64 ask for two different
# tags and each is published on its own. Nothing has to merge them into a
# manifest list: devpod never looks one up. It asks for exactly one tag and, on
# finding it, pulls the variant matching the architecture it is running on.
on:
push:
branches: [ "main" ]
# A superset of the inputs to the hash, which is the safe direction to err in.
# `build.context` is `.devcontainer`, so nothing outside this directory can
# move the tag -- a commit to `devlaunch/`, `rust/` or `pyproject.toml` has
# nothing for this workflow to do. Inside it, `.devcontainer/.dockerignore`
# narrows the hash further, to the files the image is actually built from, so
# some commits this filter catches cannot move the tag either. Those cost a
# registry lookup and nothing else: `devpod build` finds the prebuild already
# published and returns without rebuilding or repushing.
#
# Narrowing the filter to match the hash exactly is the change not to make.
# It would mean restating `.dockerignore`'s patterns here, in a second place,
# inverted -- and the failure mode of getting that wrong is a commit that
# moved the tag with no image published at it, which is a silent return to
# local builds for everyone on `main`.
#
# The workflow file itself is in the list because a change to how the image
# is built or tagged should produce one.
paths:
- '.devcontainer/**'
- '.github/workflows/devcontainer-prebuild.yml'
workflow_dispatch:
permissions:
contents: read
# The one addition over the rest of this repository's workflows: pushing to
# the registry `prebuildRepository` names, with the default GITHUB_TOKEN. No
# new secret is involved, and a fork pushes to its own namespace for the same
# reason -- the token is the one running the job.
packages: write
# Serialised rather than cancelled. Two runs pushing to one package is the race
# worth avoiding, and a cancelled prebuild leaves the tag it was building absent
# -- which is a silent return to local builds for everyone on that commit, not a
# red tick anybody sees.
#
# The matrix below does not change that, and the group stays one. Concurrency is
# scoped to the *run*, so the two architecture legs belong to the same group
# member and run in parallel inside it, while a second run still queues behind
# the first -- which is the arrangement wanted, because the race is between runs.
# It is safe only because the legs write disjoint tags: a different hash each,
# from the architecture in the hash, and only one of them claims the unqualified
# `latest`. The day two legs share a tag, the serialisation has to move down to
# the job.
concurrency:
group: devcontainer-prebuild
cancel-in-progress: false
jobs:
prebuild:
name: prebuild (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
# One architecture's failure is no reason to abandon the other's image.
# Each leg publishes a tag of its own that nothing else reads, so a
# half-succeeded run leaves one architecture pulling and the other
# building locally -- strictly better than both building locally, which is
# what a cancelled leg would leave behind, and for the same reason
# `cancel-in-progress` is false above.
fail-fast: false
matrix:
include:
# `ubuntu-24.04-arm` is a GitHub-hosted arm64 runner, generally
# available and free without limit on public repositories -- which
# blooop/devlaunch is. No self-hosted runner and no QEMU.
#
# Native is what makes this cheap *and* correct at once: see the build
# step below for why the architecture the runner happens to be is the
# only thing that decides the tag.
#
# The arm64 leg was added unexercised, and that is worth writing down
# rather than leaving to be discovered: this repository is developed on
# x86 hosts, so nothing about the image on arm64 -- the multi-arch base,
# the aarch64 pixi and claude-shim builds, docker-in-docker -- was
# verified anywhere but in a registry listing before its first run here.
# Every piece it needs was checked to exist for linux-aarch64; none of
# it was checked to build.
- arch: amd64
runner: ubuntu-latest
alias: latest
- arch: arm64
runner: ubuntu-24.04-arm
alias: latest-arm64
# The build is the base image, the pixi binary, and two features (the local
# claude-code one and docker-in-docker); the push is ~600 MB of layers unique
# to this image. Bounded for the same reason the e2e job is: a registry
# serving or accepting layers at a crawl is a job that otherwise hangs for
# six hours and reports a timeout nobody reads.
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v7
# devpod is a pixi dependency, pinned in the lockfile to the version this
# repository is written against -- so the tag this job publishes is
# computed by the same devpod that will later look for it. That agreement
# is the whole point of not installing devpod some other way here.
- uses: prefix-dev/setup-pixi@v0.10.2
with:
cache: true
frozen: true
environments: default
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `devpod build . --tag <alias>`, with the destination read out of
# devcontainer.json rather than repeated here -- see the task's comment in
# pyproject.toml. It depends on `dev-add-docker`, which is not optional on
# a runner: a fresh ~/.devpod has no provider and devpod seeds none, so
# `devpod build` would exit 1 with "no default provider found".
#
# No `--platform`, deliberately. The architecture devpod folds into the
# hash comes from the driver, and the docker driver answers with the Go
# `runtime.GOARCH` of the devpod binary itself
# (pkg/driver/docker/docker.go) -- so on this leg it is whatever pixi
# installed, the linux-aarch64 devpod on the arm64 runner, and the tag
# comes out arm64 with nothing asked for. That is the same route the
# lookup takes: `devpod up` sets no platform at all
# (pkg/devcontainer/single.go), so the tag a launch asks for is hashed with
# the arch of the machine doing the launching and nothing else.
#
# `--platform linux/arm64` would hash to the same string here --
# `normalizeArchitecture` takes the part after the slash, which agrees with
# GOARCH on a native runner -- and that is the argument against it rather
# than for it: it is a second source of truth for something the runner
# already settles, and the two can disagree without saying so. Cross-built
# from an amd64 runner it would name the arm64 tag over an emulated build,
# and the lookup side does not read the platform anyway: devpod fetches the
# prebuild with `GetImageForArch(<driver arch>)`
# (pkg/devcontainer/build.go), so the image it asks for is the one the tag
# never promised. If a platform is ever genuinely needed it belongs on this
# line and not in the pixi task, being a property of the machine the job
# runs on rather than of publishing -- and the task is also what a
# developer runs by hand on their own machine.
#
# The alias is per leg because devpod arch-qualifies nothing: `--tag`
# values are pushed next to the hash tag exactly as given
# (pkg/devcontainer/prebuild.go), so both legs passing `latest` is a tag
# owned by whichever finished last. `latest` is what `build.cacheFrom` in
# devcontainer.json points at -- a best-effort layer cache for the builders
# that know nothing about prebuilds -- and a cache import from the wrong
# architecture serves no layers, so a flapping `latest` is a cache that
# works or does not per run, silently. It stays amd64's, the architecture
# every reader of that one string runs today; arm64 takes `latest-arm64`,
# which is also what stops its publish being an opaque `devpod-<hash>` on
# the package page. Neither alias is what a `dl` launch reads: that is the
# hash tag, and each leg publishes its own.
- name: Build and publish the prebuild
run: pixi run devcontainer-prebuild ${{ matrix.alias }}
# What is on this runner, in the log, because the tag is a hash nobody can
# derive by eye and "did the prebuild move?" is the question every
# investigation of a slow container open starts with.
#
# An empty listing is a legitimate outcome, not a failure: when the
# prebuild already exists `devpod build` returns before loading anything
# into the local daemon. Hence the `|| true` -- grep with no matches exits
# 1, and this step is a report rather than a check.
#
# The architecture is named because there are two of these logs per run now
# and the tags in them are hashes: without it, telling which listing is
# which means going back to the job name.
- name: Report
if: always()
run: |
# Read from the manifest rather than repeated here: `prebuildRepository`
# is where devpod publishes and looks, so a second spelling in this echo
# would name the wrong registry in a fork's log and nowhere else.
# `sed` on the one key rather than a JSON parse: the manifest is JSONC and
# stripping `//` comments would also cut every `https://` in it.
repo=$(sed -n 's/.*"prebuildRepository": *"\([^"]*\)".*/\1/p' .devcontainer/devcontainer.json | head -1)
echo "Local ${{ matrix.arch }} tags for $repo"
echo "(empty means the prebuild was already published and nothing was rebuilt):"
docker images --format '{{.Repository}}:{{.Tag}}' \
| grep "^${repo}:" | sort || true