Skip to content

Commit 722af13

Browse files
committed
fix(ci): retain the latest published desktop prereleases
1 parent 1ebbc96 commit 722af13

2 files changed

Lines changed: 200 additions & 4 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ jobs:
103103
echo "ℹ️ No comparable base commit; skipping desktop prerelease"
104104
exit 0
105105
fi
106-
if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then
106+
if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/|\.github/workflows/(ci|desktop-release)\.yml$)'; then
107107
echo "changed=true" >> "$GITHUB_OUTPUT"
108108
echo "✅ Desktop shell code changed"
109109
else
@@ -1140,24 +1140,30 @@ jobs:
11401140
name: Prune Desktop Prereleases
11411141
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
11421142
timeout-minutes: 5
1143-
needs: [publish-desktop-prerelease]
1143+
needs: [create-desktop-prerelease, publish-desktop-prerelease]
11441144
permissions:
11451145
contents: read
11461146
env:
11471147
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
11481148
GH_REPO: simstudioai/sim-desktop-releases
1149+
CURRENT_TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
11491150
steps:
11501151
- name: Delete stale prereleases
11511152
run: |
1153+
set -euo pipefail
1154+
: "${CURRENT_TAG:?Current publication tag is required before pruning}"
11521155
if [ -z "$GH_TOKEN" ]; then
11531156
echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases."
11541157
exit 1
11551158
fi
11561159
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
1157-
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
1158-
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
1160+
# createdAt follows the tag's commit: published releases in the release-only
1161+
# repository can all share it. Retain by publication time, with deterministic ties.
1162+
gh release list --limit 100 --json tagName,isPrerelease,isDraft,publishedAt \
1163+
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.publishedAt, .tagName) | reverse | .[5:] | .[].tagName" |
11591164
while read -r TAG; do
11601165
[ -n "$TAG" ] || continue
1166+
[ "$TAG" != "$CURRENT_TAG" ] || continue
11611167
echo "Deleting stale prerelease $TAG"
11621168
gh release delete "$TAG" --cleanup-tag --yes
11631169
done
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { describe, expect, it } from 'vitest'
6+
7+
const workflow = readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8')
8+
const job = workflow.split(' prune-desktop-prereleases:')[1]
9+
const step = job?.match(/^ {8}run: \|\r?\n((?: {10}.*(?:\r?\n|$)|\r?\n)+)/m)?.[1]
10+
if (!step) throw new Error('Desktop pruning shell step is missing')
11+
const script = step.replace(/^ {10}/gm, '')
12+
const detector = workflow.split(' - name: Diff desktop paths')[1]?.split('\n migrate:')[0]
13+
const detectorStep = detector?.match(/^ {8}run: \|\r?\n((?: {10}.*(?:\r?\n|$)|\r?\n)+)/m)?.[1]
14+
if (!detectorStep) throw new Error('Desktop change detection shell step is missing')
15+
const detectorScript = detectorStep.replace(/^ {10}/gm, '')
16+
17+
function release(tagName: string, publishedAt: string, isDraft = false, isPrerelease = true) {
18+
return {
19+
tagName,
20+
publishedAt,
21+
createdAt: '2026-08-13T04:57:48Z',
22+
isDraft,
23+
isPrerelease,
24+
}
25+
}
26+
27+
function prune(
28+
releases: ReturnType<typeof release>[],
29+
currentTag = 'v1-dev.6',
30+
ref = 'refs/heads/dev',
31+
listExit = '0'
32+
) {
33+
return spawnSync(
34+
'bash',
35+
[
36+
'-e',
37+
'-c',
38+
`
39+
gh() {
40+
if [ "$1 $2" = 'release list' ]; then
41+
[ "$LIST_EXIT" = 0 ] || return "$LIST_EXIT"
42+
while [ "$1" != '--jq' ]; do shift; done
43+
printf '%s' "$RELEASES" | jq -r "$2"
44+
elif [ "$1 $2" = 'release delete' ]; then
45+
printf 'DELETE %s\n' "$3"
46+
else
47+
return 99
48+
fi
49+
}
50+
${script}
51+
`,
52+
],
53+
{
54+
encoding: 'utf8',
55+
env: {
56+
PATH: process.env.PATH,
57+
GH_TOKEN: 'test-placeholder',
58+
GITHUB_REF: ref,
59+
CURRENT_TAG: currentTag,
60+
RELEASES: JSON.stringify(releases),
61+
LIST_EXIT: listExit,
62+
},
63+
}
64+
)
65+
}
66+
67+
const devReleases = Array.from({ length: 6 }, (_, index) =>
68+
release(`v1-dev.${6 - index}`, `2026-09-${String(16 - index).padStart(2, '0')}T00:00:00Z`)
69+
)
70+
71+
describe('desktop release retention shell', () => {
72+
it('keeps the newest five publications when every tag points to the same commit', () => {
73+
const result = prune(devReleases)
74+
expect(result.status).toBe(0)
75+
expect(result.stdout).toContain('DELETE v1-dev.1')
76+
expect(result.stdout).not.toContain('DELETE v1-dev.6')
77+
expect(result.stdout.match(/^DELETE /gm)).toHaveLength(1)
78+
})
79+
80+
it('never deletes this run’s publication even when older than the five retained releases', () => {
81+
const result = prune(devReleases, 'v1-dev.1')
82+
expect(result.status).toBe(0)
83+
expect(result.stdout).not.toContain('DELETE ')
84+
})
85+
86+
it('retains all releases below the channel limit and ignores drafts and stable releases', () => {
87+
const result = prune([
88+
...devReleases.slice(0, 5),
89+
release('v1-dev.0', '2026-01-01T00:00:00Z', true),
90+
release('v1-dev.stable', '2026-01-01T00:00:00Z', false, false),
91+
release('v1-staging.0', '2026-01-01T00:00:00Z'),
92+
])
93+
expect(result.status).toBe(0)
94+
expect(result.stdout).not.toContain('DELETE ')
95+
})
96+
97+
it('breaks equal publication timestamps deterministically regardless of API order', () => {
98+
const tied = devReleases.map((entry) => ({ ...entry, publishedAt: '2026-09-15T00:00:00Z' }))
99+
const first = prune(tied)
100+
const reversed = prune([...tied].reverse())
101+
expect(first.status).toBe(0)
102+
expect(reversed.status).toBe(0)
103+
expect(first.stdout).toBe(reversed.stdout)
104+
expect(first.stdout).toContain('DELETE v1-dev.1')
105+
})
106+
107+
it('counts legacy beta releases with staging without deleting dev releases', () => {
108+
const result = prune(
109+
[
110+
...devReleases.map((entry) => ({
111+
...entry,
112+
tagName: entry.tagName.replace('dev', 'staging'),
113+
})),
114+
release('v1-beta.0', '2026-01-01T00:00:00Z'),
115+
release('v1-dev.0', '2025-01-01T00:00:00Z'),
116+
],
117+
'v1-staging.6',
118+
'refs/heads/staging'
119+
)
120+
expect(result.status).toBe(0)
121+
expect(result.stdout).toContain('DELETE v1-staging.1')
122+
expect(result.stdout).toContain('DELETE v1-beta.0')
123+
expect(result.stdout).not.toContain('DELETE v1-dev.0')
124+
})
125+
126+
it('fails when listing fails instead of reporting successful cleanup', () => {
127+
const result = prune(devReleases, 'v1-dev.6', 'refs/heads/dev', '42')
128+
expect(result.status).toBe(42)
129+
expect(result.stdout).not.toContain('DELETE ')
130+
})
131+
132+
it('wires the publication tag from the creation job', () => {
133+
expect(job).toContain('needs: [create-desktop-prerelease, publish-desktop-prerelease]')
134+
expect(job).toContain(`CURRENT_TAG: \${{ needs.create-desktop-prerelease.outputs.version }}`)
135+
})
136+
137+
it('refuses to prune without the current publication tag', () => {
138+
const result = prune(devReleases, '')
139+
expect(result.status).not.toBe(0)
140+
expect(result.stdout).not.toContain('DELETE ')
141+
})
142+
})
143+
144+
describe('desktop release change detection shell', () => {
145+
it.each([
146+
['.github/workflows/ci.yml', true],
147+
['.github/workflows/desktop-release.yml', true],
148+
['apps/desktop/src/main/index.ts', true],
149+
['packages/desktop-bridge/src/index.ts', true],
150+
['packages/browser-protocol/src/index.ts', true],
151+
['.github/workflows/migrations.yml', false],
152+
['.github/workflows/ci.yml.backup', false],
153+
['apps/sim/app/page.tsx', false],
154+
])('classifies %s as desktop change=%s', (path, changed) => {
155+
const directory = mkdtempSync(join(tmpdir(), 'desktop-detect-'))
156+
const output = join(directory, 'outputs')
157+
try {
158+
const result = spawnSync(
159+
'bash',
160+
[
161+
'-e',
162+
'-c',
163+
`
164+
git() {
165+
case "$1" in
166+
cat-file) return 0 ;;
167+
diff) printf '%s\n' "$CHANGED_PATH" ;;
168+
*) return 99 ;;
169+
esac
170+
}
171+
${detectorScript}
172+
`,
173+
],
174+
{
175+
encoding: 'utf8',
176+
env: {
177+
PATH: process.env.PATH,
178+
BEFORE: 'previous-commit',
179+
GITHUB_OUTPUT: output,
180+
CHANGED_PATH: path,
181+
},
182+
}
183+
)
184+
expect(result.status).toBe(0)
185+
expect(readFileSync(output, 'utf8')).toBe(`changed=${changed}\n`)
186+
} finally {
187+
rmSync(directory, { recursive: true, force: true })
188+
}
189+
})
190+
})

0 commit comments

Comments
 (0)