Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
36a5879
refactor(test): 集中正式 Profile 契约
z7ping Aug 29, 2026
0ec977d
refactor(test): 增加统一 Profile 契约校验入口
z7ping Aug 29, 2026
ce6ded9
refactor(test): 暴露统一 Profile 契约校验脚本
z7ping Aug 29, 2026
a11a31a
refactor(test): 下沉模式一 Profile 探针
z7ping Aug 29, 2026
f0f263b
refactor(test): 暴露模式一 Profile 探针脚本
z7ping Aug 29, 2026
b73680d
refactor(ci): 移除 Workflow 内架构硬编码
z7ping Aug 29, 2026
2ad8eba
refactor(release): 复用统一 Profile 装配契约
z7ping Aug 29, 2026
ac20a35
test(architecture): 防止 Profile 契约再次散落
z7ping Aug 29, 2026
c97c180
ci(release): Profile 契约变化必须触发发行预检
z7ping Aug 29, 2026
b0d2f2d
refactor(profile): 复用统一正式 Profile 契约
z7ping Aug 29, 2026
1427b15
test(contract): 保留 DSH 宿主组合配置门禁
z7ping Aug 29, 2026
315122b
test(contract): 覆盖 DSH Host Loader 契约
z7ping Aug 29, 2026
be748a3
refactor(contract): 从正式 Bundle patch 推导 Loader
z7ping Aug 29, 2026
2844350
refactor(contract): 等待正式 patch 契约校验
z7ping Aug 29, 2026
e4ab079
refactor(release): 等待正式 patch 契约校验
z7ping Aug 29, 2026
e1ceacd
test(contract): 以正式 Bundle patch 作为 Loader 真相源
z7ping Aug 29, 2026
1ce89c4
ci(release): 正式 Bundle patch 变化必须触发发行预检
z7ping Aug 29, 2026
247eb91
test(ci): 防止正式 patch 变化漏跑发行预检
z7ping Aug 29, 2026
aefc9a5
test(release): 断言发行契约而非局部实现变量
z7ping Aug 29, 2026
3bc6840
test(architecture): bootstrap 断言统一正式 Profile 契约
z7ping Aug 29, 2026
9e1f16b
test(architecture): production bootstrap 复用共享 Profile 契约
z7ping Aug 29, 2026
35e0ad7
ci(release): PR 阶段跳过完整 DSH Web 烟测
z7ping Aug 29, 2026
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
38 changes: 5 additions & 33 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,48 +128,20 @@ jobs:
- name: 按锁文件安装依赖
run: pnpm install --frozen-lockfile

- name: 创建并验证正式 Narratica Profile
- name: 创建正式 Narratica Profile
env:
DSH_HOME: ${{ runner.temp }}/narratica-dsh-home
run: pnpm run profile:bootstrap > "$RUNNER_TEMP/narratica-dump-config.txt"
run: pnpm run profile:bootstrap

- name: 确认 Profile 只有一个正式 Narratica Bundle
- name: 验证正式 Profile 装配契约
env:
DSH_HOME: ${{ runner.temp }}/narratica-dsh-home
run: |
node - <<'NODE'
const fs = require('node:fs')
const path = require('node:path')
const profile = JSON.parse(fs.readFileSync(path.join(process.env.DSH_HOME, 'profiles', 'narratica', 'package.json'), 'utf8'))
const bundles = profile.dsh?.profile?.bundles ?? []
const expected = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@narratica/narratica']
for (const name of expected) if (!bundles.includes(name)) throw new Error(`缺少 Bundle: ${name}`)
for (const name of ['@narratica/bundle-core', '@narratica/bundle-production', '@narratica/bundle-app']) if (bundles.includes(name)) throw new Error(`正式 Profile 不应包含旧 Bundle: ${name}`)
console.log(bundles.join(' -> '))
NODE

- name: 确认 DSH 宿主与 Narratica 插件同时进入组合配置
run: |
for plugin in ui-layout ui-sidebar ui-conversation ui-settings narratica-stories narratica-skill-pack narratica-providers narratica-media narratica-production narratica-story-tools narratica-client; do
grep -q -- "- id: $plugin" "$RUNNER_TEMP/narratica-dump-config.txt"
done
grep -q "name: '@narratica/narratica'" "$RUNNER_TEMP/narratica-dump-config.txt"
for legacy_client in narratica-client-runtime narratica-client-layout narratica-client-workspace narratica-client-story-library narratica-client-novel narratica-client-director; do
if grep -q -- "- id: $legacy_client" "$RUNNER_TEMP/narratica-dump-config.txt"; then
echo "正式 Profile 不应再包含旧 Client Loader row: $legacy_client"
exit 1
fi
done
run: pnpm run verify:profile-contract -- "$RUNNER_TEMP/narratica-dump-config.txt"

- name: 验证模式一 Story Repository 与 Skill Provider 探针
env:
DSH_HOME: ${{ runner.temp }}/narratica-dsh-home
run: |
pnpm run profile:mode1:dump > "$RUNNER_TEMP/narratica-mode1-dump-config.txt"
grep -q "tests/fixtures/story-repository" "$RUNNER_TEMP/narratica-mode1-dump-config.txt"
grep -q "narratica-skill-pack" "$RUNNER_TEMP/narratica-mode1-dump-config.txt"
grep -q "narratica-skill-filesystem" "$RUNNER_TEMP/narratica-mode1-dump-config.txt"
grep -q "providerName: narratica-novel" "$RUNNER_TEMP/narratica-mode1-dump-config.txt"
run: pnpm run verify:mode1-profile -- "$RUNNER_TEMP/narratica-mode1-dump-config.txt"

- name: 启动正式 DSH Web 并探测首页
env:
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/release-pack-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
pull_request:
paths:
- 'scripts/release/**'
- 'scripts/profile-contract.mjs'
- 'scripts/dsh-baseline.mjs'
- 'packages/bundle/narratica/cordis.patch.yml'
- 'packages/**/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
Expand All @@ -14,6 +17,9 @@ on:
- main
paths:
- 'scripts/release/**'
- 'scripts/profile-contract.mjs'
- 'scripts/dsh-baseline.mjs'
- 'packages/bundle/narratica/cordis.patch.yml'
- 'packages/**/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
Expand Down Expand Up @@ -74,5 +80,10 @@ jobs:
- name: npm publish dry-run
run: pnpm run release:publish:dry-run -- 0.0.0-alpha.0 alpha

- name: PR 阶段说明
if: github.event_name == 'pull_request'
run: echo 'PR 阶段已完成发行打包与 npm dry-run;完整 DSH Web 烟测在 main / 手动触发时执行。'

- name: 本地 tarball DSH Profile 与 Web 烟测
if: github.event_name != 'pull_request'
run: pnpm run release:smoke:local -- 0.0.0-alpha.0
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"build": "pnpm run verify:syntax && pnpm run build:host && pnpm run generate:typert && pnpm run build:client:types && pnpm run build:client:bundles",
"typecheck": "pnpm run verify:syntax && pnpm run build:host && pnpm run generate:typert && pnpm run build:client:types",
"verify:dsh-baseline": "node scripts/verify-dsh-baseline.mjs",
"verify:profile-contract": "node scripts/verify-profile-contract.mjs",
"verify:mode1-profile": "node scripts/verify-mode1-profile-probe.mjs",
"profile:bootstrap": "pnpm run build && node scripts/bootstrap-profile.mjs",
"prestart": "pnpm run build",
"start": "dsh --profile narratica --patch tests/probes/mode1-novel-workspace.patch.yml",
Expand Down
35 changes: 2 additions & 33 deletions scripts/bootstrap-profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'

import {
DSH_BASE_BUNDLE,
DSH_PROFILE,
DSH_VERSION,
DSH_WEB_BUNDLE,
NARRATICA_BUNDLE,
} from './dsh-baseline.mjs'
import { assertFormalProfileContract } from './profile-contract.mjs'

const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '..')
Expand Down Expand Up @@ -130,37 +129,7 @@ for (const [packageName] of localProfilePlainDependencies) {
}
}

const requiredBundles = [
DSH_BASE_BUNDLE,
DSH_WEB_BUNDLE,
NARRATICA_BUNDLE,
]

for (const required of requiredBundles) {
if (!bundles.includes(required)) {
throw new Error(`Narratica profile is missing required bundle: ${required}`)
}
}

const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/'))
if (narraticaBundles.length !== 1 || narraticaBundles[0] !== NARRATICA_BUNDLE) {
throw new Error(`Formal Profile must expose exactly one Narratica Bundle: ${narraticaBundles.join(' -> ')}`)
}

for (const legacyBundle of [
'@narratica/bundle-core',
'@narratica/bundle-production',
'@narratica/bundle-app',
]) {
if (bundles.includes(legacyBundle)) {
throw new Error(`Formal Narratica profile must not expose legacy bundle: ${legacyBundle}`)
}
}

const positions = requiredBundles.map(name => bundles.indexOf(name))
if (!positions.every((position, index) => index === 0 || positions[index - 1] < position)) {
throw new Error(`Unexpected Narratica bundle order: ${bundles.join(' -> ')}`)
}
assertFormalProfileContract(profilePackage)

run(pnpm, ['exec', 'dsh', '--profile', DSH_PROFILE, '--dump-config'])
console.log(`Narratica profile ready: ${profilePackagePath}`)
116 changes: 116 additions & 0 deletions scripts/profile-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

import {
DSH_BASE_BUNDLE,
DSH_WEB_BUNDLE,
NARRATICA_BUNDLE,
} from './dsh-baseline.mjs'

const scriptDir = dirname(fileURLToPath(import.meta.url))
const formalBundlePatchPath = resolve(scriptDir, '../packages/bundle/narratica/cordis.patch.yml')

export const REQUIRED_PROFILE_BUNDLES = Object.freeze([
DSH_BASE_BUNDLE,
DSH_WEB_BUNDLE,
NARRATICA_BUNDLE,
])

export const LEGACY_NARRATICA_BUNDLES = Object.freeze([
'@narratica/bundle-core',
'@narratica/bundle-production',
'@narratica/bundle-app',
])

export const REQUIRED_DSH_HOST_LOADER_IDS = Object.freeze([
'ui-layout',
'ui-sidebar',
'ui-conversation',
'ui-settings',
])

export const LEGACY_NARRATICA_CLIENT_LOADER_IDS = Object.freeze([
'narratica-client-runtime',
'narratica-client-layout',
'narratica-client-workspace',
'narratica-client-story-library',
'narratica-client-novel',
'narratica-client-director',
])

function loaderIdPattern(id) {
return new RegExp(`(?:^|\\n)\\s*-\\s*id:\\s*${id}(?:\\s|$)`, 'm')
}

export async function loadFormalNarraticaLoaderIds() {
const patch = await readFile(formalBundlePatchPath, 'utf8')
const ids = [...patch.matchAll(/^\s*-\s*id:\s*(narratica-[A-Za-z0-9-]+)\s*$/gm)].map(match => match[1])
const unique = [...new Set(ids)]

if (unique.length !== ids.length) throw new Error('正式 Bundle patch 存在重复 Narratica Loader ID')
if (!unique.includes('narratica-client')) throw new Error('正式 Bundle patch 缺少唯一 Narratica Client Loader')
for (const legacyId of LEGACY_NARRATICA_CLIENT_LOADER_IDS) {
if (unique.includes(legacyId)) throw new Error(`正式 Bundle patch 不应再声明旧 Client Loader:${legacyId}`)
}

return Object.freeze(unique)
}

export function assertFormalProfileContract(profile, { distribution = false } = {}) {
const dependencies = profile.dependencies ?? {}
const bundles = profile.dsh?.profile?.bundles ?? []

for (const required of REQUIRED_PROFILE_BUNDLES) {
if (!bundles.includes(required)) {
throw new Error(`正式 Profile 缺少 Bundle:${required}`)
}
}

const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/'))
if (narraticaBundles.length !== 1 || narraticaBundles[0] !== NARRATICA_BUNDLE) {
throw new Error(`正式 Profile 必须只有一个 Narratica Bundle:${narraticaBundles.join(' -> ') || '无'}`)
}

for (const legacyBundle of LEGACY_NARRATICA_BUNDLES) {
if (bundles.includes(legacyBundle)) {
throw new Error(`正式 Profile 不应包含旧 Bundle:${legacyBundle}`)
}
}

const positions = REQUIRED_PROFILE_BUNDLES.map(name => bundles.indexOf(name))
if (!positions.every((position, index) => index === 0 || positions[index - 1] < position)) {
throw new Error(`正式 Profile Bundle 顺序异常:${bundles.join(' -> ')}`)
}

if (distribution) {
const narraticaDirectDependencies = Object.keys(dependencies).filter(name => name.startsWith('@narratica/'))
if (narraticaDirectDependencies.length !== 1 || narraticaDirectDependencies[0] !== NARRATICA_BUNDLE) {
throw new Error(`发行 Profile 顶层只能依赖正式入口包:${narraticaDirectDependencies.join(' -> ') || '无'}`)
}
}
}

export async function assertComposedConfigContract(dump) {
const formalNarraticaLoaderIds = await loadFormalNarraticaLoaderIds()
for (const id of [...REQUIRED_DSH_HOST_LOADER_IDS, ...formalNarraticaLoaderIds]) {
if (!loaderIdPattern(id).test(dump)) {
throw new Error(`组合配置缺少正式 Loader:${id}`)
}
}

for (const id of LEGACY_NARRATICA_CLIENT_LOADER_IDS) {
if (loaderIdPattern(id).test(dump)) {
throw new Error(`组合配置不应再出现旧 Client Loader:${id}`)
}
}

if (!dump.includes(`name: '${NARRATICA_BUNDLE}'`)) {
throw new Error(`组合配置缺少正式 Narratica 入口:${NARRATICA_BUNDLE}`)
}
}

export async function assertNarraticaProfileContract({ profile, dump, distribution = false }) {
assertFormalProfileContract(profile, { distribution })
await assertComposedConfigContract(dump)
}
38 changes: 2 additions & 36 deletions scripts/release/smoke-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'

import { assertNarraticaProfileContract } from '../profile-contract.mjs'
import {
DSH_VERSION,
ENTRY_PACKAGE,
Expand Down Expand Up @@ -158,19 +159,6 @@ if (mode === 'local') {
const profile = JSON.parse(await readFile(profilePackagePath, 'utf8'))
const dependencies = profile.dependencies ?? {}
const bundles = profile.dsh?.profile?.bundles ?? []
const requiredBundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ENTRY_PACKAGE]
for (const name of requiredBundles) {
if (!bundles.includes(name)) throw new Error(`发行烟测 Profile 缺少 Bundle:${name}`)
}
const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/'))
if (narraticaBundles.length !== 1 || narraticaBundles[0] !== ENTRY_PACKAGE) {
throw new Error(`发行烟测必须只有一个 Narratica Bundle:${narraticaBundles.join(' -> ')}`)
}

const narraticaDirectDependencies = Object.keys(dependencies).filter(name => name.startsWith('@narratica/'))
if (narraticaDirectDependencies.length !== 1 || narraticaDirectDependencies[0] !== ENTRY_PACKAGE) {
throw new Error(`发行烟测 Profile 顶层只能依赖正式入口包:${narraticaDirectDependencies.join(' -> ') || '无'}`)
}

if (mode === 'local') {
const policy = await readFile(workspacePolicyPath, 'utf8')
Expand All @@ -187,29 +175,7 @@ if (mode === 'local') {
}

const dump = run(['exec', 'dsh', '--profile', 'narratica', '--dump-config'])
const expectedLoaderIds = [
'narratica-stories',
'narratica-skill-pack',
'narratica-providers',
'narratica-media',
'narratica-production',
'narratica-client',
]
const legacyClientLoaderIds = [
'narratica-client-runtime',
'narratica-client-layout',
'narratica-client-workspace',
'narratica-client-story-library',
'narratica-client-novel',
'narratica-client-director',
]
const loaderIdPattern = id => new RegExp(`(?:^|\\n)\\s*-\\s*id:\\s*${id}(?:\\s|$)`, 'm')
for (const id of expectedLoaderIds) {
if (!loaderIdPattern(id).test(dump)) throw new Error(`发行烟测组合配置缺少 Loader:${id}`)
}
for (const id of legacyClientLoaderIds) {
if (loaderIdPattern(id).test(dump)) throw new Error(`发行烟测不应再出现旧 Client Loader:${id}`)
}
await assertNarraticaProfileContract({ profile, dump, distribution: true })

const chunks = []
const child = spawn(pnpm, ['exec', 'dsh', '--profile', 'narratica', '--port', '3189', '--no-open'], {
Expand Down
31 changes: 31 additions & 0 deletions scripts/verify-mode1-profile-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { spawnSync } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'

const outputPath = process.argv[2] ? resolve(process.argv[2]) : null
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'

const result = spawnSync(pnpm, ['run', 'profile:mode1:dump'], {
encoding: 'utf8',
env: process.env,
shell: process.platform === 'win32',
})

if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`模式一 --dump-config 失败 (${result.status})\n${result.stdout ?? ''}\n${result.stderr ?? ''}`)
}

const dump = `${result.stdout ?? ''}${result.stderr ?? ''}`
const expectedEvidence = [
'tests/fixtures/story-repository',
'narratica-skill-filesystem',
'providerName: narratica-novel',
]

for (const evidence of expectedEvidence) {
if (!dump.includes(evidence)) throw new Error(`模式一 Profile 探针缺少证据:${evidence}`)
}

if (outputPath) await writeFile(outputPath, dump)
console.log('模式一 Story Repository 与 Skill Provider 探针通过')
31 changes: 31 additions & 0 deletions scripts/verify-profile-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { spawnSync } from 'node:child_process'
import { readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'

import { DSH_PROFILE } from './dsh-baseline.mjs'
import { assertNarraticaProfileContract } from './profile-contract.mjs'

const outputPath = process.argv[2] ? resolve(process.argv[2]) : null
const distribution = process.argv.includes('--distribution')
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
const profilePackagePath = join(dshHome, 'profiles', DSH_PROFILE, 'package.json')
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'

const result = spawnSync(pnpm, ['exec', 'dsh', '--profile', DSH_PROFILE, '--dump-config'], {
encoding: 'utf8',
env: process.env,
shell: process.platform === 'win32',
})

if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`DSH --dump-config 失败 (${result.status})\n${result.stdout ?? ''}\n${result.stderr ?? ''}`)
}

const dump = `${result.stdout ?? ''}${result.stderr ?? ''}`
const profile = JSON.parse(await readFile(profilePackagePath, 'utf8'))
await assertNarraticaProfileContract({ profile, dump, distribution })

if (outputPath) await writeFile(outputPath, dump)
console.log(`正式 Profile 契约通过:${profilePackagePath}`)
Loading
Loading