Skip to content

Commit 252f5d4

Browse files
committed
fix(sync): skip a dsh release whose dependency closure is unpublished
Nightly sync selected 0.1.5-rc.3, then failed because dsh-web-app requires a sidebar package version that npm never published. Stay on the pinned release until that closure resolves.
1 parent c96a6f1 commit 252f5d4

5 files changed

Lines changed: 159 additions & 21 deletions

File tree

‎.changeset/dsh-sync-skip-broken.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'codsh-cli': patch
3+
'codsh-bundle': patch
4+
---
5+
6+
`pnpm run sync:dsh` stays on the newest `@deepseek-ai/dsh` release whose dependency closure is fully published, so a harness release that names a missing package no longer fails the nightly sync.

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ what the surface emitted and what the terminal did with it, and the emitted
4848
half is gone by the time anyone looks; replaying the file through a terminal
4949
emulator reproduces the screen it drew. Off unless the variable is set.
5050

51-
`pnpm run sync:dsh` tracks published `@deepseek-ai/dsh-*` releases. This repo never forks the harness.
51+
`pnpm run sync:dsh` tracks published `@deepseek-ai/dsh-*` releases and skips a release whose `@deepseek-ai/*` dependency closure is not fully published. This repo never forks the harness.
5252

5353
`pnpm run build` also bundles the React Flow Web panorama from `ship-web-app.tsx`
5454
with `scripts/build-ship-web.mjs`. Its JavaScript and CSS are published under

‎scripts/dsh-release-policy.mjs‎

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
1-
import { compare, maxSatisfying, valid, validRange } from 'semver'
1+
import { compare, maxSatisfying, minVersion, valid, validRange } from 'semver'
22

33
/** Compare two valid SemVer release strings. */
44
export const compareVersions = compare
55

6+
/**
7+
* npm abbreviated metadata for one package.
8+
* @typedef {object} RegistryMetadata
9+
* @property {Record<string, { dependencies?: Record<string, string> }>} versions
10+
* @property {Record<string, string>} dist-tags
11+
*/
12+
613
/**
714
* Pick the harness target from registry metadata.
815
*
916
* The promoted `latest` tag is an explicit opt-in to a new release line. An
1017
* untagged version is eligible only when a lockfile-free install of the current
1118
* range could resolve it; this keeps catching same-core RC drift without
1219
* silently opting into an unrelated alpha line.
13-
* @param {{ versions: Record<string, unknown>, 'dist-tags': Record<string, string> }} metadata - npm registry metadata.
20+
*
21+
* A newer candidate is installable only when every required `@deepseek-ai/*`
22+
* range in its dependency closure has a published version. The harness has
23+
* shipped a meta-package whose dependency was never published; selecting that
24+
* version makes the sync install fail before CI can open a PR. A broken newer
25+
* release leaves the manifest's current version in place: ranges such as
26+
* `^0.1.5-rc.2` can float onto that broken release, while the committed
27+
* lockfile still installs.
28+
* @param {RegistryMetadata} metadata - npm registry metadata for `@deepseek-ai/dsh`.
1429
* @param {string} currentRange - current @deepseek-ai/dsh manifest range.
15-
* @returns {string} selected harness version.
30+
* @param {(name: string) => Promise<RegistryMetadata>} [loadMetadata] - registry lookup used while walking the closure. Required when a candidate might pull in packages other than `@deepseek-ai/dsh`.
31+
* @returns {Promise<string>} selected harness version.
1632
*/
17-
export function selectDshTarget(metadata, currentRange) {
33+
export async function selectDshTarget(metadata, currentRange, loadMetadata) {
1834
const range = validRange(currentRange)
1935
if (range === null) throw new Error(`invalid @deepseek-ai/dsh range: ${currentRange}`)
2036

@@ -26,5 +42,57 @@ export function selectDshTarget(metadata, currentRange) {
2642
}
2743

2844
const installable = maxSatisfying(versions, range)
29-
return installable !== null && compare(latest, installable) < 0 ? installable : latest
45+
const candidate = installable !== null && compare(latest, installable) < 0 ? installable : latest
46+
if (await closureResolves(metadata, candidate, loadMetadata)) return candidate
47+
48+
const pinned = minVersion(range)?.version
49+
if (pinned && versions.includes(pinned) && compare(pinned, candidate) < 0) return pinned
50+
throw new Error(`no installable @deepseek-ai/dsh release at or below ${candidate}`)
51+
}
52+
53+
/**
54+
* @param {RegistryMetadata} rootMetadata
55+
* @param {string} version
56+
* @param {(name: string) => Promise<RegistryMetadata>} [loadMetadata]
57+
*/
58+
async function closureResolves(rootMetadata, version, loadMetadata) {
59+
/** @type {Map<string, RegistryMetadata>} */
60+
const cache = new Map([['@deepseek-ai/dsh', rootMetadata]])
61+
/** @type {Set<string>} */
62+
const seen = new Set()
63+
/** @type {Array<[string, string]>} */
64+
const pending = [['@deepseek-ai/dsh', version]]
65+
66+
while (pending.length > 0) {
67+
const [name, wanted] = pending.pop()
68+
const key = `${name}@${wanted}`
69+
if (seen.has(key)) continue
70+
seen.add(key)
71+
72+
const metadata = cache.get(name) ?? await load(name, loadMetadata)
73+
cache.set(name, metadata)
74+
const published = Object.keys(metadata.versions)
75+
const resolved = maxSatisfying(published, wanted)
76+
if (resolved === null) return false
77+
78+
const manifest = metadata.versions[resolved]
79+
for (const [dependency, dependencyRange] of Object.entries(manifest.dependencies ?? {})) {
80+
if (!dependency.startsWith('@deepseek-ai/')) continue
81+
if (validRange(dependencyRange) === null) return false
82+
pending.push([dependency, dependencyRange])
83+
}
84+
}
85+
return true
86+
}
87+
88+
/**
89+
* @param {string} name
90+
* @param {(name: string) => Promise<RegistryMetadata>} [loadMetadata]
91+
* @returns {Promise<RegistryMetadata>}
92+
*/
93+
function load(name, loadMetadata) {
94+
if (loadMetadata === undefined) {
95+
throw new Error(`registry lookup required to resolve ${name}`)
96+
}
97+
return loadMetadata(name)
3098
}
Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,112 @@
11
import { describe, expect, it } from 'vitest'
22
import { selectDshTarget } from './dsh-release-policy.mjs'
33

4-
const metadata = (versions, latest) => ({
5-
versions: Object.fromEntries(versions.map(version => [version, {}])),
4+
const metadata = (versions, latest, dependencies = {}) => ({
5+
versions: Object.fromEntries(versions.map(version => [version, {
6+
dependencies: dependencies[version] ?? {},
7+
}])),
68
'dist-tags': { latest },
79
})
810

11+
const registryOf = (entries) => async (name) => {
12+
const found = entries[name]
13+
if (found === undefined) throw new Error(`unexpected registry lookup: ${name}`)
14+
return found
15+
}
16+
917
describe('selectDshTarget', () => {
10-
it('ignores an unpromoted prerelease from the next release line', () => {
18+
it('ignores an unpromoted prerelease from the next release line', async () => {
1119
const registry = metadata(['0.1.1-rc.2', '0.1.2-alpha.2'], '0.1.1-rc.2')
1220

13-
expect(selectDshTarget(registry, '^0.1.1-rc.2')).toBe('0.1.1-rc.2')
21+
await expect(selectDshTarget(registry, '^0.1.1-rc.2')).resolves.toBe('0.1.1-rc.2')
1422
})
1523

16-
it('keeps tracking an untagged prerelease on the current core version', () => {
24+
it('keeps tracking an untagged prerelease on the current core version', async () => {
1725
const registry = metadata(['0.1.0-rc.7', '0.1.0-rc.8'], '0.1.0-rc.7')
1826

19-
expect(selectDshTarget(registry, '^0.1.0-rc.7')).toBe('0.1.0-rc.8')
27+
await expect(selectDshTarget(registry, '^0.1.0-rc.7')).resolves.toBe('0.1.0-rc.8')
2028
})
2129

22-
it('follows latest when it explicitly promotes a new release line', () => {
30+
it('follows latest when it explicitly promotes a new release line', async () => {
2331
const registry = metadata(
2432
['0.1.1-rc.2', '0.1.2-alpha.2', '0.2.0', '0.3.0-alpha.1'],
2533
'0.2.0',
2634
)
2735

28-
expect(selectDshTarget(registry, '^0.1.1-rc.2')).toBe('0.2.0')
36+
await expect(selectDshTarget(registry, '^0.1.1-rc.2')).resolves.toBe('0.2.0')
2937
})
3038

31-
it('orders numeric prerelease identifiers with SemVer rules', () => {
39+
it('orders numeric prerelease identifiers with SemVer rules', async () => {
3240
const registry = metadata(['0.1.1-alpha.2', '0.1.1-alpha.10'], '0.1.1-alpha.2')
3341

34-
expect(selectDshTarget(registry, '^0.1.1-alpha.2')).toBe('0.1.1-alpha.10')
42+
await expect(selectDshTarget(registry, '^0.1.1-alpha.2')).resolves.toBe('0.1.1-alpha.10')
3543
})
3644

37-
it('rejects an invalid current range', () => {
45+
it('rejects an invalid current range', async () => {
3846
const registry = metadata(['0.1.1-rc.2'], '0.1.1-rc.2')
3947

40-
expect(() => selectDshTarget(registry, 'not-a-range')).toThrow(/range/i)
48+
await expect(selectDshTarget(registry, 'not-a-range')).rejects.toThrow(/range/i)
4149
})
4250

43-
it('rejects an invalid latest tag', () => {
51+
it('rejects an invalid latest tag', async () => {
4452
const registry = metadata(['0.1.1-rc.2'], 'not-a-version')
4553

46-
expect(() => selectDshTarget(registry, '^0.1.1-rc.2')).toThrow(/latest/i)
54+
await expect(selectDshTarget(registry, '^0.1.1-rc.2')).rejects.toThrow(/latest/i)
55+
})
56+
57+
it('keeps the current pin when a newer candidate names an unpublished package', async () => {
58+
const dsh = metadata(
59+
['0.1.5-rc.2', '0.1.5-rc.3'],
60+
'0.1.5-rc.2',
61+
{
62+
'0.1.5-rc.2': { '@deepseek-ai/dsh-web-app': '^0.1.5-rc.2' },
63+
'0.1.5-rc.3': { '@deepseek-ai/dsh-web-app': '^0.1.5-rc.3' },
64+
},
65+
)
66+
const webApp = metadata(
67+
['0.1.5-rc.2', '0.1.5-rc.3'],
68+
'0.1.5-rc.2',
69+
{
70+
'0.1.5-rc.2': { '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': '^0.1.5-rc.2' },
71+
'0.1.5-rc.3': { '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': '^0.1.5-rc.3' },
72+
},
73+
)
74+
const preview = metadata(['0.1.5-rc.2'], '0.1.5-rc.2')
75+
const loadMetadata = registryOf({
76+
'@deepseek-ai/dsh-web-app': webApp,
77+
'@deepseek-ai/dsh-client-ui-sidebar-documentpreview': preview,
78+
})
79+
80+
await expect(selectDshTarget(dsh, '^0.1.5-rc.2', loadMetadata)).resolves.toBe('0.1.5-rc.2')
81+
})
82+
83+
it('accepts a candidate once every required dependency version is published', async () => {
84+
const dsh = metadata(
85+
['0.1.5-rc.2', '0.1.5-rc.3'],
86+
'0.1.5-rc.2',
87+
{ '0.1.5-rc.3': { '@deepseek-ai/dsh-web-app': '^0.1.5-rc.3' } },
88+
)
89+
const webApp = metadata(
90+
['0.1.5-rc.3'],
91+
'0.1.5-rc.3',
92+
{ '0.1.5-rc.3': { 'js-yaml': '^4.2.0' } },
93+
)
94+
95+
await expect(selectDshTarget(dsh, '^0.1.5-rc.2', registryOf({
96+
'@deepseek-ai/dsh-web-app': webApp,
97+
}))).resolves.toBe('0.1.5-rc.3')
98+
})
99+
100+
it('fails when the only published release has an unresolvable dependency closure', async () => {
101+
const dsh = metadata(
102+
['0.1.5-rc.3'],
103+
'0.1.5-rc.3',
104+
{ '0.1.5-rc.3': { '@deepseek-ai/dsh-web-app': '^0.1.5-rc.3' } },
105+
)
106+
const webApp = metadata(['0.1.5-rc.2'], '0.1.5-rc.2')
107+
108+
await expect(selectDshTarget(dsh, '^0.1.5-rc.3', registryOf({
109+
'@deepseek-ai/dsh-web-app': webApp,
110+
}))).rejects.toThrow(/no installable/)
47111
})
48112
})

‎scripts/sync-dsh.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ if (dshRange === undefined) throw new Error('no @deepseek-ai/dsh range found in
9696
// also track the highest version a lockfile-free install would resolve: the
9797
// harness has published same-core RCs without moving its tags. An unrelated
9898
// alpha tag is neither signal and must not silently move codsh onto that line.
99-
const dshLatest = selectDshTarget(dshMeta, dshRange)
99+
const dshLatest = await selectDshTarget(dshMeta, dshRange, registry)
100100
if (dshLatest !== dshMeta['dist-tags'].latest) {
101101
console.log(`note: @deepseek-ai/dsh publishes ${dshLatest}, but its latest tag still reads ${dshMeta['dist-tags'].latest}\n`)
102102
}

0 commit comments

Comments
 (0)