mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
chore(release): bring main's post-tag fixes into release/v3.8.51 — sync-back step 2/2
The eighteen commits main carries beyond the cycle branch, and what each one
became here:
already in release/v3.8.51 by its own PR (no-op, verified by content):
b090b601a5 / 026e1cadaa deps: nanoid 3.3.18 equal, dompurify 3.4.14 newer
918fba5e39 .gitignore: /_tasks already anchored
5f0a394091 #10026 hide health-check-excluded models — same helper, 5 call sites
c68cda7dfb #11075 shared passthrough providers — superseded by #11071/#11078
superseded, one piece kept:
ca23eed77c #10055 memoize models.dev pricing — the cycle memoizes on the
catalog cache version already; only the resetDbInstance() hook is
ported, wired to that memo
applied as-is:
8778ea7d18 stamp dist/BUILD_SHA before the npm provenance gate (#11721)
aa52351113 decouple the Bun image from the release manifest (#11724)
925feb27b8 let the bun digest artifact be absent (#11740)
b65ef333da size the install-upgrade gate to a measured run
0ce21232db #11845 converge install/upgrade schemas (migration renumbered in
the next commit: 163 collides with 163_radar_feed_cache_generated_at)
b7c07edad8 #11855 install-upgrade gate on disk, not tmpfs
8e2fb04329 #11864 drop *.nft.json from the npm tarball (413)
dea6bb8b6b #11877 publish npm from a hosted runner (provenance 422)
handled by the sync script that follows (CHANGELOG protocol):
b4ec7807ab Release v3.8.50 — squash of content this branch already carries
5458026c21 / c44c0a29e8 CHANGELOG aggregation, stats and top-25
applied separately (its own commit, ten files):
65e81158ab #11088 Ollama capability routing — a 5,094-file squash from a
stale base; only the Ollama files are the change
Every cherry-pick that touched a file this branch had also changed was
resolved by hand and re-run through the tests both sides own for it.
This commit is contained in:
@@ -1013,6 +1013,13 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
|||||||
# to disable the check. Used by: src/lib/db/migrationRunner.ts. Default: 50.
|
# to disable the check. Used by: src/lib/db/migrationRunner.ts. Default: 50.
|
||||||
#OMNIROUTE_MAX_PENDING_MIGRATIONS=50
|
#OMNIROUTE_MAX_PENDING_MIGRATIONS=50
|
||||||
|
|
||||||
|
# Working directory for the check:install-upgrade release gate. It builds two ~3 GB
|
||||||
|
# install trees plus a ~275 MB tarball, so it needs roughly 12 GB — more than the
|
||||||
|
# 12 GB RAM-backed tmpfs that /tmp is on the self-hosted runner, where it exhausted
|
||||||
|
# the tmpfs and npm silently truncated the package. Defaults to <repo>/.install-upgrade
|
||||||
|
# on real disk. Used by: scripts/check/check-install-upgrade.mjs. Default: <repo>/.install-upgrade.
|
||||||
|
#OMNIROUTE_INSTALL_UPGRADE_WORKDIR=/var/tmp/omniroute-install-upgrade
|
||||||
|
|
||||||
# Trust user-managed RTK project filter rules without strict signature checks.
|
# Trust user-managed RTK project filter rules without strict signature checks.
|
||||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||||
|
|||||||
63
.github/workflows/docker-publish.yml
vendored
63
.github/workflows/docker-publish.yml
vendored
@@ -195,6 +195,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push BUN base platform image by digest
|
- name: Build and push BUN base platform image by digest
|
||||||
id: build-bun-base
|
id: build-bun-base
|
||||||
|
# Bun is a best-effort compatibility target, not a supported runtime
|
||||||
|
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||||
|
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||||
|
# runner-base / runner-web images never reach the registry either. The
|
||||||
|
# image is still built and pushed whenever it succeeds — only its power to
|
||||||
|
# block the release is removed.
|
||||||
|
continue-on-error: true
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -213,6 +220,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push BUN web platform image by digest
|
- name: Build and push BUN web platform image by digest
|
||||||
id: build-bun-web
|
id: build-bun-web
|
||||||
|
# Bun is a best-effort compatibility target, not a supported runtime
|
||||||
|
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||||
|
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||||
|
# runner-base / runner-web images never reach the registry either. The
|
||||||
|
# image is still built and pushed whenever it succeeds — only its power to
|
||||||
|
# block the release is removed.
|
||||||
|
continue-on-error: true
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -240,8 +254,15 @@ jobs:
|
|||||||
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
|
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
|
||||||
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
||||||
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
||||||
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
# Empty when the (non-blocking) bun build produced no image. `if` blocks,
|
||||||
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
# not `[ -n ] && touch`: under `set -e` a failing AND-list aborts the step,
|
||||||
|
# which is precisely the case being handled here.
|
||||||
|
if [ -n "$DIGEST_BUN_BASE" ]; then
|
||||||
|
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
||||||
|
fi
|
||||||
|
if [ -n "$DIGEST_BUN_WEB" ]; then
|
||||||
|
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload base digests
|
- name: Upload base digests
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -264,7 +285,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: digests-bun-base-${{ matrix.arch }}
|
name: digests-bun-base-${{ matrix.arch }}
|
||||||
path: /tmp/digests/bun-base/*
|
path: /tmp/digests/bun-base/*
|
||||||
if-no-files-found: error
|
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||||
|
# digest is the expected outcome of a failed/skipped bun image — the
|
||||||
|
# manifest step already treats these tags as optional. Leaving `error`
|
||||||
|
# here just relocates the blocker from the manifest to the upload.
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Upload bun-web digests
|
- name: Upload bun-web digests
|
||||||
@@ -272,7 +297,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: digests-bun-web-${{ matrix.arch }}
|
name: digests-bun-web-${{ matrix.arch }}
|
||||||
path: /tmp/digests/bun-web/*
|
path: /tmp/digests/bun-web/*
|
||||||
if-no-files-found: error
|
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||||
|
# digest is the expected outcome of a failed/skipped bun image — the
|
||||||
|
# manifest step already treats these tags as optional. Leaving `error`
|
||||||
|
# here just relocates the blocker from the manifest to the upload.
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
@@ -330,6 +359,9 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Download bun-base digests
|
- name: Download bun-base digests
|
||||||
|
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||||
|
# exist at all. The manifest step treats these tags as optional.
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
with:
|
with:
|
||||||
pattern: digests-bun-base-*
|
pattern: digests-bun-base-*
|
||||||
@@ -337,6 +369,9 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Download bun-web digests
|
- name: Download bun-web digests
|
||||||
|
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||||
|
# exist at all. The manifest step treats these tags as optional.
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
with:
|
with:
|
||||||
pattern: digests-bun-web-*
|
pattern: digests-bun-web-*
|
||||||
@@ -348,7 +383,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
create_manifest() {
|
create_manifest() {
|
||||||
local image="$1" suffix="$2" dir="$3"
|
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||||
tags+=(-t "${image}:latest${suffix}")
|
tags+=(-t "${image}:latest${suffix}")
|
||||||
@@ -358,6 +393,10 @@ jobs:
|
|||||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||||
done < <(find "$dir" -type f | sort)
|
done < <(find "$dir" -type f | sort)
|
||||||
if [ "${#refs[@]}" -eq 0 ]; then
|
if [ "${#refs[@]}" -eq 0 ]; then
|
||||||
|
if [ -n "$optional" ]; then
|
||||||
|
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
echo "No image digests in $dir" >&2
|
echo "No image digests in $dir" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -366,15 +405,15 @@ jobs:
|
|||||||
|
|
||||||
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
||||||
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
||||||
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base
|
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||||
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
|
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||||
|
|
||||||
- name: Create GHCR manifest
|
- name: Create GHCR manifest
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
create_manifest() {
|
create_manifest() {
|
||||||
local image="$1" suffix="$2" dir="$3"
|
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||||
tags+=(-t "${image}:latest${suffix}")
|
tags+=(-t "${image}:latest${suffix}")
|
||||||
@@ -384,6 +423,10 @@ jobs:
|
|||||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||||
done < <(find "$dir" -type f | sort)
|
done < <(find "$dir" -type f | sort)
|
||||||
if [ "${#refs[@]}" -eq 0 ]; then
|
if [ "${#refs[@]}" -eq 0 ]; then
|
||||||
|
if [ -n "$optional" ]; then
|
||||||
|
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
echo "No image digests in $dir" >&2
|
echo "No image digests in $dir" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -392,8 +435,8 @@ jobs:
|
|||||||
|
|
||||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base
|
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
|
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||||
|
|
||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
if: needs.prepare.outputs.version != 'main'
|
if: needs.prepare.outputs.version != 'main'
|
||||||
|
|||||||
172
.github/workflows/npm-publish.yml
vendored
172
.github/workflows/npm-publish.yml
vendored
@@ -63,10 +63,14 @@ jobs:
|
|||||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.resolve.outputs.version }}
|
||||||
|
tag: ${{ steps.resolve.outputs.tag }}
|
||||||
|
skip: ${{ steps.resolve.outputs.skip }}
|
||||||
permissions:
|
permissions:
|
||||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||||
id-token: write # npm provenance
|
id-token: write # npm provenance (GitHub Packages step)
|
||||||
packages: write # publish to npm.pkg.github.com
|
packages: write # publish to npm.pkg.github.com
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -226,6 +230,28 @@ jobs:
|
|||||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||||
run: npm run build:cli
|
run: npm run build:cli
|
||||||
|
|
||||||
|
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||||||
|
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||||||
|
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||||||
|
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||||||
|
# be vouched for). Without this step the build+validate pair in this job is
|
||||||
|
# structurally incompatible and fails 100% of the time — the same gap that was
|
||||||
|
# fixed in ci.yml's Package Artifact job.
|
||||||
|
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||||
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||||
|
node scripts/build/write-build-sha.mjs
|
||||||
|
|
||||||
|
# The guard checks ancestry against origin/main by default, which is correct
|
||||||
|
# here (a release tag is cut from main), but the ref has to exist locally for
|
||||||
|
# `git merge-base` to resolve it.
|
||||||
|
- name: Fetch main for the provenance probe
|
||||||
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
|
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||||||
|
|
||||||
- name: Validate npm package artifact
|
- name: Validate npm package artifact
|
||||||
if: steps.resolve.outputs.skip != 'true'
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
run: npm run check:pack-artifact
|
run: npm run check:pack-artifact
|
||||||
@@ -265,7 +291,12 @@ jobs:
|
|||||||
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
||||||
- name: Prove clean-install AND upgrade-over-previous both boot
|
- name: Prove clean-install AND upgrade-over-previous both boot
|
||||||
if: steps.resolve.outputs.skip != 'true'
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
timeout-minutes: 30
|
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
|
||||||
|
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
|
||||||
|
# its budget had never been measured against a real run. It then blew the limit
|
||||||
|
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
|
||||||
|
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
|
||||||
|
timeout-minutes: 60
|
||||||
run: npm run check:install-upgrade
|
run: npm run check:install-upgrade
|
||||||
|
|
||||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||||
@@ -287,41 +318,33 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
npm --version
|
npm --version
|
||||||
|
|
||||||
- name: Publish to npm (staged — owner approves with 2FA)
|
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
|
||||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
|
||||||
|
# runner environment"), and the heavy verification above cannot move to a
|
||||||
|
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
|
||||||
|
# this job's runs-on comment). So this job proves the bytes and hands them
|
||||||
|
# over; a tiny hosted job does the upload.
|
||||||
|
- name: Pack the verified tarball for the upload job
|
||||||
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.resolve.outputs.version }}
|
VERSION: ${{ steps.resolve.outputs.version }}
|
||||||
TAG: ${{ steps.resolve.outputs.tag }}
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
|
||||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
# rebuilding bytes this job has already built, validated and boot-smoked.
|
||||||
npm stage publish --provenance --access public --tag "$TAG"
|
npm pack --ignore-scripts
|
||||||
{
|
TARBALL="omniroute-${VERSION}.tgz"
|
||||||
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
|
||||||
echo ""
|
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||||
echo "The exact bytes are parked on the registry. To release them:"
|
|
||||||
echo '```'
|
|
||||||
echo "npm stage list omniroute # find the stage id"
|
|
||||||
echo "npm stage approve <id> # owner 2FA — THE publish"
|
|
||||||
echo '```'
|
|
||||||
echo "To verify the staged bytes first: npm stage download <id> → run"
|
|
||||||
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
|
|
||||||
echo "To discard: npm stage reject <id>."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
|
||||||
|
|
||||||
- name: Publish to npm (DIRECT — emergency fallback)
|
- name: Hand the tarball to the hosted publish job
|
||||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
env:
|
uses: actions/upload-artifact@v7
|
||||||
VERSION: ${{ steps.resolve.outputs.version }}
|
with:
|
||||||
TAG: ${{ steps.resolve.outputs.tag }}
|
name: npm-tarball
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
|
||||||
run: |
|
retention-days: 1
|
||||||
set -euo pipefail
|
if-no-files-found: error
|
||||||
npm publish --provenance --access public --tag "$TAG"
|
|
||||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
|
||||||
|
|
||||||
- name: Publish to GitHub Packages
|
- name: Publish to GitHub Packages
|
||||||
if: steps.resolve.outputs.skip != 'true'
|
if: steps.resolve.outputs.skip != 'true'
|
||||||
@@ -338,6 +361,91 @@ jobs:
|
|||||||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||||||
echo "✅ Action finished for GitHub Packages"
|
echo "✅ Action finished for GitHub Packages"
|
||||||
|
|
||||||
|
# npm REFUSES `--provenance` from a self-hosted runner:
|
||||||
|
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||||||
|
# Unsupported GitHub Actions runner environment: "self-hosted".
|
||||||
|
# Only "github-hosted" runners are supported when publishing with provenance.
|
||||||
|
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
|
||||||
|
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
|
||||||
|
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
|
||||||
|
# SLSA attestation and 3.8.50 must not regress that.
|
||||||
|
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
|
||||||
|
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
|
||||||
|
# this job, which needs no memory at all, performs the upload.
|
||||||
|
stage-npm:
|
||||||
|
needs: publish
|
||||||
|
if: needs.publish.outputs.skip != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # npm provenance — the whole reason this job is separate
|
||||||
|
steps:
|
||||||
|
- name: Download the tarball the publish job proved
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: npm-tarball
|
||||||
|
path: .
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
- name: Ensure npm supports staged publishing
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
CUR=$(npm --version)
|
||||||
|
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||||||
|
# Pinned exact version (supply-chain: never float @latest in a publish
|
||||||
|
# job); bump deliberately when a newer npm is required.
|
||||||
|
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||||||
|
npm install -g --ignore-scripts npm@11.15.0
|
||||||
|
fi
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Publish to npm (staged — owner approves with 2FA)
|
||||||
|
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.publish.outputs.version }}
|
||||||
|
TAG: ${{ needs.publish.outputs.tag }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TARBALL="omniroute-${VERSION}.tgz"
|
||||||
|
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||||
|
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||||
|
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||||
|
# --ignore-scripts: publishing a built tarball must never re-run
|
||||||
|
# prepublishOnly (build:cli-api && build:cli) on this small runner.
|
||||||
|
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||||
|
{
|
||||||
|
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
||||||
|
echo ""
|
||||||
|
echo "The exact bytes are parked on the registry. To release them:"
|
||||||
|
echo '```'
|
||||||
|
echo "npm stage list omniroute # find the stage id"
|
||||||
|
echo "npm stage approve <id> # owner 2FA — THE publish"
|
||||||
|
echo '```'
|
||||||
|
echo "To verify the staged bytes first: npm stage download <id> → run"
|
||||||
|
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
|
||||||
|
echo "To discard: npm stage reject <id>."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
||||||
|
|
||||||
|
- name: Publish to npm (DIRECT — emergency fallback)
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.publish.outputs.version }}
|
||||||
|
TAG: ${{ needs.publish.outputs.tag }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TARBALL="omniroute-${VERSION}.tgz"
|
||||||
|
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||||
|
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||||
|
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||||
|
|
||||||
publish-opencode-plugin:
|
publish-opencode-plugin:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -293,3 +293,6 @@ docker-compose.yml.bak
|
|||||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||||
/.sandbox/
|
/.sandbox/
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
# check:install-upgrade work trees (~12 GB, disposable)
|
||||||
|
/.install-upgrade/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- **fix(db):** `model_capabilities` is created by a migration instead of lazily on the first
|
||||||
|
models.dev sync, so a clean install and an upgraded install converge on the same schema
|
||||||
|
regardless of which features have run
|
||||||
|
- **fix(ci):** `check:install-upgrade` now fails on an `npm` install truncated by ENOSPC
|
||||||
|
(npm reports it as a warning and still exits 0), authenticates its health probe so the
|
||||||
|
version assertion works against the hardened health payload, frees the clean-install tree
|
||||||
|
before the upgrade phase, and no longer reports a schema divergence computed from a boot
|
||||||
|
that never served
|
||||||
4
changelog.d/fixes/11856-npm-payload-nft-manifests.md
Normal file
4
changelog.d/fixes/11856-npm-payload-nft-manifests.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- Excluded Next.js Node File Trace manifests (`*.nft.json`) from the published npm
|
||||||
|
tarball. They are build-time metadata and are never read while serving, but had
|
||||||
|
grown to 668.7 MB — 61% of the package — which pushed the upload past the
|
||||||
|
registry limit and made `npm publish` fail with `413 Payload Too Large`.
|
||||||
4
changelog.d/fixes/11868-npm-provenance-hosted-runner.md
Normal file
4
changelog.d/fixes/11868-npm-provenance-hosted-runner.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- Split the npm registry upload into its own GitHub-hosted job. npm refuses
|
||||||
|
`--provenance` from a self-hosted runner (`422 ... Only "github-hosted" runners
|
||||||
|
are supported`), which blocked the v3.8.50 publish; the heavy verification
|
||||||
|
cannot move to a hosted runner, so it now hands the proven tarball over instead.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_doc": "Tables that exist ONLY in databases upgraded from an older version — residue whose CREATE left the migration set in some past cycle but survives where it already existed. Harmless (nothing references them), but recorded here so check-install-upgrade.mjs can still fail on a NEW divergence. The opposite direction (a table a clean install creates but an upgrade does not) is NEVER allowlisted: it means every existing user is missing structure the code expects.",
|
"_doc": "Tables that exist ONLY in databases upgraded from an older version. Two causes, and they call for different fixes: (a) residue whose CREATE left the migration set in some past cycle but survives where it already existed — allowlist it here; (b) a table created LAZILY at runtime with `CREATE TABLE IF NOT EXISTS` inside a feature code path — whether a database has it depends on whether that feature ran, so it diverges by TIMING and can show up on EITHER side. Fix (b) with a migration instead of an entry here (see src/lib/db/migrations/163_model_capabilities.sql); an allowlist entry only hides it in one direction. Either way, recorded so check-install-upgrade.mjs can still fail on a NEW divergence. The opposite direction (a table a clean install creates but an upgrade does not) is NEVER allowlisted: it means every existing user is missing structure the code expects.",
|
||||||
"residualTables": {
|
"residualTables": {
|
||||||
"cache_metrics": "Measured 2026-07-30 on a real 3.8.48 install upgraded to 3.8.49 (VPS .16, 165 MB database, 114 → 117 tables). Present in upgraded databases, absent from clean installs. No code path referenced it during the upgrade (zero `no such table` in 150 log lines, both installs healthy). Left in place rather than dropped: a DROP migration on a table we cannot prove is unused everywhere is the riskier change. Revisit when the cache subsystem is next touched."
|
"cache_metrics": "Measured 2026-07-30 on a real 3.8.48 install upgraded to 3.8.49 (VPS .16, 165 MB database, 114 → 117 tables). Present in upgraded databases, absent from clean installs. Cause identified 2026-08-27: it is case (b) above — created lazily by `ensureCacheMetricsTable()` at src/lib/semanticCache.ts:34, never by a migration, so it appears only where the semantic cache has run. No code path referenced it during the upgrade (zero `no such table` in 150 log lines, both installs healthy). Left as an allowlist entry rather than promoted to a migration or dropped: unlike model_capabilities it did not block a release, and creating a table for a subsystem we cannot prove is live is not a change to make blind. Revisit when the cache subsystem is next touched."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
"dedicatedGate": true
|
"dedicatedGate": true
|
||||||
},
|
},
|
||||||
"zizmorFindings": {
|
"zizmorFindings": {
|
||||||
"value": 192,
|
"value": 194,
|
||||||
"_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.",
|
"_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.",
|
||||||
"_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.",
|
"_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.",
|
||||||
"_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.",
|
"_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.",
|
||||||
@@ -180,7 +180,8 @@
|
|||||||
"_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning.",
|
"_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning.",
|
||||||
"_rebaseline_2026_07_13_v3847_release_preflight": "159 -> 169 (+10). Findings from cycle-merged workflow changes: #6716 (PR gate restructure), #6781 (unit fast-path shard 2->4), #6788 (TIA tsx loader split), #6881 (electron-updater latest.yml manifests in release assets) — same deliberate @vN unpinned-uses convention as prior rebaselines; no new template-injection/artipacked/cache-poisoning classes. Measured via `npm run check:workflows -- --ratchet` = 169 on the v3.8.47 release pre-flight.",
|
"_rebaseline_2026_07_13_v3847_release_preflight": "159 -> 169 (+10). Findings from cycle-merged workflow changes: #6716 (PR gate restructure), #6781 (unit fast-path shard 2->4), #6788 (TIA tsx loader split), #6881 (electron-updater latest.yml manifests in release assets) — same deliberate @vN unpinned-uses convention as prior rebaselines; no new template-injection/artipacked/cache-poisoning classes. Measured via `npm run check:workflows -- --ratchet` = 169 on the v3.8.47 release pre-flight.",
|
||||||
"_rebaseline_2026_07_28_v3849_release_preflight": "176 -> 189 (+13). Pre-flight de fechamento da v3.8.49 (934 commits no ciclo). Deriva de workflow: 1 workflow novo (build-rinseaid-image.yml) mais os bumps de action do Dependabot ao longo do ciclo — todos da MESMA classe unpinned-uses @vN, convenção deliberada do repo (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só estes violaria a convenção. Nenhuma classe nova de template-injection / artipacked / cache-poisoning / dangerous-triggers. Nesta mesma passada foram CORRIGIDAS 3 diretivas shellcheck malformadas (SC1125: `# shellcheck disable=SC2086 — texto`, em que o travessão invalida o par key=value) em ci.yml e nightly-release-green.yml. Medido com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 189.",
|
"_rebaseline_2026_07_28_v3849_release_preflight": "176 -> 189 (+13). Pre-flight de fechamento da v3.8.49 (934 commits no ciclo). Deriva de workflow: 1 workflow novo (build-rinseaid-image.yml) mais os bumps de action do Dependabot ao longo do ciclo — todos da MESMA classe unpinned-uses @vN, convenção deliberada do repo (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só estes violaria a convenção. Nenhuma classe nova de template-injection / artipacked / cache-poisoning / dangerous-triggers. Nesta mesma passada foram CORRIGIDAS 3 diretivas shellcheck malformadas (SC1125: `# shellcheck disable=SC2086 — texto`, em que o travessão invalida o par key=value) em ci.yml e nightly-release-green.yml. Medido com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 189.",
|
||||||
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner."
|
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner.",
|
||||||
|
"_rebaseline_2026_08_28_npm_publish_hosted_stage_job": "192 -> 194 (+2). Job novo `stage-npm` em .github/workflows/npm-publish.yml: o npm RECUSA `--provenance` vindo de runner self-hosted (422 \"Unsupported GitHub Actions runner environment\"), e o job `publish` nao pode migrar para runner hospedado porque 16 GB nao bastam para o fallback next-build do build:cli (documentado no proprio runs-on). A separacao foi a unica saida que preserva a atestacao SLSA que a 3.8.49 ja tem. Os +2 sao da MESMA convencao deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16): unpinned-uses @vN em actions/download-artifact@v8 + actions/setup-node@v7, mais o cache-poisoning que o proprio setup-node@v7 ja gera nos outros 2 jobs deste MESMO arquivo (linhas 85 e 463) e que ja esta na baseline. Fixar por SHA so este job violaria a convencao. Nenhuma classe nova: zero template-injection / artipacked / dangerous-triggers / excessive-permissions — o job declara apenas contents:read + id-token:write, que e o minimo para a proveniencia. Medido pelo job Quality Gates (Extended) no run 33162... da PR #11877 = 194."
|
||||||
},
|
},
|
||||||
"vulnCount": {
|
"vulnCount": {
|
||||||
"value": 22,
|
"value": 22,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
|||||||
| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. |
|
| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. |
|
||||||
| `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(unset)_ | `src/lib/db/migrationRunner/extraDirs.ts` | Additional migration directories as `namespace=dir` entries separated by the platform path delimiter (e.g. `ee=/opt/app/enterprise/db/migrations`). Files found there are recorded as `<namespace>-<number>`, so a distribution shipping its own migrations never collides with the upstream numeric slots. A malformed entry, an invalid namespace or a missing directory throws at startup instead of silently skipping the schema. |
|
| `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(unset)_ | `src/lib/db/migrationRunner/extraDirs.ts` | Additional migration directories as `namespace=dir` entries separated by the platform path delimiter (e.g. `ee=/opt/app/enterprise/db/migrations`). Files found there are recorded as `<namespace>-<number>`, so a distribution shipping its own migrations never collides with the upstream numeric slots. A malformed entry, an invalid namespace or a missing directory throws at startup instead of silently skipping the schema. |
|
||||||
| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | Mass-pending-migrations safety threshold (#3416). Startup aborts if more than this many migrations are pending on an existing DB (guards against a wiped tracking table). Raise it to restore an older backup; set to `0` to disable the check. |
|
| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | Mass-pending-migrations safety threshold (#3416). Startup aborts if more than this many migrations are pending on an existing DB (guards against a wiped tracking table). Raise it to restore an older backup; set to `0` to disable the check. |
|
||||||
|
| `OMNIROUTE_INSTALL_UPGRADE_WORKDIR` | _(`<repo>/.install-upgrade`)_ | `scripts/check/check-install-upgrade.mjs` | Working directory for the `check:install-upgrade` release gate. It needs roughly 12 GB (two ~3 GB install trees plus the tarball), so it must not run on a small tmpfs — on the self-hosted runner `/tmp` is a 12 GB RAM-backed tmpfs and the gate exhausted it, truncating the package. |
|
||||||
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. |
|
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. |
|
||||||
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. |
|
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. |
|
||||||
| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. |
|
| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. |
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
"!**/*.test.js",
|
"!**/*.test.js",
|
||||||
"!**/*.test.mjs",
|
"!**/*.test.mjs",
|
||||||
"!**/*.spec.ts",
|
"!**/*.spec.ts",
|
||||||
"!**/*.spec.tsx"
|
"!**/*.spec.tsx",
|
||||||
|
"!**/*.nft.json"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"open-sse",
|
"open-sse",
|
||||||
|
|||||||
@@ -30,10 +30,12 @@
|
|||||||
* Requires `npm run build:cli` first — this is a --with-build gate, like check:pack-boot.
|
* Requires `npm run build:cli` first — this is a --with-build gate, like check:pack-boot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync, spawn } from "node:child_process";
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
const BOOT_DEADLINE_MS = 180_000;
|
const BOOT_DEADLINE_MS = 180_000;
|
||||||
@@ -43,6 +45,59 @@ const ALLOWLIST_PATH = "config/quality/install-upgrade-allowlist.json";
|
|||||||
const log = (msg) => console.log(`[install-upgrade] ${msg}`);
|
const log = (msg) => console.log(`[install-upgrade] ${msg}`);
|
||||||
const warn = (msg) => console.log(`[install-upgrade] ⚠️ ${msg}`);
|
const warn = (msg) => console.log(`[install-upgrade] ⚠️ ${msg}`);
|
||||||
|
|
||||||
|
/** Root of the installed package inside an `npm install -g --prefix` tree. */
|
||||||
|
function packageRootFor(prefix) {
|
||||||
|
return path.join(prefix, "lib", "node_modules", "omniroute");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credential for the health probe.
|
||||||
|
*
|
||||||
|
* GHSA-mvf8-qc78-5mxm hardened /api/monitoring/health: an ANONYMOUS caller now gets only
|
||||||
|
* `{ status }` — the version, node version, pid and provider config are reserved for a
|
||||||
|
* management principal (src/app/api/monitoring/health/route.ts → publicHealthView). An
|
||||||
|
* unauthenticated probe therefore reads `body.version === undefined`, and this gate's
|
||||||
|
* version assertion could never pass again; the v3.8.50 publish run failed with
|
||||||
|
* "clean: health reports version undefined, expected 3.8.50" for exactly that reason.
|
||||||
|
*
|
||||||
|
* The gate spawns the server itself, so it can mint the credential instead of guessing one:
|
||||||
|
* `OMNIROUTE_INTERNAL_SERVICE_TOKEN` + the `x-omniroute-internal-service-token` header is
|
||||||
|
* accepted by requireManagementAuth() via isTrustedLoopbackInternalServiceRequest(), and the
|
||||||
|
* probe is loopback by construction. Unlike the machine token `check:pack-boot` derives, this
|
||||||
|
* does not depend on a readable machine-id, and an older PREVIOUS version that never gated
|
||||||
|
* health simply ignores the header. The assertion keeps its full strength — it just presents
|
||||||
|
* a credential.
|
||||||
|
*/
|
||||||
|
const INTERNAL_SERVICE_TOKEN = crypto.randomBytes(32).toString("hex");
|
||||||
|
const INTERNAL_SERVICE_HEADER = "x-omniroute-internal-service-token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondary credential: the same loopback machine token `check:pack-boot` derives from the
|
||||||
|
* packaged CLI. Sent alongside the internal-service token so a build that only honours one
|
||||||
|
* of the two still answers with the full payload.
|
||||||
|
*/
|
||||||
|
function derivePackagedCliToken(prefix) {
|
||||||
|
const cliModuleUrl = pathToFileURL(
|
||||||
|
path.join(packageRootFor(prefix), "bin", "cli", "utils", "cliToken.mjs")
|
||||||
|
).href;
|
||||||
|
try {
|
||||||
|
return execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--input-type=module",
|
||||||
|
"--eval",
|
||||||
|
"import(process.argv[1]).then(async m => process.stdout.write(await m.getCliToken()))",
|
||||||
|
cliModuleUrl,
|
||||||
|
],
|
||||||
|
{ encoding: "utf8", env: { ...process.env } }
|
||||||
|
).trim();
|
||||||
|
} catch {
|
||||||
|
// A truncated/broken install cannot derive a token. Returning null keeps the boot
|
||||||
|
// probe running (it will fail loudly on its own) instead of crashing the gate here.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pickTarball(packJson) {
|
function pickTarball(packJson) {
|
||||||
const filename = JSON.parse(packJson)?.[0]?.filename;
|
const filename = JSON.parse(packJson)?.[0]?.filename;
|
||||||
if (!filename) throw new Error("npm pack --json returned no filename");
|
if (!filename) throw new Error("npm pack --json returned no filename");
|
||||||
@@ -121,6 +176,11 @@ async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
|
|||||||
if (!fs.existsSync(binPath)) {
|
if (!fs.existsSync(binPath)) {
|
||||||
return { ok: false, failures: [`${label}: bin not found at ${binPath}`], tail: [] };
|
return { ok: false, failures: [`${label}: bin not found at ${binPath}`], tail: [] };
|
||||||
}
|
}
|
||||||
|
const cliToken = derivePackagedCliToken(prefix);
|
||||||
|
const probeHeaders = {
|
||||||
|
[INTERNAL_SERVICE_HEADER]: INTERNAL_SERVICE_TOKEN,
|
||||||
|
...(cliToken ? { "x-omniroute-cli-token": cliToken } : {}),
|
||||||
|
};
|
||||||
const child = spawn(binPath, ["serve", "--port", String(port)], {
|
const child = spawn(binPath, ["serve", "--port", String(port)], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -130,6 +190,7 @@ async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
|
|||||||
API_KEY_SECRET: "install-upgrade-gate-api-key-secret-long",
|
API_KEY_SECRET: "install-upgrade-gate-api-key-secret-long",
|
||||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
|
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
|
||||||
|
OMNIROUTE_INTERNAL_SERVICE_TOKEN: INTERNAL_SERVICE_TOKEN,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: true,
|
detached: true,
|
||||||
@@ -151,20 +212,33 @@ async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
|
|||||||
let result = { ok: false, failures: [`${label}: never became healthy`], tail };
|
let result = { ok: false, failures: [`${label}: never became healthy`], tail };
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (childExit !== null) {
|
if (childExit !== null) {
|
||||||
result = { ok: false, failures: [`${label}: exited with code ${childExit} before serving`], tail };
|
result = {
|
||||||
|
ok: false,
|
||||||
|
failures: [`${label}: exited with code ${childExit} before serving`],
|
||||||
|
tail,
|
||||||
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
|
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
|
||||||
|
headers: probeHeaders,
|
||||||
|
});
|
||||||
const body = await res.json().catch(() => null);
|
const body = await res.json().catch(() => null);
|
||||||
if (res.status === 200 && body && typeof body === "object") {
|
if (res.status === 200 && body && typeof body === "object") {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
// `status` may legitimately report degraded (no providers configured) — the gate
|
// `status` may legitimately report degraded (no providers configured) — the gate
|
||||||
// targets boot crashes and version mismatches, not health of a bare install.
|
// targets boot crashes and version mismatches, not health of a bare install.
|
||||||
if (expectVersion && body.version !== expectVersion) {
|
const reportedVersion = body.version ?? body.system?.version;
|
||||||
failures.push(`${label}: health reports version ${body.version}, expected ${expectVersion}`);
|
if (expectVersion && reportedVersion !== expectVersion) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: health reports version ${reportedVersion}, expected ${expectVersion}` +
|
||||||
|
(reportedVersion === undefined
|
||||||
|
? " — the payload carries no version at all, which is the ANONYMOUS health " +
|
||||||
|
"view: the probe's credentials were not accepted (see GHSA-mvf8-qc78-5mxm)"
|
||||||
|
: "")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
result = { ok: failures.length === 0, version: body.version, failures, tail };
|
result = { ok: failures.length === 0, version: reportedVersion, failures, tail };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -183,13 +257,72 @@ async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function npmInstallInto(prefix, spec) {
|
/**
|
||||||
execFileSync("npm", ["install", "-g", "--prefix", prefix, "--no-audit", "--no-fund", spec], {
|
* `npm install` reports ENOSPC as a *warning* per failed tar entry and still exits 0.
|
||||||
encoding: "utf8",
|
*
|
||||||
maxBuffer: 128 * 1024 * 1024,
|
* That is not a theoretical concern: on the v3.8.50 publish run the Phase B upgrade install
|
||||||
});
|
* emitted 5611 `npm warn tar TAR_ENTRY_ERROR ENOSPC: no space left on device` lines, exited
|
||||||
|
* 0, and left a truncated package behind. `omniroute serve` then "exited with code 0 before
|
||||||
|
* serving", no migration ever ran, and the gate concluded the release was missing 15 tables
|
||||||
|
* — a full false alarm produced by a full disk. Each install tree is ~3 GB, and the run
|
||||||
|
* builds two of them plus a ~275 MB tarball.
|
||||||
|
*
|
||||||
|
* So: surface the truncation at the install, where it is unambiguous.
|
||||||
|
*/
|
||||||
|
function npmInstallInto(prefix, spec, label = spec) {
|
||||||
|
// spawnSync (not execFileSync): execFileSync forwards the child's stderr straight to the
|
||||||
|
// parent's, so the ENOSPC warnings scrolled past in CI without the script ever seeing
|
||||||
|
// them. spawnSync hands both streams back.
|
||||||
|
const run = spawnSync(
|
||||||
|
"npm",
|
||||||
|
["install", "-g", "--prefix", prefix, "--no-audit", "--no-fund", spec],
|
||||||
|
{ encoding: "utf8", maxBuffer: 512 * 1024 * 1024 }
|
||||||
|
);
|
||||||
|
const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
|
||||||
|
// Keep the install log visible, but a truncated package emits thousands of identical
|
||||||
|
// warnings — collapse them so the real message is not buried.
|
||||||
|
const stderrLines = String(run.stderr ?? "").split("\n");
|
||||||
|
const shown = stderrLines.length > 60 ? stderrLines.slice(0, 40) : stderrLines;
|
||||||
|
if (String(run.stderr ?? "").trim()) {
|
||||||
|
process.stderr.write(shown.join("\n") + "\n");
|
||||||
|
if (stderrLines.length > 60) {
|
||||||
|
process.stderr.write(`[install-upgrade] … ${stderrLines.length - 40} more npm line(s)\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoDiskExhaustion(output, label);
|
||||||
|
if (run.error) throw run.error;
|
||||||
|
if (run.status !== 0) {
|
||||||
|
throw new Error(`${label}: npm install exited with code ${run.status}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertNoDiskExhaustion(output, label) {
|
||||||
|
if (!/ENOSPC|no space left on device/i.test(output)) return;
|
||||||
|
const count = (output.match(/ENOSPC/g) ?? []).length;
|
||||||
|
throw new Error(
|
||||||
|
`${label}: the install ran out of disk space (${count} ENOSPC error(s) from npm). ` +
|
||||||
|
`The package tree is truncated, so anything measured from it — boot, schema, ` +
|
||||||
|
`migrations — is meaningless. Free space in ${workDirForMessages} (each install tree is ` +
|
||||||
|
`~3 GB) and re-run. This is an environment failure, NOT a schema divergence.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort free bytes on the filesystem backing `dir`, or null when unavailable. */
|
||||||
|
function freeBytes(dir) {
|
||||||
|
try {
|
||||||
|
return fs.statfsSync(dir).bavail * fs.statfsSync(dir).bsize;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GB = 1024 ** 3;
|
||||||
|
|
||||||
|
// Set once the work directory exists, so the ENOSPC message names the filesystem that
|
||||||
|
// actually ran out — pointing at /tmp when the gate works elsewhere sends the reader to
|
||||||
|
// free space on the wrong volume (which is what happened during the v3.8.50 publish).
|
||||||
|
let workDirForMessages = os.tmpdir();
|
||||||
|
|
||||||
function resolvePreviousVersion(current, explicit) {
|
function resolvePreviousVersion(current, explicit) {
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
const out = execFileSync("npm", ["view", "omniroute", "dist-tags.latest"], { encoding: "utf8" });
|
const out = execFileSync("npm", ["view", "omniroute", "dist-tags.latest"], { encoding: "utf8" });
|
||||||
@@ -198,7 +331,9 @@ function resolvePreviousVersion(current, explicit) {
|
|||||||
if (latest === current) {
|
if (latest === current) {
|
||||||
// The version under test is already published (re-run of a shipped release): step back
|
// The version under test is already published (re-run of a shipped release): step back
|
||||||
// to the highest published version strictly below it.
|
// to the highest published version strictly below it.
|
||||||
const all = JSON.parse(execFileSync("npm", ["view", "omniroute", "versions", "--json"], { encoding: "utf8" }));
|
const all = JSON.parse(
|
||||||
|
execFileSync("npm", ["view", "omniroute", "versions", "--json"], { encoding: "utf8" })
|
||||||
|
);
|
||||||
const stable = all.filter((v) => !/-(rc|alpha|beta|pre|next)/.test(v) && v !== current);
|
const stable = all.filter((v) => !/-(rc|alpha|beta|pre|next)/.test(v) && v !== current);
|
||||||
return stable[stable.length - 1];
|
return stable[stable.length - 1];
|
||||||
}
|
}
|
||||||
@@ -218,11 +353,25 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||||
const allowlist = loadAllowlist(ROOT);
|
const allowlist = loadAllowlist(ROOT);
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-install-upgrade-"));
|
// NOT os.tmpdir(): on the self-hosted runner /tmp is a 12 GB tmpfs backed by RAM, while
|
||||||
|
// the root filesystem has ~66 GB free. This gate needs ~12 GB, so it exhausted the tmpfs
|
||||||
|
// and npm truncated the package — 58269 ENOSPC errors on the v3.8.50 publish, which the
|
||||||
|
// previous code could only report as a crash. Freeing disk did not help because the disk
|
||||||
|
// was never the constraint. Work on real disk beside the repo instead.
|
||||||
|
const workRoot =
|
||||||
|
process.env.OMNIROUTE_INSTALL_UPGRADE_WORKDIR || path.join(ROOT, ".install-upgrade");
|
||||||
|
fs.mkdirSync(workRoot, { recursive: true });
|
||||||
|
const tmp = fs.mkdtempSync(path.join(workRoot, "omniroute-install-upgrade-"));
|
||||||
|
workDirForMessages = tmp;
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Timed, because this turned out to be the expensive part: on the 2026-08-27
|
||||||
|
// v3.8.50 publish `npm pack` alone took 24m37s, leaving 5 of the step's 30-minute
|
||||||
|
// budget for two installs and two boots. Without a duration here the log showed
|
||||||
|
// only "packing…" then a timeout, which reads like a hang and is not.
|
||||||
|
const packStarted = Date.now();
|
||||||
log(`packing v${version}…`);
|
log(`packing v${version}…`);
|
||||||
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
|
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
@@ -230,13 +379,31 @@ async function main() {
|
|||||||
maxBuffer: 128 * 1024 * 1024,
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
const tarball = path.join(tmp, pickTarball(packOut));
|
const tarball = path.join(tmp, pickTarball(packOut));
|
||||||
|
const packMb = (fs.statSync(tarball).size / 1024 / 1024).toFixed(1);
|
||||||
|
log(`packed in ${Math.round((Date.now() - packStarted) / 1000)}s (${packMb} MB)`);
|
||||||
|
|
||||||
|
// Each install tree is ~3 GB and this run builds two of them, side by side, plus the
|
||||||
|
// ~275 MB tarball. On the v3.8.50 publish run that overflowed the runner disk mid-way
|
||||||
|
// through the Phase B upgrade install; npm warned per truncated tar entry and still
|
||||||
|
// exited 0, and every later measurement was taken from a broken tree.
|
||||||
|
const availableBytes = freeBytes(tmp);
|
||||||
|
if (availableBytes !== null) {
|
||||||
|
log(`free space in ${tmp}: ${(availableBytes / GB).toFixed(1)} GB`);
|
||||||
|
if (availableBytes < 12 * GB) {
|
||||||
|
warn(
|
||||||
|
`only ${(availableBytes / GB).toFixed(1)} GB free — this gate needs roughly 12 GB ` +
|
||||||
|
`(two ~3 GB install trees, the second installed over twice, plus the tarball). ` +
|
||||||
|
`An install truncated by ENOSPC looks like a schema divergence.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Phase A: clean install -------------------------------------------------
|
// ---- Phase A: clean install -------------------------------------------------
|
||||||
log("PHASE A — clean install of the packed tarball");
|
log("PHASE A — clean install of the packed tarball");
|
||||||
const aPrefix = path.join(tmp, "a-prefix");
|
const aPrefix = path.join(tmp, "a-prefix");
|
||||||
const aData = path.join(tmp, "a-data");
|
const aData = path.join(tmp, "a-data");
|
||||||
fs.mkdirSync(aData, { recursive: true });
|
fs.mkdirSync(aData, { recursive: true });
|
||||||
npmInstallInto(aPrefix, tarball);
|
npmInstallInto(aPrefix, tarball, "clean install");
|
||||||
const a = await bootAndProbe({
|
const a = await bootAndProbe({
|
||||||
prefix: aPrefix,
|
prefix: aPrefix,
|
||||||
dataDir: aData,
|
dataDir: aData,
|
||||||
@@ -245,14 +412,34 @@ async function main() {
|
|||||||
label: "clean",
|
label: "clean",
|
||||||
});
|
});
|
||||||
failures.push(...a.failures);
|
failures.push(...a.failures);
|
||||||
|
const cleanBooted = a.ok;
|
||||||
if (a.ok) log(`clean install healthy on v${a.version}`);
|
if (a.ok) log(`clean install healthy on v${a.version}`);
|
||||||
|
else if (a.tail?.length) {
|
||||||
|
console.error("[install-upgrade] last output from the clean-install server:");
|
||||||
|
console.error(a.tail.join("").split("\n").slice(-40).join("\n"));
|
||||||
|
}
|
||||||
const aDb = findDb(aData);
|
const aDb = findDb(aData);
|
||||||
const freshTables = aDb ? readTables(aDb) : null;
|
const freshTables = aDb ? readTables(aDb) : null;
|
||||||
if (!freshTables) failures.push("clean: no SQLite database was created");
|
if (!freshTables) failures.push("clean: no SQLite database was created");
|
||||||
else log(`clean install schema: ${freshTables.size} tables`);
|
else log(`clean install schema: ${freshTables.size} tables`);
|
||||||
|
|
||||||
|
// Phase A is fully measured (boot verdict + schema snapshot); its ~3 GB install tree is
|
||||||
|
// dead weight from here on and Phase B needs the room. The DATA_DIR stays — only the
|
||||||
|
// node_modules tree goes.
|
||||||
|
if (!skipUpgrade) {
|
||||||
|
fs.rmSync(aPrefix, { recursive: true, force: true });
|
||||||
|
const reclaimed = freeBytes(tmp);
|
||||||
|
log(
|
||||||
|
"released the clean-install tree before the upgrade phase" +
|
||||||
|
(reclaimed !== null ? ` (${(reclaimed / GB).toFixed(1)} GB free)` : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Phase B: upgrade over the previous published version -------------------
|
// ---- Phase B: upgrade over the previous published version -------------------
|
||||||
let upgradedTables = null;
|
let upgradedTables = null;
|
||||||
|
// `--skip-upgrade` never reaches the convergence block (upgradedTables stays null), so
|
||||||
|
// defaulting this to true keeps that path unchanged.
|
||||||
|
let upgradeBooted = true;
|
||||||
if (skipUpgrade) {
|
if (skipUpgrade) {
|
||||||
warn("PHASE B skipped (--skip-upgrade)");
|
warn("PHASE B skipped (--skip-upgrade)");
|
||||||
} else {
|
} else {
|
||||||
@@ -262,7 +449,7 @@ async function main() {
|
|||||||
const bData = path.join(tmp, "b-data");
|
const bData = path.join(tmp, "b-data");
|
||||||
fs.mkdirSync(bData, { recursive: true });
|
fs.mkdirSync(bData, { recursive: true });
|
||||||
|
|
||||||
npmInstallInto(bPrefix, `omniroute@${previous}`);
|
npmInstallInto(bPrefix, `omniroute@${previous}`, `previous(${previous}) install`);
|
||||||
const before = await bootAndProbe({
|
const before = await bootAndProbe({
|
||||||
prefix: bPrefix,
|
prefix: bPrefix,
|
||||||
dataDir: bData,
|
dataDir: bData,
|
||||||
@@ -273,14 +460,16 @@ async function main() {
|
|||||||
if (!before.ok) {
|
if (!before.ok) {
|
||||||
// A broken PREVIOUS version is not this release's fault — degrade to a warning so a
|
// A broken PREVIOUS version is not this release's fault — degrade to a warning so a
|
||||||
// historically bad publish cannot block the current one.
|
// historically bad publish cannot block the current one.
|
||||||
warnings.push(`previous version ${previous} did not boot cleanly — upgrade path unverified`);
|
warnings.push(
|
||||||
|
`previous version ${previous} did not boot cleanly — upgrade path unverified`
|
||||||
|
);
|
||||||
for (const f of before.failures) warn(f);
|
for (const f of before.failures) warn(f);
|
||||||
} else {
|
} else {
|
||||||
const beforeDb = findDb(bData);
|
const beforeDb = findDb(bData);
|
||||||
const beforeTables = beforeDb ? readTables(beforeDb) : new Set();
|
const beforeTables = beforeDb ? readTables(beforeDb) : new Set();
|
||||||
log(`previous(${previous}) schema: ${beforeTables.size} tables — upgrading in place`);
|
log(`previous(${previous}) schema: ${beforeTables.size} tables — upgrading in place`);
|
||||||
|
|
||||||
npmInstallInto(bPrefix, tarball);
|
npmInstallInto(bPrefix, tarball, "upgrade install");
|
||||||
const after = await bootAndProbe({
|
const after = await bootAndProbe({
|
||||||
prefix: bPrefix,
|
prefix: bPrefix,
|
||||||
dataDir: bData,
|
dataDir: bData,
|
||||||
@@ -290,6 +479,11 @@ async function main() {
|
|||||||
});
|
});
|
||||||
failures.push(...after.failures);
|
failures.push(...after.failures);
|
||||||
if (after.ok) log(`upgrade healthy on v${after.version}`);
|
if (after.ok) log(`upgrade healthy on v${after.version}`);
|
||||||
|
upgradeBooted = after.ok;
|
||||||
|
if (!after.ok && after.tail?.length) {
|
||||||
|
console.error("[install-upgrade] last output from the upgraded server:");
|
||||||
|
console.error(after.tail.join("").split("\n").slice(-40).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
const afterDb = findDb(bData);
|
const afterDb = findDb(bData);
|
||||||
upgradedTables = afterDb ? readTables(afterDb) : null;
|
upgradedTables = afterDb ? readTables(afterDb) : null;
|
||||||
@@ -306,7 +500,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Schema convergence -----------------------------------------------------
|
// ---- Schema convergence -----------------------------------------------------
|
||||||
if (freshTables && upgradedTables) {
|
// Only meaningful when BOTH servers actually served. A boot that died before serving
|
||||||
|
// never ran a migration, so its database still holds the PREVIOUS release's schema and
|
||||||
|
// every post-baseline table shows up as "a clean install creates but an upgrade does
|
||||||
|
// not" — which is what the v3.8.50 publish run reported after ENOSPC truncated the
|
||||||
|
// upgrade install. Comparing there does not add information, it manufactures a
|
||||||
|
// 15-table false alarm on top of the real failure. The run still fails: the boot
|
||||||
|
// failure is already in `failures`.
|
||||||
|
if (freshTables && upgradedTables && !(cleanBooted && upgradeBooted)) {
|
||||||
|
warn(
|
||||||
|
"schema convergence NOT evaluated — a phase failed to boot, so its database was " +
|
||||||
|
"never migrated and any table difference would describe the broken boot, not the schema"
|
||||||
|
);
|
||||||
|
} else if (freshTables && upgradedTables) {
|
||||||
const verdict = evaluateConvergence({
|
const verdict = evaluateConvergence({
|
||||||
freshTables,
|
freshTables,
|
||||||
upgradedTables,
|
upgradedTables,
|
||||||
@@ -334,7 +540,10 @@ async function main() {
|
|||||||
|
|
||||||
// Only run the (expensive) gate when invoked directly — importing this module for the pure
|
// Only run the (expensive) gate when invoked directly — importing this module for the pure
|
||||||
// helper above must not pack, install or boot anything.
|
// helper above must not pack, install or boot anything.
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)
|
||||||
|
) {
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(`[install-upgrade] crashed: ${err?.message ?? err}`);
|
console.error(`[install-upgrade] crashed: ${err?.message ?? err}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
43
src/lib/db/migrations/163_model_capabilities.sql
Normal file
43
src/lib/db/migrations/163_model_capabilities.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- 163_model_capabilities.sql
|
||||||
|
--
|
||||||
|
-- Promote `model_capabilities` from a lazily-created runtime table to a real migration.
|
||||||
|
--
|
||||||
|
-- WHY: the table was only ever created by `ensureCapabilitiesTable()` in
|
||||||
|
-- src/lib/modelsDevSync.ts, on demand, the first time a models.dev capability sync ran.
|
||||||
|
-- Whether a database has it therefore depends on TIMING, not on the schema version — so a
|
||||||
|
-- clean install and an upgraded install diverge for no structural reason. The v3.8.50
|
||||||
|
-- publish run hit exactly that: `check:install-upgrade` reported `model_capabilities` as a
|
||||||
|
-- table "present only after upgrade", because the older database had already run a sync
|
||||||
|
-- and the freshly-installed one had not.
|
||||||
|
--
|
||||||
|
-- Creating it here makes both install paths converge deterministically.
|
||||||
|
-- `ensureCapabilitiesTable()` stays in place as an idempotent safety net (it is a
|
||||||
|
-- CREATE TABLE IF NOT EXISTS and now always a no-op); tests/unit/db-install-upgrade-schema-parity.test.ts
|
||||||
|
-- pins the two definitions against drift.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS is required, not decorative: every database that ever ran a models.dev
|
||||||
|
-- sync already has this table, and this migration must be a no-op there.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
tool_call BOOLEAN,
|
||||||
|
reasoning BOOLEAN,
|
||||||
|
attachment BOOLEAN,
|
||||||
|
structured_output BOOLEAN,
|
||||||
|
temperature BOOLEAN,
|
||||||
|
modalities_input TEXT,
|
||||||
|
modalities_output TEXT,
|
||||||
|
knowledge_cutoff TEXT,
|
||||||
|
release_date TEXT,
|
||||||
|
last_updated TEXT,
|
||||||
|
status TEXT,
|
||||||
|
family TEXT,
|
||||||
|
open_weights BOOLEAN,
|
||||||
|
limit_context INTEGER,
|
||||||
|
limit_input INTEGER,
|
||||||
|
limit_output INTEGER,
|
||||||
|
interleaved_field TEXT,
|
||||||
|
last_synced TEXT,
|
||||||
|
PRIMARY KEY (provider, model_id)
|
||||||
|
);
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
import { getDbInstance } from "./db/core";
|
import { getDbInstance } from "./db/core";
|
||||||
import { invalidateDbCache, getModelCatalogCacheVersion } from "./db/readCache";
|
import { invalidateDbCache, getModelCatalogCacheVersion } from "./db/readCache";
|
||||||
import { backupDbFile } from "./db/backup";
|
import { backupDbFile } from "./db/backup";
|
||||||
|
import { registerDbStateResetter } from "./db/stateReset";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
transformModelsDevToPricing,
|
transformModelsDevToPricing,
|
||||||
@@ -231,6 +232,15 @@ function mapCapabilityRecord(record: Record<string, unknown>): ModelCapabilityEn
|
|||||||
let pricingMemo: PricingByProvider | null = null;
|
let pricingMemo: PricingByProvider | null = null;
|
||||||
let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at 0), guarantees a miss on the first call
|
let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at 0), guarantees a miss on the first call
|
||||||
|
|
||||||
|
// resetDbInstance() (tests, DB swaps) must also drop the pricing memo below —
|
||||||
|
// ported from main's #10055 and wired to THIS memo (keyed on the catalog cache
|
||||||
|
// version), not to a second cache of its own.
|
||||||
|
function invalidateModelsDevPricingCache(): void {
|
||||||
|
pricingMemo = null;
|
||||||
|
pricingMemoVersion = -1;
|
||||||
|
}
|
||||||
|
registerDbStateResetter(invalidateModelsDevPricingCache);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read synced pricing from `models_dev_pricing` namespace.
|
* Read synced pricing from `models_dev_pricing` namespace.
|
||||||
* Results are memoized until `saveModelsDevPricing` / `clearModelsDevPricing`.
|
* Results are memoized until `saveModelsDevPricing` / `clearModelsDevPricing`.
|
||||||
@@ -285,6 +295,7 @@ export function saveModelsDevPricing(data: PricingByProvider): void {
|
|||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
backupDbFile("pre-write");
|
backupDbFile("pre-write");
|
||||||
|
invalidateModelsDevPricingCache();
|
||||||
invalidateDbCache("pricing");
|
invalidateDbCache("pricing");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +306,7 @@ export function clearModelsDevPricing(): void {
|
|||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run();
|
db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run();
|
||||||
backupDbFile("pre-write");
|
backupDbFile("pre-write");
|
||||||
|
invalidateModelsDevPricingCache();
|
||||||
invalidateDbCache("pricing");
|
invalidateDbCache("pricing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
// @ts-expect-error — plain .mjs gate script, no type declarations by design
|
// @ts-expect-error — plain .mjs gate script, no type declarations by design
|
||||||
import { evaluateConvergence } from "../../scripts/check/check-install-upgrade.mjs";
|
import {
|
||||||
|
assertNoDiskExhaustion,
|
||||||
|
evaluateConvergence,
|
||||||
|
} from "../../scripts/check/check-install-upgrade.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole point of this gate is that the two directions of schema divergence are NOT
|
* The whole point of this gate is that the two directions of schema divergence are NOT
|
||||||
@@ -66,7 +69,11 @@ test("UNKNOWN residue fails — a new divergence must not hide behind the allowl
|
|||||||
assert.equal(v.ok, false);
|
assert.equal(v.ok, false);
|
||||||
assert.deepEqual(v.unknownResidue, ["surprise_table"]);
|
assert.deepEqual(v.unknownResidue, ["surprise_table"]);
|
||||||
assert.match(v.failures[0], /surprise_table/);
|
assert.match(v.failures[0], /surprise_table/);
|
||||||
assert.doesNotMatch(v.failures[0], /cache_metrics/, "the known one must not be re-reported as new");
|
assert.doesNotMatch(
|
||||||
|
v.failures[0],
|
||||||
|
/cache_metrics/,
|
||||||
|
"the known one must not be re-reported as new"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("both directions at once report both failures", () => {
|
test("both directions at once report both failures", () => {
|
||||||
@@ -93,3 +100,43 @@ test("empty/missing inputs do not crash", () => {
|
|||||||
assert.equal(v.ok, true);
|
assert.equal(v.ok, true);
|
||||||
assert.deepEqual(v.onlyFresh, []);
|
assert.deepEqual(v.onlyFresh, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── ENOSPC guard ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The v3.8.50 publish run (CI 33104507735) failed with "15 tables a CLEAN install creates
|
||||||
|
// but an UPGRADE does not". None of them was missing: the Phase B upgrade install had hit
|
||||||
|
// `npm warn tar TAR_ENTRY_ERROR ENOSPC: no space left on device` 5611 times, npm still
|
||||||
|
// exited 0, the truncated `omniroute serve` "exited with code 0 before serving", and the
|
||||||
|
// database therefore still held the 3.8.49 schema. npm reporting disk exhaustion as a
|
||||||
|
// warning is what let a full disk masquerade as a schema defect.
|
||||||
|
|
||||||
|
test("npm ENOSPC warnings are raised as an install failure, not ignored", () => {
|
||||||
|
const enospc = "npm warn tar TAR_ENTRY_ERROR ENOSPC: no space left on device, write\n".repeat(3);
|
||||||
|
assert.throws(
|
||||||
|
() => assertNoDiskExhaustion(enospc, "upgrade install"),
|
||||||
|
(err: Error) => {
|
||||||
|
assert.match(err.message, /upgrade install/);
|
||||||
|
assert.match(err.message, /ran out of disk space/);
|
||||||
|
assert.match(err.message, /3 ENOSPC error/);
|
||||||
|
// The operator must not go looking for a migration that is not missing.
|
||||||
|
assert.match(err.message, /NOT a schema divergence/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a clean install log does not trip the disk guard", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertNoDiskExhaustion(
|
||||||
|
"npm warn deprecated boolean@3.2.0: Package no longer supported.\nadded 900 packages\n",
|
||||||
|
"clean install"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the guard also catches the bare kernel message without the ENOSPC code", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => assertNoDiskExhaustion("Error: no space left on device", "clean install"),
|
||||||
|
/ran out of disk space/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
118
tests/unit/db-install-upgrade-schema-parity.test.ts
Normal file
118
tests/unit/db-install-upgrade-schema-parity.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// ENVIRONMENT NOTE (sandbox better-sqlite3 / glibc limitation, not a code defect):
|
||||||
|
// This test opens a real SQLite database through `src/lib/db/core.ts`. better-sqlite3 is a
|
||||||
|
// native addon; production and CI load it normally, but some sandboxes ship a system glibc
|
||||||
|
// older than the prebuilt binary requires ("GLIBC_2.29 not found"), in which case the
|
||||||
|
// runtime cascades to node:sqlite/sql.js. See tests/unit/_helpers/betterSqlite3Availability.ts.
|
||||||
|
//
|
||||||
|
// WHY THIS FILE EXISTS
|
||||||
|
// --------------------
|
||||||
|
// `npm run check:install-upgrade` (scripts/check/check-install-upgrade.mjs) proves that a
|
||||||
|
// CLEAN install and an UPGRADE converge on the same schema, but it costs a full `npm pack`
|
||||||
|
// plus three global installs and three boots (~17 min in CI) and it can only ever run at
|
||||||
|
// publish time, against an already-published previous version. It is not a development
|
||||||
|
// feedback loop, and the v3.8.50 publish run is what proved it: the gate reported 15
|
||||||
|
// "missing" tables, and the deterministic half of that verdict was never checkable locally.
|
||||||
|
//
|
||||||
|
// This file pins the deterministic half in milliseconds:
|
||||||
|
//
|
||||||
|
// 1. Every migration file on disk is actually reachable by the runner on a fresh install.
|
||||||
|
// A file the runner never applies is a table no user ever gets.
|
||||||
|
// 2. `model_capabilities` is created by the MIGRATION SET, not lazily at runtime.
|
||||||
|
// A table created on demand by `CREATE TABLE IF NOT EXISTS` inside a feature code
|
||||||
|
// path exists or not depending on whether that feature happened to run before the
|
||||||
|
// snapshot — so it diverges between the two install paths by TIMING, not by schema.
|
||||||
|
// That is precisely how `model_capabilities` surfaced as a divergence on the v3.8.50
|
||||||
|
// publish run (present in the upgraded database, absent from the clean one).
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const MIGRATIONS_DIR = path.join(REPO_ROOT, "src", "lib", "db", "migrations");
|
||||||
|
|
||||||
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-install-upgrade-parity-"));
|
||||||
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||||
|
|
||||||
|
const core = await import("../../src/lib/db/core.ts");
|
||||||
|
|
||||||
|
// A clean install: core.ts applies the inline SCHEMA_SQL, the `ensure*Columns()` helpers,
|
||||||
|
// then runMigrations(). This is the exact code path Phase A of the gate exercises.
|
||||||
|
const db = core.getDbInstance();
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
try {
|
||||||
|
core.resetDbInstance();
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function migrationFiles(): Array<{ version: string; name: string }> {
|
||||||
|
return fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.sort()
|
||||||
|
.map((file) => /^(\d{3,})_(.+)\.sql$/.exec(file))
|
||||||
|
.filter((m): m is RegExpExecArray => m !== null)
|
||||||
|
.map((m) => ({ version: m[1], name: m[2] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTable(name: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a clean install applies every migration file on disk", () => {
|
||||||
|
const ledger = new Set(
|
||||||
|
(
|
||||||
|
db.prepare("SELECT version FROM _omniroute_migrations").all() as Array<{ version: string }>
|
||||||
|
).map((row) => row.version)
|
||||||
|
);
|
||||||
|
const unapplied = migrationFiles()
|
||||||
|
.filter((m) => !ledger.has(m.version))
|
||||||
|
.map((m) => `${m.version}_${m.name}`);
|
||||||
|
assert.deepEqual(
|
||||||
|
unapplied,
|
||||||
|
[],
|
||||||
|
"migration files the runner never applied — an upgrade would not create their tables either"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("model_capabilities comes from the migration set, not from a lazy runtime CREATE", () => {
|
||||||
|
assert.ok(
|
||||||
|
hasTable("model_capabilities"),
|
||||||
|
"model_capabilities must be created by a migration so a clean install and an upgrade " +
|
||||||
|
"converge deterministically instead of depending on whether the models.dev sync ran"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the model_capabilities migration does not drift from ensureCapabilitiesTable()", () => {
|
||||||
|
const source = fs.readFileSync(path.join(REPO_ROOT, "src", "lib", "modelsDevSync.ts"), "utf8");
|
||||||
|
const ddl = /CREATE TABLE IF NOT EXISTS model_capabilities\s*\(([\s\S]*?)\n\s*\)/.exec(source);
|
||||||
|
assert.ok(ddl, "ensureCapabilitiesTable() DDL not found in src/lib/modelsDevSync.ts");
|
||||||
|
|
||||||
|
const runtimeColumns = ddl[1]
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0 && !/^PRIMARY KEY/i.test(line))
|
||||||
|
.map((line) => line.replace(/,$/, "").split(/\s+/)[0])
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const migrationColumns = (
|
||||||
|
db.prepare("PRAGMA table_info(model_capabilities)").all() as Array<{ name: string }>
|
||||||
|
)
|
||||||
|
.map((column) => column.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
migrationColumns,
|
||||||
|
runtimeColumns,
|
||||||
|
"the migration and the runtime helper must create the same columns — a drift here means " +
|
||||||
|
"an upgraded database keeps the old shape while a clean install gets the new one"
|
||||||
|
);
|
||||||
|
});
|
||||||
72
tests/unit/npm-payload-nft-manifests-excluded.test.ts
Normal file
72
tests/unit/npm-payload-nft-manifests-excluded.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import type { Dirent } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3.8.50 was refused by the registry with `413 Payload Too Large` on
|
||||||
|
* `POST /-/stage/package/omniroute`: the tarball had reached 288.7 MB packed
|
||||||
|
* (1.1 GB unpacked), against 174.5 MB for the 3.8.49 that published fine.
|
||||||
|
*
|
||||||
|
* 668.7 MB of that — 61% of the whole package — was 842 `*.nft.json` files.
|
||||||
|
* Those are Next.js Node File Trace manifests: build-time metadata used to
|
||||||
|
* COMPUTE the standalone bundle, never read while serving. They had doubled
|
||||||
|
* since 3.8.49 (325.0 MB across 748 files), which is what tipped the payload
|
||||||
|
* over the limit.
|
||||||
|
*
|
||||||
|
* The guard is the `files[]` negation, so a future entry that re-widens the
|
||||||
|
* glob (or a rewrite of the array) cannot silently put them back.
|
||||||
|
*/
|
||||||
|
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, "../../package.json"), "utf8")) as {
|
||||||
|
files?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test("package.json files[] excludes Next's .nft.json trace manifests", () => {
|
||||||
|
const files = pkg.files ?? [];
|
||||||
|
assert.ok(files.length > 0, "package.json must declare files[]");
|
||||||
|
assert.ok(
|
||||||
|
files.includes("!**/*.nft.json"),
|
||||||
|
"files[] must negate **/*.nft.json — they are build metadata and were 61% of the 3.8.50 payload"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the negation sits after the positive dist/ entry it has to override", () => {
|
||||||
|
// npm applies files[] in order: a negation listed BEFORE the directory that
|
||||||
|
// pulls the files in is a no-op. Positive anchor, so this test cannot pass
|
||||||
|
// just because both strings happen to be present somewhere.
|
||||||
|
const files = pkg.files ?? [];
|
||||||
|
const dist = files.indexOf("dist/");
|
||||||
|
const negation = files.indexOf("!**/*.nft.json");
|
||||||
|
assert.notEqual(dist, -1, "dist/ must still be published");
|
||||||
|
assert.ok(negation > dist, "the .nft.json negation must come after dist/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no source module reads a .nft.json at runtime", () => {
|
||||||
|
// If this ever stops holding, the exclusion above becomes a runtime break
|
||||||
|
// rather than a size win — which is exactly the assumption worth pinning.
|
||||||
|
const roots = ["src", "open-sse", "bin"];
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
const dir = join(import.meta.dirname, "../..", root);
|
||||||
|
const stack = [dir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name !== "node_modules") stack.push(full);
|
||||||
|
} else if (/\.(ts|tsx|mjs|js)$/.test(entry.name)) {
|
||||||
|
if (readFileSync(full, "utf8").includes(".nft.json")) hits.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(hits, [], `nothing may depend on .nft.json at runtime: ${hits.join(", ")}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user