fix: address Python release review feedback
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-11-python-publication-workflow.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-11-python-publication-workflow.md
|
||||||
2026-08-11-python-publication-workflow.md: ee83f4729bf88749cd89ac69c55de62ea52ebf7d
|
2026-08-11-python-publication-workflow.md: 870db08e1d59ad7840fa9acf822915f83ecbd31b
|
||||||
2026-08-11-python-publication-workflow.zh.md: 4f75a55189c0fb560e212678f9d4e386ef557bb8
|
2026-08-11-python-publication-workflow.zh.md: 0b2b4a71b909a510bc5a7f52132dbb0ba2bf3e67
|
||||||
@@ -14,12 +14,14 @@ The `Release (Python)` GitHub workflow exposes credential-free validation to pul
|
|||||||
|
|
||||||
A run with `publish=true` must use the `python-v<repository-version>` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token.
|
A run with `publish=true` must use the `python-v<repository-version>` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token.
|
||||||
|
|
||||||
Publication consumes the aggregate artifact produced and checked in the same workflow run. A runtime job uploads all three platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files.
|
Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all three platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files.
|
||||||
|
|
||||||
Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror.
|
Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror.
|
||||||
|
|
||||||
Repository versions may be stable or use the supported prerelease spellings. Tags retain the repository spelling, while wheel filenames, metadata, dependency pins, and artifact lookup use the normalized PEP 440 spelling.
|
Repository versions may be stable or use the supported prerelease spellings. Tags retain the repository spelling, while wheel filenames, metadata, dependency pins, and artifact lookup use the normalized PEP 440 spelling.
|
||||||
|
|
||||||
|
The runtime package's `platforms.json` is the source of truth for native wheel tags and executable names. The repository release builder and the isolated Hatch build hook validate and load that file independently. GitHub Actions and GitLab CI call one repository-owned macOS deployment-target check for both the runtime executable and its required spawn helper, so every Mach-O file in the wheel must fit the declared platform tag.
|
||||||
|
|
||||||
Both Python build-system requirements pin Hatchling 1.30.1. The next available Hatchling release emits Core Metadata 2.5, which the pinned Twine 6.2.0 validator rejects; keeping the builder exact makes local, GitHub, and GitLab output agree until the validation toolchain supports that metadata version.
|
Both Python build-system requirements pin Hatchling 1.30.1. The next available Hatchling release emits Core Metadata 2.5, which the pinned Twine 6.2.0 validator rejects; keeping the builder exact makes local, GitHub, and GitLab output agree until the validation toolchain supports that metadata version.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
@@ -42,7 +44,7 @@ The complete release candidate and the public release both run from the private
|
|||||||
|
|
||||||
The private automation repository owner and name, workflow filename, and each job's environment (`pypi-runtime` for runtime and `pypi` for SDK) are part of the Trusted Publisher identity. A source-repository transfer, workflow rename, or environment rename requires updating the affected PyPI publishers and the publisher-repository variable when the repository identity changes. Changing the read-only public mirror changes package metadata URLs instead, not the publishing identity.
|
The private automation repository owner and name, workflow filename, and each job's environment (`pypi-runtime` for runtime and `pypi` for SDK) are part of the Trusted Publisher identity. A source-repository transfer, workflow rename, or environment rename requires updating the affected PyPI publishers and the publisher-repository variable when the repository identity changes. Changing the read-only public mirror changes package metadata URLs instead, not the publishing identity.
|
||||||
|
|
||||||
PyPI publication remains non-atomic across the two distribution projects. Runtime-first ordering narrows the visible failure mode, while separate publication jobs and retained hashes let a failed SDK upload resume with the exact checked bytes; an uploaded filename is never replaced.
|
PyPI publication remains non-atomic across the two distribution projects. Runtime-first ordering narrows the visible failure mode, while separate publication jobs and checksum verification let a failed SDK upload resume with the exact checked bytes; an uploaded filename is never replaced.
|
||||||
|
|
||||||
Disabling public attestations gives up public cryptographic provenance for the upload identity. Trusted Publishing still authenticates each upload, and the retained aggregate artifact keeps the checked wheel hashes inside the private release workflow.
|
Disabling public attestations gives up public cryptographic provenance for the upload identity. Trusted Publishing still authenticates each upload, and the retained aggregate artifact keeps the checked wheel hashes inside the private release workflow.
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ GitHub 的 `Release (Python)` 工作流为带有 `python-release-dry-run` 标签
|
|||||||
|
|
||||||
设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v<repository-version>` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。
|
设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v<repository-version>` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。
|
||||||
|
|
||||||
发布过程使用同一次工作流运行中生成并检查过的汇总产物。一个运行时作业先上传全部三个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。
|
发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部三个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。
|
||||||
|
|
||||||
两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。
|
两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。
|
||||||
|
|
||||||
仓库版本可以是稳定版,也可以使用受支持的预发布写法。标签保留仓库写法,wheel 包文件名、元数据、依赖版本固定和产物查找则使用规范化的 PEP 440 写法。
|
仓库版本可以是稳定版,也可以使用受支持的预发布写法。标签保留仓库写法,wheel 包文件名、元数据、依赖版本固定和产物查找则使用规范化的 PEP 440 写法。
|
||||||
|
|
||||||
|
运行时包的 `platforms.json` 是原生 wheel 包标签和可执行文件名的事实来源。仓库发行构建器与隔离 Hatch 构建钩子会分别校验并加载该文件。GitHub Actions 与 GitLab CI 对运行时可执行文件及其必需的 spawn helper 调用同一个仓库自有的 macOS 部署目标检查,因此 wheel 包中的每个 Mach-O 文件都必须符合声明的平台标签。
|
||||||
|
|
||||||
两个 Python 构建系统依赖都固定使用 Hatchling 1.30.1。下一个可用的 Hatchling 版本会生成 Core Metadata 2.5,而固定使用的 Twine 6.2.0 校验器会拒绝该版本;精确固定构建器后,本地、GitHub 与 GitLab 的输出会保持一致,直到校验工具链支持该元数据版本。
|
两个 Python 构建系统依赖都固定使用 Hatchling 1.30.1。下一个可用的 Hatchling 版本会生成 Core Metadata 2.5,而固定使用的 Twine 6.2.0 校验器会拒绝该版本;精确固定构建器后,本地、GitHub 与 GitLab 的输出会保持一致,直到校验工具链支持该元数据版本。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
@@ -42,7 +44,7 @@ GitHub 的 `Release (Python)` 工作流为带有 `python-release-dry-run` 标签
|
|||||||
|
|
||||||
私有自动化仓库 owner 和仓库名、工作流文件名以及每个作业的环境(运行时使用 `pypi-runtime`,SDK 使用 `pypi`)都是 Trusted Publisher 身份的一部分。源码仓库转移、工作流改名或环境改名后,必须更新受影响的 PyPI Publisher;仓库身份变化时还必须更新发布仓库变量。只读公开镜像发生变化时,需要修改的是包元数据 URL,而不是发布身份。
|
私有自动化仓库 owner 和仓库名、工作流文件名以及每个作业的环境(运行时使用 `pypi-runtime`,SDK 使用 `pypi`)都是 Trusted Publisher 身份的一部分。源码仓库转移、工作流改名或环境改名后,必须更新受影响的 PyPI Publisher;仓库身份变化时还必须更新发布仓库变量。只读公开镜像发生变化时,需要修改的是包元数据 URL,而不是发布身份。
|
||||||
|
|
||||||
两个分发项目之间的 PyPI 发布仍然不是原子操作。运行时优先的顺序会缩小可见的失败状态;独立的发布作业和保留的哈希则让失败的 SDK 上传能够从经过检查的精确文件继续执行,并且绝不替换已上传的同名文件。
|
两个分发项目之间的 PyPI 发布仍然不是原子操作。运行时优先的顺序会缩小可见的失败状态;独立的发布作业和校验和验证则让失败的 SDK 上传能够从经过检查的精确文件继续执行,并且绝不替换已上传的同名文件。
|
||||||
|
|
||||||
禁用公开 attestation 会放弃上传身份的公开密码学 provenance。Trusted Publishing 仍会认证每次上传,而保留的汇总产物会在私有发布工作流内部保存经过检查的 wheel 包哈希。
|
禁用公开 attestation 会放弃上传身份的公开密码学 provenance。Trusted Publishing 仍会认证每次上传,而保留的汇总产物会在私有发布工作流内部保存经过检查的 wheel 包哈希。
|
||||||
|
|
||||||
|
|||||||
@@ -282,23 +282,9 @@ jobs:
|
|||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
env:
|
env:
|
||||||
EXE: ${{ steps.runtime.outputs.exe }}
|
EXE: ${{ steps.runtime.outputs.exe }}
|
||||||
run: |
|
run: >-
|
||||||
set -euo pipefail
|
python3 scripts/check-macos-deployment-target.py
|
||||||
minos="$(otool -l "$EXE" | awk '/LC_BUILD_VERSION/{seen=1; next} seen && /minos/{print $2; exit}')"
|
"$EXE" "$EXE-spawn-helper"
|
||||||
[ -n "$minos" ] || { echo "::error::No macOS deployment target found in $EXE"; exit 1; }
|
|
||||||
python3 - "$minos" <<'PY'
|
|
||||||
import sys
|
|
||||||
|
|
||||||
actual = tuple(int(part) for part in sys.argv[1].split("."))
|
|
||||||
claimed = (14, 0)
|
|
||||||
width = max(len(actual), len(claimed))
|
|
||||||
actual += (0,) * (width - len(actual))
|
|
||||||
claimed += (0,) * (width - len(claimed))
|
|
||||||
if actual > claimed:
|
|
||||||
raise SystemExit(
|
|
||||||
f"::error::Executable requires macOS {sys.argv[1]} but the wheel claims macosx_14_0"
|
|
||||||
)
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Run wheel in a manylinux 2.28 container
|
- name: Run wheel in a manylinux 2.28 container
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ jobs:
|
|||||||
name: python-release-${{ needs.validate.outputs.version }}
|
name: python-release-${{ needs.validate.outputs.version }}
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
|
- name: Verify release artifact hashes
|
||||||
|
run: cd dist && sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
- name: Select runtime wheels
|
- name: Select runtime wheels
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/runtime
|
mkdir -p dist/runtime
|
||||||
@@ -226,6 +229,9 @@ jobs:
|
|||||||
name: python-release-${{ needs.validate.outputs.version }}
|
name: python-release-${{ needs.validate.outputs.version }}
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
|
- name: Verify release artifact hashes
|
||||||
|
run: cd dist && sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
- name: Select SDK wheel
|
- name: Select SDK wheel
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/sdk
|
mkdir -p dist/sdk
|
||||||
|
|||||||
+1
-15
@@ -60,21 +60,7 @@ sdk-wheel:
|
|||||||
fi
|
fi
|
||||||
- |
|
- |
|
||||||
if [ "$PLATFORM" = macos-arm64 ]; then
|
if [ "$PLATFORM" = macos-arm64 ]; then
|
||||||
minos="$(otool -l "$EXE" | awk '/LC_BUILD_VERSION/{seen=1; next} seen && /minos/{print $2; exit}')"
|
python3 scripts/check-macos-deployment-target.py "$EXE" "$EXE-spawn-helper"
|
||||||
test -n "$minos"
|
|
||||||
python3 - "$minos" <<'PY'
|
|
||||||
import sys
|
|
||||||
|
|
||||||
actual = tuple(int(part) for part in sys.argv[1].split("."))
|
|
||||||
claimed = (14, 0)
|
|
||||||
width = max(len(actual), len(claimed))
|
|
||||||
actual += (0,) * (width - len(actual))
|
|
||||||
claimed += (0,) * (width - len(claimed))
|
|
||||||
if actual > claimed:
|
|
||||||
raise SystemExit(
|
|
||||||
f"Executable requires macOS {sys.argv[1]} but the wheel claims macosx_14_0"
|
|
||||||
)
|
|
||||||
PY
|
|
||||||
fi
|
fi
|
||||||
artifacts:
|
artifacts:
|
||||||
paths: [release/$PLATFORM/*.whl]
|
paths: [release/$PLATFORM/*.whl]
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/i18n/README.md
|
# pnpm run verify-translation-pairing --write docs/i18n/README.md
|
||||||
README.md: 23400801426f77dae5136406cd747dbe4b06a4c5
|
README.md: 3acddd310a423b6a19014063418d81350562b188
|
||||||
README.zh.md: fe3cc7b5a5403fc9cf0c9ce536178d4fa7581e3c
|
README.zh.md: abc601f91bb3778c887f1249426481ef0920b73a
|
||||||
+1
-1
@@ -18,7 +18,7 @@ This repo's documentation is read by people and agents both inside and outside t
|
|||||||
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).
|
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).
|
||||||
|
|
||||||
When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.
|
When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.
|
||||||
- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.
|
- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/<repository-path>` URL to the same counterpart so the switcher still resolves there.
|
||||||
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).
|
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).
|
||||||
|
|
||||||
## The gate: verify-translation-pairing
|
## The gate: verify-translation-pairing
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。
|
用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。
|
||||||
|
|
||||||
当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。
|
当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。
|
||||||
- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。
|
- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/<repository-path>` URL,使切换行在该位置仍可访问。
|
||||||
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||||
|
|
||||||
## 门禁:verify-translation-pairing
|
## 门禁:verify-translation-pairing
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/i18n/translation-rules.md
|
# pnpm run verify-translation-pairing --write docs/i18n/translation-rules.md
|
||||||
translation-rules.md: ce20ed9a9673b0782ef07c9a4a21ff1c98ace960
|
translation-rules.md: 79ec3de50ecbb57bc84cac39a0edc9b5ac26c5d2
|
||||||
translation-rules.zh.md: daea57ab1d3a1abbad442982c8bb1c189478b8a8
|
translation-rules.zh.md: 5861c074e8e6cd79e3fd2799cf4152c6bac93182
|
||||||
@@ -28,7 +28,7 @@ The pairing gate checks heading depths, fenced code blocks, table row and column
|
|||||||
- tables (same columns, same row order; header cells translated per terminology),
|
- tables (same columns, same row order; header cells translated per terminology),
|
||||||
- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,
|
- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,
|
||||||
- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,
|
- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,
|
||||||
- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.
|
- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not.
|
||||||
|
|
||||||
The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.
|
The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
- 表格(相同的列、相同的行序;表头单元格按术语表翻译);
|
- 表格(相同的列、相同的行序;表头单元格按术语表翻译);
|
||||||
- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;
|
- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;
|
||||||
- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;
|
- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;
|
||||||
- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。
|
- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。
|
||||||
|
|
||||||
本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。
|
本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
|
# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
|
||||||
README.md: 0774ad206b366c59c40edf21c0cdb9b3993510f9
|
README.md: 71dedf4cb8064d55bd64b32008b452158a1b154f
|
||||||
README.zh.md: bbe9f871b183bc83e1113594dfd29e1f81e06ac2
|
README.zh.md: 83c99ed33b2a4ffe00bcb3fe670be455664bfa18
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DeepSeek Harness Runtime Wheel
|
# DeepSeek Harness Runtime Wheel
|
||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.zh.md)
|
||||||
|
|
||||||
Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs.
|
Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](https://gi
|
|||||||
|
|
||||||
A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
|
A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
|
||||||
|
|
||||||
Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v<repository-version>` release tag must match it.
|
Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v<repository-version>` release tag must match it.
|
||||||
|
|
||||||
## Resolution API
|
## Resolution API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DeepSeek Harness 运行时 wheel 包
|
# DeepSeek Harness 运行时 wheel 包
|
||||||
|
|
||||||
[English](README.md) | 中文
|
[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) | 中文
|
||||||
|
|
||||||
Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。
|
Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,
|
|||||||
|
|
||||||
exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。
|
exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。
|
||||||
|
|
||||||
每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v<repository-version>` 发布标签必须与其匹配。
|
每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v<repository-version>` 发布标签必须与其匹配。
|
||||||
|
|
||||||
## 解析 API
|
## 解析 API
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import stat
|
import stat
|
||||||
@@ -8,11 +9,30 @@ from pathlib import Path
|
|||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
_PLATFORMS = {
|
def _load_platforms() -> dict[str, tuple[str, str]]:
|
||||||
"linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
|
"""Load and validate the platform manifest inside an isolated wheel build."""
|
||||||
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
|
path = Path(__file__).with_name("platforms.json")
|
||||||
"macos-arm64": ("macosx_14_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
|
try:
|
||||||
}
|
payload = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise RuntimeError(f"could not read runtime platform manifest from {path}") from error
|
||||||
|
if not isinstance(payload, dict) or not payload:
|
||||||
|
raise RuntimeError(f"{path} must contain a non-empty platform object")
|
||||||
|
platforms: dict[str, tuple[str, str]] = {}
|
||||||
|
for name, raw in payload.items():
|
||||||
|
if (
|
||||||
|
not isinstance(name, str)
|
||||||
|
or not isinstance(raw, dict)
|
||||||
|
or set(raw) != {"tag", "executable"}
|
||||||
|
or not isinstance(raw["tag"], str)
|
||||||
|
or not isinstance(raw["executable"], str)
|
||||||
|
):
|
||||||
|
raise RuntimeError(f"{path} platform entries must contain string tag and executable fields")
|
||||||
|
platforms[name] = (raw["tag"], raw["executable"])
|
||||||
|
return platforms
|
||||||
|
|
||||||
|
|
||||||
|
_PLATFORMS = _load_platforms()
|
||||||
|
|
||||||
|
|
||||||
def _host_platform_tag() -> str:
|
def _host_platform_tag() -> str:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"linux-x64": {
|
||||||
|
"tag": "manylinux_2_28_x86_64",
|
||||||
|
"executable": "dsh-jsonrpc-agent-pkg-linux-x64"
|
||||||
|
},
|
||||||
|
"linux-arm64": {
|
||||||
|
"tag": "manylinux_2_28_aarch64",
|
||||||
|
"executable": "dsh-jsonrpc-agent-pkg-linux-arm64"
|
||||||
|
},
|
||||||
|
"macos-arm64": {
|
||||||
|
"tag": "macosx_14_0_arm64",
|
||||||
|
"executable": "dsh-jsonrpc-agent-pkg-macos-arm64"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write python/sdk/README.md
|
# pnpm run verify-translation-pairing --write python/sdk/README.md
|
||||||
README.md: 649dbed7afa0f11177f87984033aa58ac7d8ac2e
|
README.md: 0f91f3d9b5e770852a0c1e638b1506a14b8d65c0
|
||||||
README.zh.md: 75e6ba91152af527395fa53903e53fd0d056bdb5
|
README.zh.md: a3adb241f5d27b49ecab2f1541dfd9775a34a24f
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DeepSeek Harness Python SDK
|
# DeepSeek Harness Python SDK
|
||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)
|
||||||
|
|
||||||
Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The
|
Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The
|
||||||
runtime inherits normal DeepSeek Harness environment variables such as
|
runtime inherits normal DeepSeek Harness environment variables such as
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DeepSeek Harness Python SDK
|
# DeepSeek Harness Python SDK
|
||||||
|
|
||||||
[English](README.md) | 中文
|
[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md) | 中文
|
||||||
|
|
||||||
通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。
|
通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Tests for macOS runtime wheel deployment-target validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SCRIPT = ROOT / "scripts" / "check-macos-deployment-target.py"
|
||||||
|
checker = SimpleNamespace(**runpy.run_path(str(SCRIPT)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_otool_parser_uses_the_newest_macho_slice() -> None:
|
||||||
|
output = """
|
||||||
|
cmd LC_BUILD_VERSION
|
||||||
|
minos 11.0
|
||||||
|
cmd LC_BUILD_VERSION
|
||||||
|
minos 13.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert checker.parse_otool_deployment_target(output) == (13, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_otool_parser_requires_a_deployment_target() -> None:
|
||||||
|
with pytest.raises(ValueError, match="contains no LC_BUILD_VERSION"):
|
||||||
|
checker.parse_otool_deployment_target("Load command 0\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_tag_rejects_a_newer_executable_target() -> None:
|
||||||
|
checker.ensure_compatible(Path("runtime"), (13, 5), "macosx_14_0_arm64")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="requires macOS 14.1"):
|
||||||
|
checker.ensure_compatible(Path("spawn-helper"), (14, 1), "macosx_14_0_arm64")
|
||||||
@@ -61,6 +61,14 @@ def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None:
|
|||||||
assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64"
|
assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64"
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None:
|
||||||
|
manifest = tmp_path / "platforms.json"
|
||||||
|
manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n')
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="tag and executable fields"):
|
||||||
|
build_python_release.load_platforms(manifest)
|
||||||
|
|
||||||
|
|
||||||
def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None:
|
def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None:
|
||||||
destination = tmp_path / "staging"
|
destination = tmp_path / "staging"
|
||||||
|
|
||||||
@@ -96,6 +104,9 @@ def test_stage_runtime_copies_platform_payload(
|
|||||||
assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected
|
assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected
|
||||||
pyproject = (destination / "pyproject.toml").read_text()
|
pyproject = (destination / "pyproject.toml").read_text()
|
||||||
assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject
|
assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject
|
||||||
|
assert (destination / "platforms.json").read_bytes() == (
|
||||||
|
ROOT / "python" / "sdk-runtime" / "platforms.json"
|
||||||
|
).read_bytes()
|
||||||
assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes()
|
assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes()
|
||||||
assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == (
|
assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == (
|
||||||
ROOT / "THIRD_PARTY_NOTICES.md"
|
ROOT / "THIRD_PARTY_NOTICES.md"
|
||||||
|
|||||||
@@ -19,11 +19,32 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SDK_DISTRIBUTION = "deepseek-harness-sdk"
|
SDK_DISTRIBUTION = "deepseek-harness-sdk"
|
||||||
RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin"
|
RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin"
|
||||||
PLATFORMS = {
|
PLATFORM_MANIFEST = ROOT / "python" / "sdk-runtime" / "platforms.json"
|
||||||
"linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
|
|
||||||
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
|
|
||||||
"macos-arm64": ("macosx_14_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
|
def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]]:
|
||||||
}
|
"""Load the release platform tag and executable pairs from the build manifest."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"could not read runtime platform manifest from {path}") from error
|
||||||
|
if not isinstance(payload, dict) or not payload:
|
||||||
|
raise ValueError(f"{path} must contain a non-empty platform object")
|
||||||
|
platforms: dict[str, tuple[str, str]] = {}
|
||||||
|
for name, raw in payload.items():
|
||||||
|
if (
|
||||||
|
not isinstance(name, str)
|
||||||
|
or not isinstance(raw, dict)
|
||||||
|
or set(raw) != {"tag", "executable"}
|
||||||
|
or not isinstance(raw["tag"], str)
|
||||||
|
or not isinstance(raw["executable"], str)
|
||||||
|
):
|
||||||
|
raise ValueError(f"{path} platform entries must contain string tag and executable fields")
|
||||||
|
platforms[name] = (raw["tag"], raw["executable"])
|
||||||
|
return platforms
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS = load_platforms()
|
||||||
|
|
||||||
|
|
||||||
def runtime_suffixes(executable_name: str) -> tuple[str, ...]:
|
def runtime_suffixes(executable_name: str) -> tuple[str, ...]:
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reject runtime executables that require newer macOS than their wheel tag."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import runpy
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py"))
|
||||||
|
MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(value: str) -> tuple[int, ...]:
|
||||||
|
"""Parse a dot-separated numeric deployment version."""
|
||||||
|
if re.fullmatch(r"\d+(?:\.\d+)*", value) is None:
|
||||||
|
raise ValueError(f"invalid macOS deployment version: {value!r}")
|
||||||
|
return tuple(int(part) for part in value.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def claimed_version(platform_tag: str) -> tuple[int, ...]:
|
||||||
|
"""Return the minimum macOS version encoded by a wheel platform tag."""
|
||||||
|
match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}")
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_otool_deployment_target(output: str) -> tuple[int, ...]:
|
||||||
|
"""Return the newest deployment target from one or more Mach-O slices."""
|
||||||
|
versions = [
|
||||||
|
parse_version(match.group(1))
|
||||||
|
for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE)
|
||||||
|
]
|
||||||
|
if not versions:
|
||||||
|
raise ValueError("otool output contains no LC_BUILD_VERSION deployment target")
|
||||||
|
return max(versions)
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_target(executable: Path) -> tuple[int, ...]:
|
||||||
|
"""Read one Mach-O executable's deployment target with ``otool``."""
|
||||||
|
if not executable.is_file():
|
||||||
|
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
|
||||||
|
result = subprocess.run(
|
||||||
|
["otool", "-l", str(executable)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return parse_otool_deployment_target(result.stdout)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"{executable}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_compatible(
|
||||||
|
executable: Path, actual: tuple[int, ...], platform_tag: str
|
||||||
|
) -> None:
|
||||||
|
"""Reject an executable whose deployment target exceeds its wheel claim."""
|
||||||
|
claimed = claimed_version(platform_tag)
|
||||||
|
width = max(len(actual), len(claimed))
|
||||||
|
padded_actual = actual + (0,) * (width - len(actual))
|
||||||
|
padded_claimed = claimed + (0,) * (width - len(claimed))
|
||||||
|
if padded_actual > padded_claimed:
|
||||||
|
rendered = ".".join(str(part) for part in actual)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_deployment_targets(
|
||||||
|
executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG
|
||||||
|
) -> list[tuple[Path, tuple[int, ...]]]:
|
||||||
|
"""Validate every executable and return its measured deployment target."""
|
||||||
|
measured = [(executable, deployment_target(executable)) for executable in executables]
|
||||||
|
for executable, actual in measured:
|
||||||
|
ensure_compatible(executable, actual, platform_tag)
|
||||||
|
return measured
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("executables", type=Path, nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
for executable, version in validate_deployment_targets(args.executables):
|
||||||
|
rendered = ".".join(str(part) for part in version)
|
||||||
|
print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+20
-10
@@ -159,9 +159,18 @@ describe('Python release workflows', () => {
|
|||||||
expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } })
|
expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } })
|
||||||
expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}')
|
expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}')
|
||||||
const validateSteps = JSON.stringify(validate.steps)
|
const validateSteps = JSON.stringify(validate.steps)
|
||||||
|
const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request')
|
||||||
|
if (!isRecord(authorize) || typeof authorize.run !== 'string') {
|
||||||
|
throw new TypeError('Python release validation must authorize publication requests')
|
||||||
|
}
|
||||||
expect(validateSteps).toContain('PUBLIC_PYPI_RELEASE_ENABLED')
|
expect(validateSteps).toContain('PUBLIC_PYPI_RELEASE_ENABLED')
|
||||||
expect(validateSteps).toContain('PYPI_PUBLISHER_REPOSITORY')
|
expect(authorize).toMatchObject({
|
||||||
expect(validateSteps).not.toContain('REPOSITORY_PRIVATE')
|
env: {
|
||||||
|
PYPI_PUBLISHER_REPOSITORY: '${{ vars.PYPI_PUBLISHER_REPOSITORY }}',
|
||||||
|
REPOSITORY: '${{ github.repository }}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(authorize.run).toContain('[ "$REPOSITORY" = "$PYPI_PUBLISHER_REPOSITORY" ]')
|
||||||
expect(validateSteps).toContain('100000000')
|
expect(validateSteps).toContain('100000000')
|
||||||
expect(publishRuntime).toMatchObject({
|
expect(publishRuntime).toMatchObject({
|
||||||
if: "github.event_name == 'workflow_dispatch' && inputs.publish",
|
if: "github.event_name == 'workflow_dispatch' && inputs.publish",
|
||||||
@@ -179,6 +188,8 @@ describe('Python release workflows', () => {
|
|||||||
const sdkSteps = publishSdk.steps.filter(isRecord)
|
const sdkSteps = publishSdk.steps.filter(isRecord)
|
||||||
const runtimePublish = runtimeSteps.find(step => step.name === 'Publish runtime wheels')
|
const runtimePublish = runtimeSteps.find(step => step.name === 'Publish runtime wheels')
|
||||||
const sdkPublish = sdkSteps.find(step => step.name === 'Publish SDK wheel')
|
const sdkPublish = sdkSteps.find(step => step.name === 'Publish SDK wheel')
|
||||||
|
const runtimeHashes = runtimeSteps.find(step => step.name === 'Verify release artifact hashes')
|
||||||
|
const sdkHashes = sdkSteps.find(step => step.name === 'Verify release artifact hashes')
|
||||||
expect([...runtimeSteps, ...sdkSteps].some(
|
expect([...runtimeSteps, ...sdkSteps].some(
|
||||||
step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'),
|
step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'),
|
||||||
)).toBe(false)
|
)).toBe(false)
|
||||||
@@ -191,6 +202,8 @@ describe('Python release workflows', () => {
|
|||||||
expect(sdkPublish).toMatchObject({
|
expect(sdkPublish).toMatchObject({
|
||||||
with: { 'packages-dir': 'dist/sdk/', attestations: false },
|
with: { 'packages-dir': 'dist/sdk/', attestations: false },
|
||||||
})
|
})
|
||||||
|
expect(runtimeHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' })
|
||||||
|
expect(sdkHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes the native wheel builder to the release caller with normalized versions', () => {
|
it('exposes the native wheel builder to the release caller with normalized versions', () => {
|
||||||
@@ -218,12 +231,13 @@ describe('Python release workflows', () => {
|
|||||||
expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt')
|
expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt')
|
||||||
expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28')
|
expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28')
|
||||||
expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" })
|
expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" })
|
||||||
expect(JSON.stringify(macosCheck)).not.toContain('sort -V')
|
expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py')
|
||||||
|
expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper')
|
||||||
expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" })
|
expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" })
|
||||||
expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED')
|
expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('decodes the GitLab macOS deployment check with a column-zero heredoc', () => {
|
it('uses the shared macOS deployment-target check in GitLab', () => {
|
||||||
const workflow = loadWorkflow('.gitlab-ci.yml')
|
const workflow = loadWorkflow('.gitlab-ci.yml')
|
||||||
const runtimeWheel = workflow['.runtime-wheel']
|
const runtimeWheel = workflow['.runtime-wheel']
|
||||||
if (!isRecord(runtimeWheel) || !Array.isArray(runtimeWheel.script)) {
|
if (!isRecord(runtimeWheel) || !Array.isArray(runtimeWheel.script)) {
|
||||||
@@ -237,12 +251,8 @@ describe('Python release workflows', () => {
|
|||||||
throw new TypeError('GitLab CI must check the macOS deployment target')
|
throw new TypeError('GitLab CI must check the macOS deployment target')
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = macosCheck.split('\n')
|
expect(macosCheck).toContain('scripts/check-macos-deployment-target.py')
|
||||||
const opener = lines.indexOf(' python3 - "$minos" <<\'PY\'')
|
expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"')
|
||||||
const terminator = lines.indexOf('PY', opener + 1)
|
|
||||||
expect(lines[opener + 1]).toBe('import sys')
|
|
||||||
expect(terminator).toBeGreaterThan(opener)
|
|
||||||
expect(lines[terminator + 1]).toBe('fi')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [
|
|||||||
{ file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
|
{ file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
|
||||||
{ file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
|
{ file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
|
||||||
{ file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 },
|
{ file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 },
|
||||||
// One insertion, once: a duplicated log entry is what a non-idempotent apply produced.
|
// The vendored README owns this required entry; reject its deletion or duplication.
|
||||||
{ file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 },
|
{ file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 },
|
||||||
{ file: 'knip.json', text: '@cordisjs', count: 0 },
|
{ file: 'knip.json', text: '@cordisjs', count: 0 },
|
||||||
{ file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 },
|
{ file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 },
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
storeGitBlob,
|
storeGitBlob,
|
||||||
} from './translation-pairing-git.ts'
|
} from './translation-pairing-git.ts'
|
||||||
import {
|
import {
|
||||||
linksTo,
|
|
||||||
isTranslationScopeFile,
|
isTranslationScopeFile,
|
||||||
|
languageSwitcherTargets,
|
||||||
|
linksTo,
|
||||||
parseTranslationMarkdown,
|
parseTranslationMarkdown,
|
||||||
requiresSourceLanguageSwitcher,
|
requiresSourceLanguageSwitcher,
|
||||||
translationStructureDiff,
|
translationStructureDiff,
|
||||||
@@ -164,15 +165,17 @@ function loadRecordOwners(
|
|||||||
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
|
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
|
||||||
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
|
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
|
||||||
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
|
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
|
||||||
if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) {
|
const sourceSwitcherTargets = languageSwitcherTargets(paths.source)
|
||||||
|
const zhSwitcherTargets = languageSwitcherTargets(paths.zh)
|
||||||
|
if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, zhSwitcherTargets)) {
|
||||||
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
|
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
|
||||||
}
|
}
|
||||||
if (!linksTo(zhTree, basename(paths.source))) {
|
if (!linksTo(zhTree, sourceSwitcherTargets)) {
|
||||||
throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
|
throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
|
||||||
}
|
}
|
||||||
const divergences = translationStructureDiff(
|
const divergences = translationStructureDiff(
|
||||||
translationStructureSignature(sourceTree, basename(paths.zh)),
|
translationStructureSignature(sourceTree, zhSwitcherTargets),
|
||||||
translationStructureSignature(zhTree, basename(paths.source)),
|
translationStructureSignature(zhTree, sourceSwitcherTargets),
|
||||||
)
|
)
|
||||||
if (divergences.length > 0) {
|
if (divergences.length > 0) {
|
||||||
throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
|
throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
blobHash,
|
blobHash,
|
||||||
isTranslationScopeFile,
|
isTranslationScopeFile,
|
||||||
|
languageSwitcherTargets,
|
||||||
|
linksTo,
|
||||||
pairAnchorOfArgument,
|
pairAnchorOfArgument,
|
||||||
parseTranslationMarkdown,
|
parseTranslationMarkdown,
|
||||||
parseTranslationPairingCliArgs,
|
parseTranslationPairingCliArgs,
|
||||||
@@ -151,6 +153,20 @@ describe('translation pairing switchers', () => {
|
|||||||
expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
|
expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
|
||||||
expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
|
expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts only the canonical public URL for an absolute switcher', () => {
|
||||||
|
const targets = languageSwitcherTargets('python/sdk/README.zh.md')
|
||||||
|
const canonical = parseTranslationMarkdown(
|
||||||
|
'[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)',
|
||||||
|
)
|
||||||
|
const wrongPath = parseTranslationMarkdown(
|
||||||
|
'[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(linksTo(canonical, targets)).toBe(true)
|
||||||
|
expect(translationStructureSignature(canonical, targets).links).toEqual([])
|
||||||
|
expect(linksTo(wrongPath, targets)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('translation pairing records', () => {
|
describe('translation pairing records', () => {
|
||||||
|
|||||||
@@ -302,11 +302,19 @@ export function parseTranslationMarkdown(content: string): Nodes {
|
|||||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the tree contains a link to exactly `target`. */
|
const PUBLIC_REPOSITORY_BLOB_ROOT = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/'
|
||||||
export function linksTo(tree: Nodes, target: string): boolean {
|
|
||||||
|
/** Return the accepted relative and public-repository links to one counterpart. */
|
||||||
|
export function languageSwitcherTargets(counterpart: string): string[] {
|
||||||
|
return [basename(counterpart), `${PUBLIC_REPOSITORY_BLOB_ROOT}${counterpart}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the tree contains a link to any accepted target. */
|
||||||
|
export function linksTo(tree: Nodes, targets: string | readonly string[]): boolean {
|
||||||
|
const accepted = new Set(typeof targets === 'string' ? [targets] : targets)
|
||||||
let found = false
|
let found = false
|
||||||
const visit = (node: Nodes): void => {
|
const visit = (node: Nodes): void => {
|
||||||
if (node.type === 'link' && node.url === target) found = true
|
if (node.type === 'link' && accepted.has(node.url)) found = true
|
||||||
if ('children' in node) for (const child of node.children) visit(child)
|
if ('children' in node) for (const child of node.children) visit(child)
|
||||||
}
|
}
|
||||||
visit(tree)
|
visit(tree)
|
||||||
@@ -335,8 +343,14 @@ export function requiresSourceLanguageSwitcher(source: string): boolean {
|
|||||||
].includes(source)
|
].includes(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect the ordered structural signature, skipping one switcher target. */
|
/** Collect the ordered structural signature, skipping accepted switcher targets. */
|
||||||
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
|
export function translationStructureSignature(
|
||||||
|
tree: Nodes,
|
||||||
|
switcherTargets: string | readonly string[],
|
||||||
|
): TranslationStructureSignature {
|
||||||
|
const acceptedSwitchers = new Set(
|
||||||
|
typeof switcherTargets === 'string' ? [switcherTargets] : switcherTargets,
|
||||||
|
)
|
||||||
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||||
const visit = (node: Nodes): void => {
|
const visit = (node: Nodes): void => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
@@ -355,7 +369,7 @@ export function translationStructureSignature(tree: Nodes, switcherTarget: strin
|
|||||||
: `bullet:items=${node.children.length}`)
|
: `bullet:items=${node.children.length}`)
|
||||||
break
|
break
|
||||||
case 'link':
|
case 'link':
|
||||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
if (!acceptedSwitchers.has(node.url)) sig.links.push(node.url)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
// Every other node kind is prose or a container, not part of the signature.
|
// Every other node kind is prose or a container, not part of the signature.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
translationPairPaths,
|
translationPairPaths,
|
||||||
} from './translation-pairing-record.ts'
|
} from './translation-pairing-record.ts'
|
||||||
import {
|
import {
|
||||||
|
languageSwitcherTargets,
|
||||||
linksTo,
|
linksTo,
|
||||||
parseTranslationMarkdown,
|
parseTranslationMarkdown,
|
||||||
parseTranslationPairingCliArgs,
|
parseTranslationPairingCliArgs,
|
||||||
@@ -252,15 +253,17 @@ for (const source of [...pairAnchors].sort()) {
|
|||||||
|
|
||||||
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
||||||
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
||||||
if (!linksTo(zhTree, basename(source))) {
|
const sourceSwitcherTargets = languageSwitcherTargets(source)
|
||||||
|
const zhSwitcherTargets = languageSwitcherTargets(zh)
|
||||||
|
if (!linksTo(zhTree, sourceSwitcherTargets)) {
|
||||||
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
||||||
}
|
}
|
||||||
if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) {
|
if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) {
|
||||||
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
||||||
}
|
}
|
||||||
for (const divergence of translationStructureDiff(
|
for (const divergence of translationStructureDiff(
|
||||||
translationStructureSignature(sourceTree, basename(zh)),
|
translationStructureSignature(sourceTree, zhSwitcherTargets),
|
||||||
translationStructureSignature(zhTree, basename(source)),
|
translationStructureSignature(zhTree, sourceSwitcherTargets),
|
||||||
)) {
|
)) {
|
||||||
errors.push(`${source} ↔ ${zh}: ${divergence}`)
|
errors.push(`${source} ↔ ${zh}: ${divergence}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user