Skip to content

Commit eb72e54

Browse files
committed
feat(updates): a build takes the newest bundle it can serve, instead of failing
The build fetched whatever the bundle repos called latest and then refused to continue if it was too new. That is the right answer for a release whose version bump was forgotten and the wrong one for everything else: a core built from an older branch would fail on a console that had simply moved on, when the console it wants is sitting one release back. So the build asks the same question the server asks — which release of this bundle can this core serve — and takes the newest that fits. Against the current releases, a core at 4.0.0-beta.21 now walks past adminui 6.1.1 and 6.1.0, which both want beta.22, and builds with 6.0.0. The decision is shared rather than reimplemented. `resolveBundleRelease` moves out of the update handler into `shared/bundleRelease`, with the network injected, and both callers use it: the server when somebody presses update, the build when it assembles an image. An image that bakes in a console the running server would have refused is the failure this whole mechanism exists to prevent, so the two must not be able to disagree — and the build imports the compiled module rather than carrying a copy, which `npm run build` makes possible by running `tsc` before it fetches. Missing `dist/` degrades to the static URL, which is what this did before any of it existed. Moving it out also made it testable. It was private and reached the network, so nothing covered it; there are now seven cases on it, including the two that motivated the walk (an older core offered the last bundle built for it, a release from before manifests ending the walk because nothing below it can be newer) and the channel rules. Two things follow from the build no longer being strict: - The release job checks the tag against package.json. A forgotten bump used to fail at the fetch; now it would quietly resolve an older console and ship it, so the check moves to where the intended version is actually written down twice. - The build steps are given GITHUB_TOKEN. Release listings come from the API, which allows sixty requests an hour to anonymous callers, and a CI runner's IP address is not its own.
1 parent 240e6e6 commit eb72e54

7 files changed

Lines changed: 493 additions & 172 deletions

File tree

.github/workflows/release.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,28 @@ jobs:
4040
- name: Install dependencies
4141
run: npm ci
4242

43+
# The build below asks each bundle repo for the newest release this version can serve,
44+
# so a forgotten version bump would no longer fail loudly — it would quietly resolve an
45+
# older console and ship it. The tag is the one place the intended version is written
46+
# down twice, so check them against each other here instead.
47+
- name: Check the version matches the tag
48+
shell: bash
49+
run: |
50+
TAG="${{ github.event.release.tag_name }}"
51+
PKG=$(node -p "require('./package.json').version")
52+
if [ "${TAG#v}" != "$PKG" ]; then
53+
echo "Release tag $TAG does not match package.json ($PKG)."
54+
echo "Bump the version in the commit being tagged, or tag v$PKG."
55+
exit 1
56+
fi
57+
echo "releasing $PKG as $TAG"
58+
4359
- name: Build
4460
run: npm run build
61+
env:
62+
# Release listings come from the API, which allows 60 requests an hour per IP to
63+
# anonymous callers — and a CI runner's IP is not its own.
64+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4565

4666
- name: Run tests
4767
run: npm test
@@ -142,6 +162,7 @@ jobs:
142162
run: npm run build
143163
env:
144164
BUILD_CHANNEL: dev
165+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
145166

146167
- name: Run tests
147168
run: npm test
@@ -208,6 +229,8 @@ jobs:
208229

209230
- name: Build
210231
run: npm run build
232+
env:
233+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
211234

212235
- name: Run tests
213236
run: npm test

scripts/bundleCompat.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323
import { promises as fs } from 'node:fs';
2424
import { join } from 'node:path';
25+
import { pathToFileURL } from 'node:url';
2526

2627
function parseVersion(input) {
2728
const trimmed = String(input ?? '').trim().replace(/^v/i, '');
@@ -147,3 +148,76 @@ export async function assertBundleFitsCore(dir, coreVersion, label) {
147148
` or set SONN_SKIP_BUNDLE_COMPAT=1 if you know this pairing is fine.`,
148149
);
149150
}
151+
152+
/**
153+
* The compiled resolver, or null when there is nothing compiled yet.
154+
*
155+
* `npm run build` runs `tsc` before it fetches the bundles, so during a real build `dist/` is
156+
* there and the decision about which release fits is the *same code the server runs* — which
157+
* is the point: an image must not bake in a console the running server would have refused.
158+
*
159+
* Running `npm run fetch:admin` on its own in a fresh clone is the case where it is missing.
160+
* That degrades to the static `releases/latest` URL, which is exactly what this did before
161+
* any of it existed, and `assertBundleFitsCore` still refuses a pairing that cannot work.
162+
*/
163+
async function loadResolver(cwd) {
164+
try {
165+
const url = (rel) => pathToFileURL(join(cwd, 'dist', rel)).href;
166+
const [release, upstream] = await Promise.all([
167+
import(url('shared/bundleRelease.js')),
168+
import(url('adapters/http/adminApi/misc/upstreamJson.js')),
169+
]);
170+
if (typeof release.resolveBundleRelease !== 'function') return null;
171+
return { release, fetchJson: upstream.fetchUpstreamJson };
172+
} catch {
173+
return null;
174+
}
175+
}
176+
177+
/**
178+
* Where to download a bundle from, preferring the newest release this core can actually serve.
179+
*
180+
* The reason this is not simply `releases/latest`: a bundle repo moves on, and a core being
181+
* built from an older branch would otherwise either bake in a console it cannot serve or fail
182+
* outright. Reaching one release back is the same answer the server gives an install on an
183+
* older core, and it keeps a build of an older core producing a working image.
184+
*
185+
* Honours the existing pins first — `*_DIST_URL` and `*_RELEASE` name a version, and naming
186+
* one is not asking for our opinion.
187+
*/
188+
export async function resolveBundleUrl({ cwd, repo, assetName, releaseEnv, distUrlEnv, label }) {
189+
const urlOverride = (process.env[distUrlEnv] ?? '').trim();
190+
if (urlOverride) return { distUrl: urlOverride, picked: 'pinned' };
191+
192+
const explicit = (process.env[releaseEnv] ?? '').trim();
193+
if (explicit && explicit !== 'latest') {
194+
return {
195+
distUrl: `https://github.com/${repo}/releases/download/${encodeURIComponent(explicit)}/${assetName}`,
196+
picked: 'pinned',
197+
};
198+
}
199+
200+
const latest = `https://github.com/${repo}/releases/latest/download/${assetName}`;
201+
const loaded = await loadResolver(cwd);
202+
if (!loaded) return { distUrl: latest, picked: 'latest' };
203+
204+
const coreVersion = await readCoreVersion(cwd);
205+
try {
206+
const resolved = await loaded.release.resolveBundleRelease({
207+
repo,
208+
assetName,
209+
coreVersion,
210+
channel: loaded.release.channelFor(coreVersion ?? '', process.env.SERVER_RELEASE_CHANNEL),
211+
newestWins: await isDevBuild(cwd),
212+
fetchJson: loaded.fetchJson,
213+
log: (message, detail) => console.log(`[bundle-compat] ${label}: ${message}`, detail ?? ''),
214+
});
215+
if (resolved.picked === 'compatible' && resolved.release !== 'latest') {
216+
console.log(`[bundle-compat] ${label}: ${resolved.release} is the newest that fits core ${coreVersion}`);
217+
}
218+
return { distUrl: resolved.distUrl, picked: resolved.picked };
219+
} catch (err) {
220+
console.warn(`[bundle-compat] ${label}: could not resolve a release (${err?.message ?? err}), using latest`);
221+
return { distUrl: latest, picked: 'latest' };
222+
}
223+
}

scripts/fetch-admin-dist.mjs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@ import { join } from 'node:path';
55
import { pipeline } from 'node:stream/promises';
66
import { spawn } from 'node:child_process';
77
import https from 'node:https';
8-
import { assertBundleFitsCore, readCoreVersion } from './bundleCompat.mjs';
8+
import { assertBundleFitsCore, readCoreVersion, resolveBundleUrl } from './bundleCompat.mjs';
99

1010
const repo = 'sonn-audio/adminui';
1111
const assetName = 'admin-dist.tgz';
12-
const release = process.env.ADMINUI_RELEASE ?? 'latest';
13-
const distUrl =
14-
process.env.ADMINUI_DIST_URL ??
15-
(release === 'latest'
16-
? `https://github.com/${repo}/releases/latest/download/${assetName}`
17-
: `https://github.com/${repo}/releases/download/${release}/${assetName}`);
12+
// Not simply `releases/latest`: the bundle repos move on, so a core built from an older
13+
// branch asks for the newest release it can actually serve. Pins and a full URL still win,
14+
// and a checkout with no `dist/` yet falls back to exactly what this used to do.
15+
const { distUrl } = await resolveBundleUrl({
16+
cwd: process.cwd(),
17+
repo,
18+
assetName,
19+
releaseEnv: 'ADMINUI_RELEASE',
20+
distUrlEnv: 'ADMINUI_DIST_URL',
21+
label: 'admin ui',
22+
});
1823

1924
const targetDir = join(process.cwd(), 'public', 'admin');
2025
const archivePath = join(tmpdir(), `admin-dist-${Date.now()}.tgz`);

scripts/fetch-player-dist.mjs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@ import { fileURLToPath } from 'node:url';
66
import { pipeline } from 'node:stream/promises';
77
import { spawn } from 'node:child_process';
88
import https from 'node:https';
9-
import { assertBundleFitsCore, readCoreVersion } from './bundleCompat.mjs';
9+
import { assertBundleFitsCore, readCoreVersion, resolveBundleUrl } from './bundleCompat.mjs';
1010

1111
const comingSoonHtml = join(dirname(fileURLToPath(import.meta.url)), 'player-coming-soon.html');
1212

1313
const repo = 'sonn-audio/player';
1414
const assetName = 'player-dist.tgz';
15-
const release = process.env.PLAYER_RELEASE ?? 'latest';
16-
const distUrl =
17-
process.env.PLAYER_DIST_URL ??
18-
(release === 'latest'
19-
? `https://github.com/${repo}/releases/latest/download/${assetName}`
20-
: `https://github.com/${repo}/releases/download/${release}/${assetName}`);
15+
// Not simply `releases/latest`: the bundle repos move on, so a core built from an older
16+
// branch asks for the newest release it can actually serve. Pins and a full URL still win,
17+
// and a checkout with no `dist/` yet falls back to exactly what this used to do.
18+
const { distUrl } = await resolveBundleUrl({
19+
cwd: process.cwd(),
20+
repo,
21+
assetName,
22+
releaseEnv: 'PLAYER_RELEASE',
23+
distUrlEnv: 'PLAYER_DIST_URL',
24+
label: 'player',
25+
});
2126

2227
const targetDir = join(process.cwd(), 'public', 'player');
2328
const archivePath = join(tmpdir(), `player-dist-${Date.now()}.tgz`);

0 commit comments

Comments
 (0)