Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions .ci/package-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# .ci/package-smoke.sh — published-package smoke for the four workspace packages.
#
# `package:lint` (publint/attw) checks the type/export surface but never installs
# a tarball, resolves the real node_modules layout, or boots a bin. This script
# owns exactly that consumer path: pack the four @1c-odata/* tarballs — the same
# `pnpm pack` artifact release.yml publishes to npm — then install and exercise
# EACH package in its own isolated scratch project. Catches a dropped `files`
# entry, a broken `bin`, a broken `exports` map, or a `workspace:*` left
# unrewritten — each of which stays green in the workspace, where symlinked
# siblings and the full source tree paper over it.
#
# One scratch project PER package, not one shared install: a consumer installs
# one package, and a combined flat install would let siblings hoist modules into
# scope and mask per-package manifest problems. Isolation keeps each install and
# each import resolving against that package's own graph alone.
#
# Caller provides REPO (repo root) and a writable TMP.
set -euo pipefail

REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
TMP="${TMP:-$(mktemp -d)}"
TARS="$TMP/tars"
SCRATCH="$TMP/scratch"
# Start clean even on a reused TMP (a local re-run after a version bump would
# otherwise leave two tarballs per package, and the `head -1` picks below would
# silently smoke the OLD one).
rm -rf "$TARS" "$SCRATCH"
mkdir -p "$TARS" "$SCRATCH"

# pnpm rewrites each `workspace:*` to the exact local version in the tarball —
# publishing the directory instead would ship an uninstallable manifest.
( cd "$REPO" && pnpm --filter '@1c-odata/client' --filter '@1c-odata/metadata' \
--filter '@1c-odata/cli' --filter '@1c-odata/mcp' pack --pack-destination "$TARS" )

# Resolve exact tarball paths. The `mcp` glob must match a digit right after
# `mcp-` (versions start with a digit) so it can never pick up an `mcp-<name>`
# sibling package. `head -1` guards against a stray second tarball (TARS is a
# fresh dir, but be defensive so a multi-line value can't slip into the specs
# below). `1c-odata-cli-*` cannot match the client tarball: `client` continues
# with a letter where this glob requires the hyphen.
C=$(ls "$TARS"/1c-odata-client-*.tgz | head -1)
D=$(ls "$TARS"/1c-odata-metadata-*.tgz | head -1)
L=$(ls "$TARS"/1c-odata-cli-*.tgz | head -1)
M=$(ls "$TARS"/1c-odata-mcp-[0-9]*.tgz | head -1)

# One isolated scratch install for one package: dependencies carries ONLY the
# package under test, while `overrides` pins every @1c-odata/* sibling to its
# local tarball so npm resolves the unpublished workspace deps from disk, never
# the registry (which would 404 or, worse, serve a stale published version).
# An override only applies where the graph actually pulls that name, so listing
# all four is safe in every scratch. (file: TARBALLS always unpack into real
# trees — no --install-links needed; that flag only affects file: DIRECTORY
# deps.) --ignore-scripts because this is the one install in CI with no lockfile
# pinning the third-party graph: npm resolves the tarballs' caret ranges to the
# newest versions in range, and running their lifecycle scripts would execute
# unpinned third-party code — while everything this smoke asserts (files,
# exports resolution, bin shims) needs no scripts.
install_isolated() { # $1 = npm package name, $2 = its tarball path
local dir="$SCRATCH/${1##*/}"
mkdir -p "$dir"
( cd "$dir" && PKG_NAME="$1" PKG_TAR="$2" TAR_C="$C" TAR_D="$D" TAR_L="$L" TAR_M="$M" node -e "
const fs=require('fs');
const e=process.env;
fs.writeFileSync('package.json', JSON.stringify({
name:'scratch', private:true, version:'0.0.0', type:'module',
dependencies:{ [e.PKG_NAME]: 'file:'+e.PKG_TAR },
overrides:{
'@1c-odata/client':'file:'+e.TAR_C,'@1c-odata/metadata':'file:'+e.TAR_D,
'@1c-odata/cli':'file:'+e.TAR_L,'@1c-odata/mcp':'file:'+e.TAR_M
}
}, null, 2));
" && npm install --ignore-scripts --no-audit --no-fund )
}

install_isolated '@1c-odata/client' "$C"
install_isolated '@1c-odata/metadata' "$D"
install_isolated '@1c-odata/cli' "$L"
install_isolated '@1c-odata/mcp' "$M"

# Import every `exports` entrypoint from each installed tree and touch one known
# symbol per entrypoint — a dropped dist file or a phantom dependency fails HERE,
# at consumer-side module resolution, instead of passing green in the workspace.
cat > "$SCRATCH/client/smoke.mjs" <<'EOF'
const { ODataV3Client } = await import('@1c-odata/client')
const { and } = await import('@1c-odata/client/filter')
const internal = await import('@1c-odata/client/internal')
for (const [name, value] of Object.entries({ ODataV3Client, and }))
if (typeof value !== 'function') { console.error(`${name}: not a function`); process.exit(1) }
if (Object.keys(internal).length === 0) { console.error('client/internal: empty module'); process.exit(1) }
console.log('client imports: OK')
EOF
cat > "$SCRATCH/metadata/smoke.mjs" <<'EOF'
const { createDynamicClient, parseEdmx } = await import('@1c-odata/metadata')
for (const [name, value] of Object.entries({ createDynamicClient, parseEdmx }))
if (typeof value !== 'function') { console.error(`${name}: not a function`); process.exit(1) }
console.log('metadata imports: OK')
EOF
cat > "$SCRATCH/cli/smoke.mjs" <<'EOF'
const { defineCodegenConfig } = await import('@1c-odata/cli')
const { generate } = await import('@1c-odata/cli/codegen')
for (const [name, value] of Object.entries({ defineCodegenConfig, generate }))
if (typeof value !== 'function') { console.error(`${name}: not a function`); process.exit(1) }
console.log('cli imports: OK')
EOF
cat > "$SCRATCH/mcp/smoke.mjs" <<'EOF'
const { createMcpServer } = await import('@1c-odata/mcp')
const { ConnectionPool } = await import('@1c-odata/mcp/internal')
for (const [name, value] of Object.entries({ createMcpServer, ConnectionPool }))
if (typeof value !== 'function') { console.error(`${name}: not a function`); process.exit(1) }
console.log('mcp imports: OK')
EOF
# Scope note: a runtime dep DROPPED from a manifest is deliberately not chased
# here — `pnpm pack` rebuilds dist via `prepack`, and tsdown externalizes exactly
# what `dependencies` lists, so an undeclared import gets BUNDLED at build time
# and the shipped tarball keeps working (verified empirically: deleting `c12`
# from the cli manifest produces a green, self-contained tarball). What actually
# breaks consumers — and what this smoke owns — is the manifest/layout layer:
# `files`, `bin`, `exports`, the `workspace:*` rewrite, and the install itself.
for p in client metadata cli mcp; do
( cd "$SCRATCH/$p" && node smoke.mjs )
done

# Both bins through their `bin` mapping — invoked as the exact shim npm created
# in node_modules/.bin (the shebang path a registry install gives the consumer),
# each from its own isolated scratch. Deliberately NOT `npx`: with the local shim
# missing — exactly the broken-`bin` failure this smoke exists to catch — npx
# falls back to fetching a same-named package from the registry and executing
# that, masking the breakage and adding a network/code-exec path.
# The grep anchors on the indented Commands-section line — a bare `grep -q serve`
# would match the word "server" in the program DESCRIPTION and stay green with
# the command itself deleted.
# Materialize-then-match (not `… | grep -q`): under pipefail a late writer hitting
# the pipe grep -q already closed would SIGPIPE and trip set -e.
help=$(cd "$SCRATCH/cli" && ./node_modules/.bin/1c-odata --help); grep -qE '^[[:space:]]+generate\b' <<<"$help"
help=$(cd "$SCRATCH/mcp" && ./node_modules/.bin/1c-odata-mcp --help); grep -qE '^[[:space:]]+serve\b' <<<"$help"

echo "package-smoke: OK"
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,36 @@ jobs:
# masked the fact that coverage was never generated at all.
if-no-files-found: error

package-smoke:
runs-on: ubuntu-latest
needs: lint-and-typecheck
timeout-minutes: 10
# Sole owner of the published-package path: pack the four @1c-odata/* tarballs
# (the same `pnpm pack` artifact release.yml publishes), install each into its
# own isolated scratch project (pinned to the local tarballs so npm never hits
# the registry for the unpublished siblings), import every `exports` entrypoint
# from the installed tree and run both bins through their `bin` mapping.
# Catches a dropped `files` entry / broken `bin` / broken `exports` /
# unrewritten `workspace:*` that `package:lint` (types + exports only) cannot.
# Secret-free; fork-safe.
# GUARDRAIL: never add any `secrets.*` here — that demands the owner gate and
# forfeits fork coverage.
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup
- name: Pack + install the tarballs, import entrypoints, run both bins
env:
REPO: ${{ github.workspace }}
TMP: ${{ runner.temp }}/pkgsmoke
# Blank out the workflow-level turbo credentials for this step: the
# scratch `npm install` resolves unpinned third-party versions (no
# lockfile), and even with --ignore-scripts in the script itself,
# nothing that runs here needs — or should see — a write-capable
# remote-cache token.
TURBO_TOKEN: ''
TURBO_TEAM: ''
run: bash .ci/package-smoke.sh

test-example:
runs-on: ubuntu-latest
needs: [detect-secrets, test-and-build]
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pnpm changeset # add a release note (consumer-facing)

Windows runner is materially slower than Linux/Mac for `tsc --noEmit` over thousands of generated `.ts` files (NTFS overhead). Tsc-validate timeouts reflect this: 90s for `trade_v11.5` (always-on), 60s for `bp_v3.0` (`CI=true || CI_RUN_BIG_FIXTURES=1`).

**package-smoke** ([`.ci/package-smoke.sh`](./.ci/package-smoke.sh)) owns the published-artifact path: pack the four workspace tarballs → install each into its own isolated scratch npm project → import every `exports` entrypoint + run both bins (`1c-odata`, `1c-odata-mcp`). Catches `files`/`bin`/`exports`/workspace-rewrite mistakes that `package:lint` (types + exports only) cannot. Secret-free, fork-safe.

## Conventions

- **Cyrillic identifiers everywhere.** 1С metadata uses Russian (`Catalog_Валюты`, `ФайлХранилище`, `Document_РеализацияТоваровУслуг`). Codegen emits Cyrillic filenames. `.gitattributes` forces `eol=lf` + `UTF-8 working-tree-encoding` for `.ts`.
Expand Down
Loading