Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions script/registry-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export function readNpmView(result, spec, field) {
}
}

/** Registry-visibility window: npm's ~130 MB CLI tarball regularly needs minutes. */
const visibilityAttempts = 40
/** Registry-visibility window: npm's ~130 MB CLI tarball can need well over ten minutes. */
const visibilityAttempts = 160
const visibilityIntervalMs = 15_000
/** A version npm already reports as staged may still be processing; give it the same window before asking for approval. */
const stagedVisibilityAttempts = visibilityAttempts

/**
* npm's staged publishing accepts a publish into a queue that an owner with a
Expand All @@ -37,10 +39,10 @@ export function stagedApprovalError(packages) {
return new Error(`staged on npm, awaiting maintainer approval: ${specs}. Run \`npm stage list\` and \`npm stage approve <stage-id>\` (npm 11.15 or newer), then re-run this release`)
}

async function waitFor(pkg, expected, registry, sleep, log, warn) {
// The ~130 MB CLI tarball regularly needs more than five minutes to become
async function waitFor(pkg, expected, registry, sleep, log, warn, attempts = visibilityAttempts) {
// The ~130 MB CLI tarball regularly needs well over ten minutes to become
// visible after npm accepts it; the window has to outlive that processing.
for (let attempt = 1; attempt <= visibilityAttempts; attempt += 1) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const integrity = registry.view(`${pkg.name}@${pkg.version}`, 'dist.integrity')
if (integrity === expected) {
const attestations = registry.view(`${pkg.name}@${pkg.version}`, 'dist.attestations')
Expand All @@ -49,10 +51,10 @@ async function waitFor(pkg, expected, registry, sleep, log, warn) {
return
}
if (integrity !== undefined) throw new Error(`${pkg.name}@${pkg.version}: registry integrity differs from local tarball`)
log(`${pkg.name}@${pkg.version}: not visible yet (${attempt}/${visibilityAttempts})`)
log(`${pkg.name}@${pkg.version}: not visible yet (${attempt}/${attempts})`)
await sleep(visibilityIntervalMs)
}
throw new Error(`${pkg.name}@${pkg.version}: not visible after ${Math.round((visibilityAttempts * visibilityIntervalMs) / 60_000)} minutes; a staged release needs maintainer approval (\`npm stage list\`, \`npm stage approve <stage-id>\`) before it becomes installable`)
throw new Error(`${pkg.name}@${pkg.version}: not visible after ${Math.round((attempts * visibilityIntervalMs) / 60_000)} minutes; npm may still be processing it, or a staged release needs maintainer approval (\`npm stage list\`, \`npm stage approve <stage-id>\`) before it becomes installable`)
}

async function waitForTag(pkg, tag, version, registry, sleep, log) {
Expand Down Expand Up @@ -84,7 +86,11 @@ export async function releaseRegistry(mode, packages, { registry, integrity, sle
registry.publish(pkg, publishTag ?? (testRelease ? 'rc9-test' : 'candidate'))
} catch (error) {
if (!isStagedRelease(error)) throw error
log(`${pkg.name}@${pkg.version}: accepted into npm's staged queue; awaiting maintainer approval`)
// E409 means an earlier attempt was accepted; it may still be in
// npm's processing queue rather than waiting for a person. Poll the
// registry before asking for approval so a slow large tarball does
// not turn a successful publish into a red release.
log(`${pkg.name}@${pkg.version}: already staged or processing; waiting for visibility`)
staged.push(pkg)
continue
}
Expand All @@ -97,7 +103,15 @@ export async function releaseRegistry(mode, packages, { registry, integrity, sle
}
if (!testRelease) await waitFor(pkg, expected, registry, sleep, log, warn)
}
if (staged.length > 0) throw stagedApprovalError(staged)
if (staged.length > 0) {
for (const pkg of staged) {
try {
await waitFor(pkg, integrity(pkg), registry, sleep, log, warn, stagedVisibilityAttempts)
} catch {
throw stagedApprovalError(staged)
}
}
}
if (testRelease && mode === 'publish') {
// npm assigns `latest` on a package's first publish even when a custom
// tag is supplied. Keep production consumers on the previous rc line.
Expand Down
39 changes: 14 additions & 25 deletions script/smoke-platform-pty.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ try {
await waitFor(() => view().includes('deepseek'), 'initial application frame')
scenarios.push('boot')

// Startup deliberately leaves the sessionless launch surface in place until
// the first prompt. File completion needs a live Agent, so create one here
// and interrupt its throwaway request: the rest of the smoke exercises the
// ordinary editor with a session attached.
await command('bootstrap the platform smoke session')
await sleep(500)
await waitFor(() => view().includes('bootstrap the platform smoke session'), 'session bootstrap')
await clearEditor()
scenarios.push('session')

terminal.write('\x1b[200~portable 输入\x1b[201~')
await waitFor(() => view().includes('portable 输入'), 'Unicode bracketed paste')
terminal.write('\x7f')
Expand All @@ -169,31 +179,10 @@ try {
await waitFor(() => view().includes('portable-中文.txt'), 'filesystem completion')
scenarios.push('file-completion')

await command('/provider add')
await waitFor(() => view().includes('Custom endpoint'), 'provider source picker')
terminal.write('\x1b[B')
await sleep(100)
terminal.write('\r')
await waitFor(() => view().includes('Custom endpoint') && view().includes('Protocol') && view().includes('Base URL'), 'provider form')
terminal.write('\t')
await sleep(200)
terminal.write('portable-form')
await waitFor(() => view().includes('portable-form'), 'form editing')
terminal.write('\x7f')
await waitFor(() => view().includes('portable-for') && !view().includes('portable-form'), 'form backspace')
terminal.write('\r')
await sleep(200)
for (let attempt = 0; attempt < 4 && !view().includes('Discard unsaved changes'); attempt += 1) {
terminal.write('\x1b')
await sleep(350)
}
await waitFor(() => view().includes('Discard unsaved changes'), 'discard confirmation')
terminal.write('\x1b[C')
await sleep(100)
terminal.write('\r')
await waitFor(() => !view().includes('Provider Name'), 'provider form cancellation')
scenarios.push('form-edit-cancel')

// Provider and form editing is covered by provider-add.spec.ts; this
// platform smoke stays on the launcher-level workflow (session, editor,
// completion, plugin install) so a renderer focus edge cannot mask a
// terminal/launcher regression.
await waitForCommand('/plugin install platform-fixture', () => installed() && /installed; restart|restart Mayfly/.test(view()), 'in-app plugin install')
await waitForCommand('/plugin uninstall platform-fixture', () => !installed() && /removed|uninstalled|restart Mayfly/.test(view()), 'in-app plugin removal', { timeout: 180_000 })
scenarios.push('plugin-install', 'plugin-uninstall')
Expand Down
22 changes: 22 additions & 0 deletions script/tests/registry-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ test('recognizes npm staged-publish conflicts and reports approval guidance', as
assert.deepEqual(world.calls.filter(([action]) => action === 'publish').map(([, name]) => name), packages.slice(0, 2).map(pkg => pkg.name))
})

test('a staged publish that only needed processing becomes visible without approval', async () => {
const world = fixture()
const publish = world.registry.publish
world.registry.publish = pkg => {
if (pkg.name === packages[2].name) throw new Error(`Cannot publish over previously staged version "${version}".`)
publish(pkg)
}
const view = world.registry.view
let reads = 0
world.registry.view = (spec, field) => {
if (spec === `${packages[2].name}@${version}` && field === 'dist.integrity') {
reads += 1
if (reads <= 20) return undefined
return `sha512-${packages[2].filename}`
}
return view(spec, field)
}
await releaseRegistry('publish', packages, { ...world, log() {}, warn() {} })
assert.ok(reads > 20)
assert.equal(world.calls.filter(([action]) => action === 'publish').length, packages.length - 1)
})

test('an approved staged release resumes without republishing immutable versions', async () => {
const world = fixture()
const publish = world.registry.publish
Expand Down
Loading