Skip to content

Commit c53f440

Browse files
committed
feat(helm): derive chart appVersion from the release instead of gating on it
appVersion is what the chart's image tags default to, so a stale one publishes a chart that installs an older Sim than the release it ships with, and images.yaml -- the list an operator mirrors into a disconnected registry -- names the wrong tags with it. Published chart versions are immutable, so each stale value is frozen the moment it ships. It was bumped by hand, drifted forty releases, and drifted twice more after a check started catching it. That is the tell: the check could refuse to publish but could not supply the value, so the only thing it reliably produced was a red build on every release and a chart that never shipped. Only 1.11.0 was ever published for exactly this reason. The publish jobs now derive it. On a release merge the tag does not exist yet -- this commit is what cuts it -- so the version comes from the commit subject, and from the latest release for every other push. Both publish paths run the same script before packaging, so the OCI artifact and the HTTP repo cannot disagree. Removes the post-merge check, which is now unreachable by construction, and does not replace it with the pre-merge title gate that was considered: that one guarded ground truth with a heuristic, and its failure mode was passing silently. Precedent is cert-manager, whose chart also lives in its application repo and which injects the version at package time. The projects that commit the value and bump by hand -- argo-cd, ingress-nginx, prometheus-community -- all keep the chart in a separate repository, where a human is already editing Chart.yaml as the unit of change. We are the former shape. Verified the resolution across six subjects: release commit, multi-line body carrying a decoy version, ordinary push, leading whitespace, shell metacharacters (no expansion), and a version that is not at the start. Also verified the script's exit codes directly rather than through a pipe, since a gate that cannot fail is the thing being replaced here. Sets appVersion to v0.8.26, two releases ahead of where it was stuck, and regenerates images.yaml with it.
1 parent b5757ad commit c53f440

4 files changed

Lines changed: 186 additions & 51 deletions

File tree

.github/workflows/helm.yml

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,44 @@ jobs:
232232
username: ${{ github.repository_owner }}
233233
password: ${{ secrets.GITHUB_TOKEN }}
234234

235+
- name: Setup Bun
236+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
237+
with:
238+
bun-version: 1.4.1
239+
240+
# The release this chart ships with. On a release merge the tag does not
241+
# exist yet -- it is cut by this very commit (detect-version in ci.yml) --
242+
# so the subject is the only source available, and the source of truth for
243+
# every other push is the latest release.
244+
- name: Resolve the app release
245+
id: release
246+
env:
247+
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
248+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
249+
run: |
250+
set -euo pipefail
251+
subject=${HEAD_COMMIT_MESSAGE%%$'\n'*}
252+
if [[ "$subject" =~ ^[[:space:]]*(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
253+
resolved="${BASH_REMATCH[1]}"
254+
echo "Release commit; shipping the chart with ${resolved}."
255+
else
256+
resolved=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)
257+
echo "Not a release commit; shipping the chart with the latest release ${resolved}."
258+
fi
259+
if [ -z "$resolved" ]; then
260+
echo "::error::Could not resolve an app release to ship this chart with."
261+
exit 1
262+
fi
263+
echo "version=${resolved}" >> "$GITHUB_OUTPUT"
264+
265+
# Derives appVersion rather than checking it, so the published chart cannot
266+
# be pinned to an older Sim than the release it ships with. The committed
267+
# value is kept current too, but nothing depends on a human remembering.
268+
- name: Sync chart appVersion to the release
269+
env:
270+
APP_VERSION: ${{ steps.release.outputs.version }}
271+
run: bun run scripts/sync-chart-appversion.ts --version "${APP_VERSION}"
272+
235273
- name: Package chart
236274
id: package
237275
run: |
@@ -247,44 +285,6 @@ jobs:
247285
echo "repository=ghcr.io/${GITHUB_REPOSITORY_OWNER}/charts/${name}"
248286
} >> "$GITHUB_OUTPUT"
249287
250-
# `appVersion` is what the image tags default to, so a stale one publishes
251-
# a chart that silently installs an old Sim -- and because published chart
252-
# versions are immutable, every stale value is frozen forever. It sat six
253-
# releases behind before this check existed, bumped only by hand.
254-
#
255-
# BEHIND is the failure. AHEAD is normal and must not be blocked: a
256-
# version tag is cut by the main-branch merge commit that releases it
257-
# (detect-version in ci.yml), so appVersion legitimately names a release
258-
# that does not exist yet while that release is still being built. Failing
259-
# on any mismatch would race that workflow and block the very publish the
260-
# bump was for. `helm/sim/ci/kind-overlay.yaml` documents the same
261-
# circularity, and it is why appVersion went unbumped for so long.
262-
#
263-
# Compares against the latest GitHub release rather than a hardcoded value
264-
# so the check cannot go stale itself. Prereleases and drafts are excluded:
265-
# the `/releases/latest` endpoint already returns neither.
266-
- name: appVersion does not lag the app release
267-
env:
268-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
269-
run: |
270-
set -euo pipefail
271-
app_version=$(helm show chart helm/sim | awk '/^appVersion:/ {print $2}' | tr -d '"')
272-
latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)
273-
if [ -z "$latest" ]; then
274-
echo "::error::Could not resolve the latest release; refusing to publish unverified."
275-
exit 1
276-
fi
277-
if [ "$app_version" = "$latest" ]; then
278-
echo "appVersion ${app_version} matches the latest release."
279-
exit 0
280-
fi
281-
oldest=$(printf '%s\n%s\n' "$app_version" "$latest" | sort -V | head -1)
282-
if [ "$oldest" = "$app_version" ]; then
283-
echo "::error::Chart.yaml appVersion is ${app_version} but the latest release is ${latest}. Bump appVersion (and the chart version) so the chart does not publish an install pinned to an older Sim."
284-
exit 1
285-
fi
286-
echo "::notice::appVersion ${app_version} is ahead of the latest release ${latest}, which is expected while that release is still being cut."
287-
288288
# Chart versions are immutable once published: whoever pinned a version
289289
# must keep resolving the same bytes forever. The PR gate above already
290290
# forces a version bump on every chart change, so a version that is
@@ -452,6 +452,46 @@ jobs:
452452
echo "::warning::No gh-pages branch, so the HTTP chart repo was not updated. Create it and point GitHub Pages at it to activate this job. The OCI publish is unaffected."
453453
fi
454454
455+
- name: Setup Bun
456+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
457+
with:
458+
bun-version: 1.4.1
459+
460+
# The release this chart ships with. On a release merge the tag does not
461+
# exist yet -- it is cut by this very commit (detect-version in ci.yml) --
462+
# so the subject is the only source available, and the source of truth for
463+
# every other push is the latest release.
464+
- name: Resolve the app release
465+
if: steps.pages.outputs.exists == 'true'
466+
id: release
467+
env:
468+
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
469+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
470+
run: |
471+
set -euo pipefail
472+
subject=${HEAD_COMMIT_MESSAGE%%$'\n'*}
473+
if [[ "$subject" =~ ^[[:space:]]*(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
474+
resolved="${BASH_REMATCH[1]}"
475+
echo "Release commit; shipping the chart with ${resolved}."
476+
else
477+
resolved=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)
478+
echo "Not a release commit; shipping the chart with the latest release ${resolved}."
479+
fi
480+
if [ -z "$resolved" ]; then
481+
echo "::error::Could not resolve an app release to ship this chart with."
482+
exit 1
483+
fi
484+
echo "version=${resolved}" >> "$GITHUB_OUTPUT"
485+
486+
# Derives appVersion rather than checking it, so the published chart cannot
487+
# be pinned to an older Sim than the release it ships with. The committed
488+
# value is kept current too, but nothing depends on a human remembering.
489+
- name: Sync chart appVersion to the release
490+
if: steps.pages.outputs.exists == 'true'
491+
env:
492+
APP_VERSION: ${{ steps.release.outputs.version }}
493+
run: bun run scripts/sync-chart-appversion.ts --version "${APP_VERSION}"
494+
455495
- name: Configure Git
456496
if: steps.pages.outputs.exists == 'true'
457497
env:

helm/sim/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: sim
33
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
44
type: application
5-
version: 1.11.1
6-
appVersion: "v0.8.24"
5+
version: 1.11.2
6+
appVersion: "v0.8.26"
77
kubeVersion: ">=1.25.0-0"
88
home: https://sim.ai
99
icon: https://raw.githubusercontent.com/simstudioai/sim/main/apps/sim/public/logo/primary/primary.svg

helm/sim/images.yaml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,22 @@
2222
# render it twice. That override also CHANGES where the chart pulls from, to
2323
# `<your-registry>/nvidia/k8s-device-plugin` — mirror the device plugin there
2424
# instead of to the `mirror` path listed below, or the pull fails.
25-
appVersion: v0.8.24
25+
appVersion: v0.8.26
2626
images:
2727
- source: busybox:1.36
2828
mirror: busybox:1.36
2929
- source: curlimages/curl:8.5.0
3030
mirror: curlimages/curl:8.5.0
31-
- source: ghcr.io/simstudioai/copilot:v0.8.24
32-
mirror: simstudioai/copilot:v0.8.24
33-
- source: ghcr.io/simstudioai/migrations:v0.8.24
34-
mirror: simstudioai/migrations:v0.8.24
35-
- source: ghcr.io/simstudioai/pii:v0.8.24
36-
mirror: simstudioai/pii:v0.8.24
37-
- source: ghcr.io/simstudioai/realtime:v0.8.24
38-
mirror: simstudioai/realtime:v0.8.24
39-
- source: ghcr.io/simstudioai/simstudio:v0.8.24
40-
mirror: simstudioai/simstudio:v0.8.24
31+
- source: ghcr.io/simstudioai/copilot:v0.8.26
32+
mirror: simstudioai/copilot:v0.8.26
33+
- source: ghcr.io/simstudioai/migrations:v0.8.26
34+
mirror: simstudioai/migrations:v0.8.26
35+
- source: ghcr.io/simstudioai/pii:v0.8.26
36+
mirror: simstudioai/pii:v0.8.26
37+
- source: ghcr.io/simstudioai/realtime:v0.8.26
38+
mirror: simstudioai/realtime:v0.8.26
39+
- source: ghcr.io/simstudioai/simstudio:v0.8.26
40+
mirror: simstudioai/simstudio:v0.8.26
4141
- source: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
4242
mirror: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
4343
- source: ollama/ollama:0.23.2

scripts/sync-chart-appversion.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Writes the application release a chart ships with into `helm/sim/Chart.yaml`
4+
* and regenerates the image inventory that derives from it.
5+
*
6+
* `appVersion` is what the chart's image tags default to, so a stale one
7+
* publishes a chart that silently installs an older Sim, and `helm/sim/images.yaml`
8+
* — the list an operator mirrors into a disconnected registry — names the wrong
9+
* tags with it. Because published chart versions are immutable, a stale value is
10+
* frozen the moment it ships.
11+
*
12+
* It was bumped by hand and drifted for forty releases, then twice more after a
13+
* check started catching it. The check could only refuse to publish; it could not
14+
* supply the value. The publish jobs run this instead, so the number is derived
15+
* from the release rather than remembered.
16+
*
17+
* Only node builtins are imported so this runs on a CI job with no dependencies
18+
* installed, matching `generate-image-manifest.ts`.
19+
*
20+
* @example
21+
* ```
22+
* bun run scripts/sync-chart-appversion.ts --version v0.8.26
23+
* bun run scripts/sync-chart-appversion.ts --version v0.8.26 --check
24+
* ```
25+
*/
26+
import { spawnSync } from 'node:child_process'
27+
import { readFile, writeFile } from 'node:fs/promises'
28+
import { dirname, resolve } from 'node:path'
29+
import { fileURLToPath } from 'node:url'
30+
31+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
32+
const CHART_PATH = resolve(ROOT, 'helm/sim/Chart.yaml')
33+
34+
/** A release tag as this repository cuts them: `v1.2.3`. */
35+
const RELEASE_TAG = /^v\d+\.\d+\.\d+$/
36+
37+
/** Matches the top-level `appVersion:` key, quoted or bare. */
38+
const APP_VERSION_LINE = /^appVersion:.*$/m
39+
40+
function parseArgs(argv: string[]): { version: string; check: boolean } {
41+
const versionIndex = argv.indexOf('--version')
42+
const version = versionIndex === -1 ? '' : (argv[versionIndex + 1] ?? '')
43+
if (!RELEASE_TAG.test(version)) {
44+
throw new Error(
45+
`--version must be a release tag like v1.2.3, got ${version ? `"${version}"` : '<missing>'}`
46+
)
47+
}
48+
return { version, check: argv.includes('--check') }
49+
}
50+
51+
async function main() {
52+
const { version, check } = parseArgs(process.argv.slice(2))
53+
54+
const chart = await readFile(CHART_PATH, 'utf8')
55+
if (!APP_VERSION_LINE.test(chart)) {
56+
throw new Error(`No top-level appVersion key in ${CHART_PATH}`)
57+
}
58+
59+
const next = chart.replace(APP_VERSION_LINE, `appVersion: "${version}"`)
60+
const changed = next !== chart
61+
62+
if (check) {
63+
if (changed) {
64+
console.error(
65+
`helm/sim/Chart.yaml appVersion does not match ${version}. Run:\n` +
66+
` bun run scripts/sync-chart-appversion.ts --version ${version}`
67+
)
68+
process.exit(1)
69+
}
70+
console.log(`appVersion is already ${version}.`)
71+
return
72+
}
73+
74+
if (changed) {
75+
await writeFile(CHART_PATH, next)
76+
console.log(`Set appVersion to ${version}.`)
77+
} else {
78+
console.log(`appVersion was already ${version}.`)
79+
}
80+
81+
/**
82+
* The inventory embeds appVersion in every first-party image tag, so it has to
83+
* follow. Spawned rather than imported because the generator writes on import
84+
* of its own main and owns its formatting.
85+
*/
86+
const generated = spawnSync('bun', ['run', 'scripts/generate-image-manifest.ts'], {
87+
cwd: ROOT,
88+
stdio: 'inherit',
89+
})
90+
if (generated.status !== 0) {
91+
throw new Error(`generate-image-manifest.ts exited with ${generated.status}`)
92+
}
93+
}
94+
95+
if (import.meta.main) await main()

0 commit comments

Comments
 (0)