Merge pull request #3649 from deepseek-harness/fix/packed-install-private-npm-cache

fix(release): isolate packed-install npm cache
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-10-05 09:23:14 +08:00
commit 534acc5952
8 files changed
+169 -6

No files matched your search

@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-06-packed-install-private-npm-cache.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.md
/agent-note-packed-install-verification-owns-its-npm-cache:
en: 54d54ee850476cb9
zh: 8d666e40eb2372f0
/agent-note-packed-install-verification-owns-its-npm-cache/problem:
en: 5cd500387242a7cc
zh: 75de2c0049631634
/agent-note-packed-install-verification-owns-its-npm-cache/decision:
en: 8a9ead906e1148d6
zh: 6a5b911fc5849be2
/agent-note-packed-install-verification-owns-its-npm-cache/alternatives-considered:
en: b2637475da22cec0
zh: 5c0e3979caf44461
/agent-note-packed-install-verification-owns-its-npm-cache/consequences:
en: a123744738fac02f
zh: 54bda8e161cb6e85
/agent-note-packed-install-verification-owns-its-npm-cache/verification:
en: e37107020148c23e
zh: 94227a591fad9f17
@@ -0,0 +1,29 @@
# Agent Note: Packed-install verification owns its npm cache
Status: implemented
English | [中文](2026-09-06-packed-install-private-npm-cache.zh.md)
## Problem
The [packed-install verifier](../../../../scripts/release/verify-packed-install.ts) removes its temporary consumer but an npm install using the ambient cache leaves tarball content and logs outside that cleanup. Repeated release rehearsals can therefore grow the shared runner user cache even when every consumer is removed. A full root filesystem can fail unrelated CI jobs.
## Decision
The verifier passes `--cache <consumerRoot>/.npm-cache` only to its npm install invocation. The explicit argument overrides npm environment and user-configuration cache settings without changing the environment of the installed executable. The existing `finally` removes this cache together with the consumer after success, an install failure, or an executable failure.
The [release sequence decision](../process/2026-08-10-npm-release-sequences.md) still owns artifact selection and publication. This decision only adds cache ownership to the verifier; it does not change other npm consumers.
## Alternatives considered
- **Delete the shared user cache:** unrelated jobs also own its contents. The verifier must not delete shared host state.
- **Change global `TMPDIR` or npm configuration:** that affects processes outside this invocation and is unnecessary when the consumer already has an owned temporary directory.
- **Keep a persistent verifier cache:** that retains cross-run downloads but requires a separate retention policy. Invocation ownership accepts repeated downloads instead.
## Consequences
This prevents this verifier from adding content to the ambient npm cache; it neither reclaims existing host cache contents nor guarantees that disk exhaustion from other producers stops. External dependencies can require fresh downloads on each invocation. A forced termination that prevents `finally` from running can leave the consumer and its cache behind; this change adds no crash-recovery cleanup.
## Verification
[The focused regression](../../../../scripts/release/verify-packed-install.spec.ts) runs the real verifier and npm offline against a tiny local tarball. A lifecycle script records actual private `_cacache` files before cleanup. The tests verify consumer removal after success, npm install failure, and installed-entry failure, while inherited environment and user-config cache canaries remain unchanged. Removing the cache argument makes the private-cache assertion fail. The executable test follows the POSIX release runner path; it does not exercise Windows command-shim resolution.
@@ -0,0 +1,29 @@
# Agent Note: 打包安装验证拥有独立 npm 缓存
Status: implemented
[English](2026-09-06-packed-install-private-npm-cache.md) | 中文
## 问题
[打包安装验证器](../../../../scripts/release/verify-packed-install.ts) 会删除临时消费目录,但使用环境配置缓存的 npm install 会把 tarball 内容和日志留在清理范围之外。因此,即使每个消费目录均被删除,重复的发布演练仍可能扩大共享 runner 用户缓存。根文件系统满载会使无关 CI 作业失败。
## 决策
验证器仅向自己的 npm install 调用传入 `--cache <consumerRoot>/.npm-cache`。显式参数覆盖 npm 环境变量和用户配置中的缓存设置,不改变已安装可执行文件的环境。现有 `finally` 在成功、安装失败或可执行文件失败后将此缓存与消费目录一起删除。
[发布序列决策](../process/2026-08-10-npm-release-sequences.zh.md) 仍负责产物选择与发布。本决策仅为验证器增加缓存所有权,不改变其他 npm 消费方。
## 曾考虑的替代方案
- **删除共享用户缓存:**其他作业也拥有其中的内容。验证器不得删除共享宿主状态。
- **修改全局 `TMPDIR` 或 npm 配置:**这会影响本次调用之外的进程,而消费目录已有独立临时目录,因此没有必要。
- **保留持久化验证缓存:**这能跨运行复用下载,但需要独立保留策略。调用级所有权选择接受重复下载。
## 后果
这会阻止该验证器向环境配置的 npm 缓存添加内容;既不会回收现有宿主缓存,也不保证其他来源导致的磁盘耗尽停止。外部依赖可能需要在每次调用时重新下载。若强制终止使 `finally` 无法运行,消费目录及其缓存仍可能残留;本改动不增加崩溃恢复清理。
## 验证
[定向回归测试](../../../../scripts/release/verify-packed-install.spec.ts) 使用微型本地 tarball 离线运行真实验证器和 npm。生命周期脚本在清理前记录私有 `_cacache` 中的实际文件。测试验证成功、npm 安装失败和已安装入口失败后消费目录均被删除,同时继承环境和用户配置指定缓存中的探针文件保持不变。移除缓存参数会使私有缓存断言失败。可执行测试沿用 POSIX 发布 runner 路径,不覆盖 Windows 命令垫片解析。
@@ -33,8 +33,8 @@
en: d83f6cf38529c5b5
zh: 4df5659b2154d3b7
/agent-note-private-npm-publication-as-three-independent-sequences/decision/release-family-objects:
en: e8bb80d936e2cbbd
zh: bc4ae297e98a5446
en: 5bc6a83d124b682d
zh: 17383ec5c3c7e0e5
/agent-note-private-npm-publication-as-three-independent-sequences/decision/workflow-shape-pack-on-pr-push-publish-from-a-manual-dispatch-workflow:
en: ea0acc6409da4d5c
zh: ac0bd2fe41f4fe67
@@ -113,7 +113,7 @@ The entity in this domain is a **release family**: a set of packages sharing one
| `publishOrder` | topological order over the sections npm installs plus peer declarations, ties broken by package name; a cycle among installed dependencies is reported rather than resolved arbitrarily, and a peer edge no order can honour is dropped and named |
| `pack` | packs a whole family into one directory and records the upload order |
| `verify` | the family's version baseline, the publish order it prints in full, and — when publishing — that the run comes from that family's tag and its members are publishable |
| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable |
| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer with an [owned npm cache](../bug-fix/2026-09-06-packed-install-private-npm-cache.md) and drives the installed executable |
| `publish` | the three registry states above |
| `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable |
@@ -113,7 +113,7 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
| `publishOrder` | 按 npm 会安装的依赖段加 peer 声明做拓扑序,同层按包名排;安装依赖成环是报错而不是随意定序,任何排不进去的 peer 边被丢弃并点名 |
| `pack` | 把整族打进一个目录并记录上传顺序 |
| `verify` | 族的版本基线、完整打印出来的发布顺序;发布时还要求本次运行来自该族的 tag、且成员可发布 |
| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 |
| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进带[独立 npm 缓存](../bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md)的一次性 consumer,并驱动已安装的可执行入口 |
| `publish` | 上面那三态 |
| `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import |
@@ -0,0 +1,82 @@
/** Packed-install npm cache ownership through the real offline verifier entry. */
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../..')
const roots: string[] = []
afterEach(() => {
for (const directory of roots.splice(0)) rmSync(directory, { recursive: true, force: true })
})
// The release verifier invokes POSIX npm directly, not the Windows npm.cmd shim.
describe.skipIf(process.platform === 'win32')('packed-install npm cache', () => {
// The verifier starts npm, lifecycle scripts, and the installed entry serially.
it.each(['success', 'install failure', 'entry failure'] as const)(
'owns cache writes and removes them after %s',
(outcome) => {
const directory = mkdtempSync(join(tmpdir(), 'dsh-packed-cache-test-'))
roots.push(directory)
const source = join(directory, 'package')
const packed = join(directory, 'packed')
const ambient = join(directory, 'ambient-cache')
const configured = join(directory, 'configured-cache')
const temporary = join(directory, 'temporary')
for (const path of [join(source, 'lib'), packed, ambient, configured, temporary]) mkdirSync(path, { recursive: true })
writeFileSync(join(ambient, 'sentinel'), 'ambient cache must survive\n')
writeFileSync(join(configured, 'sentinel'), 'configured cache must survive\n')
const npmrc = join(directory, 'npmrc')
writeFileSync(npmrc, `cache=${configured}\n`)
const evidence = join(directory, 'evidence.json')
const probe = `
const fs = require('node:fs')
const path = require('node:path')
const consumer = process.env.INIT_CWD || process.cwd()
const cache = path.join(consumer, '.npm-cache', '_cacache')
const files = fs.existsSync(cache) ? fs.readdirSync(cache, { recursive: true }).filter(name => fs.statSync(path.join(cache, name)).isFile()) : []
fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files }))
`
writeFileSync(join(source, 'probe.cjs'), probe + (outcome === 'install failure' ? '\nprocess.exit(37)\n' : ''))
writeFileSync(join(source, 'lib/bin.js'), outcome === 'entry failure' ? 'process.exit(37)\n' : 'console.log("0.0.1")\n')
writeFileSync(join(source, 'package.json'), JSON.stringify({
name: '@deepseek-ai/dsh',
version: '0.0.1',
scripts: { postinstall: 'node probe.cjs' },
}))
const archive = spawnSync('tar', ['-czf', join(packed, 'dsh.tgz'), '-C', directory, 'package'], { encoding: 'utf8', timeout: 30_000 })
expect(archive.error).toBeUndefined()
expect(archive.signal).toBeNull()
expect(archive.status, archive.stderr).toBe(0)
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key)))
Object.assign(environment, {
npm_config_cache: ambient,
npm_config_userconfig: npmrc,
npm_config_offline: 'true',
npm_config_ignore_scripts: 'false',
CACHE_EVIDENCE: evidence,
TMPDIR: temporary,
TMP: temporary,
TEMP: temporary,
})
const result = spawnSync(process.execPath, [
'--import', 'tsx', 'scripts/release/verify-packed-install.ts', '--family', 'dsh', '--from', packed,
], { cwd: root, env: environment, encoding: 'utf8', timeout: 120_000 })
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stdout + result.stderr).toBe(outcome === 'success' ? 0 : 1)
const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[] }
expect(observation.files.length, 'npm must write tarball content into the consumer-private cache').toBeGreaterThan(0)
expect(observation.consumer).toContain('dsh-packed-dsh-')
expect(existsSync(observation.consumer), 'the verifier finally must remove its consumer and cache').toBe(false)
expect(readdirSync(ambient)).toEqual(['sentinel'])
expect(readFileSync(join(ambient, 'sentinel'), 'utf8')).toBe('ambient cache must survive\n')
expect(readdirSync(configured)).toEqual(['sentinel'])
expect(readFileSync(join(configured, 'sentinel'), 'utf8')).toBe('configured cache must survive\n')
},
180_000,
)
})
+3 -2
View File
@@ -1,6 +1,7 @@
/**
* Install packed tarballs into a throwaway consumer outside the repository and
* drive the installed executable with plain Node.
* drive the installed executable with plain Node. Its npm cache belongs to that
* consumer and is removed with it on normal completion or failure.
*
* Every tarball the installed tree needs comes from `--from`, so the only
* registry traffic is for external dependencies. That matters beyond hermetic
@@ -105,7 +106,7 @@ function main(): void {
// that cannot install them must still start — which is what optional means
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
// its tarball is supplied through --from.
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional', '--loglevel=http'],
capture('npm', ['install', '--cache', join(consumerRoot, '.npm-cache'), '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional', '--loglevel=http'],
{ cwd: consumerRoot, env: environment })
const installedEntry = join(consumerRoot, 'node_modules', entry.packageName)