diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7cb735a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,76 @@ +name: Publish approved npm release +on: + release: + types: [published] +permissions: + contents: read +concurrency: + group: npm-release-${{ github.event.release.tag_name }} + cancel-in-progress: false +jobs: + candidate: + # Disabled until a maintainer separately approves/configures OIDC and protections. + if: github.repository == 'agent-axiom/streamsplit' && vars.NPM_PUBLISH_ENABLED == 'true' && !github.event.release.prerelease + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + source-sha: ${{ steps.source.outputs.sha }} + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + # Deliberately no dependency cache in release jobs. + - name: Verify tag, version, clean source, and main ancestry + id: source + run: | + node scripts/verify-release.mjs source + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - run: npm ci --ignore-scripts + - run: npm run release:check + - run: node scripts/verify-release.mjs candidate + - uses: actions/upload-artifact@v4 + with: + name: npm-candidate-${{ steps.source.outputs.sha }} + path: release/ + if-no-files-found: error + retention-days: 14 + publish: + needs: candidate + runs-on: ubuntu-latest + timeout-minutes: 10 + # Configure required reviewers, prevent self-review, and restrict tags BEFORE enabling. + environment: npm + permissions: + contents: read + id-token: write + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.candidate.outputs.source-sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v4 + with: + name: npm-candidate-${{ needs.candidate.outputs.source-sha }} + path: release/ + - name: Check OIDC-capable npm and exact candidate + run: | + node -e 'const [a,b,c] = process.argv[1].split(".").map(Number); if (a < 11 || (a === 11 && (b < 5 || (b === 5 && c < 1)))) process.exit(1)' "$(npm --version)" + node scripts/verify-release.mjs candidate + - name: Publish verified tarball through OIDC + # Uses only the verified archive; package lifecycle scripts are disabled. + run: npm publish "release/agent-axiom-streamsplit-$(node -p 'JSON.parse(require("fs").readFileSync("package.json")).version').tgz" --access public --ignore-scripts --registry=https://registry.npmjs.org + - name: Verify registry integrity and provenance metadata + run: node scripts/verify-release.mjs registry diff --git a/README.md b/README.md index d813c09..304eb02 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ npm ci npm run demo:short ``` -The demo catches a broken UTF-8 decoder in SSE, reproduces a two-chunk failure, then checks fixed SSE and NDJSON parsers. +The demo catches a broken UTF-8 decoder in SSE, reproduces a two-chunk failure, then replays it with the fix. + +Try three real parser integrations: `npm run example:sse`, `npm run example:ndjson`, and `npm run example:json`. [Adapters, edge cases, and tested versions →](docs/INTEGRATIONS.md) ## One assertion diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index c46d4fa..b4c910d 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -2,7 +2,7 @@ [Home](../README.md) · [Usage](USAGE.md) · [API](API.md) · [Safety](SAFETY.md) -These are compatibility tests maintained in this repository, not claims of adoption or endorsement by upstream projects. Both packages are development-only dependencies; StreamSplit's published runtime still has none. +These are compatibility tests maintained in this repository, not claims of adoption or endorsement by upstream projects. All three packages are development-only dependencies; StreamSplit's published runtime still has none. ## Verified targets @@ -11,12 +11,16 @@ These are compatibility tests maintained in this repository, not claims of adopt | [eventsource-parser](https://github.com/rexxars/eventsource-parser) | 3.0.6 on Node 20/22/24 | `createParser({onEvent})`, `feed(text)`, `reset({consume: true})` | | [eventsource-parser](https://github.com/rexxars/eventsource-parser) | 4.1.1 on Node 22 | Same adapter; v4 requires Node >=22.12 | | [ndjson](https://github.com/ndjson/ndjson.js) | 2.0.0 on Node 20/22/24 | Transform stream, awaited writes, readable completion | +| [@streamparser/json](https://github.com/juanjoDiaz/streamparser-json) | 0.0.26 on Node 20/22/24 | `JSONParser`, byte `write`, `onValue`, guarded `end` | Versions are pinned in `package-lock.json` and the explicit v4 CI job. Current-source APIs and supported engines are documented by [eventsource-parser](https://github.com/rexxars/eventsource-parser/blob/main/package.json) and [ndjson](https://github.com/ndjson/ndjson.js/blob/master/package.json). This is fixture-specific compatibility evidence, not a conformance suite for either project. ```sh npm ci npm run test:integrations +npm run example:sse # broken decoding → exact replay → fixed adapter +npm run example:ndjson # real Transform, CRLF, Unicode, final record at EOF +npm run example:json # real JSONParser, selected array elements ``` To repeat the v4 check on Node 22.12 or newer: @@ -43,6 +47,16 @@ Feed byte chunks directly to `ndjson.parse()`. Its decoder handles UTF-8. Await The tests assert the actual expected Unicode records, split CRLF handling, blank lines, and the final record without a trailing newline. An invalid record fixture uses explicit `errorPolicy: 'compare'` to check the error and emitted prefix together. +## Streaming JSON: choose complete values and preserve EOF + +[Complete adapter source](https://github.com/agent-axiom/streamsplit/blob/main/examples/integrations/stream-json.mjs) · [Runnable examples](https://github.com/agent-axiom/streamsplit/blob/main/examples/integrations/demo.mjs) + +The pinned `@streamparser/json` accepts `Uint8Array` directly. A fresh `JSONParser` uses `paths: ['$']` for complete roots or `paths: ['$.*']` for array elements. Emit the callback's `value`, not its live `parent`/`stack` structures. `keepStack: false` avoids retaining already-emitted siblings. Partial-preview callbacks are deliberately disabled: their timing and count may depend on chunk boundaries. + +Call `end()` unless `isEnded` is already true. A root number needs EOF to finish, while a complete object can end the parser earlier. `separator: ''` explicitly enables concatenated roots. See the [upstream API and lifecycle](https://github.com/juanjoDiaz/streamparser-json/tree/main/packages/plainjs). + +Tests assert exact values for UTF-8, escaped quotes/newlines, escaped surrogate pairs, exponent numbers, arrays, booleans, null, selected elements, and concatenated documents. A truncated array checks both the completed prefix and EOF error using `errorPolicy: 'compare'`; harness emission errors must still propagate. These are integration examples, not a claim that every possible input or partition was tested. + ## A useful boundary: diagnostic callbacks can differ intentionally For eventsource-parser 4.1.1, `unknown: value\n` emits an `unknown-field` diagnostic when fed whole. Feeding the same 15 bytes as sizes `[1, 14]` omits that diagnostic. StreamSplit reproduces the difference when the adapter's `diagnostics: true` option includes diagnostic callbacks in the event trace. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 46d6900..55c9f42 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,7 +2,7 @@ [Home](../README.md) · [Usage](USAGE.md) · [Integrations](INTEGRATIONS.md) · [Safety](SAFETY.md) -This repository can build and verify a release candidate without registry credentials. It does not automatically publish to npm, create tokens, configure a trusted publisher, or claim a registry release exists. +This repository can build and verify a release candidate without registry credentials. The read-only readiness workflow does not publish. A separate opt-in publishing workflow is prepared below; it stays disabled until explicitly configured. ## Build the exact candidate @@ -15,28 +15,41 @@ The command runs the full test/type/packed-consumer checks and short demo, then The manually triggered **Release readiness** GitHub Actions workflow runs the same checks and stores the candidate for 14 days. It has read-only repository permissions and no OIDC permission or registry-publishing step. -## Maintainer decisions before publication +## Maintainer decisions before the next publication 1. Confirm the npm account and its right to publish under `@agent-axiom`. Owning the GitHub organization does not confer npm scope ownership. Confirm 2FA and the exact intended package name/version. 2. Check the current registry state at release time. An unauthenticated registry E404 only means the package is not publicly accessible; it does not establish ownership or availability of its scope. 3. Review the exact tarball, its contents/hash, the clean source commit, and that commit's CI result. Confirm public publication of this version. A version cannot simply be overwritten with a different tarball. -4. Perform the authorized first publication using the owner's interactive npm authentication. Do not copy long-lived tokens into the repository, chat, or CI. First publication and creating persistent publisher access are separate decisions. -5. Verify that the installed registry version, exported API, metadata, and tarball match the reviewed candidate. Only then replace the README's not-yet-published notice with the verified single-command install. +4. Perform only the separately authorized publication, using interactive npm authentication or the configured workflow below. Do not copy long-lived tokens into the repository, chat, or CI. Publication and creating persistent publisher access are separate decisions. +5. Verify that the installed registry version, exported API, metadata, and tarball match the reviewed candidate. Version 0.1.0 is already published; do not overwrite it or imply these repository changes are in that version. The package metadata sets public access explicitly, but metadata alone does not authorize or perform publication. `prepublishOnly` runs the complete verification suite as an additional guard. -## Trusted publishing for later releases +## Prepared automatic release workflow -Requirements checked against official documentation on **2026-10-06**; recheck them when configuring the publisher: +[Publish approved npm release](https://github.com/agent-axiom/streamsplit/blob/main/.github/workflows/publish.yml) reacts to a published, non-prerelease GitHub release. It does nothing unless `NPM_PUBLISH_ENABLED` is exactly `true`. This PR neither enables that variable nor creates an npm publisher, environment protection, tag, or release. -- The package must already exist, and the npm account needs package write access and 2FA. -- GitHub OIDC publication requires a GitHub-hosted runner, Node >=22.14, npm >=11.5.1, and a publishing job with `id-token: write` plus `contents: read`. -- npm's publisher configuration must match repository owner `agent-axiom`, repository `streamsplit`, the exact workflow basename, and its environment when configured. -- Publisher setup creates persistent publishing access. Have the owner approve and perform that setup; the current readiness workflow intentionally cannot publish. -- Current npm publisher configurations default to stage-only publication; direct publishing must be explicitly permitted if desired. New configurations must successfully publish within two days, so configure close to the actual release. -- Use owner-approved release/environment protections. GitHub OIDC provenance is automatic for a public package from a public repository; verify the published version's provenance rather than assuming it exists. +Once enabled, the workflow: -npm also supports staged publication, including a public placeholder for a new package. Staging is still an external publication action and is not part of this readiness check. +1. Requires a stable `vX.Y.Z` tag matching `package.json`, a clean checkout, and a tagged commit in `main` history. Bump both package manifests in a reviewed PR before a new release. +2. Installs locked dependencies without install scripts, runs `release:check`, and uploads the tested archive with its source commit, SHA-256, and npm integrity. Release jobs do not restore dependency caches. +3. Waits at the `npm` environment. Only this job has OIDC permission. It downloads the same candidate, verifies its source/hash, and publishes that tarball with lifecycle scripts disabled. It neither rebuilds nor installs dependencies while holding publishing permission. +4. Checks the registry's name, version, integrity, and provenance metadata. This metadata check does not independently cryptographically verify the attestation. If a post-publish check fails, inspect the registry before rerunning; an already-published version cannot be overwritten. + +The Node 20/22/24 PR checks must pass for the exact release commit before a maintainer approves the release. The workflow itself verifies on Node 24; it does not query or bypass branch protections. + +## Separate approvals and setup + +Official requirements rechecked **2026-10-08**: + +- npm publisher setup needs package write access and account 2FA. The package must exist. `npm trust` setup commands require npm >=11.15.0; publishing itself requires npm >=11.5.1 and Node >=22.14. +- Approve persistent publishing access for `@agent-axiom/streamsplit` from GitHub owner `agent-axiom`, repository `streamsplit`, workflow filename `publish.yml`, environment `npm`. The filename is case-sensitive and excludes `.github/workflows/`. +- This workflow uses direct `npm publish`, which must be explicitly allowed. New publishers otherwise default to staging; granting stage-only access will not enable this workflow. Dist-tag management is not needed. +- Before enabling, approve/configure the GitHub `npm` environment with required reviewers, prevent self-review, and restrict deployment tags. Protect release tags and `main` as appropriate. A YAML environment name alone does not establish these protections. +- Configure near the approved next release: a new publisher expires unless it successfully publishes within two days. Use GitHub-hosted runners. Public GitHub/public npm OIDC releases receive automatic provenance. +- After verifying protections and npm configuration, separately enable `NPM_PUBLISH_ENABLED=true`. Publishing the next GitHub release starts the workflow; environment approval releases the reviewed candidate. No npm token secret is required. + +Creating persistent trust, enabling automatic publication, and approving a specific release are separate actions. None has been performed by preparing these files. Changing existing npm publishing-access settings or revoking credentials also requires its own authorization. ## Official references diff --git a/examples/integrations/demo.mjs b/examples/integrations/demo.mjs new file mode 100644 index 0000000..a7c17c5 --- /dev/null +++ b/examples/integrations/demo.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { assertChunkInvariant, checkChunkInvariant, replayChunkInvariant } from '../../dist/index.js'; +import { eventsourceAdapter } from './eventsource.mjs'; +import { ndjsonAdapter } from './ndjson.mjs'; +import { streamJsonAdapter } from './stream-json.mjs'; + +const cases = { + sse: { input: 'data: Hello 🌍\r\n\r\n', createParser: eventsourceAdapter, expected: [{ kind: 'event', data: 'Hello 🌍', event: null, id: null }] }, + ndjson: { input: '{"city":"東京"}\r\n{"emoji":"🌍"}', createParser: ndjsonAdapter, expected: [{ city: '東京' }, { emoji: '🌍' }] }, + json: { input: '[{"city":"東京"},{"value":-1.25e+3}]', createParser: emit => streamJsonAdapter(emit, { paths: ['$.*'] }), expected: [{ city: '東京' }, { value: -1250 }] }, +}; +const name = process.argv[2] ?? 'sse'; +assert.ok(Object.hasOwn(cases, name), 'Choose sse, ndjson, or json.'); +const { input: text, createParser, expected } = cases[name]; +const input = new TextEncoder().encode(text); +const events = []; +const parser = createParser(value => events.push(value)); +await parser.write(input); +await parser.end(); +assert.deepEqual(events, expected); +console.log(`${name}: expected semantic output ${JSON.stringify(events)}`); + +if (name === 'sse') { + // Deliberately break the decoding adapter, not the upstream parser. + const broken = emit => eventsourceAdapter(emit, { streaming: false }); + const result = await checkChunkInvariant({ input, createParser: broken, seed: 42 }); + assert.equal(result.ok, false); + console.log(`BROKEN: split UTF-8 corrupts ${JSON.stringify(result.failure.actual.events)}`); + console.log(`REPLAY: exact chunk sizes ${JSON.stringify(result.failure.fixture.chunkSizes)}`); + assert.equal((await replayChunkInvariant({ fixture: result.failure.fixture, createParser: broken })).ok, false); + assert.equal((await replayChunkInvariant({ fixture: result.failure.fixture, createParser })).ok, true); + console.log('FIX: decoder.decode(chunk, { stream: true }); exact fixture now passes'); +} +const coverage = await assertChunkInvariant({ input, createParser, seed: 42 }); +assert.equal(coverage.allSingleCutsChecked, true); +assert.equal(coverage.bytewiseChecked, true); +console.log(`PASS: ${coverage.schedulesChecked} schedules; every single cut and bytewise reads checked`); diff --git a/examples/integrations/stream-json.mjs b/examples/integrations/stream-json.mjs new file mode 100644 index 0000000..3fec893 --- /dev/null +++ b/examples/integrations/stream-json.mjs @@ -0,0 +1,13 @@ +import { JSONParser } from '@streamparser/json'; + +/** Compare complete selected values, not chunk-dependent partial previews. */ +export function streamJsonAdapter(emit, { paths = ['$'], separator } = {}) { + const parser = new JSONParser({ paths, separator, keepStack: false }); + // parent/stack are live parser internals; emit only each completed value. + parser.onValue = ({ value }) => emit(value); + return { + write(chunk) { parser.write(chunk); }, + // A complete single root can end the parser before the source reaches EOF. + end() { if (!parser.isEnded) parser.end(); }, + }; +} diff --git a/package-lock.json b/package-lock.json index 59bfa74..2eb54a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@streamparser/json": "0.0.26", "eventsource-parser": "3.0.6", "ndjson": "2.0.0", "typescript": "5.9.3" @@ -17,6 +18,13 @@ "node": ">=20" } }, + "node_modules/@streamparser/json": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.26.tgz", + "integrity": "sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", diff --git a/package.json b/package.json index 1b7519e..32af929 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,13 @@ "demo:short": "npm run build && node examples/demo-short.mjs", "test:integrations": "npm run build && node --test test/integrations.test.mjs", "release:check": "node scripts/release-check.mjs", - "prepublishOnly": "npm run check" + "prepublishOnly": "npm run check", + "example:sse": "npm run build && node examples/integrations/demo.mjs sse", + "example:ndjson": "npm run build && node examples/integrations/demo.mjs ndjson", + "example:json": "npm run build && node examples/integrations/demo.mjs json" }, "devDependencies": { + "@streamparser/json": "0.0.26", "eventsource-parser": "3.0.6", "ndjson": "2.0.0", "typescript": "5.9.3" diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..ffca1f0 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const readJSON = path => JSON.parse(readFileSync(path, 'utf8')); +export function validateReleaseTag(tag, version) { + assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, 'Only stable versions are published by this workflow.'); + assert.equal(tag, `v${version}`, 'Release tag must match package.json exactly.'); +} +export function validateCandidate(manifest, archive, { name, version, sourceCommit }) { + assert.equal(manifest.package, name); + assert.equal(manifest.version, version); + assert.match(sourceCommit, /^[a-f0-9]{40}$/); + assert.equal(manifest.sourceCommit, sourceCommit, 'Candidate must come from the verified clean checkout.'); + assert.equal(manifest.filename, `agent-axiom-streamsplit-${version}.tgz`); + assert.equal(manifest.sha256, createHash('sha256').update(archive).digest('hex'), 'Candidate SHA-256 mismatch.'); + assert.equal(manifest.integrity, `sha512-${createHash('sha512').update(archive).digest('base64')}`, 'Candidate npm integrity mismatch.'); +} +export function validateRegistry(manifest, metadata) { + assert.equal(metadata.name, manifest.package); + assert.equal(metadata.version, manifest.version); + assert.equal(metadata.dist?.integrity, manifest.integrity, 'Registry archive differs from the verified candidate.'); + assert.ok(metadata.dist?.attestations?.url, 'Expected OIDC provenance metadata is missing; investigate before calling this release verified.'); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const pkg = readJSON(join(root, 'package.json')); + validateReleaseTag(process.env.RELEASE_TAG, pkg.version); + const git = args => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + const head = git(['rev-parse', 'HEAD']); + const lock = readJSON(join(root, 'package-lock.json')); + assert.equal(lock.version, pkg.version, 'Lockfile version must match package.json.'); + assert.equal(lock.packages[''].version, pkg.version); + assert.equal(lock.packages[''].name, pkg.name); + if (process.argv[2] === 'source') { + assert.equal(process.env.GITHUB_REPOSITORY, 'agent-axiom/streamsplit'); + assert.equal(process.env.GITHUB_EVENT_NAME, 'release'); + assert.equal(git(['rev-parse', `refs/tags/${process.env.RELEASE_TAG}^{commit}`]), head); + execFileSync('git', ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], { cwd: root }); + assert.equal(git(['status', '--porcelain']), '', 'Release checkout must be clean.'); + console.log(`Verified stable tag ${process.env.RELEASE_TAG} at ${head} on main history.`); + } else { + const manifest = readJSON(join(root, 'release/manifest.json')); + assert.equal(basename(manifest.filename), manifest.filename); + validateCandidate(manifest, readFileSync(join(root, 'release', manifest.filename)), { name: pkg.name, version: pkg.version, sourceCommit: head }); + if (process.argv[2] === 'registry') { + const metadata = JSON.parse(execFileSync('npm', ['view', `${pkg.name}@${pkg.version}`, '--json', '--registry=https://registry.npmjs.org'], { cwd: root, encoding: 'utf8' })); + validateRegistry(manifest, metadata); + console.log('Registry version, candidate integrity, and provenance metadata verified.'); + } else { + assert.equal(process.argv[2], 'candidate', 'Choose source, candidate, or registry.'); + console.log(`Verified candidate ${manifest.filename} for source ${head}.`); + } + } +} diff --git a/test/docs.test.mjs b/test/docs.test.mjs index 3b2af35..b4d3497 100644 --- a/test/docs.test.mjs +++ b/test/docs.test.mjs @@ -36,3 +36,16 @@ test('the short demo proves the broken fixture and its fixed replay', () => { assert.match(output, /FIXED: PASS/); assert.match(output, /73 tested schedules/); }); + +for (const name of ['sse', 'ndjson', 'json']) { + test(`the runnable ${name} integration example checks its expected output`, () => { + const output = execFileSync(process.execPath, ['examples/integrations/demo.mjs', name], { cwd: root, encoding: 'utf8' }); + assert.match(output, /expected semantic output/); + assert.match(output, /PASS: \d+ schedules; every single cut and bytewise reads checked/); + if (name === 'sse') { + assert.match(output, /BROKEN: split UTF-8/); + assert.match(output, /REPLAY: exact chunk sizes \[13,7\]/); + assert.match(output, /exact fixture now passes/); + } + }); +} diff --git a/test/integrations.test.mjs b/test/integrations.test.mjs index f67748d..edee5b2 100644 --- a/test/integrations.test.mjs +++ b/test/integrations.test.mjs @@ -76,3 +76,37 @@ test('real ndjson Transform: invalid fixtures compare parser errors without swal test('real ndjson Transform: adapter propagates harness emission limits', async () => { await assert.rejects(assertChunkInvariant({ input: bytes('{"a":1}\n'), createParser: ndjsonAdapter, maxEvents: 0 }), LimitExceededError); }); + +// This parser consumes bytes directly, including split UTF-8 sequences. +import { streamJsonAdapter } from '../examples/integrations/stream-json.mjs'; + +for (const [name, text, expected, options] of [ + ['nested values and escaped Unicode', '{"city":"東京 🌍","escaped":"\\uD83C\\uDF0D","text":"a\\\"b\\nc","n":-1.25e+3,"ok":true,"empty":null}', [{ city: '東京 🌍', escaped: '🌍', text: 'a"b\nc', n: -1250, ok: true, empty: null }], {}], + ['array element selection', '[{"city":"東京"},{"emoji":"🌍"},false,null,[1,2]]', [{ city: '東京' }, { emoji: '🌍' }, false, null, [1, 2]], { paths: ['$.*'] }], + ['a final number flushed at EOF', '-1.25e+3', [-1250], {}], + ['concatenated root records', '{"a":"café"}{"b":"🌍"}', [{ a: 'café' }, { b: '🌍' }], { separator: '' }], +]) { + test(`real @streamparser/json: ${name}`, async () => { + const input = bytes(text); + const createParser = emit => streamJsonAdapter(emit, options); + assert.deepEqual(await collect(input, createParser), expected); + const coverage = await assertChunkInvariant({ input, createParser, seed: 2026 }); + assert.equal(coverage.allSingleCutsChecked, true); + assert.equal(coverage.bytewiseChecked, true); + }); +} + +test('real @streamparser/json: truncated input preserves the emitted prefix and EOF error', async () => { + const input = bytes('[{"ok":"🌍"},{"unfinished":'); + const createParser = emit => streamJsonAdapter(emit, { paths: ['$.*'] }); + const events = []; + const parser = createParser(value => events.push(value)); + parser.write(input); + assert.throws(() => parser.end()); + assert.deepEqual(events, [{ ok: '🌍' }]); + await assertChunkInvariant({ input, createParser, errorPolicy: 'compare' }); +}); + +test('real @streamparser/json: adapter propagates harness emission limits', async () => { + await assert.rejects(assertChunkInvariant({ input: bytes('{"a":1}'), createParser: streamJsonAdapter, maxEvents: 0 }), LimitExceededError); +}); diff --git a/test/release.test.mjs b/test/release.test.mjs new file mode 100644 index 0000000..05092f8 --- /dev/null +++ b/test/release.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { validateReleaseTag, validateCandidate, validateRegistry } from '../scripts/verify-release.mjs'; + +test('release guard accepts exact stable tags and rejects mismatches, prereleases, and shell input', () => { + validateReleaseTag('v0.1.1', '0.1.1'); + for (const [tag, version] of [['v0.1.0', '0.1.1'], ['v0.1.1-beta.1', '0.1.1-beta.1'], ['v01.1.1', '01.1.1'], ['v0.1.1; echo nope', '0.1.1']]) { + assert.throws(() => validateReleaseTag(tag, version)); + } +}); + +test('release guard binds candidate bytes, source, package, and version', () => { + const archive = Buffer.from('synthetic archive'); + const expected = { name: '@agent-axiom/streamsplit', version: '0.1.1', sourceCommit: 'a'.repeat(40) }; + const manifest = { package: expected.name, version: expected.version, sourceCommit: expected.sourceCommit, filename: 'agent-axiom-streamsplit-0.1.1.tgz', sha256: createHash('sha256').update(archive).digest('hex'), integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }; + validateCandidate(manifest, archive, expected); + assert.throws(() => validateCandidate(manifest, Buffer.from('tampered'), expected)); + for (const changed of [{ sourceCommit: null }, { sourceCommit: 'b'.repeat(40) }, { package: '@other/package' }, { version: '0.1.2' }, { filename: '../candidate.tgz' }, { integrity: 'sha512-wrong' }]) { + assert.throws(() => validateCandidate({ ...manifest, ...changed }, archive, expected)); + } + const metadata = { name: expected.name, version: expected.version, dist: { integrity: manifest.integrity, attestations: { url: 'https://registry.npmjs.org/-/npm/v1/attestations/example' } } }; + validateRegistry(manifest, metadata); + assert.throws(() => validateRegistry(manifest, { ...metadata, dist: { integrity: 'wrong' } })); + assert.throws(() => validateRegistry(manifest, { ...metadata, dist: { integrity: manifest.integrity } })); +}); + +test('release workflow is opt-in and confines OIDC to the protected publishing job', () => { + const source = readFileSync(new URL('../.github/workflows/publish.yml', import.meta.url), 'utf8'); + const [candidate, publish] = source.split('\n publish:\n'); + assert.match(candidate, /vars\.NPM_PUBLISH_ENABLED == 'true'/); + assert.doesNotMatch(candidate, /id-token: write/); + assert.match(publish, /environment: npm/); + assert.match(publish, /id-token: write/); + assert.match(publish, /--ignore-scripts/); + assert.doesNotMatch(source, /secrets\.|NODE_AUTH_TOKEN|npm login/); +}); + + +test('release CLI rejects dirty and unmerged source checkouts', () => { + const cwd = mkdtempSync(join(tmpdir(), 'streamsplit-release-guard-')); + const env = { ...process.env, RELEASE_TAG: 'v0.1.1', GITHUB_REPOSITORY: 'agent-axiom/streamsplit', GITHUB_EVENT_NAME: 'release' }; + const git = (...args) => execFileSync('git', args, { cwd, stdio: 'pipe' }); + const run = () => execFileSync(process.execPath, ['scripts/verify-release.mjs', 'source'], { cwd, env, stdio: 'pipe' }); + try { + mkdirSync(join(cwd, 'scripts')); + copyFileSync(new URL('../scripts/verify-release.mjs', import.meta.url), join(cwd, 'scripts/verify-release.mjs')); + const pkg = { name: '@agent-axiom/streamsplit', version: '0.1.1' }; + writeFileSync(join(cwd, 'package.json'), JSON.stringify(pkg)); + writeFileSync(join(cwd, 'package-lock.json'), JSON.stringify({ ...pkg, packages: { '': pkg } })); + git('init', '-q'); + git('add', '.'); + const commit = () => git('-c', 'user.name=Release test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'synthetic source'); + commit(); + git('tag', 'v0.1.1'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + assert.match(run().toString(), /Verified stable tag/); + writeFileSync(join(cwd, 'untracked.txt'), 'dirty'); + assert.throws(run); + git('add', '.'); + commit(); + git('tag', '-f', 'v0.1.1'); + assert.throws(run, 'A release commit outside main history must fail.'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +});