diff --git a/.github/workflows/build-deepseek-harness-workstation.yml b/.github/workflows/build-deepseek-harness-workstation.yml index 6bb3c3a..b127436 100644 --- a/.github/workflows/build-deepseek-harness-workstation.yml +++ b/.github/workflows/build-deepseek-harness-workstation.yml @@ -8,7 +8,7 @@ on: required: false default: "" dsh_version: - description: "DeepSeek Harness npm version or dist-tag; leave empty to resolve latest" + description: "DeepSeek Harness npm version or dist-tag; leave empty to resolve the highest published version" required: false default: "" platforms: @@ -176,7 +176,7 @@ jobs: tags: ${{ steps.params.outputs.tags }} labels: | org.opencontainers.image.title=DeepSeek Harness Workstation - org.opencontainers.image.description=Authenticated DeepSeek Harness workstation with Node.js, Python, Go, Rust, compilers, Docker client tools, and development CLI. + org.opencontainers.image.description=Authenticated DeepSeek Harness workstation with Node.js, Python, Go, native compilers, Docker client tools, and development CLI. org.opencontainers.image.vendor=${{ github.repository_owner }} org.opencontainers.image.source=https://github.com/${{ github.repository }} @@ -243,14 +243,13 @@ jobs: deepseek-harness-builder/scripts/check-caddy-vulnerabilities.sh \ --image "${TEST_IMAGE_AMD64}" - - name: Scan amd64 workstation image + - name: Scan amd64 workstation image (block fixable critical) run: | set -euo pipefail scripts/trivy-image-gate.sh \ --image "${TEST_IMAGE_AMD64}" \ --output "${RUNNER_TEMP}/deepseek-harness-workstation-amd64.trivy.json" \ - --max-fixable-critical 0 \ - --max-fixable-high 0 + --max-fixable-critical 0 - name: Build arm64 workstation test image uses: docker/build-push-action@v7 @@ -283,14 +282,13 @@ jobs: --image "${TEST_IMAGE_ARM64}" \ --skip-sandbox-probe - - name: Scan arm64 workstation image + - name: Scan arm64 workstation image (block fixable critical) run: | set -euo pipefail scripts/trivy-image-gate.sh \ --image "${TEST_IMAGE_ARM64}" \ --output "${RUNNER_TEMP}/deepseek-harness-workstation-arm64.trivy.json" \ - --max-fixable-critical 0 \ - --max-fixable-high 0 + --max-fixable-critical 0 - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -345,5 +343,5 @@ jobs: echo "- Platforms: ${PARAM_PLATFORMS}" echo "- amd64: full authentication, WebSocket, isolation, resource, and compiler smoke" echo "- arm64: QEMU authentication, native-module, and compiler smoke; host-kernel sandbox probe skipped under user-mode emulation" - echo "- Security: pnpm audit, Caddy dependency-graph/govulncheck gate, and zero-fixable HIGH/CRITICAL Trivy gates" + echo "- Security: pnpm audit, Caddy dependency-graph/govulncheck gate, zero-fixable CRITICAL Trivy gate, and reported HIGH findings" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-deepseek-harness.yml b/.github/workflows/build-deepseek-harness.yml index bc1ef57..9756c34 100644 --- a/.github/workflows/build-deepseek-harness.yml +++ b/.github/workflows/build-deepseek-harness.yml @@ -8,7 +8,7 @@ on: required: false default: "" dsh_version: - description: "DeepSeek Harness npm version or dist-tag; leave empty to resolve latest" + description: "DeepSeek Harness npm version or dist-tag; leave empty to resolve the highest published version" required: false default: "" platforms: diff --git a/.github/workflows/check-deepseek-harness-component-updates.yml b/.github/workflows/check-deepseek-harness-component-updates.yml new file mode 100644 index 0000000..5493990 --- /dev/null +++ b/.github/workflows/check-deepseek-harness-component-updates.yml @@ -0,0 +1,54 @@ +name: Check DeepSeek Harness Component Updates + +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + inputs: + fail_on_updates: + description: "Fail the manual run when update candidates are found" + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: check-deepseek-harness-component-updates + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24.19.0" + + - name: Run component-update checker regression tests + run: python3 deepseek-harness-builder/scripts/test-component-updates.py + + - name: Run DeepSeek Harness version resolver regression tests + run: | + node --test deepseek-harness-builder/scripts/test-resolve-latest-npm-version.mjs + bash deepseek-harness-builder/scripts/test-prepare-dsh-version.sh + + - name: Check authoritative upstream versions + env: + FAIL_ON_UPDATES: ${{ inputs.fail_on_updates || 'false' }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + args=(--summary "${GITHUB_STEP_SUMMARY}") + if [[ "${FAIL_ON_UPDATES}" == "true" ]]; then + args+=(--fail-on-updates) + fi + python3 deepseek-harness-builder/scripts/check-component-updates.py "${args[@]}" diff --git a/.github/workflows/validate-deepseek-harness-component-pins.yml b/.github/workflows/validate-deepseek-harness-component-pins.yml index eaa9894..7dcd615 100644 --- a/.github/workflows/validate-deepseek-harness-component-pins.yml +++ b/.github/workflows/validate-deepseek-harness-component-pins.yml @@ -5,6 +5,7 @@ on: paths: - ".github/workflows/build-deepseek-harness.yml" - ".github/workflows/build-deepseek-harness-workstation.yml" + - ".github/workflows/check-deepseek-harness-component-updates.yml" - ".github/workflows/validate-deepseek-harness-component-pins.yml" - ".github/workflows/verify-deepseek-harness-pr.yml" - "deepseek-harness-builder/**" @@ -23,8 +24,21 @@ jobs: with: persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24.19.0" + - name: Validate pinned component contract run: bash deepseek-harness-builder/scripts/check-component-pins.sh - name: Run component-pin checker regression tests run: bash deepseek-harness-builder/scripts/test-component-pins.sh + + - name: Run component-update checker regression tests + run: python3 deepseek-harness-builder/scripts/test-component-updates.py + + - name: Run DeepSeek Harness version resolver regression tests + run: | + node --test deepseek-harness-builder/scripts/test-resolve-latest-npm-version.mjs + bash deepseek-harness-builder/scripts/test-prepare-dsh-version.sh diff --git a/.github/workflows/verify-deepseek-harness-pr.yml b/.github/workflows/verify-deepseek-harness-pr.yml index 8e9b78b..df7d142 100644 --- a/.github/workflows/verify-deepseek-harness-pr.yml +++ b/.github/workflows/verify-deepseek-harness-pr.yml @@ -148,11 +148,10 @@ jobs: deepseek-harness-builder/scripts/workstation-smoke-test.sh \ --image "${WORKSTATION_IMAGE}" - - name: Scan amd64 workstation image + - name: Scan amd64 workstation image (block fixable critical) run: | set -euo pipefail scripts/trivy-image-gate.sh \ --image "${WORKSTATION_IMAGE}" \ --output "${RUNNER_TEMP}/deepseek-harness-pr-workstation-amd64.trivy.json" \ - --max-fixable-critical 0 \ - --max-fixable-high 0 + --max-fixable-critical 0 diff --git a/deepseek-harness-builder/README.md b/deepseek-harness-builder/README.md index 6a7b955..b4d7623 100644 --- a/deepseek-harness-builder/README.md +++ b/deepseek-harness-builder/README.md @@ -13,13 +13,15 @@ One Dockerfile produces two independently tested variants under one image reposi Both workflows publish the same tags to `ghcr.io/okxlin/deepseek-harness` and to `docker.io/$DOCKERHUB_USERNAME/deepseek-harness`. Configure `DOCKERHUB_USERNAME` as a GitHub Actions repository variable or secret and configure `DOCKERHUB_TOKEN` as a repository secret. The Docker Hub token is used only by the registry login action and is never passed to the image build. -Scheduled runs resolve the current npm `@deepseek-ai/dsh` version, update the image build context, and publish matching `` and `-workstation` tags. Manual workflow runs can still override the DSH version or the published tag. Use a floating tag for an AppStore `latest` channel and the matching version tag for a numbered AppStore version. +Scheduled runs resolve the highest published npm semantic version of `@deepseek-ai/dsh`, update the image build context, and publish matching `` and `-workstation` tags. This intentionally does not depend on the package's `latest` dist-tag, which can lag a newer prerelease; an explicit manual `latest`, `next`, or exact version still follows the requested npm selector. Manual workflow runs can also override the published image tag. Use a floating tag for an AppStore `latest` channel and the matching version tag for a numbered AppStore version. Exact component pins are defined in [the Dockerfile](image/Dockerfile), [package.json](image/package.json), [pnpm-lock.yaml](image/pnpm-lock.yaml), and the [runtime](../.github/workflows/build-deepseek-harness.yml) and [workstation](../.github/workflows/build-deepseek-harness-workstation.yml) workflows. Those build inputs are the source of truth; this README intentionally describes capabilities and update policy without duplicating version numbers. Pull requests that affect DeepSeek Harness build inputs run a read-only component-pin contract check. It rejects floating base-image tags, malformed checksums, and partial updates where repeated Dockerfile version declarations or source URLs no longer agree. A separate PR workflow builds the committed inputs as local amd64 runtime and workstation images, then runs the dependency audit, smoke contracts, Caddy gate, and Trivy gate. Neither workflow receives registry credentials, logs in, or publishes images; the release workflows remain responsible for multi-architecture publishing. -Go is aligned with the official stable version endpoint at . Rust is aligned with the official stable channel manifest at . Their Docker Official Image indexes are digest-pinned for reproducible `amd64` and `arm64` selection. +A daily read-only workflow compares the fixed component versions with authoritative GitHub, Go module proxy, Go, Node.js, npm, PyPI, and Python release sources. It writes update candidates to the Actions summary and emits a warning without changing files, opening a pull request, or blocking scheduled releases. Source or policy errors fail the check; a manual run can also enable strict mode to fail when updates are available. A reported version is only a review candidate: checksum and image-digest updates plus the existing build, smoke, and vulnerability gates are still required. + +Go is aligned with the official stable version endpoint at . Its Docker Official Image index is digest-pinned for reproducible `amd64` and `arm64` selection. actionlint is rebuilt with that pinned Go toolchain from a checksum-pinned official source archive; the other standalone workstation tools are downloaded from their official GitHub releases with architecture-specific SHA-256 checksums. The three workstation Docker client binaries are rebuilt with the pinned Go release from checksum-pinned official source archives. The Buildx source closure imports the legacy `github.com/docker/docker` module only for its frozen random-name generator; the build retains that exact vendored package locally and removes the unrelated daemon module before compiling Buildx and Compose. This keeps the daemon-only AuthZ issue [CVE-2026-34040](https://github.com/moby/moby/security/advisories/GHSA-x744-4wpc-v9h2) out of the client dependency graph instead of weakening the image scan threshold. @@ -40,7 +42,7 @@ Only Caddy listens on the container interface. The DSH port is not exposed to si ## Build ```bash -# Default/lightweight image. Resolves npm @deepseek-ai/dsh@latest by default. +# Default/lightweight image. Resolves the highest published DSH version by default. deepseek-harness-builder/scripts/build-local.sh \ --target runtime \ --tag deepseek-harness:local @@ -51,7 +53,7 @@ deepseek-harness-builder/scripts/build-local.sh \ --tag deepseek-harness-workstation:local ``` -The local build helper resolves the requested `@deepseek-ai/dsh` npm version, updates `package.json` and `pnpm-lock.yaml` in a temporary build context, and passes the resolved version as Docker `DSH_VERSION`. Use `--version ` or an npm dist-tag to select a DSH release. Direct `docker build` remains supported for the checked-in baseline context, and derives `DSH_VERSION` from `package.json` when no build argument is supplied. +The local build helper resolves the requested `@deepseek-ai/dsh` npm version, updates `package.json` and `pnpm-lock.yaml` in a temporary build context, and passes the resolved version as Docker `DSH_VERSION`. With no selector it compares all published versions using SemVer precedence; use `--version ` or an npm dist-tag to select a specific DSH release channel. Direct `docker build` remains supported for the checked-in baseline context, and derives `DSH_VERSION` from `package.json` when no build argument is supplied. The production dependency closure is pinned by `pnpm-lock.yaml` in the active build context. pnpm lifecycle scripts are fail-closed and limited to the reviewed packages in `pnpm-workspace.yaml`. @@ -59,7 +61,7 @@ The image build applies a narrowly scoped, source-shape-checked compatibility pa The custom Caddy build verifies the Caddy and go-authcrunch source archives, removes go-authcrunch's unused GPG public-key parser, and runs the upstream identity-package tests before linking. It applies the two-line Caddy upstream CEL compatibility fix to the pinned Caddy source, then raises `cel-go` to its fixed release. SSH public-key support remains available to caddy-security, while the generated `CADDY_GO_PACKAGES.txt` manifest must contain no `golang.org/x/crypto/openpgp` package. The rate-limit module and its license are pinned and verified during the same build. The build also raises `grpc`, `klauspost/compress`, and `x/text` to their fixed versions. -Both images include a checksum-pinned standalone pnpm bundle and remove npm and Corepack. This keeps one audited Node.js package-manager surface and prevents the selected pnpm version from silently following a package-manager channel. +Both images include a checksum-pinned standalone pnpm bundle and remove Corepack, so the selected pnpm version cannot silently follow a package-manager channel. The lightweight runtime also removes npm and npx. The workstation replaces the older npm bundled with Node.js with a separately checksum-pinned npm 11 bundle, provides npm and npx for development compatibility, and verifies their versions during the image build and smoke tests. ## Development environments @@ -72,14 +74,15 @@ It intentionally omits npm, Python, Go, Rust, GCC/G++, and Make. The workstation image inherits the same DSH/authentication runtime and adds: -- Node.js and pnpm +- Node.js, npm, npx, and pnpm - Python 3.12.14 with pip, venv, pipx, pytest, and development headers -- Go, Rust, and Cargo +- Go - Docker CLI, Compose, and Buildx (client tools only) -- GCC/G++, Clang, GDB, CMake, Ninja, Autoconf/Automake, libtool, pkg-config, and common native-library headers -- Git LFS, GitHub CLI, ShellCheck, shfmt, yamllint, pre-commit, fd, bat, fzf, tmux, Vim, SQLite, and common network/debug/archive tools +- GCC/G++, Clang, clang-format, GDB, CMake, Ninja, Autoconf/Automake, libtool, pkg-config, and common native-library headers +- actionlint, yq, uv/uvx, Ruff, ShellCheck, shfmt, yamllint, and pre-commit +- Git LFS, GitHub CLI, just, hyperfine, entr, fd, bat, fzf, tmux, Vim, SQLite, ncdu, and common network/debug/archive tools -It does not add code-server, Codex/Claude CLIs, proxy daemons, `sudo`, or a Docker daemon. Docker client tools are present, but no daemon socket is mounted by the image, so the default workstation has no host-container control path. User installs under `/home/node` persist in the workstation HOME volume, while projects persist through the direct `/workspace` mount. +It intentionally omits Rust and Cargo from the default workstation; projects that need them can install a project-specific toolchain under the persistent HOME volume. It also does not add code-server, Codex/Claude CLIs, proxy daemons, `sudo`, or a Docker daemon. Docker client tools are present, but no daemon socket is mounted by the image, so the default workstation has no host-container control path. User installs under `/home/node` persist in the workstation HOME volume, while projects persist through the direct `/workspace` mount. ## Run behind 1Panel/OpenResty @@ -127,7 +130,7 @@ docker run -d \ ghcr.io/okxlin/deepseek-harness:workstation ``` -The HOME volume contains user-installed pnpm, pipx, Cargo, and Go tools. Application state lives under `/data`. The image working directory and `DSH_WORKSPACE` both default to `/workspace`; the web **Add workspace** dialog also opens there, and its `Home` shortcut resolves to `/workspace`. `/home/node` is the user HOME and persistent tool volume, not the default project directory. The workspace bind is intended for project files and host-side backup or file access. +The HOME volume contains user-installed pnpm, pipx, uv, Go, and other project-specific tools. Application state lives under `/data`. The image working directory and `DSH_WORKSPACE` both default to `/workspace`; the web **Add workspace** dialog also opens there, and its `Home` shortcut resolves to `/workspace`. `/home/node` is the user HOME and persistent tool volume, not the default project directory. The workspace bind is intended for project files and host-side backup or file access. Docker CLI, Compose, and Buildx work against a remote `DOCKER_HOST` without additional mounts. To control the host Docker daemon, explicitly add: @@ -375,7 +378,7 @@ is no longer needed. ## Resource use -The authenticated amd64 smoke tests currently settle around `167-180 MiB` and about `20-21` PIDs. The workstation toolchains are dormant, so they do not materially raise idle memory, but they do raise disk use: the current local amd64 Docker sizes are about `700 MB` for the lightweight image and `2.59 GB` for the workstation image before registry compression. Debian rebuilds can move those figures. +The authenticated amd64 smoke tests currently settle around `167-180 MiB` and about `20-21` PIDs. The workstation toolchains are dormant, so they do not materially raise idle memory, but they do raise disk use: current local amd64 Docker sizes are about `670-700 MB` for the lightweight image and `2.1-2.2 GB` for the workstation image before registry compression, depending on the resolved DSH release. Debian rebuilds can move those figures. The CI ceiling remains `256 MiB` for the idle flow. This is not a workload limit: terminals, repositories, language servers, compilers, and model tools can require substantially more memory. @@ -392,7 +395,7 @@ deepseek-harness-builder/scripts/smoke-test.sh \ --variant runtime ``` -The test uses a temporary host bind for `/data` and named test volumes for HOME and workspace. Both variants mount the test workspace directly at `/workspace`; the workstation also mounts HOME directly at `/home/node`. It simulates the 1Panel/OpenResty headers and checks login redirects, browser autofill attributes, wrong-password rejection, protected cookies, forged identity headers, both DSH WebSockets, logout, loopback binding, secret isolation, persistent JWT state, container-recreation persistence, the `/workspace` directory-picker default, resource use, fail-closed configuration errors, pnpm, and the selected image variant. +The test uses a temporary host bind for `/data` and named test volumes for HOME and workspace. Both variants mount the test workspace directly at `/workspace`; the workstation also mounts HOME directly at `/home/node`. It simulates the 1Panel/OpenResty headers and checks login redirects, browser autofill attributes, wrong-password rejection, protected cookies, forged identity headers, both DSH WebSockets, logout, loopback binding, secret isolation, persistent JWT state, container-recreation persistence, the `/workspace` directory-picker default, resource use, fail-closed configuration errors, the Node.js package-manager contract, and the selected image variant. For deployments that place authentication in an outer reverse proxy, also run the passthrough contract: @@ -426,7 +429,7 @@ deepseek-harness-builder/scripts/workstation-smoke-test.sh \ --image deepseek-harness-workstation:local ``` -The Compose contract check parses both socket-switch states and proves that the package-local state bind is mounted directly at `/data`, that one named volume is mounted directly at `/home/node`, that the package-local workspace is mounted directly at `/workspace`, the default socket source is `/dev/null`, the enabled source is `/var/run/docker.sock`, and the HTTP port remains loopback-bound. The workstation-specific image test compiles and runs C, C++, Go, and Rust probes, creates a Python virtual environment, checks normal and login-shell PATH behavior, verifies the CLI set, confirms all Docker client binaries use the pinned Go toolchain without the legacy daemon module, confirms Docker has no daemon access by default, characterizes optional socket-group mapping with an isolated Unix socket, verifies that the image declares only `/home/node`, and confirms that HOME, application state, and workspace are real writable directories rather than symbolic links. It also runs the installed DSH sandbox executor under `no-new-privileges`: `workspace-write` must permit a project write and deny a writable path outside the workspace, while an explicit `danger-full-access` retry must permit that outside write without adding container privileges. +The Compose contract check parses both socket-switch states and proves that the package-local state bind is mounted directly at `/data`, that one named volume is mounted directly at `/home/node`, that the package-local workspace is mounted directly at `/workspace`, the default socket source is `/dev/null`, the enabled source is `/var/run/docker.sock`, and the HTTP port remains loopback-bound. The workstation-specific image test compiles and runs C, C++, and Go probes, creates a Python virtual environment, verifies the checksum-pinned actionlint, yq, uv/uvx, and Ruff tools, checks normal and login-shell PATH behavior, verifies the CLI set, confirms Rust and Cargo remain absent, confirms all Docker client binaries use the pinned Go toolchain without the legacy daemon module, confirms Docker has no daemon access by default, characterizes optional socket-group mapping with an isolated Unix socket, verifies that the image declares only `/home/node`, and confirms that HOME, application state, and workspace are real writable directories rather than symbolic links. It also runs the installed DSH sandbox executor under `no-new-privileges`: `workspace-write` must permit a project write and deny a writable path outside the workspace, while an explicit `danger-full-access` retry must permit that outside write without adding container privileges. Run the Caddy vulnerability gate after building the image: @@ -449,4 +452,4 @@ entrypoint copies the legacy workstation application state into `/data`. After verifying the migrated data, subsequent containers still require `/data`; the legacy path alone is not a replacement for it. -Caddy and caddy-security are compiled together and pinned. Updating Caddy alone is not assumed safe. Scheduled workflows resolve npm `@deepseek-ai/dsh@latest`, temporarily update `package.json` and `pnpm-lock.yaml` in the build workspace, pass the resolved version as the Docker `DSH_VERSION` build argument, and publish the `runtime` target as `latest` plus `` and the `workstation` target as `workstation` plus `-workstation`. Manual runs may override either the DSH package version or the final image tag while retaining the same validation and optional floating tag behavior. Each workflow pushes its verified multi-platform manifest to both GHCR and Docker Hub only after auditing the frozen pnpm production tree, rebuilding and validating the plugin, running the Caddy dependency-graph/govulncheck gate, executing its amd64 and arm64 smoke contracts, and applying zero-fixable HIGH/CRITICAL Trivy gates to both architectures. If either registry login or publication fails, the workflow fails instead of reporting a complete release. +Caddy and caddy-security are compiled together and pinned. Updating Caddy alone is not assumed safe. Scheduled release workflows resolve the highest published npm SemVer for `@deepseek-ai/dsh`, temporarily update `package.json` and `pnpm-lock.yaml` in the build workspace, pass the resolved version as the Docker `DSH_VERSION` build argument, and publish the `runtime` target as `latest` plus `` and the `workstation` target as `workstation` plus `-workstation`. Manual runs may override either the DSH package version or dist-tag, or the final image tag, while retaining the same validation and optional floating tag behavior. Each workflow pushes its verified multi-platform manifest to both GHCR and Docker Hub only after auditing the frozen pnpm production tree, rebuilding and validating the plugin, running the Caddy dependency-graph/govulncheck gate, and executing its amd64 and arm64 smoke contracts. Runtime releases apply zero-fixable HIGH/CRITICAL Trivy gates. The broader workstation toolchain blocks fixable CRITICAL findings and reports fixable HIGH findings for deterministic review while the daily component check detects new upstream releases. If either registry login or publication fails, the workflow fails instead of reporting a complete release. diff --git a/deepseek-harness-builder/README.zh-CN.md b/deepseek-harness-builder/README.zh-CN.md index d2f3b15..67b740b 100644 --- a/deepseek-harness-builder/README.zh-CN.md +++ b/deepseek-harness-builder/README.zh-CN.md @@ -13,13 +13,15 @@ 两个工作流都会把相同标签发布到 `ghcr.io/okxlin/deepseek-harness` 和 `docker.io/$DOCKERHUB_USERNAME/deepseek-harness`。请把 `DOCKERHUB_USERNAME` 配置为 GitHub Actions 仓库变量或 Secret,并把 `DOCKERHUB_TOKEN` 配置为仓库 Secret。Docker Hub token 只用于 Registry 登录,不会传入镜像构建上下文。 -定时任务会解析当前 npm `@deepseek-ai/dsh` 版本,更新镜像构建上下文,并发布匹配的 `` 和 `-workstation` 标签。手动工作流仍可覆盖 DSH 版本或发布标签。AppStore `latest` 通道使用浮动标签,编号 AppStore 版本使用匹配的版本标签。 +定时任务会解析 npm 中已发布 `@deepseek-ai/dsh` 的最高 SemVer,更新镜像构建上下文,并发布匹配的 `` 和 `-workstation` 标签。该默认行为有意不依赖可能落后于新预发布版本的 `latest` dist-tag;手动显式传入 `latest`、`next` 或准确版本时,仍按请求的 npm selector 解析。手动工作流也可覆盖发布标签。AppStore `latest` 通道使用浮动标签,编号 AppStore 版本使用匹配的版本标签。 组件的准确固定版本由 [Dockerfile](image/Dockerfile)、[package.json](image/package.json)、[pnpm-lock.yaml](image/pnpm-lock.yaml),以及 [runtime](../.github/workflows/build-deepseek-harness.yml) 和 [workstation](../.github/workflows/build-deepseek-harness-workstation.yml) 工作流定义。这些构建输入是唯一版本来源;README 只说明能力和更新策略,不重复维护具体版本号。 影响 DeepSeek Harness 构建输入的 PR 会运行一个只读的组件固定输入契约检查。它会拒绝浮动基础镜像标签、格式错误的 checksum,以及 Dockerfile 内重复版本声明或源码 URL 不再一致的半更新。另一个 PR 工作流会使用提交的输入构建本地 amd64 runtime 和 workstation 镜像,再运行依赖审计、冒烟契约、Caddy 门禁和 Trivy 门禁。两个工作流都不会获得 registry 凭据、登录或发布镜像;多架构发布仍由发布工作流负责。 -Go 跟随官方稳定版本端点 。Rust 跟随官方稳定通道清单 。它们的 Docker Official Image index 均固定 digest,用于可复现地选择 `amd64` 和 `arm64`。 +另有一个每日只读工作流,会把固定组件版本与 GitHub、Go module proxy、Go、Node.js、npm、PyPI 和 Python 的权威版本源进行比较。它会把更新候选写入 Actions Summary 并发出 warning,但不会修改文件、创建 PR 或阻断定时发布。版本源或策略错误会让检查失败;手动运行时也可以启用严格模式,在发现更新时失败。报告出的版本只是待审候选,仍需更新对应 checksum 和镜像 digest,并通过现有构建、smoke 与漏洞门禁。 + +Go 跟随官方稳定版本端点 ,其 Docker Official Image index 固定 digest,用于可复现地选择 `amd64` 和 `arm64`。actionlint 使用该固定 Go 工具链从 checksum 固定的官方源码归档重新构建;其他独立 workstation 工具从各自官方 GitHub Release 下载,并按架构固定 SHA-256 checksum。 workstation 中的三个 Docker 客户端二进制文件使用已固定的 Go 版本从校验和固定的官方源码归档重新构建。Buildx 源码闭包只为了冻结的随机名称生成器而导入旧 `github.com/docker/docker` 模块;构建会在本地保留该 vendored 包,并在编译 Buildx 和 Compose 前移除无关 daemon 模块。这样可以把 daemon-only AuthZ 问题 [CVE-2026-34040](https://github.com/moby/moby/security/advisories/GHSA-x744-4wpc-v9h2) 排除在客户端依赖图之外,而不是放宽镜像扫描阈值。 @@ -40,7 +42,7 @@ browser ## 构建 ```bash -# 默认/轻量镜像。默认解析 npm @deepseek-ai/dsh@latest。 +# 默认/轻量镜像。默认解析已发布的最高 DSH 版本。 deepseek-harness-builder/scripts/build-local.sh \ --target runtime \ --tag deepseek-harness:local @@ -51,7 +53,7 @@ deepseek-harness-builder/scripts/build-local.sh \ --tag deepseek-harness-workstation:local ``` -本地构建辅助脚本会解析请求的 `@deepseek-ai/dsh` npm 版本,在临时构建上下文中更新 `package.json` 和 `pnpm-lock.yaml`,并把解析后的版本作为 Docker `DSH_VERSION` 传入。使用 `--version ` 或 npm dist-tag 可以选择 DSH 版本。直接 `docker build` 仍支持已提交的基线上下文;未提供构建参数时会从 `package.json` 推导 `DSH_VERSION`。 +本地构建辅助脚本会解析请求的 `@deepseek-ai/dsh` npm 版本,在临时构建上下文中更新 `package.json` 和 `pnpm-lock.yaml`,并把解析后的版本作为 Docker `DSH_VERSION` 传入。未给 selector 时会按 SemVer 优先级比较全部已发布版本;使用 `--version ` 或 npm dist-tag 可以指定准确版本或发布通道。直接 `docker build` 仍支持已提交的基线上下文;未提供构建参数时会从 `package.json` 推导 `DSH_VERSION`。 生产依赖闭包由活跃构建上下文中的 `pnpm-lock.yaml` 固定。pnpm 生命周期脚本 fail-closed,并限制在 `pnpm-workspace.yaml` 中已审查的软件包内。 @@ -59,7 +61,7 @@ deepseek-harness-builder/scripts/build-local.sh \ 自定义 Caddy 构建会校验 Caddy 和 go-authcrunch 源码归档 checksum,移除 go-authcrunch 未使用的 GPG 公钥解析器,并在链接前运行上游 identity 包测试。它会对固定 Caddy 源码应用上游的两行 CEL 兼容修复,再将 `cel-go` 提升到已修复版本。caddy-security 仍保留 SSH 公钥支持,同时生成的 `CADDY_GO_PACKAGES.txt` 清单不得包含 `golang.org/x/crypto/openpgp` 包。限速模块及其许可证也会在同一构建中固定和校验。构建还会把 `grpc`、`klauspost/compress` 和 `x/text` 提升到已修复版本。 -两个镜像都包含校验和固定的独立 pnpm bundle,并移除 npm 和 Corepack。这会保留单一、已审计的 Node.js 包管理器面,避免所选 pnpm 版本静默跟随包管理器通道。 +两个镜像都包含校验和固定的独立 pnpm bundle,并移除 Corepack,避免所选 pnpm 版本静默跟随包管理器通道。轻量 runtime 还会移除 npm 和 npx;workstation 会用单独校验和固定的 npm 11 bundle 替换 Node.js 自带的旧 npm,为开发兼容性提供 npm 和 npx,并在镜像构建及 smoke 测试中校验版本。 ## 开发环境 @@ -72,14 +74,15 @@ deepseek-harness-builder/scripts/build-local.sh \ workstation 镜像继承相同的 DSH/认证运行时,并额外包含: -- Node.js 和 pnpm +- Node.js、npm、npx 和 pnpm - Python 3.12.14,含 pip、venv、pipx、pytest 和开发头文件 -- Go、Rust 和 Cargo +- Go - Docker CLI、Compose 和 Buildx,仅客户端工具 -- GCC/G++、Clang、GDB、CMake、Ninja、Autoconf/Automake、libtool、pkg-config 和常见原生库头文件 -- Git LFS、GitHub CLI、ShellCheck、shfmt、yamllint、pre-commit、fd、bat、fzf、tmux、Vim、SQLite,以及常见网络、调试、归档工具 +- GCC/G++、Clang、clang-format、GDB、CMake、Ninja、Autoconf/Automake、libtool、pkg-config 和常见原生库头文件 +- actionlint、yq、uv/uvx、Ruff、ShellCheck、shfmt、yamllint 和 pre-commit +- Git LFS、GitHub CLI、just、hyperfine、entr、fd、bat、fzf、tmux、Vim、SQLite、ncdu,以及常见网络、调试、归档工具 -它不包含 code-server、Codex/Claude CLI、代理守护进程、`sudo` 或 Docker daemon。镜像中有 Docker 客户端工具,但默认不会挂载 daemon socket,因此默认 workstation 没有宿主-容器控制通道。用户安装在 `/home/node` 下的工具会随 workstation HOME 卷持久化,项目则通过直接 `/workspace` 挂载持久化。 +默认 workstation 有意不包含 Rust 和 Cargo;需要它们的项目可以把项目专用工具链安装到持久化 HOME 卷。它也不包含 code-server、Codex/Claude CLI、代理守护进程、`sudo` 或 Docker daemon。镜像中有 Docker 客户端工具,但默认不会挂载 daemon socket,因此默认 workstation 没有宿主-容器控制通道。用户安装在 `/home/node` 下的工具会随 workstation HOME 卷持久化,项目则通过直接 `/workspace` 挂载持久化。 ## 在 1Panel/OpenResty 后运行 @@ -126,7 +129,7 @@ docker run -d \ ghcr.io/okxlin/deepseek-harness:workstation ``` -HOME 卷包含用户安装的 pnpm、pipx、Cargo、Go 工具。应用状态位于 `/data`。镜像工作目录和 `DSH_WORKSPACE` 都默认是 `/workspace`;网页的 **Add workspace** 对话框也会从这里打开,里面的 `Home` 快捷入口也解析到 `/workspace`。`/home/node` 是用户 HOME 和工具持久卷,不是默认项目目录。workspace bind 用于项目文件,以及宿主侧备份或文件访问。 +HOME 卷包含用户安装的 pnpm、pipx、uv、Go 及其他项目专用工具。应用状态位于 `/data`。镜像工作目录和 `DSH_WORKSPACE` 都默认是 `/workspace`;网页的 **Add workspace** 对话框也会从这里打开,里面的 `Home` 快捷入口也解析到 `/workspace`。`/home/node` 是用户 HOME 和工具持久卷,不是默认项目目录。workspace bind 用于项目文件,以及宿主侧备份或文件访问。 Docker CLI、Compose 和 Buildx 可以通过远程 `DOCKER_HOST` 工作,不需要额外挂载。若要控制宿主 Docker daemon,必须显式加入: @@ -360,7 +363,7 @@ docker run --rm --entrypoint tar \ ## 资源使用 -当前 amd64 认证烟雾测试稳定在约 `167-180 MiB` 和约 `20-21` 个 PID。workstation 工具链空闲时不会显著提高内存,但会提高磁盘占用:当前本地 amd64 Docker size 在 registry 压缩前约为轻量镜像 `700 MB`、workstation `2.59 GB`。Debian 重建可能改变这些数字。 +当前 amd64 认证烟雾测试稳定在约 `167-180 MiB` 和约 `20-21` 个 PID。workstation 工具链空闲时不会显著提高内存,但会提高磁盘占用:当前本地 amd64 Docker size 在 registry 压缩前约为轻量镜像 `670-700 MB`、workstation `2.1-2.2 GB`,具体取决于解析到的 DSH 版本。Debian 重建可能改变这些数字。 CI 对空闲流程的上限仍为 `256 MiB`。这不是工作负载限制:终端、仓库、语言服务器、编译器和模型工具可能需要更多内存。 @@ -377,7 +380,7 @@ deepseek-harness-builder/scripts/smoke-test.sh \ --variant runtime ``` -测试使用临时宿主 bind 的 `/data`,以及 HOME 和 workspace 的命名测试卷。workstation 会把 HOME 直接挂到 `/home/node`,把测试 workspace 直接挂到 `/workspace`;它会模拟 1Panel/OpenResty 头,并检查登录重定向、浏览器自动填充属性、错误密码拒绝、受保护 Cookie、伪造 identity header、两个 DSH WebSocket、登出、loopback 绑定、secret 隔离、JWT 状态持久化、容器重建持久化、目录选择器默认打开 `/workspace`、资源使用、fail-closed 配置错误、pnpm 和所选镜像变体。 +测试使用临时宿主 bind 的 `/data`,以及 HOME 和 workspace 的命名测试卷。workstation 会把 HOME 直接挂到 `/home/node`,把测试 workspace 直接挂到 `/workspace`;它会模拟 1Panel/OpenResty 头,并检查登录重定向、浏览器自动填充属性、错误密码拒绝、受保护 Cookie、伪造 identity header、两个 DSH WebSocket、登出、loopback 绑定、secret 隔离、JWT 状态持久化、容器重建持久化、目录选择器默认打开 `/workspace`、资源使用、fail-closed 配置错误、Node.js 包管理器合约和所选镜像变体。 如果认证由外部反向代理承担,还应运行透传合约: @@ -411,7 +414,7 @@ deepseek-harness-builder/scripts/workstation-smoke-test.sh \ --image deepseek-harness-workstation:local ``` -Compose 合约检查会解析 socket 开关的两种状态,并证明包内状态 bind 直接挂载到 `/data`、一个命名卷直接挂载到 `/home/node`、包内 workspace 直接挂载到 `/workspace`、默认 socket 源是 `/dev/null`、启用源是 `/var/run/docker.sock`,且 HTTP 端口保持 loopback 绑定。workstation 专用镜像测试会编译并运行 C、C++、Go、Rust 探针,创建 Python 虚拟环境,检查普通和登录 shell 的 PATH 行为,验证 CLI 集合,确认所有 Docker 客户端二进制文件都使用已固定的 Go 工具链且不包含旧 daemon 模块,确认 Docker 默认没有 daemon 访问,使用隔离 Unix socket 刻画可选 socket group 映射,验证镜像只声明 `/home/node`,并确认 HOME、应用状态和 workspace 都是真实可写目录而不是符号链接。它还会在 `no-new-privileges` 下运行已安装的 DSH sandbox executor:`workspace-write` 必须允许项目写入并拒绝 workspace 外可写路径,而显式 `danger-full-access` 重试必须允许外部写入且不增加容器特权。 +Compose 合约检查会解析 socket 开关的两种状态,并证明包内状态 bind 直接挂载到 `/data`、一个命名卷直接挂载到 `/home/node`、包内 workspace 直接挂载到 `/workspace`、默认 socket 源是 `/dev/null`、启用源是 `/var/run/docker.sock`,且 HTTP 端口保持 loopback 绑定。workstation 专用镜像测试会编译并运行 C、C++、Go 探针,创建 Python 虚拟环境,验证 checksum 固定的 actionlint、yq、uv/uvx 和 Ruff,检查普通和登录 shell 的 PATH 行为,验证 CLI 集合,确认 Rust 和 Cargo 保持缺席,确认所有 Docker 客户端二进制文件都使用已固定的 Go 工具链且不包含旧 daemon 模块,确认 Docker 默认没有 daemon 访问,使用隔离 Unix socket 刻画可选 socket group 映射,验证镜像只声明 `/home/node`,并确认 HOME、应用状态和 workspace 都是真实可写目录而不是符号链接。它还会在 `no-new-privileges` 下运行已安装的 DSH sandbox executor:`workspace-write` 必须允许项目写入并拒绝 workspace 外可写路径,而显式 `danger-full-access` 重试必须允许外部写入且不增加容器特权。 构建镜像后运行 Caddy 漏洞门禁: @@ -431,4 +434,4 @@ Caddy 生产二进制文件已 stripped。Go 文档说明,在没有可提取 `/data` 挂载。如果新的认证状态为空,entrypoint 会把旧 workstation 应用状态复制到 `/data`。确认迁移数据后,后续容器仍必须挂载 `/data`;旧路径不能替代它。 -Caddy 和 caddy-security 会一起编译并固定版本。不能假设只更新 Caddy 是安全的。定时工作流会解析 npm `@deepseek-ai/dsh@latest`,在构建 workspace 中临时更新 `package.json` 和 `pnpm-lock.yaml`,把解析出的版本作为 Docker `DSH_VERSION` 构建参数,并把 `runtime` target 发布为 `latest` 加 ``,把 `workstation` target 发布为 `workstation` 加 `-workstation`。手动运行可以覆盖 DSH 包版本或最终镜像标签,同时保留相同验证和可选浮动标签行为。每个工作流都会先审计冻结的 pnpm 生产依赖树、重建并验证插件、运行 Caddy 依赖图和 `govulncheck` 门禁、执行 amd64 和 arm64 烟雾合约,并对两个架构应用零可修复 HIGH/CRITICAL Trivy 门禁,然后才把验证过的多平台 manifest 推送到 GHCR 和 Docker Hub。任一 registry 登录或发布失败,工作流都会失败,而不是报告完整发布。 +Caddy 和 caddy-security 会一起编译并固定版本。不能假设只更新 Caddy 是安全的。定时发布工作流会解析 npm 中 `@deepseek-ai/dsh` 已发布的最高 SemVer,在构建 workspace 中临时更新 `package.json` 和 `pnpm-lock.yaml`,把解析出的版本作为 Docker `DSH_VERSION` 构建参数,并把 `runtime` target 发布为 `latest` 加 ``,把 `workstation` target 发布为 `workstation` 加 `-workstation`。手动运行可以覆盖 DSH 包准确版本或 dist-tag,也可覆盖最终镜像标签,同时保留相同验证和可选浮动标签行为。每个工作流都会先审计冻结的 pnpm 生产依赖树、重建并验证插件、运行 Caddy 依赖图和 `govulncheck` 门禁,并执行 amd64 和 arm64 烟雾合约,然后才把验证过的多平台 manifest 推送到 GHCR 和 Docker Hub。runtime 发布继续应用零可修复 HIGH/CRITICAL Trivy 门禁;工具链范围更广的 workstation 会阻断可修复 CRITICAL,并报告可修复 HIGH 供确定性审查,同时由每日组件检查发现新的上游版本。任一 registry 登录或发布失败,工作流都会失败,而不是报告完整发布。 diff --git a/deepseek-harness-builder/configs/component-update-policy.json b/deepseek-harness-builder/configs/component-update-policy.json new file mode 100644 index 0000000..34dd5b5 --- /dev/null +++ b/deepseek-harness-builder/configs/component-update-policy.json @@ -0,0 +1,186 @@ +{ + "version": 1, + "components": [ + { + "name": "Caddy", + "pin": "CADDY_VERSION", + "source": { + "type": "github_release", + "repo": "caddyserver/caddy" + } + }, + { + "name": "caddy-security", + "pin": "CADDY_SECURITY_VERSION", + "source": { + "type": "github_release", + "repo": "greenpau/caddy-security" + } + }, + { + "name": "go-authcrunch", + "pin": "GO_AUTHCRUNCH_VERSION", + "source": { + "type": "github_release", + "repo": "greenpau/go-authcrunch" + } + }, + { + "name": "cel-go security override", + "pin": "CEL_GO_VERSION", + "source": { + "type": "go_module", + "module": "github.com/google/cel-go" + } + }, + { + "name": "gRPC security override", + "pin": "GRPC_VERSION", + "source": { + "type": "go_module", + "module": "google.golang.org/grpc" + } + }, + { + "name": "klauspost/compress security override", + "pin": "KLAUSPOST_COMPRESS_VERSION", + "source": { + "type": "go_module", + "module": "github.com/klauspost/compress" + } + }, + { + "name": "x/text security override", + "pin": "X_TEXT_VERSION", + "source": { + "type": "go_module", + "module": "golang.org/x/text" + } + }, + { + "name": "moby/go-archive security override", + "pin": "MOBY_GO_ARCHIVE_VERSION", + "source": { + "type": "go_module", + "module": "github.com/moby/go-archive" + } + }, + { + "name": "x/mod security override", + "pin": "X_MOD_VERSION", + "source": { + "type": "go_module", + "module": "golang.org/x/mod" + } + }, + { + "name": "Python 3.12", + "pin": "PYTHON_VERSION", + "source": { + "type": "python_release_line", + "release_line": "3.12", + "url": "https://www.python.org/api/v2/downloads/release/?is_published=true" + } + }, + { + "name": "pytest", + "pin": "PYTEST_VERSION", + "source": { + "type": "pypi", + "package": "pytest" + } + }, + { + "name": "pnpm", + "pin": "PNPM_VERSION", + "source": { + "type": "npm", + "package": "pnpm" + } + }, + { + "name": "Node.js 24 LTS", + "pin_from": { + "type": "dockerfile_from", + "image": "node", + "suffix": "-trixie-slim" + }, + "source": { + "type": "node_release_line", + "major": 24 + } + }, + { + "name": "npm 11 for workstation", + "pin": "NPM_VERSION", + "source": { + "type": "npm", + "package": "npm", + "selector": "next-11" + } + }, + { + "name": "Docker CLI", + "pin": "DOCKER_VERSION", + "source": { + "type": "github_tag", + "repo": "docker/cli" + } + }, + { + "name": "Docker Compose", + "pin": "DOCKER_COMPOSE_VERSION", + "source": { + "type": "github_release", + "repo": "docker/compose" + } + }, + { + "name": "Docker Buildx", + "pin": "DOCKER_BUILDX_VERSION", + "source": { + "type": "github_release", + "repo": "docker/buildx" + } + }, + { + "name": "Go", + "pin": "GO_VERSION", + "source": { + "type": "go_stable" + } + }, + { + "name": "actionlint", + "pin": "ACTIONLINT_VERSION", + "source": { + "type": "github_release", + "repo": "rhysd/actionlint" + } + }, + { + "name": "yq v4", + "pin": "YQ_VERSION", + "source": { + "type": "github_release", + "repo": "mikefarah/yq" + } + }, + { + "name": "uv", + "pin": "UV_VERSION", + "source": { + "type": "github_release", + "repo": "astral-sh/uv" + } + }, + { + "name": "Ruff", + "pin": "RUFF_VERSION", + "source": { + "type": "github_release", + "repo": "astral-sh/ruff" + } + } + ] +} diff --git a/deepseek-harness-builder/image/Dockerfile b/deepseek-harness-builder/image/Dockerfile index 4025ffb..d55e96e 100644 --- a/deepseek-harness-builder/image/Dockerfile +++ b/deepseek-harness-builder/image/Dockerfile @@ -184,6 +184,23 @@ RUN mkdir -p /opt/pnpm \ && test "$(node /opt/pnpm/bin/pnpm.mjs --version)" = "${PNPM_VERSION}" \ && rm -f /tmp/pnpm.tgz +FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d AS npm-bundle + +ARG NPM_VERSION=11.19.0 + +# Node.js 24 currently bundles an older npm release. Keep workstation npm +# independently checksum-pinned so security fixes do not wait for a new Node +# base-image release, while the runtime target can continue omitting npm. +ADD --checksum=sha256:31e9770f7dc71119a58509353b27917557aaf0ac9b5ef1a0465ee7d8ec67ae75 \ + https://registry.npmjs.org/npm/-/npm-11.19.0.tgz /tmp/npm.tgz + +RUN mkdir -p /opt/npm \ + && tar -xzf /tmp/npm.tgz -C /opt/npm --strip-components=1 \ + && test "$(node -p "require('/opt/npm/package.json').version")" = "${NPM_VERSION}" \ + && test "$(node /opt/npm/bin/npm-cli.js --version)" = "${NPM_VERSION}" \ + && test "$(node /opt/npm/bin/npx-cli.js --version)" = "${NPM_VERSION}" \ + && rm -f /tmp/npm.tgz + FROM golang:1.26.6-trixie@sha256:b75d466dd608587fd66cca705a307ba65b889827d06ad61d6a75f0482b51b7c7 AS docker-tools-builder ARG DOCKER_BUILDX_VERSION=0.36.1 @@ -299,11 +316,6 @@ FROM golang:1.26.6-trixie@sha256:b75d466dd608587fd66cca705a307ba65b889827d06ad61 RUN go version | grep -F 'go1.26.6' -FROM rust:1.97.1-slim-trixie@sha256:8e8cf8f7fd54a2d23d5a743b3a03f56e26b6c774276c33fa0595111704ebb15c AS rust-toolchain - -RUN rustc --version | grep -F 'rustc 1.97.1 ' \ - && cargo --version | grep -F 'cargo 1.97.1 ' - FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d AS runtime-base ARG CADDY_VERSION=2.11.4 @@ -384,6 +396,110 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=45s --retries=4 CMD ["hea STOPSIGNAL SIGTERM ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] +FROM go-toolchain AS workstation-tools + +ARG TARGETARCH +ARG ACTIONLINT_VERSION=1.7.12 +ARG ACTIONLINT_SOURCE_SHA256=454800bd4f854592bcfe79b161f71d56e35940eb7016e48a26dd356adc9d400a +ARG RUFF_VERSION=0.16.3 +ARG RUFF_SHA256_AMD64=7ab3b978d2c0b1c96b2323d4e5c4f35284ae1cdf35d2f7399595c74c805f5fa3 +ARG RUFF_SHA256_ARM64=b9cc833f5db856484b38718c9da195a6ec990707307bda30530913a09705419a +ARG UV_VERSION=0.12.5 +ARG UV_SHA256_AMD64=68a509da24b06b4223a1c0175fb5eb5bc79342b76cbeff0cfe51ac3f5b17b6b2 +ARG UV_SHA256_ARM64=9bf43b4d1a07665bf64d4c4e710930b382321a785e0eb10aac07f46471f86a31 +ARG YQ_VERSION=4.53.6 +ARG YQ_SHA256_AMD64=c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385 +ARG YQ_SHA256_ARM64=88a1016bc1d657375a35864e4f44b6f333df8ff97b559f51bba0adcb2169df09 + +RUN case "${TARGETARCH}" in \ + amd64) \ + astral_arch=x86_64; \ + ruff_sha256="${RUFF_SHA256_AMD64}"; \ + uv_sha256="${UV_SHA256_AMD64}"; \ + yq_arch=amd64; \ + yq_sha256="${YQ_SHA256_AMD64}" \ + ;; \ + arm64) \ + astral_arch=aarch64; \ + ruff_sha256="${RUFF_SHA256_ARM64}"; \ + uv_sha256="${UV_SHA256_ARM64}"; \ + yq_arch=arm64; \ + yq_sha256="${YQ_SHA256_ARM64}" \ + ;; \ + *) \ + printf 'unsupported workstation tool architecture: %s\n' "${TARGETARCH}" >&2; \ + exit 1 \ + ;; \ + esac \ + && install -d \ + /out/bin \ + /out/licenses/actionlint \ + /out/licenses/ruff \ + /out/licenses/uv \ + /out/licenses/yq \ + /tmp/actionlint-source \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/actionlint-source.tar.gz \ + "https://codeload.github.com/rhysd/actionlint/tar.gz/refs/tags/v${ACTIONLINT_VERSION}" \ + && printf '%s %s\n' "${ACTIONLINT_SOURCE_SHA256}" /tmp/actionlint-source.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/actionlint-source.tar.gz -C /tmp/actionlint-source --strip-components=1 \ + && grep -Fxq 'module github.com/rhysd/actionlint' /tmp/actionlint-source/go.mod \ + && cd /tmp/actionlint-source \ + && go mod download \ + && go mod verify \ + && CGO_ENABLED=0 go build \ + -mod=readonly \ + -trimpath \ + -ldflags="-s -w -X github.com/rhysd/actionlint.version=${ACTIONLINT_VERSION}" \ + -o /out/bin/actionlint \ + ./cmd/actionlint \ + && cd / \ + && install -m 0644 /tmp/actionlint-source/LICENSE.txt /out/licenses/actionlint/LICENSE.txt \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/yq \ + "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${yq_arch}" \ + && printf '%s %s\n' "${yq_sha256}" /tmp/yq | sha256sum -c - \ + && install -m 0755 /tmp/yq /out/bin/yq \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/yq-license \ + "https://raw.githubusercontent.com/mikefarah/yq/v${YQ_VERSION}/LICENSE" \ + && printf '%s %s\n' \ + '697db34dabb21562fe84487a2ccd031fbd45382b89c2cbdec8ef31682c486040' \ + /tmp/yq-license | sha256sum -c - \ + && install -m 0644 /tmp/yq-license /out/licenses/yq/LICENSE \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/uv.tar.gz \ + "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${astral_arch}-unknown-linux-gnu.tar.gz" \ + && printf '%s %s\n' "${uv_sha256}" /tmp/uv.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/uv.tar.gz -C /tmp \ + && install -m 0755 "/tmp/uv-${astral_arch}-unknown-linux-gnu/uv" /out/bin/uv \ + && install -m 0755 "/tmp/uv-${astral_arch}-unknown-linux-gnu/uvx" /out/bin/uvx \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/uv-license-mit \ + "https://raw.githubusercontent.com/astral-sh/uv/${UV_VERSION}/LICENSE-MIT" \ + && printf '%s %s\n' \ + '860e3d7a86b84e6a7012c7a635fc64df475cebc6cce34dfeb73a5982ec58176c' \ + /tmp/uv-license-mit | sha256sum -c - \ + && install -m 0644 /tmp/uv-license-mit /out/licenses/uv/LICENSE-MIT \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/uv-license-apache \ + "https://raw.githubusercontent.com/astral-sh/uv/${UV_VERSION}/LICENSE-APACHE" \ + && printf '%s %s\n' \ + 'c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4' \ + /tmp/uv-license-apache | sha256sum -c - \ + && install -m 0644 /tmp/uv-license-apache /out/licenses/uv/LICENSE-APACHE \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/ruff.tar.gz \ + "https://github.com/astral-sh/ruff/releases/download/${RUFF_VERSION}/ruff-${astral_arch}-unknown-linux-gnu.tar.gz" \ + && printf '%s %s\n' "${ruff_sha256}" /tmp/ruff.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/ruff.tar.gz -C /tmp \ + && install -m 0755 "/tmp/ruff-${astral_arch}-unknown-linux-gnu/ruff" /out/bin/ruff \ + && curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLo /tmp/ruff-license \ + "https://raw.githubusercontent.com/astral-sh/ruff/${RUFF_VERSION}/LICENSE" \ + && printf '%s %s\n' \ + '2597d854122b77ddc71971564ca2350a37608575ce324adc5650a2b2051c8f18' \ + /tmp/ruff-license | sha256sum -c - \ + && install -m 0644 /tmp/ruff-license /out/licenses/ruff/LICENSE \ + && /out/bin/actionlint -version | grep -Fx "${ACTIONLINT_VERSION}" \ + && go version -m /out/bin/actionlint | grep -F 'go1.26.6' \ + && /out/bin/yq --version | grep -F "version v${YQ_VERSION}" \ + && /out/bin/uv --version | grep -F "uv ${UV_VERSION} " \ + && /out/bin/uvx --version | grep -F "uvx ${UV_VERSION} " \ + && /out/bin/ruff --version | grep -Fx "ruff ${RUFF_VERSION}" + FROM runtime-base AS workstation WORKDIR / @@ -392,13 +508,12 @@ ARG DOCKER_BUILDX_VERSION=0.36.1 ARG DOCKER_COMPOSE_VERSION=5.5.0 ARG DOCKER_VERSION=29.7.2 ARG GO_VERSION=1.26.6 +ARG NPM_VERSION=11.19.0 ARG PYTHON_VERSION=3.12.14 ARG PYTEST_VERSION=9.1.1 ARG PNPM_VERSION=11.22.0 -ARG RUST_VERSION=1.97.1 -ENV CARGO_HOME=/home/node/.cargo \ - AUTH_STATE_DIR=/data/auth \ +ENV AUTH_STATE_DIR=/data/auth \ CADDY_CONFIG_HOME=/data/caddy/config \ CADDY_DATA_HOME=/data/caddy/data \ DSH_IMAGE_VARIANT=workstation \ @@ -406,17 +521,17 @@ ENV CARGO_HOME=/home/node/.cargo \ GOPATH=/home/node/go \ PIPX_BIN_DIR=/home/node/.local/bin \ PIPX_HOME=/home/node/.local/pipx \ - RUSTUP_HOME=/usr/local/rustup \ - PATH=/home/node/.local/bin:/home/node/go/bin:/home/node/.cargo/bin:/usr/local/go/bin:/usr/local/cargo/bin:/home/node/.local/share/pnpm:/opt/dsh/node_modules/.bin:${PATH} + PATH=/home/node/.local/bin:/home/node/go/bin:/usr/local/go/bin:/home/node/.local/share/pnpm:/opt/dsh/node_modules/.bin:${PATH} COPY --from=go-toolchain /usr/local/go /usr/local/go -COPY --from=rust-toolchain /usr/local/cargo /usr/local/cargo -COPY --from=rust-toolchain /usr/local/rustup /usr/local/rustup COPY --from=docker-tools-builder /out/docker /usr/local/bin/docker COPY --from=docker-tools-builder /out/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx COPY --from=docker-tools-builder /out/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose COPY --from=docker-tools-builder /licenses /usr/share/licenses/deepseek-harness-docker +COPY --from=workstation-tools /out/bin/ /usr/local/bin/ +COPY --from=workstation-tools /out/licenses/ /usr/share/licenses/deepseek-harness-workstation-tools/ COPY --from=python-toolchain /usr/local /usr/local +COPY --from=npm-bundle /opt/npm /opt/npm RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -426,17 +541,21 @@ RUN apt-get update \ bind9-dnsutils \ build-essential \ clang \ + clang-format \ cmake \ diffutils \ direnv \ + entr \ fd-find \ fzf \ gdb \ gh \ git-lfs \ htop \ + hyperfine \ iproute2 \ iputils-ping \ + just \ libbz2-dev \ libffi-dev \ liblzma-dev \ @@ -445,10 +564,13 @@ RUN apt-get update \ libssl-dev \ libtool \ lsof \ + mtr-tiny \ + ncdu \ netcat-openbsd \ ninja-build \ openssl \ patch \ + pigz \ pipx \ pkg-config \ pre-commit \ @@ -477,12 +599,12 @@ RUN apt-get update \ /usr/local/bin/corepack \ /usr/local/bin/npm \ /usr/local/bin/npx \ + && ln -s /opt/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s /opt/npm/bin/npx-cli.js /usr/local/bin/npx \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && ln -s /usr/bin/batcat /usr/local/bin/bat \ && install -d -m 0750 -o node -g node \ /home/node/.cache \ - /home/node/.cargo \ - /home/node/.cargo/bin \ /home/node/.local \ /home/node/.local/bin \ /home/node/.local/pipx \ @@ -490,16 +612,17 @@ RUN apt-get update \ /home/node/go \ && chown -R node:node /home/node \ && test "$(pnpm --version)" = "${PNPM_VERSION}" \ + && test "$(npm --version)" = "${NPM_VERSION}" \ + && test "$(npx --version)" = "${NPM_VERSION}" \ && docker --version | grep -F "Docker version ${DOCKER_VERSION}," \ && docker compose version | grep -F "Docker Compose version v${DOCKER_COMPOSE_VERSION}" \ && docker buildx version | grep -F "github.com/docker/buildx v${DOCKER_BUILDX_VERSION} " \ && go version | grep -F "go${GO_VERSION}" \ - && rustc --version | grep -F "rustc ${RUST_VERSION} " \ - && cargo --version | grep -F "cargo ${RUST_VERSION} " \ && python3 --version | grep -Fx "Python ${PYTHON_VERSION}" \ && python3 -m pytest --version | grep -Fx "pytest ${PYTEST_VERSION}" \ && gcc --version \ - && ! command -v npm \ + && ! command -v rustc \ + && ! command -v cargo \ && ! command -v corepack COPY profile.d/workstation.sh /etc/profile.d/deepseek-harness-workstation.sh diff --git a/deepseek-harness-builder/image/profile.d/workstation.sh b/deepseek-harness-builder/image/profile.d/workstation.sh index 5432c70..fc73546 100644 --- a/deepseek-harness-builder/image/profile.d/workstation.sh +++ b/deepseek-harness-builder/image/profile.d/workstation.sh @@ -2,5 +2,5 @@ # Debian login shells replace PATH in /etc/profile. Restore the workstation # tool and user-install locations without changing the lightweight image. -PATH="/home/node/.local/bin:/home/node/go/bin:/home/node/.cargo/bin:/usr/local/go/bin:/usr/local/cargo/bin:/home/node/.local/share/pnpm:/opt/dsh/node_modules/.bin:${PATH}" +PATH="/home/node/.local/bin:/home/node/go/bin:/usr/local/go/bin:/home/node/.local/share/pnpm:/opt/dsh/node_modules/.bin:${PATH}" export PATH diff --git a/deepseek-harness-builder/scripts/build-local.sh b/deepseek-harness-builder/scripts/build-local.sh index ca8ec84..4649c46 100755 --- a/deepseek-harness-builder/scripts/build-local.sh +++ b/deepseek-harness-builder/scripts/build-local.sh @@ -16,7 +16,8 @@ Usage: build-local.sh [options] [-- DOCKER_BUILD_ARGS...] Options: --target TARGET Docker target: runtime or workstation (default: runtime) --tag TAG Local image tag; defaults by target - --version VERSION DeepSeek Harness npm version or dist-tag; defaults to latest + --version VERSION DeepSeek Harness npm version or dist-tag; defaults to the + highest published semantic version --platform PLATFORM Optional Docker build platform -h, --help Show this help EOF diff --git a/deepseek-harness-builder/scripts/check-component-pins.sh b/deepseek-harness-builder/scripts/check-component-pins.sh index c06493d..f1a8c6f 100755 --- a/deepseek-harness-builder/scripts/check-component-pins.sh +++ b/deepseek-harness-builder/scripts/check-component-pins.sh @@ -150,7 +150,11 @@ for name_and_count in \ 'DOCKER_COMPOSE_VERSION:2' \ 'DOCKER_VERSION:2' \ 'GO_VERSION:1' \ - 'RUST_VERSION:1' \ + 'NPM_VERSION:2' \ + 'ACTIONLINT_VERSION:1' \ + 'RUFF_VERSION:1' \ + 'UV_VERSION:1' \ + 'YQ_VERSION:1' \ 'MOBY_GO_ARCHIVE_VERSION:1' \ 'X_MOD_VERSION:1'; do require_arg "${name_and_count%%:*}" "${name_and_count##*:}" @@ -161,6 +165,19 @@ checksum_value="${arg_values[${checksum_arg}]:-}" [[ "${checksum_value}" =~ ^[a-f0-9]{64}$ ]] \ || fail "ARG ${checksum_arg} must be a lowercase SHA-256 digest" +for checksum_arg in \ + ACTIONLINT_SOURCE_SHA256 \ + RUFF_SHA256_AMD64 \ + RUFF_SHA256_ARM64 \ + UV_SHA256_AMD64 \ + UV_SHA256_ARM64 \ + YQ_SHA256_AMD64 \ + YQ_SHA256_ARM64; do + checksum_value="${arg_values[${checksum_arg}]:-}" + [[ "${checksum_value}" =~ ^[a-f0-9]{64}$ ]] \ + || fail "ARG ${checksum_arg} must be a lowercase SHA-256 digest" +done + [[ "${arg_values[CADDY_RATELIMIT_REF]:-}" =~ ^[a-f0-9]{40}$ ]] \ || fail 'ARG CADDY_RATELIMIT_REF must be an immutable 40-character Git commit' @@ -253,8 +270,7 @@ for line_number in "${!docker_lines[@]}"; do || fail "Go base-image tag ${image_tag} does not match ARG GO_VERSION" ;; rust) - [[ "${image_tag}" == "${arg_values[RUST_VERSION]:-}-slim-trixie" ]] \ - || fail "Rust base-image tag ${image_tag} does not match ARG RUST_VERSION" + fail 'Dockerfile must not use a Rust base image' ;; node) if [[ -z "${node_tag}" ]]; then @@ -272,16 +288,28 @@ for line_number in "${!docker_lines[@]}"; do fi done -for required_image in caddy python node golang rust; do +for required_image in caddy python node golang; do (( ${image_counts[${required_image}]:-0} > 0 )) \ || fail "Dockerfile must retain a pinned ${required_image} base image" done +for removed_rust_literal in \ + 'ARG RUST_VERSION=' \ + 'CARGO_HOME=' \ + 'RUSTUP_HOME=' \ + '/usr/local/cargo' \ + '/usr/local/rustup'; do + if grep -Fq -- "${removed_rust_literal}" "${dockerfile}"; then + fail "Dockerfile must not retain removed Rust workstation input: ${removed_rust_literal}" + fi +done + [[ "${node_tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-trixie-slim$ ]] \ || fail "Node base-image tag must be an exact Trixie patch release, got: ${node_tag:-missing}" declare -A expected_remote_adds=() expected_remote_adds["https://registry.npmjs.org/pnpm/-/pnpm-${arg_values[PNPM_VERSION]:-}.tgz"]=1 +expected_remote_adds["https://registry.npmjs.org/npm/-/npm-${arg_values[NPM_VERSION]:-}.tgz"]=1 expected_remote_adds["https://codeload.github.com/docker/cli/tar.gz/refs/tags/v${arg_values[DOCKER_VERSION]:-}"]=1 expected_remote_adds["https://codeload.github.com/docker/compose/tar.gz/refs/tags/v${arg_values[DOCKER_COMPOSE_VERSION]:-}"]=1 expected_remote_adds["https://codeload.github.com/docker/buildx/tar.gz/refs/tags/v${arg_values[DOCKER_BUILDX_VERSION]:-}"]=1 @@ -344,6 +372,51 @@ require_literal \ require_literal \ "golang.org/x/mod@v\${X_MOD_VERSION}" \ 'the Buildx and Compose x/mod version pin' +require_literal \ + 'https://codeload.github.com/rhysd/actionlint/tar.gz/refs/tags/v${ACTIONLINT_VERSION}' \ + 'the actionlint source URL tied to ACTIONLINT_VERSION' +require_literal \ + 'CGO_ENABLED=0 go build' \ + 'the actionlint source build' +require_literal \ + '"${ACTIONLINT_SOURCE_SHA256}" /tmp/actionlint-source.tar.gz | sha256sum -c -' \ + 'the actionlint source checksum verification' +require_literal \ + 'https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${yq_arch}' \ + 'the yq release URL tied to YQ_VERSION' +require_literal \ + 'ruff_sha256="${RUFF_SHA256_AMD64}"' \ + 'the amd64 Ruff checksum selection' +require_literal \ + 'ruff_sha256="${RUFF_SHA256_ARM64}"' \ + 'the arm64 Ruff checksum selection' +require_literal \ + 'uv_sha256="${UV_SHA256_AMD64}"' \ + 'the amd64 uv checksum selection' +require_literal \ + 'uv_sha256="${UV_SHA256_ARM64}"' \ + 'the arm64 uv checksum selection' +require_literal \ + 'yq_sha256="${YQ_SHA256_AMD64}"' \ + 'the amd64 yq checksum selection' +require_literal \ + 'yq_sha256="${YQ_SHA256_ARM64}"' \ + 'the arm64 yq checksum selection' +require_literal \ + '"${yq_sha256}" /tmp/yq | sha256sum -c -' \ + 'the yq release checksum verification' +require_literal \ + 'https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${astral_arch}-unknown-linux-gnu.tar.gz' \ + 'the uv release URL tied to UV_VERSION' +require_literal \ + '"${uv_sha256}" /tmp/uv.tar.gz | sha256sum -c -' \ + 'the uv release checksum verification' +require_literal \ + 'https://github.com/astral-sh/ruff/releases/download/${RUFF_VERSION}/ruff-${astral_arch}-unknown-linux-gnu.tar.gz' \ + 'the Ruff release URL tied to RUFF_VERSION' +require_literal \ + '"${ruff_sha256}" /tmp/ruff.tar.gz | sha256sum -c -' \ + 'the Ruff release checksum verification' if grep -nE -- '(:latest|refs/heads/(main|master)|@(main|master))' "${dockerfile}" >/dev/null; then fail 'Dockerfile contains a forbidden floating source reference' diff --git a/deepseek-harness-builder/scripts/check-component-updates.py b/deepseek-harness-builder/scripts/check-component-updates.py new file mode 100644 index 0000000..43d3694 --- /dev/null +++ b/deepseek-harness-builder/scripts/check-component-updates.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +"""Compare DeepSeek Harness component pins with authoritative upstream releases.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Iterable + + +SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +ARG_RE = re.compile( + r"^\s*ARG\s+([A-Za-z_][A-Za-z0-9_]*)=([^\s#]*)\s*(?:#.*)?$", + re.IGNORECASE, +) +FROM_RE = re.compile(r"^\s*FROM\s+(?:(?:--\S+)\s+)*(\S+)", re.IGNORECASE) + + +class UpdateCheckError(RuntimeError): + """A deterministic pin or upstream-source check failed.""" + + +class SameOriginHTTPSRedirectHandler(urllib.request.HTTPRedirectHandler): + """Keep metadata redirects on the original HTTPS origin.""" + + def redirect_request( + self, + request: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + new_url: str, + ) -> urllib.request.Request | None: + absolute_url = urllib.parse.urljoin(request.full_url, new_url) + original = urllib.parse.urlparse(request.full_url) + redirected = urllib.parse.urlparse(absolute_url) + if ( + redirected.scheme != "https" + or not redirected.hostname + or redirected.username is not None + or redirected.password is not None + or (redirected.hostname, redirected.port) + != (original.hostname, original.port) + ): + raise UpdateCheckError( + f"component source redirected outside its HTTPS origin: {absolute_url}" + ) + return super().redirect_request( + request, fp, code, msg, headers, absolute_url + ) + + +class SourceClient: + MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + + def __init__(self, fixture_dir: Path | None = None) -> None: + self.fixture_dir = fixture_dir + + def get_text(self, fixture: str, url: str) -> str: + if self.fixture_dir is not None: + fixture_path = self.fixture_dir / fixture + try: + payload = fixture_path.read_bytes() + except OSError as exc: + raise UpdateCheckError( + f"cannot read fixture {fixture_path}: {exc}" + ) from exc + if len(payload) > self.MAX_RESPONSE_BYTES: + raise UpdateCheckError( + f"fixture {fixture_path} exceeds {self.MAX_RESPONSE_BYTES} bytes" + ) + try: + return payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise UpdateCheckError(f"fixture {fixture_path} is not UTF-8") from exc + + parsed_url = urllib.parse.urlparse(url) + if ( + parsed_url.scheme != "https" + or not parsed_url.hostname + or parsed_url.username is not None + or parsed_url.password is not None + ): + raise UpdateCheckError(f"component source must use an absolute HTTPS URL: {url}") + + headers = {"User-Agent": "release-factory-component-update-check"} + hostname = parsed_url.hostname + if hostname == "api.github.com": + headers["Accept"] = "application/vnd.github+json" + headers["X-GitHub-Api-Version"] = "2022-11-28" + github_token = os.environ.get("GITHUB_TOKEN", "") + if github_token and hostname == "api.github.com": + headers["Authorization"] = f"Bearer {github_token}" + + request = urllib.request.Request(url, headers=headers) + opener = urllib.request.build_opener(SameOriginHTTPSRedirectHandler()) + try: + with opener.open(request, timeout=30) as response: + final_url = urllib.parse.urlparse(response.geturl()) + if ( + final_url.scheme != "https" + or not final_url.hostname + or (final_url.hostname, final_url.port) + != (parsed_url.hostname, parsed_url.port) + ): + raise UpdateCheckError( + "component source redirected outside its HTTPS origin: " + f"{response.geturl()}" + ) + content_length = response.headers.get("Content-Length") + if content_length and int(content_length) > self.MAX_RESPONSE_BYTES: + raise UpdateCheckError( + f"component source response exceeds {self.MAX_RESPONSE_BYTES} bytes: {url}" + ) + payload = response.read(self.MAX_RESPONSE_BYTES + 1) + if len(payload) > self.MAX_RESPONSE_BYTES: + raise UpdateCheckError( + f"component source response exceeds {self.MAX_RESPONSE_BYTES} bytes: {url}" + ) + return payload.decode("utf-8") + except ( + OSError, + TimeoutError, + UnicodeDecodeError, + ValueError, + urllib.error.URLError, + ) as exc: + raise UpdateCheckError(f"cannot fetch {url}: {exc}") from exc + + def get_json(self, fixture: str, url: str) -> Any: + try: + return json.loads(self.get_text(fixture, url)) + except json.JSONDecodeError as exc: + raise UpdateCheckError(f"invalid JSON from {url}: {exc}") from exc + + +def parse_args() -> argparse.Namespace: + script_dir = Path(__file__).resolve().parent + builder_dir = script_dir.parent + parser = argparse.ArgumentParser( + description="Check pinned DeepSeek Harness components for upstream updates." + ) + parser.add_argument( + "--dockerfile", + type=Path, + default=builder_dir / "image" / "Dockerfile", + help="Dockerfile containing component pins", + ) + parser.add_argument( + "--policy", + type=Path, + default=builder_dir / "configs" / "component-update-policy.json", + help="component source policy", + ) + parser.add_argument("--fixture-dir", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--summary", type=Path, help="write a Markdown summary") + parser.add_argument( + "--fail-on-updates", + action="store_true", + help="exit 1 when one or more newer versions are found", + ) + return parser.parse_args() + + +def semver_key(version: str) -> tuple[int, int, int]: + match = SEMVER_RE.fullmatch(version) + if not match: + raise UpdateCheckError(f"unsupported non-stable semantic version: {version}") + return tuple(int(part) for part in match.groups()) + + +def normalize_semver(version: str) -> str: + semver_key(version) + return version.removeprefix("v") + + +def latest_stable(versions: Iterable[str]) -> str: + stable = [ + normalize_semver(version) + for version in versions + if isinstance(version, str) and SEMVER_RE.fullmatch(version) + ] + if not stable: + raise UpdateCheckError("upstream returned no stable semantic versions") + return max(stable, key=semver_key) + + +def read_dockerfile(dockerfile: Path) -> tuple[str, dict[str, str]]: + try: + dockerfile_text = dockerfile.read_text(encoding="utf-8") + except OSError as exc: + raise UpdateCheckError(f"cannot read Dockerfile {dockerfile}: {exc}") from exc + + pins: dict[str, str] = {} + for line in dockerfile_text.splitlines(): + match = ARG_RE.match(line) + if not match: + continue + name, value = match.groups() + if name in pins and pins[name] != value: + raise UpdateCheckError( + f"Dockerfile ARG {name} has inconsistent values: {pins[name]} and {value}" + ) + pins[name] = value + return dockerfile_text, pins + + +def dockerfile_from_version( + component_name: str, + dockerfile_text: str, + image: str, + suffix: str, +) -> str: + versions: set[str] = set() + tag_pattern = re.compile(rf"^(v?\d+\.\d+\.\d+){re.escape(suffix)}$") + + for line in dockerfile_text.splitlines(): + match = FROM_RE.match(line) + if not match: + continue + image_ref = match.group(1) + if "@sha256:" not in image_ref: + continue + name_and_tag = image_ref.rsplit("@", 1)[0] + slash_index = name_and_tag.rfind("/") + colon_index = name_and_tag.rfind(":") + if colon_index <= slash_index: + continue + repository = name_and_tag[:colon_index] + tag = name_and_tag[colon_index + 1 :] + if repository.rsplit("/", 1)[-1] != image: + continue + tag_match = tag_pattern.fullmatch(tag) + if tag_match: + versions.add(normalize_semver(tag_match.group(1))) + + if len(versions) != 1: + raise UpdateCheckError( + f"component {component_name} expected one pinned {image} base-image " + f"version, got {sorted(versions)}" + ) + return versions.pop() + + +def resolve_current( + component: dict[str, Any], + pins: dict[str, str], + dockerfile_text: str, +) -> str: + pin_name = component.get("pin") + if isinstance(pin_name, str) and pin_name: + current = pins.get(pin_name, "") + if not current: + raise UpdateCheckError(f"required Dockerfile ARG {pin_name} is missing") + return normalize_semver(current) + + pin_from = component.get("pin_from") + if not isinstance(pin_from, dict) or pin_from.get("type") != "dockerfile_from": + raise UpdateCheckError( + f"component {component.get('name', 'unknown component')} has no supported pin selector" + ) + return dockerfile_from_version( + str(component.get("name", "unknown component")), + dockerfile_text, + str(pin_from["image"]), + str(pin_from["suffix"]), + ) + + +def node_release_entries(data: Any, major: int) -> list[dict[str, Any]]: + if not isinstance(data, list): + raise UpdateCheckError("Node.js release index is not a JSON array") + entries = [ + entry + for entry in data + if isinstance(entry, dict) + and entry.get("lts") not in (False, None, "") + and isinstance(entry.get("version"), str) + and SEMVER_RE.fullmatch(entry["version"]) + and semver_key(entry["version"])[0] == major + ] + if not entries: + raise UpdateCheckError(f"Node.js release index has no stable LTS v{major} release") + return entries + + +def latest_node_release(data: Any, major: int) -> dict[str, Any]: + return max(node_release_entries(data, major), key=lambda entry: semver_key(entry["version"])) + + +def upstream_version( + component: dict[str, Any], client: SourceClient +) -> tuple[str, str]: + source = component.get("source") + if not isinstance(source, dict): + raise UpdateCheckError("component source policy is missing") + source_type = source.get("type") + + if source_type == "github_release": + repo = str(source["repo"]) + url = f"https://api.github.com/repos/{repo}/releases/latest" + data = client.get_json(f"github-release-{repo.replace('/', '-')}.json", url) + if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str): + raise UpdateCheckError(f"GitHub latest release for {repo} has no tag_name") + tag_name = data["tag_name"] + version = normalize_semver(tag_name) + encoded_tag = urllib.parse.quote(tag_name, safe="") + return version, f"https://github.com/{repo}/releases/tag/{encoded_tag}" + + if source_type == "github_tag": + repo = str(source["repo"]) + url = f"https://api.github.com/repos/{repo}/tags?per_page=100" + data = client.get_json(f"github-tags-{repo.replace('/', '-')}.json", url) + if not isinstance(data, list): + raise UpdateCheckError(f"GitHub tags for {repo} are not a JSON array") + version = latest_stable( + entry.get("name", "") for entry in data if isinstance(entry, dict) + ) + return version, f"https://github.com/{repo}/releases/tag/v{version}" + + if source_type == "go_module": + module = str(source["module"]) + escaped = urllib.parse.quote(module, safe="/") + url = f"https://proxy.golang.org/{escaped}/@v/list" + versions = client.get_text( + f"go-module-{module.replace('/', '-')}.txt", url + ).splitlines() + return latest_stable(versions), f"https://pkg.go.dev/{module}" + + if source_type == "go_stable": + url = "https://go.dev/VERSION?m=text" + lines = client.get_text("go-stable.txt", url).splitlines() + if not lines: + raise UpdateCheckError("Go stable endpoint returned an empty response") + return normalize_semver(lines[0].removeprefix("go")), "https://go.dev/dl/" + + if source_type == "node_release_line": + url = "https://nodejs.org/dist/index.json" + major = int(source["major"]) + release = latest_node_release(client.get_json("node-index.json", url), major) + node_version = normalize_semver(release["version"]) + release_url = f"https://nodejs.org/en/download/archive/v{node_version}" + return node_version, release_url + + if source_type == "npm": + package = str(source["package"]) + selector = str(source.get("selector", "latest")) + if not re.fullmatch(r"[A-Za-z0-9._-]+", selector): + raise UpdateCheckError(f"invalid npm selector for {package}: {selector}") + encoded = urllib.parse.quote(package, safe="") + encoded_selector = urllib.parse.quote(selector, safe="") + url = f"https://registry.npmjs.org/{encoded}/{encoded_selector}" + fixture = f"npm-{encoded}.json" + if selector != "latest": + fixture = f"npm-{encoded}-{encoded_selector}.json" + data = client.get_json(fixture, url) + if not isinstance(data, dict) or not isinstance(data.get("version"), str): + raise UpdateCheckError(f"npm metadata for {package} has no version") + version = normalize_semver(data["version"]) + return version, f"https://www.npmjs.com/package/{package}/v/{version}" + + if source_type == "pypi": + package = str(source["package"]) + url = f"https://pypi.org/pypi/{package}/json" + data = client.get_json(f"pypi-{package}.json", url) + try: + version = data["info"]["version"] + except (KeyError, TypeError) as exc: + raise UpdateCheckError(f"PyPI metadata for {package} has no version") from exc + if not isinstance(version, str): + raise UpdateCheckError(f"PyPI metadata for {package} has an invalid version") + return normalize_semver(version), f"https://pypi.org/project/{package}/" + + if source_type == "python_release_line": + url = str(source["url"]) + release_line = str(source["release_line"]) + data = client.get_json(f"python-{release_line}.json", url) + if not isinstance(data, list): + raise UpdateCheckError("Python release API response is not a JSON array") + versions: list[str] = [] + for release in data: + if not isinstance(release, dict) or release.get("pre_release") is True: + continue + name = release.get("name", "") + match = re.fullmatch(r"Python (\d+\.\d+\.\d+)", name) + if match and match.group(1).startswith(f"{release_line}."): + versions.append(match.group(1)) + latest = latest_stable(versions) + slug = latest.replace(".", "") + return latest, f"https://www.python.org/downloads/release/python-{slug}/" + + raise UpdateCheckError(f"unsupported source type: {source_type}") + + +def markdown_cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def format_summary(rows: list[dict[str, str]], errors: list[str]) -> str: + lines = [ + "## DeepSeek Harness component update check", + "", + "| Component | Pinned | Upstream | Status |", + "| --- | --- | --- | --- |", + ] + for row in rows: + name = markdown_cell(row["name"]) + current = markdown_cell(row["current"]) + upstream = markdown_cell(row["upstream"]) + status = markdown_cell(row["status"]) + lines.append( + f"| {name} | `{current}` | [{upstream}]({row['url']}) | {status} |" + ) + if errors: + lines.extend(["", "### Source errors", ""]) + lines.extend(f"- {markdown_cell(error)}" for error in errors) + lines.extend( + [ + "", + "This workflow is read-only. Update candidates still require reviewed pins, " + "checksum/digest refreshes where applicable, and the existing build, smoke, " + "and vulnerability gates.", + "", + ] + ) + return "\n".join(lines) + + +def load_policy(policy_path: Path) -> list[dict[str, Any]]: + try: + policy = json.loads(policy_path.read_text(encoding="utf-8")) + except OSError as exc: + raise UpdateCheckError(f"cannot read policy {policy_path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise UpdateCheckError(f"invalid policy JSON {policy_path}: {exc}") from exc + if not isinstance(policy, dict) or policy.get("version") != 1: + raise UpdateCheckError("component update policy must be an object with version 1") + components = policy.get("components") + if not isinstance(components, list) or not components: + raise UpdateCheckError("component update policy has no components") + if not all(isinstance(component, dict) for component in components): + raise UpdateCheckError("component update policy contains a non-object component") + return components + + +def write_summary(path: Path, summary: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as summary_file: + summary_file.write(summary) + except OSError as exc: + raise UpdateCheckError(f"cannot write summary {path}: {exc}") from exc + + +def main() -> int: + args = parse_args() + try: + components = load_policy(args.policy) + dockerfile_text, pins = read_dockerfile(args.dockerfile) + except UpdateCheckError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + client = SourceClient(args.fixture_dir) + rows: list[dict[str, str]] = [] + errors: list[str] = [] + updates = 0 + + for component in components: + name = str(component.get("name", "unknown component")) + try: + current = resolve_current(component, pins, dockerfile_text) + upstream, url = upstream_version(component, client) + if semver_key(upstream) > semver_key(current): + status = "update available" + updates += 1 + elif semver_key(upstream) == semver_key(current): + status = "current" + else: + status = "pinned newer than source" + rows.append( + { + "name": name, + "current": current, + "upstream": upstream, + "url": url, + "status": status, + } + ) + except (KeyError, TypeError, ValueError, UpdateCheckError) as exc: + errors.append(f"{name}: {exc}") + + summary = format_summary(rows, errors) + print(summary) + if args.summary: + try: + write_summary(args.summary, summary) + except UpdateCheckError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + print(f"component_updates={updates}") + if errors: + print(f"ERROR: {len(errors)} component source checks failed", file=sys.stderr) + return 2 + if updates: + message = f"{updates} pinned component update candidate(s) found" + print(f"WARNING: {message}", file=sys.stderr) + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::warning title=Component updates available::{message}") + if args.fail_on_updates: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deepseek-harness-builder/scripts/prepare-dsh-version.sh b/deepseek-harness-builder/scripts/prepare-dsh-version.sh index 354c05a..5c0b161 100755 --- a/deepseek-harness-builder/scripts/prepare-dsh-version.sh +++ b/deepseek-harness-builder/scripts/prepare-dsh-version.sh @@ -5,6 +5,7 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" image_dir="$(cd -- "${script_dir}/../image" && pwd)" dsh_version="" github_output_path="${GITHUB_OUTPUT:-/dev/null}" +version_resolver="${script_dir}/resolve-latest-npm-version.mjs" usage() { cat <<'EOF' @@ -12,7 +13,8 @@ Usage: prepare-dsh-version.sh [options] Options: --image-dir DIR Image build context directory - --version VERSION DeepSeek Harness version; defaults to npm latest + --version VERSION DeepSeek Harness version or dist-tag; defaults to the + highest published npm semantic version --github-output FILE GitHub Actions output file -h, --help Show this help EOF @@ -57,9 +59,15 @@ while [[ $# -gt 0 ]]; do done if [[ -z "${dsh_version}" ]]; then - dsh_version="$(npm view @deepseek-ai/dsh version)" + dsh_version="$( + npm view @deepseek-ai/dsh versions --json --prefer-online \ + | node "${version_resolver}" + )" else - dsh_version="$(npm view "@deepseek-ai/dsh@${dsh_version}" version)" + dsh_version="$( + npm view "@deepseek-ai/dsh@${dsh_version}" version --json --prefer-online \ + | node "${version_resolver}" + )" fi if [[ ! "${dsh_version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then diff --git a/deepseek-harness-builder/scripts/resolve-latest-npm-version.mjs b/deepseek-harness-builder/scripts/resolve-latest-npm-version.mjs new file mode 100644 index 0000000..3f5af92 --- /dev/null +++ b/deepseek-harness-builder/scripts/resolve-latest-npm-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url' + +const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + +export function parseSemver(version) { + if (typeof version !== 'string') { + throw new TypeError('npm version must be a string') + } + const match = SEMVER_RE.exec(version) + if (!match) { + throw new Error(`unsupported npm semantic version: ${version}`) + } + const prerelease = match[4] === undefined ? [] : match[4].split('.') + for (const identifier of prerelease) { + if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) { + throw new Error(`numeric prerelease identifier has a leading zero: ${version}`) + } + } + return { + version, + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease, + } +} + +function compareIdentifier(left, right) { + const leftNumeric = /^\d+$/.test(left) + const rightNumeric = /^\d+$/.test(right) + if (leftNumeric && rightNumeric) { + const leftNumber = BigInt(left) + const rightNumber = BigInt(right) + return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0 + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1 + } + return left < right ? -1 : left > right ? 1 : 0 +} + +export function compareSemver(leftVersion, rightVersion) { + const left = parseSemver(leftVersion) + const right = parseSemver(rightVersion) + for (let index = 0; index < left.core.length; index += 1) { + if (left.core[index] < right.core[index]) return -1 + if (left.core[index] > right.core[index]) return 1 + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + if (left.prerelease.length === right.prerelease.length) return 0 + return left.prerelease.length === 0 ? 1 : -1 + } + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index += 1) { + if (left.prerelease[index] === undefined) return -1 + if (right.prerelease[index] === undefined) return 1 + const comparison = compareIdentifier(left.prerelease[index], right.prerelease[index]) + if (comparison !== 0) return comparison + } + return 0 +} + +export function resolveLatestVersion(metadata) { + const versions = typeof metadata === 'string' ? [metadata] : metadata + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error('npm returned no published versions') + } + if (!versions.every(version => typeof version === 'string')) { + throw new Error('npm returned a non-string version entry') + } + versions.forEach(parseSemver) + return versions.reduce((latest, candidate) => ( + compareSemver(candidate, latest) > 0 ? candidate : latest + )) +} + +async function main() { + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + input += chunk + } + const metadata = JSON.parse(input) + process.stdout.write(`${resolveLatestVersion(metadata)}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { + process.stderr.write(`ERROR: ${error.message}\n`) + process.exitCode = 1 + }) +} diff --git a/deepseek-harness-builder/scripts/smoke-test.sh b/deepseek-harness-builder/scripts/smoke-test.sh index 1deed27..d7834ff 100755 --- a/deepseek-harness-builder/scripts/smoke-test.sh +++ b/deepseek-harness-builder/scripts/smoke-test.sh @@ -890,10 +890,7 @@ check_runtime_versions() { if docker exec "${container_name}" sh -c 'command -v corepack >/dev/null 2>&1'; then fail "Corepack is present even though pnpm is installed independently" fi - if docker exec "${container_name}" sh -c 'command -v npm >/dev/null 2>&1'; then - fail "npm is present even though pnpm is the sole bundled Node.js package manager" - fi - pass "npm and Corepack are absent" + pass "Corepack is absent" for command_name in bash git curl ssh jq rg less ps file unzip; do docker exec "${container_name}" sh -c "command -v '${command_name}' >/dev/null 2>&1" \ @@ -905,6 +902,11 @@ check_runtime_versions() { || fail "image variant metadata does not match ${VARIANT}" if [[ "${VARIANT}" == "runtime" ]]; then + for command_name in npm npx; do + if docker exec "${container_name}" sh -c "command -v '${command_name}' >/dev/null 2>&1"; then + fail "workstation Node.js package-manager command unexpectedly present in runtime image: ${command_name}" + fi + done for command_name in gcc g++ make python3 go rustc cargo; do if docker exec "${container_name}" sh -c "command -v '${command_name}' >/dev/null 2>&1"; then fail "workstation tool unexpectedly present in runtime image: ${command_name}" @@ -912,10 +914,28 @@ check_runtime_versions() { done pass "lightweight runtime omits npm and compiler toolchains" else + [[ "$(docker exec "${container_name}" npm --version)" == "11.19.0" ]] \ + || fail "npm is not pinned to 11.19.0" + [[ "$(docker exec "${container_name}" npx --version)" == "11.19.0" ]] \ + || fail "npx is not pinned to 11.19.0" [[ "$(docker exec "${container_name}" go version)" == go\ version\ go1.26.6* ]] \ || fail "Go version is not pinned to 1.26.6" - [[ "$(docker exec "${container_name}" rustc --version)" == rustc\ 1.97.1* ]] \ - || fail "Rust version is not pinned to 1.97.1" + if docker exec "${container_name}" sh -c 'command -v rustc >/dev/null 2>&1'; then + fail "Rust compiler is unexpectedly present in the workstation image" + fi + if docker exec "${container_name}" sh -c 'command -v cargo >/dev/null 2>&1'; then + fail "Cargo is unexpectedly present in the workstation image" + fi + [[ "$(docker exec "${container_name}" actionlint -version | head -n 1)" == "1.7.12" ]] \ + || fail "actionlint is not pinned to 1.7.12" + docker exec "${container_name}" yq --version | grep -Fq 'version v4.53.6' \ + || fail "yq is not pinned to 4.53.6" + docker exec "${container_name}" uv --version | grep -Fq 'uv 0.12.5 ' \ + || fail "uv is not pinned to 0.12.5" + docker exec "${container_name}" uvx --version | grep -Fq 'uvx 0.12.5 ' \ + || fail "uvx is not pinned to 0.12.5" + [[ "$(docker exec "${container_name}" ruff --version)" == "ruff 0.16.3" ]] \ + || fail "Ruff is not pinned to 0.16.3" [[ "$(docker exec "${container_name}" docker --version)" == Docker\ version\ 29.7.2,* ]] \ || fail "Docker CLI is not pinned to 29.7.2" docker exec "${container_name}" docker compose version | grep -Fq 'Docker Compose version v5.5.0' \ @@ -925,7 +945,10 @@ check_runtime_versions() { if docker exec "${container_name}" test -S /var/run/docker.sock; then fail "Docker daemon socket is unexpectedly mounted by default" fi - for command_name in python3 gcc g++ make cargo cmake clang docker gh shellcheck shfmt fd bat fzf tmux sqlite3; do + for command_name in \ + python3 gcc g++ make cmake ninja clang clang-format docker gh \ + actionlint yq uv uvx ruff just hyperfine entr shellcheck shfmt \ + fd bat fzf tmux sqlite3 ncdu pigz mtr; do docker exec "${container_name}" sh -c "command -v '${command_name}' >/dev/null 2>&1" \ || fail "workstation tool is missing: ${command_name}" done diff --git a/deepseek-harness-builder/scripts/test-component-pins.sh b/deepseek-harness-builder/scripts/test-component-pins.sh index 6ba0cd6..c0905d2 100755 --- a/deepseek-harness-builder/scripts/test-component-pins.sh +++ b/deepseek-harness-builder/scripts/test-component-pins.sh @@ -40,6 +40,36 @@ cp -- "${source_dockerfile}" "${missing_x_mod_pin}" sed -i '/^ARG X_MOD_VERSION=/d' "${missing_x_mod_pin}" expect_failure 'missing-x-mod-pin' "${missing_x_mod_pin}" 'required ARG X_MOD_VERSION is missing' +missing_npm_pin="${tmp_dir}/missing-npm-pin.Dockerfile" +cp -- "${source_dockerfile}" "${missing_npm_pin}" +sed -i '/^ARG NPM_VERSION=/d' "${missing_npm_pin}" +expect_failure 'missing-npm-pin' "${missing_npm_pin}" 'required ARG NPM_VERSION is missing' + +missing_actionlint_pin="${tmp_dir}/missing-actionlint-pin.Dockerfile" +cp -- "${source_dockerfile}" "${missing_actionlint_pin}" +sed -i '/^ARG ACTIONLINT_VERSION=/d' "${missing_actionlint_pin}" +expect_failure 'missing-actionlint-pin' "${missing_actionlint_pin}" 'required ARG ACTIONLINT_VERSION is missing' + +bad_uv_arch_checksum="${tmp_dir}/bad-uv-arch-checksum.Dockerfile" +cp -- "${source_dockerfile}" "${bad_uv_arch_checksum}" +sed -i 's/^ARG UV_SHA256_ARM64=.*/ARG UV_SHA256_ARM64=deadbeef/' "${bad_uv_arch_checksum}" +expect_failure 'bad-uv-arch-checksum' "${bad_uv_arch_checksum}" 'ARG UV_SHA256_ARM64 must be a lowercase SHA-256 digest' + +bad_actionlint_source_checksum="${tmp_dir}/bad-actionlint-source-checksum.Dockerfile" +cp -- "${source_dockerfile}" "${bad_actionlint_source_checksum}" +sed -i 's/^ARG ACTIONLINT_SOURCE_SHA256=.*/ARG ACTIONLINT_SOURCE_SHA256=deadbeef/' "${bad_actionlint_source_checksum}" +expect_failure 'bad-actionlint-source-checksum' "${bad_actionlint_source_checksum}" 'ARG ACTIONLINT_SOURCE_SHA256 must be a lowercase SHA-256 digest' + +missing_actionlint_source_verification="${tmp_dir}/missing-actionlint-source-verification.Dockerfile" +cp -- "${source_dockerfile}" "${missing_actionlint_source_verification}" +sed -i '/ACTIONLINT_SOURCE_SHA256.*sha256sum -c/d' "${missing_actionlint_source_verification}" +expect_failure 'missing-actionlint-source-verification' "${missing_actionlint_source_verification}" 'the actionlint source checksum verification' + +restored_rust_stage="${tmp_dir}/restored-rust-stage.Dockerfile" +cp -- "${source_dockerfile}" "${restored_rust_stage}" +printf '\nFROM rust:1.97.1-slim-trixie@sha256:%s AS compiler\n' "${node_digest:-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}" >> "${restored_rust_stage}" +expect_failure 'restored-rust-stage' "${restored_rust_stage}" 'Dockerfile must not use a Rust base image' + floating_node="${tmp_dir}/floating-node.Dockerfile" cp -- "${source_dockerfile}" "${floating_node}" sed -i '0,/^FROM node:/s#node:[^@[:space:]]*#node:latest#' "${floating_node}" diff --git a/deepseek-harness-builder/scripts/test-component-updates.py b/deepseek-harness-builder/scripts/test-component-updates.py new file mode 100644 index 0000000..393b427 --- /dev/null +++ b/deepseek-harness-builder/scripts/test-component-updates.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Offline regression tests for check-component-updates.py.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +import urllib.request +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +CHECKER = SCRIPT_DIR / "check-component-updates.py" +DIGEST = "a" * 64 + +SPEC = importlib.util.spec_from_file_location("component_update_checker", CHECKER) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {CHECKER}") +CHECKER_MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECKER_MODULE) + + +class ComponentUpdateCheckerTests(unittest.TestCase): + def run_checker( + self, + root: Path, + policy: dict[str, object], + dockerfile: str, + fixtures: dict[str, str], + *extra_args: str, + ) -> tuple[subprocess.CompletedProcess[str], Path]: + policy_path = root / "policy.json" + dockerfile_path = root / "Dockerfile" + fixture_dir = root / "fixtures" + summary_path = root / "summary.md" + fixture_dir.mkdir() + policy_path.write_text(json.dumps(policy), encoding="utf-8") + dockerfile_path.write_text(dockerfile, encoding="utf-8") + for name, contents in fixtures.items(): + (fixture_dir / name).write_text(contents, encoding="utf-8") + + command = [ + sys.executable, + str(CHECKER), + "--dockerfile", + str(dockerfile_path), + "--policy", + str(policy_path), + "--fixture-dir", + str(fixture_dir), + "--summary", + str(summary_path), + *extra_args, + ] + result = subprocess.run(command, text=True, capture_output=True, check=False) + return result, summary_path + + @staticmethod + def npm_policy() -> dict[str, object]: + return { + "version": 1, + "components": [ + { + "name": "pnpm", + "pin": "PNPM_VERSION", + "source": {"type": "npm", "package": "pnpm"}, + } + ], + } + + def test_update_is_reported_and_strict_mode_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result, summary_path = self.run_checker( + Path(temporary), + self.npm_policy(), + "ARG PNPM_VERSION=1.2.3\n", + {"npm-pnpm.json": '{"version":"1.2.4"}\n'}, + "--fail-on-updates", + ) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("component_updates=1", result.stdout) + summary = summary_path.read_text(encoding="utf-8") + self.assertIn("| pnpm | `1.2.3` |", summary) + self.assertIn("update available", summary) + + def test_equal_version_passes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result, summary_path = self.run_checker( + Path(temporary), + self.npm_policy(), + "ARG PNPM_VERSION=1.2.3\n", + {"npm-pnpm.json": '{"version":"1.2.3"}\n'}, + "--fail-on-updates", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("component_updates=0", result.stdout) + self.assertIn("current", summary_path.read_text(encoding="utf-8")) + + def test_missing_fixture_is_a_source_error(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result, summary_path = self.run_checker( + Path(temporary), + self.npm_policy(), + "ARG PNPM_VERSION=1.2.3\n", + {}, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("Source errors", summary_path.read_text(encoding="utf-8")) + self.assertIn("component source checks failed", result.stderr) + + def test_invalid_npm_selector_fails_before_source_access(self) -> None: + policy = self.npm_policy() + policy["components"][0]["source"]["selector"] = "../latest" + + with tempfile.TemporaryDirectory() as temporary: + result, summary_path = self.run_checker( + Path(temporary), + policy, + "ARG PNPM_VERSION=1.2.3\n", + {}, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("invalid npm selector", summary_path.read_text(encoding="utf-8")) + + def test_redirect_policy_rejects_cross_origin_and_credentials(self) -> None: + handler = CHECKER_MODULE.SameOriginHTTPSRedirectHandler() + request = urllib.request.Request( + "https://api.github.com/repos/example/project/releases/latest", + headers={"Authorization": "Bearer test-token"}, + ) + + for redirected_url in ( + "https://example.com/releases/latest", + "https://user:password@api.github.com/releases/latest", + "http://api.github.com/releases/latest", + ): + with self.subTest(redirected_url=redirected_url): + with self.assertRaises(CHECKER_MODULE.UpdateCheckError): + handler.redirect_request( + request, + None, + 302, + "Found", + {}, + redirected_url, + ) + + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "/repositories/example/project/releases/latest", + ) + self.assertIsNotNone(redirected) + self.assertEqual(redirected.host, "api.github.com") + + def test_summary_writer_preserves_existing_job_summary(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + summary_path = Path(temporary) / "summary.md" + summary_path.write_text("## Existing summary\n\n", encoding="utf-8") + + CHECKER_MODULE.write_summary(summary_path, "## Component updates\n") + + self.assertEqual( + summary_path.read_text(encoding="utf-8"), + "## Existing summary\n\n## Component updates\n", + ) + + def test_node_base_image_pin_and_workstation_npm_are_independent(self) -> None: + policy = { + "version": 1, + "components": [ + { + "name": "Node.js 24 LTS", + "pin_from": { + "type": "dockerfile_from", + "image": "node", + "suffix": "-trixie-slim", + }, + "source": {"type": "node_release_line", "major": 24}, + }, + { + "name": "npm 11 for workstation", + "pin": "NPM_VERSION", + "source": { + "type": "npm", + "package": "npm", + "selector": "next-11", + }, + }, + ], + } + dockerfile = ( + f"FROM node:24.19.0-trixie-slim@sha256:{DIGEST} AS runtime-base\n" + "ARG NPM_VERSION=11.19.0\n" + ) + node_index = json.dumps( + [ + {"version": "v25.0.0", "lts": False, "npm": "11.18.0"}, + {"version": "v24.19.0", "lts": "Krypton", "npm": "11.17.0"}, + {"version": "v24.18.1", "lts": "Krypton", "npm": "11.16.0"}, + ] + ) + + with tempfile.TemporaryDirectory() as temporary: + result, summary_path = self.run_checker( + Path(temporary), + policy, + dockerfile, + { + "node-index.json": node_index, + "npm-npm-next-11.json": '{"version":"11.19.0"}\n', + }, + "--fail-on-updates", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + summary = summary_path.read_text(encoding="utf-8") + self.assertIn("Node.js 24 LTS", summary) + self.assertIn("`24.19.0`", summary) + self.assertIn("npm 11 for workstation", summary) + self.assertIn("`11.19.0`", summary) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/deepseek-harness-builder/scripts/test-prepare-dsh-version.sh b/deepseek-harness-builder/scripts/test-prepare-dsh-version.sh new file mode 100644 index 0000000..b71917e --- /dev/null +++ b/deepseek-harness-builder/scripts/test-prepare-dsh-version.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +prepare_script="${script_dir}/prepare-dsh-version.sh" +tmp_dir="$(mktemp -d /tmp/deepseek-harness-dsh-version.XXXXXX)" +stub_dir="${tmp_dir}/bin" + +cleanup() { + rm -rf -- "${tmp_dir}" +} +trap cleanup EXIT + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +mkdir -p "${stub_dir}" + +cat > "${stub_dir}/npm" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +printf '%s\n' "$*" >> "${NPM_CALL_LOG}" +case "$*" in + 'view @deepseek-ai/dsh versions --json --prefer-online') + printf '%s\n' '["0.1.0-rc.6","0.1.0-rc.7","0.1.0-rc.8"]' + ;; + 'view @deepseek-ai/dsh@latest version --json --prefer-online') + printf '%s\n' '"0.1.0-rc.7"' + ;; + 'view @deepseek-ai/dsh@next version --json --prefer-online') + printf '%s\n' '"0.1.0-rc.8"' + ;; + *) + printf 'unexpected npm invocation: %s\n' "$*" >&2 + exit 1 + ;; +esac +EOF + +cat > "${stub_dir}/corepack" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +exit 0 +EOF + +cat > "${stub_dir}/pnpm" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +exit 0 +EOF + +chmod +x "${stub_dir}/npm" "${stub_dir}/corepack" "${stub_dir}/pnpm" + +run_case() { + local name="$1" + local requested_version="$2" + local expected_version="$3" + local expected_npm_call="$4" + local image_dir="${tmp_dir}/${name}" + local github_output="${image_dir}/github-output" + local npm_call_log="${image_dir}/npm-calls" + local -a args=() + + mkdir -p "${image_dir}" + cat > "${image_dir}/package.json" <<'EOF' +{ + "name": "deepseek-harness-version-test", + "version": "0.1.0-rc.7", + "dependencies": { + "@deepseek-ai/dsh": "0.1.0-rc.7" + }, + "packageManager": "pnpm@11.22.0" +} +EOF + + if [[ -n "${requested_version}" ]]; then + args+=(--version "${requested_version}") + fi + + PATH="${stub_dir}:${PATH}" \ + NPM_CALL_LOG="${npm_call_log}" \ + bash "${prepare_script}" \ + --image-dir "${image_dir}" \ + "${args[@]}" \ + --github-output "${github_output}" \ + >/dev/null + + actual_version="$( + node -p "require(process.argv[1]).dependencies['@deepseek-ai/dsh']" \ + "${image_dir}/package.json" + )" + [[ "${actual_version}" == "${expected_version}" ]] \ + || fail "${name}: resolved ${actual_version}, expected ${expected_version}" + grep -Fxq "dsh_version=${expected_version}" "${github_output}" \ + || fail "${name}: GitHub output does not contain ${expected_version}" + grep -Fxq "${expected_npm_call}" "${npm_call_log}" \ + || fail "${name}: npm query did not match the expected resolution mode" + printf '[dsh-version-test] PASS: %s -> %s\n' "${name}" "${expected_version}" +} + +run_case \ + highest-published \ + '' \ + '0.1.0-rc.8' \ + 'view @deepseek-ai/dsh versions --json --prefer-online' +run_case \ + explicit-latest-tag \ + latest \ + '0.1.0-rc.7' \ + 'view @deepseek-ai/dsh@latest version --json --prefer-online' +run_case \ + explicit-next-tag \ + next \ + '0.1.0-rc.8' \ + 'view @deepseek-ai/dsh@next version --json --prefer-online' diff --git a/deepseek-harness-builder/scripts/test-resolve-latest-npm-version.mjs b/deepseek-harness-builder/scripts/test-resolve-latest-npm-version.mjs new file mode 100644 index 0000000..16fdb9c --- /dev/null +++ b/deepseek-harness-builder/scripts/test-resolve-latest-npm-version.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + compareSemver, + resolveLatestVersion, +} from './resolve-latest-npm-version.mjs' + +test('selects rc.8 even when rc.7 appears first', () => { + assert.equal( + resolveLatestVersion(['0.1.0-rc.7', '0.1.0-rc.8']), + '0.1.0-rc.8', + ) +}) + +test('stable releases sort after prereleases', () => { + assert.equal( + resolveLatestVersion(['0.1.0', '0.1.0-rc.99']), + '0.1.0', + ) +}) + +test('compares numeric core and prerelease identifiers numerically', () => { + assert.equal(compareSemver('0.10.0', '0.9.99'), 1) + assert.equal(compareSemver('1.0.0-rc.10', '1.0.0-rc.9'), 1) +}) + +test('accepts a single explicit npm version response', () => { + assert.equal(resolveLatestVersion('0.1.0-rc.7'), '0.1.0-rc.7') +}) + +test('rejects malformed npm metadata', () => { + assert.throws(() => resolveLatestVersion([]), /no published versions/) + assert.throws(() => resolveLatestVersion(['not-semver']), /unsupported npm semantic version/) +}) diff --git a/deepseek-harness-builder/scripts/workstation-smoke-test.sh b/deepseek-harness-builder/scripts/workstation-smoke-test.sh index 060e0b6..0e08ee4 100755 --- a/deepseek-harness-builder/scripts/workstation-smoke-test.sh +++ b/deepseek-harness-builder/scripts/workstation-smoke-test.sh @@ -230,13 +230,23 @@ done || fail "DeepSeek Harness state is not stored under /data" pass "home, /data application state, and workspace use direct directories without symbolic links" [[ "$(node --version)" == "v24.19.0" ]] || fail "Node.js version drifted" +[[ "$(npm --version)" == "11.19.0" ]] || fail "npm version drifted" +[[ "$(npx --version)" == "11.19.0" ]] || fail "npx version drifted" [[ "$(pnpm --version)" == "11.22.0" ]] || fail "pnpm version drifted" [[ "$(go version)" == go\ version\ go1.26.6* ]] || fail "Go version drifted" -[[ "$(rustc --version)" == rustc\ 1.97.1* ]] || fail "Rust version drifted" python3 --version | grep -Fxq 'Python 3.12.14' || fail "Python is not pinned to 3.12.14" python3 -m pytest --version | grep -Fxq 'pytest 9.1.1' || fail "pytest is not available for Python 3.12.14" +command -v rustc >/dev/null 2>&1 && fail "Rust compiler should not be installed" +command -v cargo >/dev/null 2>&1 && fail "Cargo should not be installed" pass "pinned language runtimes are executable" +[[ "$(actionlint -version | head -n 1)" == "1.7.12" ]] || fail "actionlint version drifted" +yq --version | grep -Fq 'version v4.53.6' || fail "yq version drifted" +uv --version | grep -Fq 'uv 0.12.5 ' || fail "uv version drifted" +uvx --version | grep -Fq 'uvx 0.12.5 ' || fail "uvx version drifted" +[[ "$(ruff --version)" == "ruff 0.16.3" ]] || fail "Ruff version drifted" +pass "checksum-pinned standalone development tools are executable" + docker --version | grep -Fq 'Docker version 29.7.2,' || fail "Docker CLI version drifted" docker compose version | grep -Fq 'Docker Compose version v5.5.0' \ || fail "Docker Compose version drifted" @@ -257,7 +267,7 @@ for docker_binary in \ done pass "Docker CLI, Compose, and Buildx use the hardened client-only dependency graph without default daemon access" -bash -lc 'command -v go >/dev/null && command -v rustc >/dev/null && command -v pnpm >/dev/null' \ +bash -lc 'command -v go >/dev/null && command -v npm >/dev/null && command -v npx >/dev/null && command -v pnpm >/dev/null && ! command -v rustc >/dev/null && ! command -v cargo >/dev/null' \ || fail "login shells lose workstation tool paths" pass "login shells retain workstation tool paths" @@ -282,13 +292,7 @@ import "fmt" func main() { fmt.Println("go-ok") } EOF [[ "$(go run "${tmp_dir}/hello.go")" == "go-ok" ]] || fail "Go compilation output is invalid" - -cat > "${tmp_dir}/hello.rs" <<'EOF' -fn main() { println!("rust-ok"); } -EOF -rustc "${tmp_dir}/hello.rs" -o "${tmp_dir}/hello-rust" -[[ "$("${tmp_dir}/hello-rust")" == "rust-ok" ]] || fail "Rust compilation output is invalid" -pass "Go and Rust compilation works" +pass "Go compilation works" python3 -m venv "${tmp_dir}/venv" [[ "$("${tmp_dir}/venv/bin/python" -c 'print("python-ok")')" == "python-ok" ]] \ @@ -296,24 +300,41 @@ python3 -m venv "${tmp_dir}/venv" "${tmp_dir}/venv/bin/pip" --version >/dev/null pass "Python venv and pip work" +mkdir -p "${tmp_dir}/.github/workflows" +cat > "${tmp_dir}/.github/workflows/ci.yml" <<'EOF' +name: CI +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ok +EOF +actionlint "${tmp_dir}/.github/workflows/ci.yml" +[[ "$(printf 'tool: yq\n' | yq '.tool')" == "yq" ]] || fail "yq cannot parse YAML" +printf 'value = 1\n' > "${tmp_dir}/ruff-probe.py" +ruff check --isolated "${tmp_dir}/ruff-probe.py" >/dev/null +pass "GitHub Actions, YAML, and Python quality tools handle minimal inputs" + for command_name in \ - bat clang cmake direnv fd fzf gdb gh git-lfs htop lsof ninja \ - patch pipx pkg-config pre-commit rsync shellcheck shfmt sqlite3 \ - strace tmux tree vim wget yamllint zip zstd; do + actionlint bat clang clang-format cmake direnv entr fd fzf gdb gh \ + git-lfs htop hyperfine just lsof mtr ncdu ninja patch pigz pipx \ + pkg-config pre-commit rsync ruff shellcheck shfmt sqlite3 strace \ + tmux tree uv uvx vim wget yamllint yq zip zstd; do command -v "${command_name}" >/dev/null 2>&1 \ || fail "development CLI is missing: ${command_name}" done pass "workstation development CLI set is present" -for command_name in npm corepack; do - if command -v "${command_name}" >/dev/null 2>&1; then - fail "${command_name} should not coexist with the standalone pinned pnpm bundle" - fi -done +command -v npm >/dev/null 2>&1 || fail "workstation npm is missing" +command -v npx >/dev/null 2>&1 || fail "workstation npx is missing" +if command -v corepack >/dev/null 2>&1; then + fail "Corepack should not coexist with the standalone pinned pnpm bundle" +fi if command -v sudo >/dev/null 2>&1; then fail "workstation unexpectedly grants a sudo path" fi -pass "workstation omits npm, Corepack, and sudo" +pass "workstation provides pinned npm and npx while omitting Corepack and sudo" touch "${HOME}/.workstation-write-probe" rm -f "${HOME}/.workstation-write-probe" diff --git a/scripts/trivy-image-gate.sh b/scripts/trivy-image-gate.sh index 597c7b5..8c0cec5 100755 --- a/scripts/trivy-image-gate.sh +++ b/scripts/trivy-image-gate.sh @@ -6,7 +6,8 @@ usage() { Usage: trivy-image-gate.sh --image IMAGE [--output PATH] [--max-fixable-critical N] [--max-fixable-high N] Scans a container image with Trivy and fails when fixable HIGH/CRITICAL -vulnerabilities exceed the configured thresholds. +vulnerabilities exceed the configured thresholds. Findings within the +thresholds remain visible in the log and emit a GitHub Actions warning. Set TRIVY_TIMEOUT to override the default image analysis timeout. EOF @@ -125,4 +126,49 @@ set -e echo "Trivy gate for ${image}: ${summary}" echo "Trivy report: ${output}" + +python3 - "${output}" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1], encoding="utf-8")) +findings = [] + +for result in data.get("Results", []): + target = str(result.get("Target") or "unknown") + for vuln in result.get("Vulnerabilities") or []: + severity = vuln.get("Severity") + fixed = str(vuln.get("FixedVersion") or "") + if severity not in {"CRITICAL", "HIGH"} or not fixed: + continue + findings.append( + ( + 0 if severity == "CRITICAL" else 1, + severity, + str(vuln.get("VulnerabilityID") or "unknown"), + str(vuln.get("PkgName") or "unknown"), + str(vuln.get("InstalledVersion") or "unknown"), + fixed, + target, + ) + ) + +if findings: + print("Fixable Trivy findings:") + for _, severity, vulnerability, package, installed, fixed, target in sorted(findings): + fields = [severity, vulnerability, package, installed, fixed, target] + fields = [" ".join(field.split()) for field in fields] + print( + f"- {fields[0]} {fields[1]}: {fields[2]} {fields[3]} -> " + f"{fields[4]} ({fields[5]})" + ) +PY + +fixable_critical="$(sed -n 's/.*fixable_critical=\([0-9][0-9]*\).*/\1/p' <<< "${summary}")" +fixable_high="$(sed -n 's/.*fixable_high=\([0-9][0-9]*\).*/\1/p' <<< "${summary}")" +if [[ "${gate_status}" -eq 0 && "${GITHUB_ACTIONS:-}" == "true" ]] \ + && (( fixable_critical > 0 || fixable_high > 0 )); then + echo "::warning title=Fixable image vulnerabilities within configured threshold::critical=${fixable_critical}, high=${fixable_high}; see the Trivy gate log" +fi + exit "${gate_status}"