mirror of
https://github.com/dsh-market/dsh-market.git
synced 2026-09-28 05:03:07 +08:00
From pnpm 12.3.0, the native CLI, --config.minimumReleaseAge=0 is silently ignored on the command line, so the one-shot bypass in withHoistRecovery ran without the bypass and the retry failed exactly like the first attempt. The .npmrc spelling, --config.minimum-release-age=0, is honoured by pnpm 10, 11 and 12 alike, so use that. The compat matrix gains pnpm 12 and pins both the recovery and the ignored camelCase form. This is specific to that key: --config.fetch-timeout is ignored by the native CLI in both spellings, so FETCH_TIMEOUT_OVERRIDE is left alone here and tracked in #615.
This commit is contained in:
@@ -63,7 +63,7 @@ jobs:
|
||||
- run: node scripts/validate-registry.mjs
|
||||
- run: node scripts/smoke-spawn.mjs
|
||||
|
||||
# Real-pnpm matrix (9/10/11 via npx) pinning the failure signatures behind
|
||||
# Real-pnpm matrix (9/10/11/12 via npx) pinning the failure signatures behind
|
||||
# #20/#21/#22 and the recovery paths the market automates. Network-bound and
|
||||
# slower, so it runs as its own lane on Linux only.
|
||||
pnpm-compat:
|
||||
|
||||
+13
-2
@@ -13,8 +13,19 @@ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry
|
||||
import { logEvent } from './log.ts'
|
||||
import { cleanOrphanedStore } from './store.ts'
|
||||
|
||||
/** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
|
||||
export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
|
||||
/**
|
||||
* One-shot bypass for pnpm's fresh-release hold; scoped to a single command.
|
||||
*
|
||||
* Spelled like the .npmrc key, not the camelCase pnpm-workspace.yaml one:
|
||||
* from pnpm 12.3.0 (the native CLI) `--config.minimumReleaseAge=0` is
|
||||
* silently ignored — no unknown-option error — so the retry ran without the
|
||||
* bypass and failed exactly like the first attempt (#600).
|
||||
* `--config.minimum-release-age=0` is honoured by pnpm 10, 11 and 12 alike.
|
||||
* That is specific to this key, not a rule for `--config.*`: the native CLI
|
||||
* ignores `--config.fetch-timeout` in both spellings, which is why
|
||||
* FETCH_TIMEOUT_OVERRIDE below is not respelled here (#615).
|
||||
*/
|
||||
export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0'
|
||||
|
||||
/**
|
||||
* Longer per-request fetch timeout for one retried command. pnpm's default
|
||||
|
||||
+1
-1
@@ -308,7 +308,7 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
||||
// before ANY later mutation — uninstalling even an unrelated plugin fails
|
||||
// (MINIMUM_RELEASE_AGE_VIOLATION), and a later add can fail re-resolving
|
||||
// the young dep (NO_MATURE_MATCHING_VERSION). Recovery is a one-shot
|
||||
// --config.minimumReleaseAge=0 retry, automated in withHoistRecovery.
|
||||
// --config.minimum-release-age=0 retry, automated in withHoistRecovery.
|
||||
if (output.includes('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
|
||||
|| output.includes('ERR_PNPM_NO_MATURE_MATCHING_VERSION')) {
|
||||
return {
|
||||
|
||||
+1
-1
@@ -3017,7 +3017,7 @@ sendJson(response, 200, { updates })
|
||||
// releases are daily. The second is an error the market already
|
||||
// recovers from: classifyPnpmFailure reads it as
|
||||
// release-age-violation and withHoistRecovery retries once with
|
||||
// --config.minimumReleaseAge=0 (#39).
|
||||
// --config.minimum-release-age=0 (#39).
|
||||
//
|
||||
// So a version resolved BEFORE the add is not only about the
|
||||
// Desktop boundary; it is what turns a silent skip into a
|
||||
|
||||
+11
-9
@@ -40,8 +40,10 @@ const fake = vi.hoisted(() => ({
|
||||
hoistDiffTimes: 0,
|
||||
/** Simulate a too-young release in the lockfile (#39): every mutation
|
||||
* fails pnpm's supply-chain verification unless the one-shot
|
||||
* --config.minimumReleaseAge=0 override is passed (real pnpm 11 behavior
|
||||
* pinned in tests/pnpm-behavior.compat.spec.ts). */
|
||||
* --config.minimum-release-age=0 override is passed — the spelling every
|
||||
* pnpm major honours; the native CLI from 12.3.0 ignores the camelCase
|
||||
* one (#600). Real pnpm behavior is pinned in
|
||||
* tests/pnpm-behavior.compat.spec.ts. */
|
||||
youngLockfile: false,
|
||||
/** When set, every command awaits this before acting (concurrency tests). */
|
||||
gate: null as Promise<void> | null,
|
||||
@@ -188,7 +190,7 @@ vi.mock('../src/dsh-cli.ts', () => {
|
||||
const positional = args.filter(a => !a.startsWith('-'))
|
||||
const cmd = positional[0]
|
||||
const ok = { exitCode: 0, timedOut: false, stdout: '', stderr: '', cancelled: false }
|
||||
if (fake.youngLockfile && !args.includes('--config.minimumReleaseAge=0')) {
|
||||
if (fake.youngLockfile && !args.includes('--config.minimum-release-age=0')) {
|
||||
return {
|
||||
exitCode: 1, timedOut: false, stdout: '', cancelled: false,
|
||||
stderr: '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:\n dsh-loop@1.0.0 was published at 2026-08-15T00:00:00.000Z, within the minimumReleaseAge cutoff',
|
||||
@@ -2115,7 +2117,7 @@ describe('update flow — no npm publishing required', () => {
|
||||
expect(existsSync(lockfilePath)).toBe(false)
|
||||
expect(fake.calls.slice(callsBefore).filter(call => call[0] === 'add')).toEqual([
|
||||
['add', 'dsh-loop@1.3.0'],
|
||||
['add', '--force', '--config.minimumReleaseAge=0', 'dsh-loop@1.0.0'],
|
||||
['add', '--force', '--config.minimum-release-age=0', 'dsh-loop@1.0.0'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -2318,7 +2320,7 @@ describe('update flow — no npm publishing required', () => {
|
||||
expect(rollback.json.rolledBack).toBe(true)
|
||||
const rollbackAdds = fake.calls.slice(callsBeforeRollback).filter(call => call[0] === 'add')
|
||||
expect(rollbackAdds).toEqual([
|
||||
['add', '--force', '--config.minimumReleaseAge=0', 'dsh-loop@1.0.0'],
|
||||
['add', '--force', '--config.minimum-release-age=0', 'dsh-loop@1.0.0'],
|
||||
])
|
||||
expect(installedSpec('dsh-loop')).toBe('~1.0.0')
|
||||
const manifest = JSON.parse(readFileSync(join(fake.profileDir, 'node_modules', 'dsh-loop', 'package.json'), 'utf8')) as { version?: string }
|
||||
@@ -2360,7 +2362,7 @@ describe('update flow — no npm publishing required', () => {
|
||||
expect(String(rollback.json.detail)).toContain('exact rollback failed')
|
||||
const rollbackAdds = fake.calls.slice(callsBeforeRollback).filter(call => call[0] === 'add')
|
||||
expect(rollbackAdds).toEqual([
|
||||
['add', '--force', '--config.minimumReleaseAge=0', 'dsh-loop@1.0.0'],
|
||||
['add', '--force', '--config.minimum-release-age=0', 'dsh-loop@1.0.0'],
|
||||
])
|
||||
expect(installedSpec('dsh-loop')).toBe('~1.0.0')
|
||||
})
|
||||
@@ -2776,7 +2778,7 @@ describe('update flow — no npm publishing required', () => {
|
||||
expect(forced.status).toBe(200)
|
||||
expect(installedSpec('dsh-loop')).toBe('^1.2.0')
|
||||
const lastAdd = fake.calls[fake.calls.length - 1]
|
||||
expect(lastAdd).toContain('--config.minimumReleaseAge=0')
|
||||
expect(lastAdd).toContain('--config.minimum-release-age=0')
|
||||
})
|
||||
|
||||
it('restores the previous build when an update fails after pnpm wrote new files (#65 follow-up)', async () => {
|
||||
@@ -2827,7 +2829,7 @@ describe('update flow — no npm publishing required', () => {
|
||||
expect(readFileSync(entry, 'utf8')).toBe('verified-old-bytes')
|
||||
const rollbackAdds = fake.calls.slice(callsBefore).filter(call => call[0] === 'add')
|
||||
expect(rollbackAdds.at(-1)).toEqual([
|
||||
'add', '--force', '--config.minimumReleaseAge=0', 'dsh-loop@1.0.0',
|
||||
'add', '--force', '--config.minimum-release-age=0', 'dsh-loop@1.0.0',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -3339,7 +3341,7 @@ describe('uninstall flow', () => {
|
||||
expect(r.json.ok).toBe(true)
|
||||
expect(installedSpec('dsh-loop')).toBeUndefined()
|
||||
const removes = fake.calls.filter(c => c[0] === 'remove')
|
||||
expect(removes[removes.length - 1]).toContain('--config.minimumReleaseAge=0')
|
||||
expect(removes[removes.length - 1]).toContain('--config.minimum-release-age=0')
|
||||
})
|
||||
|
||||
it('reconciles the manifest when a remove fails halfway (half-uninstall)', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Real-pnpm compat matrix (`npm run test:compat`): pins the failure
|
||||
* signatures behind issues #20/#21/#22 against actual pnpm 9/10/11 in
|
||||
* signatures behind issues #20/#21/#22 against actual pnpm 9/10/11/12 in
|
||||
* throwaway profile fixtures, and proves the market's argv decision works on
|
||||
* every combination. Needs network; several minutes on a cold npx cache.
|
||||
*
|
||||
@@ -14,10 +14,11 @@ import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { RELEASE_AGE_OVERRIDE } from '../src/install.ts'
|
||||
import { classifyPnpmFailure, pluginArgsFor } from '../src/pnpm-compat.ts'
|
||||
|
||||
/** Last release of each major the market supports; behavior is per-major. */
|
||||
const PNPM = { 9: '9.15.9', 10: '10.28.2', 11: '11.21.0' } as const
|
||||
const PNPM = { 9: '9.15.9', 10: '10.28.2', 11: '11.21.0', 12: '12.4.1' } as const
|
||||
/** Version pinned by the DSH Desktop 2.0.3 distribution reported in #385. */
|
||||
const DESKTOP_PNPM = '11.8.0'
|
||||
const GIT_FIXTURE_SHA = '6ebf1e03de0ada9e653d1f8ff82ad905ab761ad9'
|
||||
@@ -273,7 +274,7 @@ describe('#39 — a too-young lockfile entry blocks every later mutation', () =>
|
||||
it('remove fails ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION on pnpm 11; the one-shot override recovers', () => {
|
||||
const dir = profileFixture({ workspace: true, extraWorkspaceYaml: `minimumReleaseAge: ${String(ageWindowMinutes())}\n` })
|
||||
// A young release lands in the lockfile via the bypass (force-update path).
|
||||
const seed = pnpm(PNPM[11], ['add', '-w', '--config.minimumReleaseAge=0', 'is-odd@3.0.1'], dir)
|
||||
const seed = pnpm(PNPM[11], ['add', '-w', RELEASE_AGE_OVERRIDE, 'is-odd@3.0.1'], dir)
|
||||
expect(seed.code, seed.out.slice(-400)).toBe(0)
|
||||
|
||||
// pnpm verifies the WHOLE lockfile before applying the mutation — even
|
||||
@@ -284,16 +285,43 @@ describe('#39 — a too-young lockfile entry blocks every later mutation', () =>
|
||||
expect(classifyPnpmFailure(blocked.out)?.code).toBe('release-age-violation')
|
||||
|
||||
// The recovery the market automates: same command + the one-shot override.
|
||||
const recovered = pnpm(PNPM[11], ['remove', '-w', '--config.minimumReleaseAge=0', 'is-odd'], dir)
|
||||
const recovered = pnpm(PNPM[11], ['remove', '-w', RELEASE_AGE_OVERRIDE, 'is-odd'], dir)
|
||||
expect(recovered.code, recovered.out.slice(-400)).toBe(0)
|
||||
expect(installedVersion(dir, 'is-odd')).toBeNull()
|
||||
})
|
||||
|
||||
it('add fails the same way on pnpm 12, and only the kebab-case override recovers (#600)', () => {
|
||||
const dir = profileFixture({ workspace: true, extraWorkspaceYaml: `minimumReleaseAge: ${String(ageWindowMinutes())}\n` })
|
||||
const seed = pnpm(PNPM[12], ['add', '-w', RELEASE_AGE_OVERRIDE, 'is-odd@3.0.1'], dir)
|
||||
expect(seed.code, seed.out.slice(-400)).toBe(0)
|
||||
|
||||
// pnpm 12 re-applies the policy to the loaded lockfile before adding
|
||||
// anything: a mature, unrelated package is refused because of the young
|
||||
// one already there. Removing the young package itself passes on 12 (the
|
||||
// lockfile it leaves behind is clean), so `add` is what pins the trap.
|
||||
const blocked = pnpm(PNPM[12], ['add', '-w', 'is-even@1.0.0'], dir)
|
||||
expect(blocked.code).not.toBe(0)
|
||||
expect(blocked.out).toContain('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
|
||||
expect(classifyPnpmFailure(blocked.out)?.code).toBe('release-age-violation')
|
||||
|
||||
// What the market passed before #600. pnpm 11 accepted it; the native
|
||||
// CLI from 12.3.0 ignores it without an "unknown option" error, so the
|
||||
// retry failed exactly like the first attempt.
|
||||
const ignored = pnpm(PNPM[12], ['add', '-w', '--config.minimumReleaseAge=0', 'is-even@1.0.0'], dir)
|
||||
expect(ignored.code).not.toBe(0)
|
||||
expect(ignored.out).toContain('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
|
||||
expect(installedVersion(dir, 'is-even')).toBeNull()
|
||||
|
||||
const recovered = pnpm(PNPM[12], ['add', '-w', RELEASE_AGE_OVERRIDE, 'is-even@1.0.0'], dir)
|
||||
expect(recovered.code, recovered.out.slice(-400)).toBe(0)
|
||||
expect(installedVersion(dir, 'is-even')).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('the override flag is harmless on pnpm 9/10 remove', () => {
|
||||
for (const version of [PNPM[9], PNPM[10]]) {
|
||||
const dir = profileFixture({ workspace: true })
|
||||
expect(pnpm(version, ['add', '-w', 'is-odd@3.0.0'], dir).code, `pnpm ${version} add`).toBe(0)
|
||||
const removed = pnpm(version, ['remove', '-w', '--config.minimumReleaseAge=0', 'is-odd'], dir)
|
||||
const removed = pnpm(version, ['remove', '-w', RELEASE_AGE_OVERRIDE, 'is-odd'], dir)
|
||||
expect(removed.code, `pnpm ${version}: ${removed.out.slice(-300)}`).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Compat lane: exercises REAL pnpm 9/10/11 (via npx) against throwaway
|
||||
// Compat lane: exercises REAL pnpm 9/10/11/12 (via npx) against throwaway
|
||||
// profile fixtures — network access and several minutes of runtime. This is
|
||||
// where the failure signatures behind #20/#21/#22 are pinned.
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user