diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 495681f..16360e2 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -4,7 +4,6 @@ on: push: branches: - main - pull_request: workflow_dispatch: concurrency: diff --git a/.github/workflows/npm-promote.yml b/.github/workflows/npm-promote.yml index d6d13b9..73338b7 100644 --- a/.github/workflows/npm-promote.yml +++ b/.github/workflows/npm-promote.yml @@ -6,7 +6,6 @@ on: version: description: Already-published lockstep version to promote required: true - default: 0.2.1 type: string release_run_id: description: npm release run containing the verified release artifact diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 992e6f8..1e34b17 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -6,18 +6,13 @@ on: version: description: Lockstep version for all release packages required: true - default: 0.3.2 + default: 0.3.3 type: string publish_to_registry: description: Publish the fully gated candidate to npm after preflight required: true default: false type: boolean - promote_latest: - description: Promote the verified release from next to latest - required: true - default: false - type: boolean permissions: contents: read @@ -40,6 +35,7 @@ jobs: [[ "${RELEASE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] source_version="$(python -c 'import json; print(json.load(open("bindings/node/package.json", encoding="utf-8"))["version"])')" test "$RELEASE_VERSION" = "$source_version" + python tools/npm_release.py ensure-unpublished --version "$RELEASE_VERSION" if [[ "${{ inputs.publish_to_registry }}" == "true" ]]; then test -f contracts/tiled-platform-baselines.json test -f contracts/apple-provider-baselines.json @@ -91,7 +87,7 @@ jobs: with: python-version: "3.11" - name: Install the hash-locked oracle - if: matrix.id != 'windows-arm64' + if: matrix.id == 'linux-x64' run: python -m pip install --require-hashes -r oracle/requirements.lock - name: Bootstrap pinned dependencies and model shell: bash @@ -150,7 +146,7 @@ jobs: -DLIGHT_OCR_BUILD_TESTS=ON -DLIGHT_OCR_BUILD_TOOLS=ON -DLIGHT_OCR_ORACLE_PYTHON="$(command -v python)" - -DLIGHT_OCR_PARITY_LIVE_ORACLE=ON + -DLIGHT_OCR_PARITY_LIVE_ORACLE=${{ matrix.id == 'linux-x64' && 'ON' || 'OFF' }} -DLIGHT_OCR_NODE_INCLUDE_DIR="$NODE_INCLUDE_DIR" -DLIGHT_OCR_NODE_EXECUTABLE="$(command -v node)" - name: Configure Node addon (Windows) @@ -165,7 +161,7 @@ jobs: -DLIGHT_OCR_BUILD_TESTS=ON -DLIGHT_OCR_BUILD_TOOLS=ON "-DLIGHT_OCR_ORACLE_PYTHON=$((Get-Command python).Source)" - -DLIGHT_OCR_PARITY_LIVE_ORACLE=${{ matrix.id == 'windows-arm64' && 'OFF' || 'ON' }} + -DLIGHT_OCR_PARITY_LIVE_ORACLE=OFF "-DLIGHT_OCR_NODE_INCLUDE_DIR=$env:NODE_INCLUDE_DIR" "-DLIGHT_OCR_NODE_LIBRARY=$env:NODE_LIBRARY" "-DLIGHT_OCR_NODE_EXECUTABLE=$((Get-Command node).Source)" @@ -328,50 +324,37 @@ jobs: runner: macos-15 node: "22" native: darwin-arm64 - - id: macos-arm64 - runner: macos-15 - node: "24" - native: darwin-arm64 + tiled: false - id: macos-x64 runner: macos-15-intel node: "22" native: darwin-x64 - - id: macos-x64 - runner: macos-15-intel - node: "24" - native: darwin-x64 + tiled: false - id: linux-x64 runner: ubuntu-24.04 node: "22" native: linux-x64-gnu + tiled: true - id: linux-x64 runner: ubuntu-24.04 node: "24" native: linux-x64-gnu + tiled: false - id: linux-arm64 runner: ubuntu-24.04-arm node: "22" native: linux-arm64-gnu - - id: linux-arm64 - runner: ubuntu-24.04-arm - node: "24" - native: linux-arm64-gnu + tiled: false - id: windows-x64 runner: windows-2022 node: "22" native: win32-x64 - - id: windows-x64 - runner: windows-2022 - node: "24" - native: win32-x64 + tiled: false - id: windows-arm64 runner: windows-11-arm node: "22" native: win32-arm64 - - id: windows-arm64 - runner: windows-11-arm - node: "24" - native: win32-arm64 + tiled: false runs-on: ${{ matrix.runner }} env: RELEASE_VERSION: ${{ inputs.version }} @@ -394,26 +377,15 @@ jobs: "../dist/release/arcships-light-ocr-model-ppocrv6-small-${RELEASE_VERSION}.tgz" \ "../dist/release/arcships-light-ocr-${{ matrix.native }}-${RELEASE_VERSION}.tgz" \ "../dist/release/arcships-light-ocr-${RELEASE_VERSION}.tgz" - cp ../tools/npm/smoke.cjs ../tools/npm/smoke.ts ../tools/npm/tiled-smoke.cjs . + cp ../tools/npm/smoke.cjs . npm ls --all - - name: Run real OCR from CJS and ESM + - name: Run the CLI and real OCR from CJS and ESM shell: bash working-directory: package-smoke env: LIGHT_OCR_SMOKE_FIXTURE: ${{ github.workspace }}/corpus/fixtures/generated-hello-123 - run: | - node smoke.cjs - node tiled-smoke.cjs \ - "$GITHUB_WORKSPACE/corpus/tiled-v1/fixtures/tiled-four-way-intersection-2048" - - name: Compile the published TypeScript declarations - shell: bash - working-directory: package-smoke - run: | - npm install --ignore-scripts --no-audit --no-fund --no-save \ - typescript@5.9.3 @types/node@22.20.1 - npx tsc --strict --noEmit --skipLibCheck false \ - --target ES2022 --module NodeNext --moduleResolution NodeNext \ - --types node smoke.ts + LIGHT_OCR_SMOKE_TILED: ${{ matrix.tiled && '1' || '0' }} + run: node smoke.cjs local-registry: needs: assemble runs-on: ubuntu-24.04 @@ -463,13 +435,16 @@ jobs: npm init --yes npm install --ignore-scripts --no-audit --no-fund --package-lock=false \ --registry "$LOCAL_REGISTRY" "@arcships/light-ocr@${RELEASE_VERSION}" - cp ../tools/npm/smoke.cjs ../tools/npm/tiled-smoke.cjs . + cp ../tools/npm/smoke.cjs ../tools/npm/smoke.ts . + npm install --ignore-scripts --no-audit --no-fund --no-save \ + typescript@5.9.3 @types/node@22.20.1 + npx tsc --strict --noEmit --skipLibCheck false \ + --target ES2022 --module NodeNext --moduleResolution NodeNext \ + --types node smoke.ts kill "$(cat ../.cache/verdaccio/server.pid)" wait "$(cat ../.cache/verdaccio/server.pid)" || true LIGHT_OCR_SMOKE_FIXTURE="$GITHUB_WORKSPACE/corpus/fixtures/generated-hello-123" \ node smoke.cjs - node tiled-smoke.cjs \ - "$GITHUB_WORKSPACE/corpus/tiled-v1/fixtures/tiled-four-way-intersection-2048" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: @@ -512,7 +487,7 @@ jobs: --tarball-dir dist/release --phase dependencies --tag next - - name: Verify facade tarball against registry dependencies + - name: Verify facade dependency resolution against the registry shell: bash run: | mkdir registry-preflight @@ -520,9 +495,6 @@ jobs: npm init --yes npm install --ignore-scripts --no-audit --no-fund --package-lock=false \ "../dist/release/arcships-light-ocr-${RELEASE_VERSION}.tgz" - cp ../tools/npm/smoke.cjs . - LIGHT_OCR_SMOKE_FIXTURE="$GITHUB_WORKSPACE/corpus/fixtures/generated-hello-123" \ - node smoke.cjs - name: Publish facade to next run: >- python tools/npm_release.py publish @@ -541,13 +513,6 @@ jobs: sudo unshare --net -- env \ LIGHT_OCR_SMOKE_FIXTURE="$GITHUB_WORKSPACE/corpus/fixtures/generated-hello-123" \ node smoke.cjs - - name: Promote the verified release to latest - if: inputs.promote_latest - run: >- - python tools/npm_release.py promote - --tarball-dir dist/release - --expected-version "${RELEASE_VERSION}" - --tag latest - name: Show published metadata shell: bash run: | diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..77e9a7a --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,74 @@ +name: pull request + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: pull-request-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + linux-x64: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.11" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Validate repository and release tooling contracts + run: python -m unittest discover -s tests/python -p 'test_*.py' + - name: Bootstrap pinned inputs and prove the dependency cache is complete + shell: bash + run: | + python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies + python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies --offline + python tools/bootstrap_models.py --cache-dir .cache/models + python tools/package_model_bundle.py + - name: Install verified Node development files + shell: bash + run: | + node_version="$(node -p process.versions.node)" + node_dev="$PWD/.cache/node-gyp/$node_version" + npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp" + test -f "$node_dev/include/node/node_api.h" + echo "NODE_INCLUDE_DIR=$node_dev/include/node" >> "$GITHUB_ENV" + - name: Configure the representative release build + shell: bash + run: >- + cmake -S . -B build-pr -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" + -DLIGHT_OCR_BUILD_NODE=ON + -DLIGHT_OCR_BUILD_TESTS=ON + -DLIGHT_OCR_BUILD_TOOLS=ON + -DLIGHT_OCR_PARITY_LIVE_ORACLE=OFF + -DLIGHT_OCR_NODE_INCLUDE_DIR="$NODE_INCLUDE_DIR" + -DLIGHT_OCR_NODE_EXECUTABLE="$(command -v node)" + - name: Build and test Core and Node contracts + run: | + cmake --build build-pr --parallel + ctest --test-dir build-pr --output-on-failure \ + -LE "acceptance|canonical-oracle|webgpu-contract" + - name: Run the complete Node adapter and CLI suite + working-directory: bindings/node + env: + LIGHT_OCR_NODE_BINARY: ${{ github.workspace }}/build-pr/node-runtime/native/light_ocr_node.node + LIGHT_OCR_RUNTIME_DESCRIPTOR: ${{ github.workspace }}/build-pr/node-runtime/native/runtime-descriptor.json + LIGHT_OCR_MODEL_BUNDLE: ${{ github.workspace }}/models/generated/ppocrv6-small-onnx-20260714.2 + run: npm test + - name: Prove runtime operation without network access + shell: bash + run: >- + sudo unshare --net -- + python tools/run_offline_check.py + --validate build-pr/bin/light_ocr_validate + --bundle models/generated/ppocrv6-small-onnx-20260714.2 + --fixture corpus/fixtures/generated-hello-123/fixture.json + --require-network-disabled diff --git a/.github/workflows/webgpu-native.yml b/.github/workflows/webgpu-native.yml index 354513b..c7e8364 100644 --- a/.github/workflows/webgpu-native.yml +++ b/.github/workflows/webgpu-native.yml @@ -3,7 +3,6 @@ name: native webgpu on: push: branches: [main] - pull_request: workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index e29e4ce..d244648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ This file records user-visible changes to `light-ocr`. Published artifact details and immutable hashes remain in [`docs/releases/`](docs/releases/). +## [0.3.3] - 2026-07-22 + +### Fixed + +- Fixed npm facade assembly to include the declared `light-ocr` bin, its executable script, and the required Node shebang. The package smoke now installs and executes `node_modules/.bin/light-ocr info --version`, preventing a source-only CLI from passing release validation again. +- Changed the reserved `detect --crop` flag from a silent no-op to a fail-closed `unsupported_capability` response until crop bytes are implemented. + +### Changed + +- Split publication and promotion into single-purpose workflows. `npm release` now rejects an already-published facade version before starting the six-platform build; `npm promote` alone may update `latest` from the original immutable release artifact. +- Replaced the ten-job pull-request fan-out with one representative Linux Core/Node/offline gate; the full six-platform, sanitizer, oracle, and WebGPU suites now run once after changes reach `main`. +- Removed release work that produced no additional evidence: live oracle setup now runs only on Linux x64 where its acceptance tests execute, TypeScript declarations compile once, every platform installs on Node 22 while Node 24 compatibility is checked once on Linux x64, tiled OCR runs once, and registry checks no longer repeat the same tiled contract. + +### Notes + +- npm `0.3.2` is immutable and its facade tarball omitted the CLI bin even though the source and changelog contained it. `0.3.3` is the corrective patch release; the `0.3.2` artifact remains recorded for provenance. + ## [0.3.2] - 2026-07-22 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 46f58bb..3b2c552 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(APPLE AND (NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET OR "Minimum macOS deployment target" FORCE) endif() -project(light_ocr VERSION 0.3.2 LANGUAGES CXX) +project(light_ocr VERSION 0.3.3 LANGUAGES CXX) if(APPLE) enable_language(OBJCXX) diff --git a/bindings/node/README.md b/bindings/node/README.md index 5c22329..b36f803 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -49,7 +49,7 @@ light-ocr info --model-info | `--schema-version` | `1` | Request exact output schema | | `--quiet` | — | Suppress non-error stderr | -`detect` does not accept `--format` (output is always JSON with `detections[]`). `detect --crop` attaches a PNG crop per detection. +`detect` does not accept `--format` (output is always JSON with `detections[]`). The reserved `detect --crop` flag currently fails with `unsupported_capability`; use returned boxes with `recognize --region` until crop bytes are implemented. ### Scenarios diff --git a/bindings/node/bin/light-ocr.cjs b/bindings/node/bin/light-ocr.cjs old mode 100644 new mode 100755 index 2f16057..361e285 --- a/bindings/node/bin/light-ocr.cjs +++ b/bindings/node/bin/light-ocr.cjs @@ -1,3 +1,4 @@ +#!/usr/bin/env node 'use strict'; // light-ocr CLI — N1 entry point (cli-design.md §3, D106) @@ -18,7 +19,7 @@ const { createEngine, OcrError } = require('../js/index.cjs'); const { parseExifOrientation } = require('../js/exif.cjs'); const PKG_VERSION = require('../package.json').version; -const CORE_VERSION = '0.3.2'; +const CORE_VERSION = '0.3.3'; const SUBCOMMANDS = new Set(['recognize', 'detect', 'info']); const EXIT = { @@ -449,16 +450,6 @@ async function runDetect(rest, flags, stdout, stderr) { }, }); - // --crop: each detection gets a PNG crop (base64-encoded) - // Crop is done in CLI JS layer from the decoded image. Since CLI doesn't - // decode (C++ decode is inside recognizeEncoded/detect), crop requires - // either a separate decode or Core returning crop bytes. For now, crop - // is not available (D-N1-3 pending); --crop returns info in appliedTransforms. - if (flags.crop === true) { - // TODO: implement crop when Core detect supports returning crop bytes - // or when CLI can decode independently. For now, note in source. - } - // detect output is always JSON (no --format flag) stdout.write(JSON.stringify(envelope, null, 2) + '\n'); } finally { @@ -514,7 +505,7 @@ function printSubcommandHelp(stdout, subcommand) { stdout.write('Usage:\n light-ocr detect [flags]\n light-ocr detect --stdin --type [flags]\n\n'); stdout.write('Flags:\n'); stdout.write(' --region x,y,w,h Restrict detection to a pageSpace rectangle\n'); - stdout.write(' --crop Attach a PNG crop per detection\n'); + stdout.write(' --crop Reserved; currently fails as unsupported\n'); stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n'); stdout.write(' --no-exif Disable EXIF orientation correction\n'); stdout.write(' --schema-version 1 Request exact output schema\n'); @@ -567,6 +558,12 @@ async function main(argv) { if (parsed.flags.crop !== undefined && parsed.flags.crop !== true) { throw { code: EXIT.invalid_argument, message: '--crop is a boolean flag' }; } + if (parsed.flags.crop === true) { + throw { + code: EXIT.unsupported_capability, + message: '--crop is not available in this release; use detection boxes with --region', + }; + } await runDetect(rest, parsed.flags, stdout, stderr); } else { die(stderr, EXIT.usage, `unknown subcommand: ${subcommand}`); diff --git a/bindings/node/package.json b/bindings/node/package.json index 3b71a1f..4792233 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr", - "version": "0.3.2", + "version": "0.3.3", "private": true, "description": "Node-API adapter for the light-ocr C++ core", "license": "Apache-2.0", diff --git a/bindings/node/test/cli.test.cjs b/bindings/node/test/cli.test.cjs index cbb00eb..a4805a7 100644 --- a/bindings/node/test/cli.test.cjs +++ b/bindings/node/test/cli.test.cjs @@ -64,10 +64,11 @@ test('help: subcommand help prints that subcommand flags', async () => { assert.match(stdout, /--region/); }); -test('help: detect help prints --crop and omits --format', async () => { +test('help: detect marks reserved --crop unsupported and omits --format', async () => { const { code, stdout } = await runCli(['detect', '--help']); assert.equal(code, EXIT.success); assert.match(stdout, /--crop/); + assert.match(stdout, /currently fails as unsupported/); // detect does not expose --format assert.doesNotMatch(stdout, /--format/); }); @@ -108,6 +109,12 @@ test('detect: --format rejected with exit 65 before not-implemented', async () = assert.match(stderr, /does not accept --format/); }); +test('detect: reserved --crop fails closed before reading input', async () => { + const { code, stderr } = await runCli(['detect', 'missing.png', '--crop']); + assert.equal(code, EXIT.unsupported_capability); + assert.match(stderr, /--crop is not available/); +}); + test('detect: not-implemented returns exit 67', async () => { // detect now delegates to engine.detect() which requires native build. // Without native, it fails with package_load_failed (exit 70) or similar. diff --git a/docs/build-and-release.md b/docs/build-and-release.md index d3ff8c1..f168c81 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -211,28 +211,33 @@ macOS arm64 高分辨率绝对 RSS gates 由 `light_ocr_memory_gate` 独立进 ## 8. CI -`.github/workflows/core.yml` 定义三类 job: +PR、main 与 release 使用分层门禁,避免同一提交在三个阶段重复执行发布级验证: + +- `.github/workflows/pull-request.yml` 只有一个 Linux x64 快速门禁:全量 Python contract tests、Release Core/Node 构建与测试,以及禁网运行检查。 +- `.github/workflows/core.yml` 只在 `main` 或手动触发,定义三类完整回归 job: - `tier1`:六个 Tier 1 原生 runner,锁定依赖/模型、离线缓存复核、Release 构建、真实模型测试、sterile/offline 检查、RSS gate、manifest/license/SBOM。 - `safety`:Linux ASan+UBSan+LSan、TSan、四个 libFuzzer 入口。 - `oracle`:hash-locked Python 环境、committed corpus/golden 身份校验、同机 live oracle 的 14 个语料全阶段对齐和首 bundle 质量基线。 +- `.github/workflows/webgpu-native.yml` 同样只在 `main` 或手动触发,执行 Linux/Windows 的完整 WebGPU contract;release 仍以将要发布的 exact WebGPU SDK 重新构建 native packages。 `.github/workflows/npm-release.yml` 是仅允许从 `main` 手动触发的发布候选与发布流程。默认 `publish_to_registry=false`,所以第一次运行不会读取 `NPM_TOKEN` 或改动 npm registry: - 0.3.0 候选先在 Linux/Python 3.11 的哈希锁工具链中重现并校验内部 WebGPU FP16 派生工件,再在 macOS/Python 3.12 中派生固定 Core ML FP16 package hashes,最后把合并的 native superset bundle 交给 Linux assemble;WebGPU 公共执行 profile 只发布 FP32,用户安装、postinstall 和首次运行都不会执行转换或联网。 - 六个平台分别原生构建 Node-API addon,并保存许可证与 SPDX SBOM。 - 汇聚为一个 facade、一个 model 和六个 native packages,执行两次 `npm pack` 并要求 tarball SHA-256 完全一致。 -- 在 macOS arm64/x64、Linux x64 glibc、Linux arm64 glibc、Windows x64、Windows arm64 上分别使用 Node.js 22 和 24,从本地 tarballs 执行 `--ignore-scripts` 安装、CJS/ESM bounded OCR、单次 tiled contract/结果 smoke 与 TypeScript compile test。 -- 八个 tarball 先发布到一次性 Verdaccio registry,只安装 facade 后停止 registry,再执行真实 bounded 与 tiled OCR,证明没有运行时下载依赖。 -- 只有以上功能/制品 gates、需要时已经单独完成的受审 baseline,以及 `publish_to_registry=true` 同时满足时,`npm-release` GitHub environment 才能读取 `NPM_TOKEN`;先发布七个依赖到 `next`,通过 registry facade 安装后再发布主包,最终禁网运行并可显式提升到 `latest`。 +- 在 macOS arm64/x64、Linux x64 glibc、Linux arm64 glibc、Windows x64、Windows arm64 上使用 Node.js 22 从本地 tarballs 执行 `--ignore-scripts` 安装、CLI 和 CJS/ESM bounded OCR;Node.js 24 的 N-API/loader 兼容性只在 Linux x64 额外验证一次,tiled contract 也只在 Linux x64/Node 22 验证一次。平台无关的 TypeScript declarations 从临时 registry 安装后的 facade 统一编译一次。 +- hash-locked Python oracle 只在实际执行完整 acceptance/oracle 的 Linux x64 release job 安装;其他五个平台不注册或安装不会运行的 live-oracle tests。 +- 八个 tarball 先发布到一次性 Verdaccio registry,只安装 facade 后停止 registry,再执行 CLI 与真实 bounded OCR,证明依赖解析正确且没有运行时下载依赖;tiled contract 已由 exact tarball 矩阵的 Linux x64/Node 22 job 单独覆盖。 +- workflow 在进入六平台构建前先查询 facade 版本;若该版本已经发布,立即失败并要求使用独立 promotion workflow,禁止为改 dist-tag 重建或重发不可变版本。只有 facade 尚未发布时才进入后续昂贵 jobs;依赖包已部分发布、facade 尚未发布的中断场景仍可用同一源码重建并由完整性检查安全续传。 +- 只有以上功能/制品 gates、需要时已经单独完成的受审 baseline,以及 `publish_to_registry=true` 同时满足时,`npm-release` GitHub environment 才能读取 `NPM_TOKEN`;先发布七个依赖到 `next`,确认 facade tarball 能从真实 registry 解析这些依赖后再发布主包,最终保留一次真实 registry 安装后的禁网 OCR。相同 bounded OCR 不在 facade 发布前后重复执行。`latest` 晋升始终由独立 promotion workflow 使用原 release artifact 完成。 `0.3.0` 发布前 dry-run 的触发命令为: ```bash gh workflow run "npm release" --ref main \ -f version=0.3.0 \ - -f publish_to_registry=false \ - -f promote_latest=false + -f publish_to_registry=false ``` 普通 push/PR 和 npm release preflight 都不运行 benchmark。只有首次建立性能基线、Core/model/ORT/compiler/thread policy/runner class 变化、准备公开新的性能数字或调查疑似性能回归时,才显式触发: @@ -241,7 +246,14 @@ gh workflow run "npm release" --ref main \ gh workflow run tiled-qualification.yml --ref main -f run_benchmark=true ``` -benchmark 结果是独立资格审查证据,不是每次发布的重复步骤。需要建立或更新 accepted baseline 时,仍须人工 review 并作为源码提交;脚本不会自动接受当前值。`promote_latest` 默认为 `false`,需要在 registry evidence 人工核对后显式选择。 +benchmark 结果是独立资格审查证据,不是每次发布的重复步骤。需要建立或更新 accepted baseline 时,仍须人工 review 并作为源码提交;脚本不会自动接受当前值。registry evidence 人工核对完成后,使用成功发布 run 的原始制品晋升,不重新运行 release pipeline: + +```bash +gh workflow run npm-promote.yml --ref main \ + -f version=0.3.0 \ + -f release_run_id= \ + -f tag=latest +``` Apple provider 的模型派生、91-function placement、tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、cold start/RSS 和 100 次生命周期 Gate 只在真实 Apple Silicon 本机执行,不进入 GitHub Actions。标准 hosted macOS runner 是虚拟 M1,不暴露可用于资格审查的 GPU/Neural Engine;普通 CI 只保留跨平台编译、契约和轻量单测。 diff --git a/docs/releases/npm-0.3.2.md b/docs/releases/npm-0.3.2.md new file mode 100644 index 0000000..f400254 --- /dev/null +++ b/docs/releases/npm-0.3.2.md @@ -0,0 +1,42 @@ +# npm 0.3.2 发布记录 + +发布日期:2026-07-22
+发布 commit:`00212277cc7f333893da0c8464b28ae78182aad0` + +版本:`0.3.2` + +协议:Apache-2.0 + +## 面向使用者的变化 + +- 新增 `light-ocr` CLI,提供默认 `recognize`、detection-only `detect` 与诊断 `info` 入口。 +- 新增 `schemaVersion: 1` 的 `DocumentResult` envelope、稳定 line/detection ID、ROI 与 JPEG EXIF orientation 修正。 +- 新增仓库内 Agent Skill,覆盖小字截图、表单字段、区域识别、置信度与错误恢复工作流。 +- 新增 Linux arm64 glibc 与 Windows arm64 CPU native packages;release set 从六包扩展为一个 facade、一个 model 和六个 native packages,共八包。 +- Windows native addon 新增 delay-load hook,可在 Electron 等重命名 Node-API host 中加载。 + +## 发布与验证证据 + +- [npm release run 29911026397](https://github.com/arcships/light-ocr/actions/runs/29911026397):从发布 commit 构建并验证六个平台、Node.js 22/24、临时 registry、禁网运行与八个确定性 tarball;随后按依赖优先、facade 最后发布到 npm `next`。 +- [GitHub Release v0.3.2](https://github.com/arcships/light-ocr/releases/tag/v0.3.2):绑定同一发布 commit 的公开 release。 +- [npm promotion run 29923513944](https://github.com/arcships/light-ocr/actions/runs/29923513944):复用上述 release run 的原始 artifact,逐包核对公开 registry integrity,再按依赖优先、facade 最后提升到 `latest`;未重建或重发 tarball。 +- 最终公开查询确认 facade、model 与六个 native packages 的 `next` 和 `latest` 均指向 `0.3.2`。 + +## 已知制品缺陷 + +`0.3.2` 的公开 facade tarball 未包含源码中的 `bin` 字段与 `bin/light-ocr.cjs`,因此 Node API 正常,但全新 npm 安装不会生成 `light-ocr` 命令。npm 已发布版本不可覆盖;该缺陷由补丁版本 `0.3.3` 修复,`latest` 随后移至 `0.3.3`。本记录保留 `0.3.2` 的原始哈希,不把后续制品冒充为同版本重发。 + +## 不可变制品 + +以下数据来自 release run 保存的 `release-manifest.json`;manifest SHA-256 为 `f8b5cb50fb305f0f333459e7cb31755ec44010a9092d893d0a9034ce403fce05`,`gitRevision` 为发布 commit,npm 版本为 `11.0.0`。promotion workflow 已逐包复核 manifest 中的 `dist.integrity` 与公开 registry 一致。 + +| Package | Tarball bytes | Unpacked bytes | SHA-256 | +| --- | ---: | ---: | --- | +| `@arcships/light-ocr` | 15,774 | 58,222 | `22e378522a5dc89ea1b4d9855dc39e7c4587106c8c109354021b2fed314ec56b` | +| `@arcships/light-ocr-model-ppocrv6-small` | 52,529,118 | 73,594,252 | `4c1681504dd162f4b343f1590970b2730e2fde06f7f7080c5e8a395fc1cd8b66` | +| `@arcships/light-ocr-darwin-arm64` | 12,077,655 | 40,007,690 | `949a5c700d39e1f71f44e9eb63450e4a0c08f44e22e051db8ac81f4ef8bcc7e0` | +| `@arcships/light-ocr-darwin-x64` | 14,008,925 | 46,045,885 | `7930ac3f41e1f3ed8676a91a68f2495f6cfc512c8d16e312393d82a115793093` | +| `@arcships/light-ocr-linux-arm64-gnu` | 9,847,553 | 25,188,559 | `7e341fb03498b39fb311b42b2983ea8b43bb2b1895df63e44e7c0d78ff40a57c` | +| `@arcships/light-ocr-linux-x64-gnu` | 16,318,723 | 44,177,523 | `d60c82b7491ffd9181aa631911a65ff815a55e304d5d0ac60f66f01470a10004` | +| `@arcships/light-ocr-win32-arm64` | 5,782,803 | 15,586,199 | `592faf431e7a540cfcfd17430837dfa533402a20a40d883a8e463c501cf2564e` | +| `@arcships/light-ocr-win32-x64` | 19,306,970 | 47,101,887 | `11f846c38052108964666515b51df33701618fa115d0acfae327eaa5cf83e4a2` | diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index ced1cdf..a53063d 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -2,6 +2,7 @@ import argparse import json +import os from pathlib import Path import shutil import subprocess @@ -78,6 +79,41 @@ def test_registry_lookup_bypasses_stale_npm_metadata(self, run: mock.Mock) -> No self.assertIn("--prefer-online", command) self.assertIn(f"--registry={npm_release.NPM_REGISTRY}", command) + @mock.patch("tools.npm_release.npm_integrity", return_value=None) + def test_unpublished_release_can_enter_the_expensive_pipeline( + self, integrity: mock.Mock + ) -> None: + npm_release.ensure_unpublished( + argparse.Namespace(version=npm_release.SOURCE_VERSION, npm="npm") + ) + + integrity.assert_called_once_with( + "npm", f"{npm_release.FACADE_PACKAGE}@{npm_release.SOURCE_VERSION}" + ) + + @mock.patch( + "tools.npm_release.npm_integrity", return_value="sha512-published-integrity" + ) + def test_published_release_must_use_the_promotion_workflow( + self, integrity: mock.Mock + ) -> None: + with self.assertRaisesRegex( + RuntimeError, "already published.*npm promote workflow" + ): + npm_release.ensure_unpublished( + argparse.Namespace(version=npm_release.SOURCE_VERSION, npm="npm") + ) + + integrity.assert_called_once() + + def test_unpublished_guard_rejects_a_version_that_does_not_match_source( + self, + ) -> None: + with self.assertRaisesRegex(RuntimeError, "does not match source version"): + npm_release.ensure_unpublished( + argparse.Namespace(version="999.0.0", npm="npm") + ) + @mock.patch("tools.npm_release.time.sleep") @mock.patch("tools.npm_release.npm_dist_tag") def test_dist_tag_verification_waits_for_registry_convergence( @@ -129,7 +165,14 @@ def test_stages_and_deterministically_packs_all_release_packages(self) -> None: facade = json.loads( (staging / "facade" / "package.json").read_text("utf-8") ) - self.assertEqual(facade["dependencies"][npm_release.MODEL_PACKAGE], source_version) + self.assertEqual(facade["bin"], {"light-ocr": "./bin/light-ocr.cjs"}) + self.assertIn("bin/", facade["files"]) + self.assertTrue( + (staging / "facade" / "bin" / "light-ocr.cjs").is_file() + ) + self.assertEqual( + facade["dependencies"][npm_release.MODEL_PACKAGE], source_version + ) self.assertEqual( len(facade["optionalDependencies"]), len(npm_release.PLATFORMS) ) @@ -187,6 +230,18 @@ def test_stages_and_deterministically_packs_all_release_packages(self) -> None: self.assertTrue( (consumer / "node_modules/@arcships/light-ocr/package.json").is_file() ) + cli = consumer / "node_modules" / ".bin" / ( + "light-ocr.cmd" if os.name == "nt" else "light-ocr" + ) + completed = subprocess.run( + [str(cli), "info", "--version"], + cwd=consumer, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + self.assertEqual(json.loads(completed.stdout)["npm"], source_version) def test_runtime_descriptor_rejects_mutated_payload_and_qualification_release( self, diff --git a/tools/npm/smoke.cjs b/tools/npm/smoke.cjs index 0f8f50a..284469b 100644 --- a/tools/npm/smoke.cjs +++ b/tools/npm/smoke.cjs @@ -1,6 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); @@ -14,6 +15,26 @@ async function main() { const cjs = require('@arcships/light-ocr'); const esm = await import('@arcships/light-ocr'); + const packageRoot = path.dirname(path.dirname(require.resolve('@arcships/light-ocr'))); + const packageMetadata = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ); + const cli = path.resolve( + packageRoot, + '..', + '..', + '.bin', + process.platform === 'win32' ? 'light-ocr.cmd' : 'light-ocr', + ); + const cliVersion = spawnSync(cli, ['info', '--version'], { + encoding: 'utf8', + shell: process.platform === 'win32', + }); + assert.equal(cliVersion.status, 0, cliVersion.stderr || cliVersion.error?.message); + const versionTriple = JSON.parse(cliVersion.stdout); + assert.equal(versionTriple.npm, packageMetadata.version); + assert.equal(versionTriple.core, packageMetadata.version); + assert.equal(versionTriple.model, 'ppocrv6-small-native-20260719.1'); const appleSupported = process.platform === 'darwin' && process.arch === 'arm64'; assert.strictEqual(esm.createEngine, cjs.createEngine); assert.strictEqual(esm.OcrError, cjs.OcrError); @@ -97,34 +118,36 @@ async function main() { } } - const tiledPixels = Buffer.alloc(2048 * 2048 * 3, 255); - const offsetX = 600; - const offsetY = 760; - for (let row = 0; row < metadata.height; ++row) { - pixels.copy( - tiledPixels, - ((offsetY + row) * 2048 + offsetX) * 3, - row * metadata.stride, - row * metadata.stride + metadata.width * 3, - ); - } - const tiled = await cjs.createEngine({ detection: { strategy: 'tiled' } }); - try { - assert.equal(tiled.info.detectionStrategy, 'tiled'); - assert.equal(tiled.info.tiledDetection.contractVersion, 'tiled-v1'); - const result = await tiled.recognize({ - data: tiledPixels, - width: 2048, - height: 2048, - stride: 2048 * 3, - pixelFormat: 'bgr8', - }, { includeDiagnostics: true }); - assert.deepEqual(result.lines.map((line) => line.text), ['HELLO 123']); - assert.equal(result.diagnostics.detectionPasses.length, 4); - assert.equal(result.diagnostics.maxLiveDetectionPassBuffers, 1); - assert.ok(result.diagnostics.suppressedDuplicateBoxes >= 1); - } finally { - await tiled.close(); + if (process.env.LIGHT_OCR_SMOKE_TILED === '1') { + const tiledPixels = Buffer.alloc(2048 * 2048 * 3, 255); + const offsetX = 600; + const offsetY = 760; + for (let row = 0; row < metadata.height; ++row) { + pixels.copy( + tiledPixels, + ((offsetY + row) * 2048 + offsetX) * 3, + row * metadata.stride, + row * metadata.stride + metadata.width * 3, + ); + } + const tiled = await cjs.createEngine({ detection: { strategy: 'tiled' } }); + try { + assert.equal(tiled.info.detectionStrategy, 'tiled'); + assert.equal(tiled.info.tiledDetection.contractVersion, 'tiled-v1'); + const result = await tiled.recognize({ + data: tiledPixels, + width: 2048, + height: 2048, + stride: 2048 * 3, + pixelFormat: 'bgr8', + }, { includeDiagnostics: true }); + assert.deepEqual(result.lines.map((line) => line.text), ['HELLO 123']); + assert.equal(result.diagnostics.detectionPasses.length, 4); + assert.equal(result.diagnostics.maxLiveDetectionPassBuffers, 1); + assert.ok(result.diagnostics.suppressedDuplicateBoxes >= 1); + } finally { + await tiled.close(); + } } process.stdout.write( `${JSON.stringify({ ok: true, node: process.version, platform: process.platform, arch: process.arch })}\n`, diff --git a/tools/npm_release.py b/tools/npm_release.py index 0f203c0..b02dab7 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -819,6 +819,7 @@ def assemble(arguments: argparse.Namespace) -> None: facade = output / "facade" facade.mkdir() copy_tree(ROOT / "bindings" / "node" / "js", facade / "js") + copy_tree(ROOT / "bindings" / "node" / "bin", facade / "bin") facade_json = common_package( FACADE_PACKAGE, version, @@ -838,6 +839,7 @@ def assemble(arguments: argparse.Namespace) -> None: "main": "./js/index.cjs", "module": "./js/index.mjs", "types": "./js/index.d.ts", + "bin": {"light-ocr": "./bin/light-ocr.cjs"}, "exports": { ".": { "types": "./js/index.d.ts", @@ -845,7 +847,7 @@ def assemble(arguments: argparse.Namespace) -> None: "require": "./js/index.cjs", } }, - "files": ["js/", "README.md", "LICENSE", "NOTICE"], + "files": ["js/", "bin/", "README.md", "LICENSE", "NOTICE"], "engines": {"node": "^22.0.0 || ^24.0.0"}, "dependencies": {MODEL_PACKAGE: version}, "optionalDependencies": { @@ -1143,6 +1145,21 @@ def wait_for_integrity(npm: str, specification: str, expected: str) -> None: ) +def ensure_unpublished(arguments: argparse.Namespace) -> None: + if arguments.version != SOURCE_VERSION: + raise RuntimeError( + f"release version {arguments.version} does not match source version " + f"{SOURCE_VERSION}" + ) + specification = f"{FACADE_PACKAGE}@{arguments.version}" + if npm_integrity(arguments.npm, specification) is not None: + raise RuntimeError( + f"{specification} is already published; promote the original release " + "artifact with the npm promote workflow instead of rebuilding it" + ) + print(json.dumps({"package": specification, "status": "unpublished"})) + + def npm_dist_tag(npm: str, package: str, tag: str) -> str | None: completed = subprocess.run( [ @@ -1280,6 +1297,11 @@ def main() -> int: packing.add_argument("--npm", default="npm") packing.set_defaults(handler=pack) + unpublished = subparsers.add_parser("ensure-unpublished") + unpublished.add_argument("--version", required=True) + unpublished.add_argument("--npm", default="npm") + unpublished.set_defaults(handler=ensure_unpublished) + publishing = subparsers.add_parser("publish") publishing.add_argument("--tarball-dir", type=Path, required=True) publishing.add_argument(