Skip to content

Commit 9b4087e

Browse files
committed
Separate builds with native runners
Instead of using one long-running job to build 6 images (3 stages x 2 platforms) and 3 manifest lists, use 2 platform-specific jobs to build 3 images (one per stage) and a third job to build the manifest lists. This change comes with two functional improvements: 1. Most significantly, per-runner disk space usage and build times go down. This is due to both parallelizing the build per platform and removing emulation for the linux/arm64 build. The new time bottleneck for the workflow is now the linux/arm64 test job, which allows for an easy follow-up improvement (see added comment). 2. As a byproduct of the platform-specific images being tagged, they can now be retrieved from GitHub REST API results, removing the need to fetch from GHCR's undocumented Docker Registry API.
1 parent 1ddb6e5 commit 9b4087e

File tree

2 files changed

+93
-48
lines changed

2 files changed

+93
-48
lines changed

.github/workflows/ci.yml

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,22 @@ jobs:
3737
cache-date: ${{ env.CACHE_DATE }}
3838
tag: ${{ env.TAG }}
3939

40-
# Build multi-platform builder and final images with caching from Docker Hub
41-
# and GitHub Container Registry; push to GitHub Container Registry.
40+
# Build platform-specific builder and final images with caching from Docker
41+
# Hub and GitHub Container Registry; push to GitHub Container Registry.
4242
build:
43+
name: build (${{ matrix.platform }})
4344
needs: vars
44-
runs-on: ubuntu-latest
45+
runs-on: ${{ matrix.runner }}
46+
strategy:
47+
matrix:
48+
include:
49+
- platform: linux/amd64
50+
arch: amd64
51+
runner: ubuntu-latest
52+
53+
- platform: linux/arm64
54+
arch: arm64
55+
runner: ubuntu-24.04-arm
4556
steps:
4657

4758
- uses: actions/checkout@v6
@@ -50,8 +61,6 @@ jobs:
5061
with:
5162
python-version: '>=3.8'
5263

53-
- uses: docker/setup-qemu-action@v3
54-
5564
# GITHUB_TOKEN is unreliable¹ so use a token from nextstrain-bot.
5665
# ¹ https://github.com/docker/build-push-action/issues/463#issuecomment-939394233
5766
- uses: docker/login-action@v3
@@ -60,7 +69,12 @@ jobs:
6069
username: nextstrain-bot
6170
password: ${{ secrets.GH_TOKEN_NEXTSTRAIN_BOT_MANAGE_PACKAGES }}
6271

63-
- run: ./devel/build -p linux/amd64,linux/arm64 -r ghcr.io -t "$TAG" -l logs/
72+
- run: |
73+
./devel/build \
74+
-p ${{ matrix.platform }} \
75+
-r ghcr.io \
76+
-t "$TAG-${{ matrix.arch }}" \
77+
-l logs-${{ matrix.arch }}/
6478
env:
6579
TAG: ${{ needs.vars.outputs.tag }}
6680
CACHE_DATE: ${{ needs.vars.outputs.cache-date }}
@@ -69,15 +83,15 @@ jobs:
6983
name: Upload build logs as artifacts
7084
uses: actions/upload-artifact@v6
7185
with:
72-
name: build-logs
73-
path: logs/
86+
name: build-logs-${{ matrix.arch }}
87+
path: logs-${{ matrix.arch }}/
7488

7589
- if: always()
7690
name: Summarize build logs for the GitHub Actions run summary page
7791
run: |
78-
for log in logs/*; do
92+
for log in logs-${{ matrix.arch }}/*; do
7993
{
80-
echo "## $(basename "$log")"
94+
echo "## [${{ matrix.platform }}] $(basename "$log")"
8195
echo ''
8296
echo '```'
8397
./devel/summarize-buildkit-output "$log" 2>&1
@@ -86,16 +100,55 @@ jobs:
86100
} >> "$GITHUB_STEP_SUMMARY"
87101
done
88102
103+
# Merge platform-specific images into multi-platform images
104+
merge-builds:
105+
needs: [vars, build]
106+
runs-on: ubuntu-latest
107+
env:
108+
TAG: ${{ needs.vars.outputs.tag }}
109+
steps:
110+
- uses: docker/login-action@v3
111+
with:
112+
registry: ghcr.io
113+
username: nextstrain-bot
114+
password: ${{ secrets.GH_TOKEN_NEXTSTRAIN_BOT_MANAGE_PACKAGES }}
115+
116+
- name: Create base-builder-build-platform image
117+
run: |
118+
docker buildx imagetools create \
119+
-t "ghcr.io/nextstrain/base-builder-build-platform:$TAG" \
120+
"ghcr.io/nextstrain/base-builder-build-platform:$TAG-amd64" \
121+
"ghcr.io/nextstrain/base-builder-build-platform:$TAG-arm64"
122+
123+
- name: Create base-builder-target-platform image
124+
run: |
125+
docker buildx imagetools create \
126+
-t "ghcr.io/nextstrain/base-builder-target-platform:$TAG" \
127+
"ghcr.io/nextstrain/base-builder-target-platform:$TAG-amd64" \
128+
"ghcr.io/nextstrain/base-builder-target-platform:$TAG-arm64"
129+
130+
- name: Create base image
131+
run: |
132+
docker buildx imagetools create \
133+
-t "ghcr.io/nextstrain/base:$TAG" \
134+
"ghcr.io/nextstrain/base:$TAG-amd64" \
135+
"ghcr.io/nextstrain/base:$TAG-arm64"
136+
89137
# Run tests with the final image from GitHub Container Registry.
90138
test:
91139
name: test (${{ matrix.platform }})
92-
needs: [vars, build]
93-
runs-on: ubuntu-latest
140+
needs: [vars, merge-builds]
141+
runs-on: ${{ matrix.runner }}
94142
strategy:
95143
matrix:
96-
platform:
97-
- linux/amd64
98-
- linux/arm64
144+
include:
145+
- platform: linux/amd64
146+
runner: ubuntu-latest
147+
- platform: linux/arm64
148+
# TODO: use ubuntu-24.04-arm and remove emulation when Nextstrain
149+
# CLI can be installed natively on aarch64.¹
150+
# ¹ <https://github.com/nextstrain/cli/pull/490>
151+
runner: ubuntu-latest
99152
permissions:
100153
contents: read
101154
id-token: write
@@ -116,9 +169,7 @@ jobs:
116169
with:
117170
python-version: ~3
118171

119-
# The ubuntu-latest runner is linux/amd64 so anything else will
120-
# run with emulation, which is still better than nothing.
121-
- if: matrix.platform != 'linux/amd64'
172+
- if: matrix.platform == 'linux/arm64'
122173
uses: docker/setup-qemu-action@v3
123174

124175
- uses: actions/checkout@v6
@@ -141,7 +192,7 @@ jobs:
141192

142193
validate-platforms:
143194
name: Validate platforms
144-
needs: [vars, build]
195+
needs: [vars, merge-builds]
145196
runs-on: ubuntu-latest
146197
steps:
147198
- uses: actions/checkout@v6
@@ -161,7 +212,7 @@ jobs:
161212
# Docker Hub, where they will persist. Do this regardless of test results.
162213
push-branch:
163214
if: startsWith(needs.vars.outputs.tag, 'branch-') && github.event_name != 'pull_request'
164-
needs: [vars, build]
215+
needs: [vars, merge-builds]
165216
runs-on: ubuntu-latest
166217
steps:
167218
- uses: actions/checkout@v6
@@ -185,7 +236,7 @@ jobs:
185236
# Docker Hub, where they will persist. Only do this if tests pass.
186237
push-build:
187238
if: startsWith(needs.vars.outputs.tag, 'build-')
188-
needs: [vars, build, test, validate-platforms]
239+
needs: [vars, merge-builds, test, validate-platforms]
189240
runs-on: ubuntu-latest
190241
steps:
191242
- uses: actions/checkout@v6
@@ -283,7 +334,7 @@ jobs:
283334
# Delete the builder and final images from GitHub Container Registry.
284335
cleanup-registry:
285336
if: always()
286-
needs: [vars, build, test, validate-platforms, push-branch, push-build]
337+
needs: [vars, merge-builds, test, validate-platforms, push-branch, push-build]
287338
runs-on: ubuntu-latest
288339
steps:
289340
- uses: actions/checkout@v6
@@ -294,5 +345,4 @@ jobs:
294345
script: |
295346
const script = require('./devel/delete-from-ghcr.js');
296347
const tag = "${{ needs.vars.outputs.tag }}";
297-
const token = "${{ secrets.GH_TOKEN_NEXTSTRAIN_BOT_MANAGE_PACKAGES }}";
298-
script({fetch, octokit: github, tag, token});
348+
script({octokit: github, tag});

devel/delete-from-ghcr.js

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// ¹ https://github.com/octokit/core.js#authentication
88
// ² https://github.com/settings/tokens/new?scopes=delete:packages
99

10-
module.exports = async ({fetch, octokit, tag, token}) => {
10+
module.exports = async ({octokit, tag}) => {
1111
org = 'nextstrain';
1212
packages = [
1313
'base',
@@ -35,44 +35,39 @@ module.exports = async ({fetch, octokit, tag, token}) => {
3535
continue;
3636
}
3737

38-
// The manifest list + one manifest per platform are pushed from the build.
39-
// Only the manifest list is tagged for direct removal via GitHub's REST API.
40-
41-
// The GitHub REST API does not provide a way to retrieve manifest digests
42-
// from the manifest list, so use GHCR's (undocumented) Docker Registry API
43-
// to do that.
44-
// This works when a GitHub token with the right permissions is passed in
45-
// base64 form as a Bearer token¹.
46-
// ¹ https://github.com/orgs/community/discussions/26279#discussioncomment-3251172
47-
const res = await fetch(`https://ghcr.io/v2/${org}/${packageName}/manifests/${tag}`,
48-
{
49-
headers: {
50-
Accept: "application/vnd.docker.distribution.manifest.list.v2+json",
51-
Authorization: `Bearer ${btoa(token)}`
52-
}
53-
});
54-
const resData = await res.json();
55-
const manifestDigests = resData.manifests.map(manifest => manifest.digest);
38+
// Delete platform-specific images first. These are created during the split
39+
// build process and should be cleaned up even if the main manifest merge
40+
// fails.
41+
const platformTags = [`${tag}-amd64`, `${tag}-arm64`];
42+
for (const platformTag of platformTags) {
43+
const platformVersionsWithTag = packageVersions.filter(version => version.metadata.container.tags.includes(platformTag));
44+
45+
if (platformVersionsWithTag.length == 0) {
46+
console.log(`${org}/${packageName}:${platformTag} was not found (may have already been deleted).`);
47+
continue;
48+
}
49+
50+
const platformVersionId = platformVersionsWithTag[0].id;
51+
console.log(`Version for ${org}/${packageName}:${platformTag} is ${platformVersionId}.`);
5652

57-
const versions = packageVersions.filter(version => manifestDigests.includes(version.name));
58-
for (const version of versions) {
59-
console.log(`Deleting the package version for ${org}/${packageName}:${version.name} ...`);
53+
console.log(`Deleting the package version for ${org}/${packageName}:${platformTag} ...`);
6054
try {
6155
await octokit.request('DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}', {
6256
org: org,
6357
package_type: 'container',
6458
package_name: packageName,
65-
package_version_id: version.id,
59+
package_version_id: platformVersionId,
6660
});
6761
console.log("Done.");
6862
} catch (deleteVersionError) {
6963
console.log(deleteVersionError);
64+
console.error(`Could not delete ${org}/${packageName}:${platformTag}.`);
7065
errorEncountered = true;
7166
continue;
7267
}
7368
}
7469

75-
// Delete the manifest list after deleting individual manifests.
70+
// Delete the merged image after deleting platform-specific images.
7671

7772
const versionsWithTag = packageVersions.filter(version => version.metadata.container.tags.includes(tag));
7873

0 commit comments

Comments
 (0)