Files
openclaude/scripts/verify-clean-install.test.ts
T
Kevin CodexandOpenClaude ca7a7e0791 feat(install): enforce and guard the zero-warning npm install contract (#2019)
* feat(install): enforce and guard the zero-warning npm install contract

`npm install -g @gitlawb/openclaude` is verified zero-warning today, but
nothing kept it that way: the runtime deps were caret ranges resolved
fresh on every user install (the published tarball ships no lockfile),
no CI step ever installed the package, and registry-side drift (a
transitive dep deprecated after we ship) is invisible to file-based CI.

Static contract (fast, offline, every PR via `bun run build`):
- Pin the 3 runtime deps to exact versions so the verified resolution IS
  the shipped resolution.
- New validators in scripts/externalsValidation.ts (unit-tested):
  dependencies must equal RUNTIME_DEPENDENCY_CONTRACT exactly (no ranges,
  no unreviewed additions), no consumer-run install hooks or funding
  field, engines.node pinned. Wired into validate-externals.ts.

Runtime verification (scripts/verify-clean-install.ts, `install:verify`):
- Tarball mode (release gate) and published mode (registry watch), each
  running cold-install and upgrade-over-previous scenarios in throwaway
  prefixes with a cold cache and normalized env/flags.
- Strict output whitelist (summary lines only) with network failures
  retried and reported as infra (exit 2), never as a hygiene verdict.
- Structural authority over the installed tree: any package declaring
  install scripts fails, the installed manifest must match the static
  contract, tarball payload/size asserted.
- Boot must be silent: --version prints the exact packed version;
  --help (which, unlike the --version zero-import fast path, loads the
  real bundle) must exit 0 with empty stderr.

CI: release publishes only after the verify passes on Node 22 (npm 10,
the supported floor — warning phrasing and EBADENGINE behavior differ
from npm 11) and Node 24, plus a final gate on the publishing machine
replacing `npm pack --dry-run`. A daily install-hygiene workflow
re-verifies the published @latest on {ubuntu, macos, windows} x
{Node 22, 24} — the only defense against post-release registry drift,
and the OS matrix covers the per-platform @vscode/ripgrep packages.

Found-by-the-guard fix: a fresh machine printed "Warning: ignoring saved
provider profile. OPENGATEWAY_API_KEY is required..." on every command
(even --help) because the injected fresh-install Opengateway default
fails validation without a key (#1651 chose ignore+warn). The default
env is still ignored, but the warning now only fires for genuinely
persisted profiles; published 0.24.0 carries the old noise, so the
verify script exempts exactly that version until the next release.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(install): address CodeRabbit review on the install-hygiene guard

- release.yml: pin install-verify to least-privilege `contents: read` and
  disable credential persistence on its checkout; same persist-credentials
  hardening on the install-hygiene cron checkout.
- verify-clean-install: previousPublishedVersion now follows the same
  retry/infra discipline as installWithRetry — transient registry failures
  retry and then exit 2 (infra) instead of silently skipping the
  upgrade-scenario coverage; a clean not-published answer still skips.
- providerProfile: the fresh-install warning suppression now keys on
  explicit provenance (persisted profile resolved once in
  applyStartupEnvFromProfile) instead of sniffing the
  DEFAULT_STARTUP_PROVIDER_ENV_VAR marker, which a persisted profile's
  env can inherit from a parent CLI process; regression test covers the
  marker-collision case.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* test(install): cover previousPublishedVersion retry/skip/infra branches

CodeRabbit follow-up: the branches deciding whether the upgrade-install
scenario runs, skips, or aborts as infra were untested. Extract the loop
as resolvePreviousPublishedVersion with injected effects (runView,
onRetry, onInfraFailure) per the repo's dependency-injection testing
convention, guard main() behind import.meta.main so the test import does
not launch a real verification, and add regression tests: first-try
success, transient-infra retry then success, clean E404 → null skip
without retries, persistent infra → onInfraFailure (exit 2 in the real
wiring), and unparseable version output → null.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-20 12:24:55 +08:00

77 lines
2.7 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { resolvePreviousPublishedVersion } from './verify-clean-install.js'
// The retry/skip/infra branches decide whether the upgrade-install scenario
// runs, is skipped, or aborts as an infra failure — regression-covered here
// with injected npm results (the real script wires runView to `npm view` and
// onInfraFailure to process.exit(2)).
const ok = (version: string) => ({ status: 0, stdout: `${version}\n`, stderr: '' })
const infraFail = { status: 1, stdout: '', stderr: 'npm error network ECONNRESET while fetching' }
const notPublished = { status: 1, stdout: '', stderr: 'npm error code E404\nnpm error 404 Not Found' }
class InfraExit extends Error {
constructor(readonly combined: string) {
super('infra exit')
}
}
function run(results: Array<{ status: number; stdout: string; stderr: string }>, retries = 3) {
let calls = 0
const retryAttempts: number[] = []
const value = resolvePreviousPublishedVersion({
runView: () => {
const result = results[calls]
calls++
if (!result) throw new Error(`runView called ${calls} times, only ${results.length} results provided`)
return result
},
onRetry: attempt => retryAttempts.push(attempt),
onInfraFailure: combined => {
throw new InfraExit(combined)
},
retries,
})
return { value, calls, retryAttempts }
}
describe('resolvePreviousPublishedVersion', () => {
test('returns the version on first success without retrying', () => {
const { value, calls, retryAttempts } = run([ok('0.24.0')])
expect(value).toBe('0.24.0')
expect(calls).toBe(1)
expect(retryAttempts).toEqual([])
})
test('transient infra failure retries and then succeeds', () => {
const { value, calls, retryAttempts } = run([infraFail, infraFail, ok('0.24.0')])
expect(value).toBe('0.24.0')
expect(calls).toBe(3)
expect(retryAttempts).toEqual([1, 2])
})
test('clean unavailability (E404) returns null immediately — skip, not infra', () => {
const { value, calls, retryAttempts } = run([notPublished])
expect(value).toBeNull()
expect(calls).toBe(1)
expect(retryAttempts).toEqual([])
})
test('persistent infra failure invokes onInfraFailure after exhausting retries', () => {
let caught: InfraExit | null = null
try {
run([infraFail, infraFail, infraFail])
} catch (error) {
caught = error as InfraExit
}
expect(caught).toBeInstanceOf(InfraExit)
expect(caught!.combined).toContain('ECONNRESET')
})
test('unparseable success output returns null rather than a bogus version', () => {
const { value } = run([ok('not-a-version')])
expect(value).toBeNull()
})
})