Repository navigation
312 lines (300 loc) · 13.7 KB
/
Copy pathimage.yml
File metadata and controls
312 lines (300 loc) · 13.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
# Builds the image for arm64 and amd64, checks it with check.sh and publishes
# it as ghcr.io/hyperstructure/sandbox:<fctl version>, plus :<major>.<minor>
# and :latest when that release is the newest they cover. An image is this
# repository's main with a release of fctl and the newest Claude Code.
#
# - fctl's Release workflow starts it with `-f version=…` for each release,
# as soon as fctl.sh has the release, and waits for it, starting it again
# when a newer run cancels it while it waits (as the fctl-actions GitHub
# App, in hyperstructure/fctl's .github/workflows/release.yml): keep this
# file's name and its `version` input.
# - Every hour it builds the latest fctl release's image when there is none
# yet (the release's own run failed, or didn't start), or when the image
# predates the last change to it on main. A push to main that changes the
# image does the same at once.
# - Every day it also rebuilds it when Claude Code has a newer release than
# the one inside, or when the image is a week old (for Debian's updates):
# fctl turns off Claude Code's auto-updater, so an image's Claude Code is as
# old as the image.
# - `gh workflow run image.yml` does what the daily run does;
# `-f version=0.3.0` builds another release's image, `-f force=true` even
# when it is current.
# - Pull requests that change the image build and check it without
# publishing.
#
# Each architecture builds on its own runner and is pushed by digest, without
# a tag, and checked; the last job tags the two as one image. Only main
# publishes.
name: Image
on:
pull_request:
paths:
- Dockerfile
- provision.sh
- check.sh
- .dockerignore
- .github/workflows/image.yml
push:
branches: [main]
paths:
- Dockerfile
- provision.sh
- check.sh
- .dockerignore
- .github/workflows/image.yml
schedule:
- cron: "23 * * * *"
- cron: "41 5 * * *"
workflow_dispatch:
inputs:
version:
description: The fctl release to build, like 0.3.0 (default the latest)
required: false
force:
description: Build even when the published image is current
type: boolean
default: false
permissions:
contents: read
# One publishing run at a time, each planning from what the one before it
# published. A pull request's new commits cancel its older runs.
concurrency:
group: image-${{ github.event_name == 'pull_request' && github.ref || 'publish' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE: ghcr.io/hyperstructure/sandbox
DESCRIPTION: The default sandbox for fctl sessions
SOURCE: https://github.com/hyperstructure/sandbox
jobs:
plan:
name: What to build
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: read
outputs:
version: ${{ steps.plan.outputs.version }}
claude: ${{ steps.plan.outputs.claude }}
tags: ${{ steps.plan.outputs.tags }}
created: ${{ steps.plan.outputs.created }}
build: ${{ steps.plan.outputs.build }}
push: ${{ steps.plan.outputs.push }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Versions and tags
id: plan
env:
EVENT: ${{ github.event_name }}
SCHEDULE: ${{ github.event.schedule }}
VERSION: ${{ inputs.version }}
FORCE: ${{ inputs.force && 'yes' || '' }}
GH_TOKEN: ${{ github.token }}
run: |
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
releases=https://fctl.sh/releases
latest=$(curl -fsS --retry 3 "$releases/latest" | tr -d '[:space:]')
[[ $latest =~ $semver ]] || { echo "::error::fctl.sh/releases/latest says '$latest'"; exit 1; }
# What https://claude.ai/install.sh installs; every architecture gets this one.
claude=$(curl -fsS --retry 3 https://downloads.claude.ai/claude-code-releases/latest | tr -d '[:space:]')
[[ $claude =~ $semver ]] || { echo "::error::downloads.claude.ai says the latest Claude Code is '$claude'"; exit 1; }
version=${VERSION:-$latest} version=${version#v}
[[ $version =~ $semver ]] || { echo "::error::$version is not a version like 0.3.0"; exit 1; }
released() { curl -fs --retry 3 -o /dev/null "$releases/v$1/manifest.json"; }
# fctl's Release workflow starts this as soon as it has published the
# release, which some of fctl.sh's locations may not show for a minute.
for try in 1 2 3 4 5; do
if released "$version"; then break; fi
if [ "$try" = 5 ]; then echo "::error::fctl $version is not on fctl.sh/releases"; exit 1; fi
sleep 30
done
# Only main publishes; pull requests and other branches build and check.
push=no
if [ "$EVENT" != pull_request ] && [ "$GITHUB_REF" = refs/heads/main ]; then push=yes; fi
# fctl's releases count up one patch at a time and fctl.sh keeps them
# all, so this one is the newest of its minor unless the next patch is
# there. A release later than fctl.sh's latest, which fctl.sh caches
# for a minute, came out moments ago and is the newest.
IFS=. read -r major minor patch <<<"$version"
tags="$IMAGE:$version"
if [ "$version" != "$latest" ] && [ "$(printf '%s\n' "$latest" "$version" | sort -V | tail -n 1)" = "$version" ]; then
tags="$tags $IMAGE:$major.$minor $IMAGE:latest"
else
if ! released "$major.$minor.$((patch + 1))"; then tags="$tags $IMAGE:$major.$minor"; fi
if [ "$version" = "$latest" ]; then tags="$tags $IMAGE:latest"; fi
fi
what="fctl $version with Claude Code $claude"
build=yes why="it was started with force=true"
if [ "$push" = yes ] && [ -z "$FORCE" ]; then
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null
if index=$(docker buildx imagetools inspect --raw "$IMAGE:$version" 2>/dev/null); then
have=$(jq -r '.annotations["sh.fctl.claude-code.version"] // ""' <<<"$index")
revision=$(jq -r '.annotations["org.opencontainers.image.revision"] // ""' <<<"$index")
built=$(jq -r '.annotations["org.opencontainers.image.created"] // "1970-01-01"' <<<"$index")
age=$(( ($(date +%s) - $(date -d "$built" +%s 2>/dev/null || echo 0)) / 86400 ))
# The image is current when it was built from the last commit that
# changed what goes into it, or from a later one.
changed=$(git log -1 --format=%H -- Dockerfile provision.sh .dockerignore .github/workflows/image.yml)
# Claude Code's and Debian's updates count once a day, and when
# started by hand.
daily=no
if [ "$SCHEDULE" = "41 5 * * *" ] || [ "$EVENT" = workflow_dispatch ]; then daily=yes; fi
if ! git merge-base --is-ancestor "$changed" "$revision" 2>/dev/null; then
why="$IMAGE:$version predates $(git log -1 --format='%h ("%s")' "$changed")"
elif [ "$daily" = yes ] && [ "$have" != "$claude" ]; then
why="$IMAGE:$version has Claude Code $have"
elif [ "$daily" = yes ] && [ "$age" -ge 7 ]; then
why="$IMAGE:$version is $age days old"
else
build=no why="$IMAGE:$version is current (Claude Code $have, built $age days ago from ${revision:0:7})"
fi
else
why="$IMAGE:$version isn't published yet"
fi
fi
{
echo "version=$version"
echo "claude=$claude"
echo "tags=$tags"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "build=$build"
echo "push=$push"
} >> "$GITHUB_OUTPUT"
{
if [ "$build" = no ]; then
echo "Nothing to build: $why."
elif [ "$push" = yes ]; then
echo "Building $what for \`$tags\`: $why."
else
echo "Building $what from \`$GITHUB_REF\`, without publishing it."
fi
} >> "$GITHUB_STEP_SUMMARY"
build:
name: Build (${{ matrix.arch }})
needs: plan
if: needs.plan.outputs.build == 'yes'
strategy:
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
packages: write
env:
VERSION: ${{ needs.plan.outputs.version }}
CLAUDE: ${{ needs.plan.outputs.claude }}
CREATED: ${{ needs.plan.outputs.created }}
PUSH: ${{ needs.plan.outputs.push }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build
id: build
env:
GH_TOKEN: ${{ github.token }}
ARCH: ${{ matrix.arch }}
run: |
docker buildx create --name sandbox --driver docker-container --use >/dev/null
if [ "$PUSH" = yes ]; then
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null
output="type=image,name=$IMAGE,push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true"
else
output="type=docker,name=sandbox:check"
fi
docker buildx build . \
--platform "linux/$ARCH" --pull --provenance=false \
--build-arg "FCTL_VERSION=$VERSION" \
--build-arg "CLAUDE_VERSION=$CLAUDE" \
--label "org.opencontainers.image.title=sandbox" \
--label "org.opencontainers.image.description=$DESCRIPTION" \
--label "org.opencontainers.image.url=$SOURCE" \
--label "org.opencontainers.image.source=$SOURCE" \
--label "org.opencontainers.image.licenses=MIT" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.created=$CREATED" \
--label "sh.fctl.version=$VERSION" \
--label "sh.fctl.claude-code.version=$CLAUDE" \
--output "$output" \
--metadata-file "$RUNNER_TEMP/build.json"
if [ "$PUSH" = yes ]; then
digest=$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/build.json")
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE@$digest" >> "$GITHUB_OUTPUT"
else
echo "image=sandbox:check" >> "$GITHUB_OUTPUT"
fi
- name: Check it
env:
CHECK: ${{ steps.build.outputs.image }}
ARCH: ${{ matrix.arch }}
run: |
docker run --rm -i -e FCTL_VERSION="$VERSION" -e CLAUDE_VERSION="$CLAUDE" "$CHECK" sh -s < check.sh
docker image inspect --format '{{.Size}}' "$CHECK" |
awk -v arch="$ARCH" '{ printf "%s: %.2f GB\n", arch, $1 / 1e9 }' >> "$GITHUB_STEP_SUMMARY"
- name: Note its digest
if: needs.plan.outputs.push == 'yes'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
mkdir -p "$RUNNER_TEMP/digests"
touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
- if: needs.plan.outputs.push == 'yes'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish
needs: [plan, build]
if: needs.plan.outputs.push == 'yes'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: write
env:
VERSION: ${{ needs.plan.outputs.version }}
CLAUDE: ${{ needs.plan.outputs.claude }}
CREATED: ${{ needs.plan.outputs.created }}
TAGS: ${{ needs.plan.outputs.tags }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: digest-*
path: ${{ runner.temp }}/digests
merge-multiple: true
- name: Tag both architectures as one image
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null
sources=()
for file in "$RUNNER_TEMP"/digests/*; do sources+=("$IMAGE@sha256:$(basename "$file")"); done
[ "${#sources[@]}" = 2 ] || { echo "::error::expected a digest for each architecture, got ${sources[*]}"; exit 1; }
tags=()
for tag in $TAGS; do tags+=(--tag "$tag"); done
# On the index too: GHCR shows these, and the next runs read the
# Claude Code version, the commit and the date from them.
docker buildx imagetools create "${tags[@]}" \
--annotation "index:org.opencontainers.image.description=$DESCRIPTION" \
--annotation "index:org.opencontainers.image.source=$SOURCE" \
--annotation "index:org.opencontainers.image.licenses=MIT" \
--annotation "index:org.opencontainers.image.version=$VERSION" \
--annotation "index:org.opencontainers.image.revision=$GITHUB_SHA" \
--annotation "index:org.opencontainers.image.created=$CREATED" \
--annotation "index:sh.fctl.claude-code.version=$CLAUDE" \
"${sources[@]}"
docker buildx imagetools inspect "$IMAGE:$VERSION"
{
echo "Published fctl $VERSION with Claude Code $CLAUDE as:"
for tag in $TAGS; do echo "- \`$tag\`"; done
} >> "$GITHUB_STEP_SUMMARY"