diff --git a/API-FRICTION.md b/API-FRICTION.md index 95675ec..1fe0fe6 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -187,6 +187,8 @@ Each entry records: | F-149 | Release checks could stall or accept an unbound tag | Tooling/Release | resolved | | F-150 | Nx worktree caches followed the common Git directory | Tooling | monitoring | | F-151 | Artifact actions targeted deprecated Node 20 | Tooling | resolved | +| F-152 | Version bumps invalidated workspace bundle evidence | Tooling/Release | resolved | +| F-153 | Changesets left release-facing version claims behind | Tooling/Release | resolved | ## Findings @@ -3616,3 +3618,39 @@ Each entry records: `download-artifact` v7 by exact commit. - Verification: workflow contracts require immutable action revisions; the final GitHub matrix exercises every upload path before merge. + +### F-152 — Version-only releases invalidated workspace bundle evidence + +- Status: resolved +- Severity: high +- Owner: Tooling/Release +- Observed in: the first automated Changesets version pull request +- Friction: the comparison baseline required the TanStack workspace package + version to match its last measured release. Changesets advanced the package + to 0.0.2 without changing measured source, so both the bundle gate and docs + contract rejected an otherwise unchanged artifact. +- Decision: keep exact installed-version checks for external registry + packages. For the TanStack workspace, use the existing exact Git revision of + every measured input as the authoritative provenance boundary; a version-only + release no longer requires rewriting bundle measurements. +- Verification: focused contracts accept a workspace release changing from + 0.0.1 to 0.0.2, still reject external package-version drift, and retain the + exact workspace source-revision check. + +### F-153 — Changesets left release-facing version claims behind + +- Status: resolved +- Severity: high +- Owner: Tooling/Release +- Observed in: auditing the generated 0.0.2 version pull request +- Friction: Changesets advanced manifests, package changelogs, and the lockfile + but left the root README, canonical docs, comparison evidence links, and + marketing material on 0.0.1. Merging the generated pull request would have + published a newer package line beside stale install and status claims. +- Decision: derive the previous release from the generated root changelog, + advance an explicit allowlist of release-facing sources, then run the + repository's canonical docs sync before formatting the version pull request. + Historical changelog and friction evidence remain immutable. +- Verification: focused tests cover version-heading discovery, complete + replacement, idempotency, and missing-version rejection. Generated package + docs remain outputs of `pnpm docs:sync`, never hand-edited sources. diff --git a/benchmarks/comparison/README.md b/benchmarks/comparison/README.md index f13236d..34f0100 100644 --- a/benchmarks/comparison/README.md +++ b/benchmarks/comparison/README.md @@ -67,9 +67,10 @@ pnpm benchmark:update-baseline Update the baseline only after reviewing the built files and confirming that a size change is intentional. The check permits 3% or 512 bytes, whichever is -larger. It rejects package-version or chart/tier matrix drift before comparing -bytes. The TanStack source revision must exactly match the last commit that -changed core source or a transitive TanStack comparison input; +larger. It rejects external package-version or chart/tier matrix drift before +comparing bytes. TanStack workspace releases may advance without rewriting +unchanged measurements; its source revision must exactly match the last commit +that changed core source or a transitive TanStack comparison input; documentation-only commits do not stale the evidence. It also requires normal comparison artifacts to contain zero bytes from the stress-probe modules; optional measurement machinery must disappear through direct build-time diff --git a/docs/comparison.md b/docs/comparison.md index f5dd108..ecc0cd6 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -4,9 +4,9 @@ description: Compare current TanStack Charts workspace source with pinned Chart. --- TanStack Charts `0.0.1` is a pre-alpha release. Its results on this page measure -the workspace implementation prepared for the next patch, not the published -`0.0.1` artifact. This comparison records architectural differences and -reproducible evidence without turning untested behavior into a checkmark. +the exact workspace source included in this release and pinned competitor +packages. This comparison records architectural differences and reproducible +evidence without turning untested behavior into a checkmark. ## Tested versions diff --git a/package.json b/package.json index 4678854..f9ed45e 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "test": "nx run charts-workspace:test-unit --parallel=5", "typecheck": "nx run charts-workspace:typecheck", "changeset": "changeset", - "changeset:version": "changeset version && node scripts/sync-release-changelog.mjs && pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile && pnpm format" + "changeset:version": "changeset version && node scripts/sync-release-changelog.mjs && node scripts/sync-release-version.mjs && pnpm docs:sync && pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile && pnpm format" }, "devDependencies": { "@changesets/changelog-github": "0.7.0", diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index f5dd108..ecc0cd6 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -4,9 +4,9 @@ description: Compare current TanStack Charts workspace source with pinned Chart. --- TanStack Charts `0.0.1` is a pre-alpha release. Its results on this page measure -the workspace implementation prepared for the next patch, not the published -`0.0.1` artifact. This comparison records architectural differences and -reproducible evidence without turning untested behavior into a checkmark. +the exact workspace source included in this release and pinned competitor +packages. This comparison records architectural differences and reproducible +evidence without turning untested behavior into a checkmark. ## Tested versions diff --git a/scripts/compare-chart-libraries.mjs b/scripts/compare-chart-libraries.mjs index 1506262..8ad3589 100644 --- a/scripts/compare-chart-libraries.mjs +++ b/scripts/compare-chart-libraries.mjs @@ -16,6 +16,7 @@ import { formatComparisonImplementationDetail, } from './benchmark/comparison-capabilities.mjs' import { + comparisonInstalledVersionFailure, tanstackComparisonRevision, tanstackComparisonSourceFailure, } from './comparison-source-revision.mjs' @@ -1164,12 +1165,13 @@ async function checkBundleBaseline(bundles, actualVersions) { continue } const actualVersion = actualVersions[library.id] - if (actualVersion && actualVersion !== expectedVersion) { - failures.push( - `${library.label}: installed version ${actualVersion} does not match baseline ${expectedVersion}`, - ) - } const source = baseline.sources?.[library.id] + const versionFailure = comparisonInstalledVersionFailure( + source, + actualVersion, + expectedVersion, + ) + if (versionFailure) failures.push(`${library.label}: ${versionFailure}`) if (library.id === 'tanstack') { const sourceFailure = tanstackComparisonSourceFailure( source, diff --git a/scripts/comparison-source-revision.mjs b/scripts/comparison-source-revision.mjs index 341acba..4f2f390 100644 --- a/scripts/comparison-source-revision.mjs +++ b/scripts/comparison-source-revision.mjs @@ -38,3 +38,19 @@ export function tanstackComparisonSourceFailure(source, expectedRevision) { return `bundle baseline workspace revision ${source.revision} does not match measured inputs ${expectedRevision}` } } + +export function comparisonInstalledVersionFailure( + source, + actualVersion, + baselineVersion, +) { + if ( + source?.kind === 'workspace' || + !actualVersion || + actualVersion === baselineVersion + ) { + return + } + + return `installed version ${actualVersion} does not match baseline ${baselineVersion}` +} diff --git a/scripts/comparison-source-revision.test.mjs b/scripts/comparison-source-revision.test.mjs index d5e5c83..629414b 100644 --- a/scripts/comparison-source-revision.test.mjs +++ b/scripts/comparison-source-revision.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { + comparisonInstalledVersionFailure, tanstackComparisonInputPaths, tanstackComparisonRevision, tanstackComparisonSourceFailure, @@ -56,6 +57,23 @@ describe('TanStack comparison source provenance', () => { `bundle baseline workspace revision ${recordedRevision} does not match measured inputs ${expectedRevision}`, ) }) + + it('uses source revisions for workspaces and versions for installed packages', () => { + expect( + comparisonInstalledVersionFailure( + { kind: 'workspace', revision: 'a'.repeat(40) }, + '0.0.2', + '0.0.1', + ), + ).toBeUndefined() + expect( + comparisonInstalledVersionFailure( + { kind: 'package', packageName: 'chart.js', version: '4.5.1' }, + '4.5.2', + '4.5.1', + ), + ).toBe('installed version 4.5.2 does not match baseline 4.5.1') + }) }) function commitAll(repository, message) { diff --git a/scripts/docs-contract.mjs b/scripts/docs-contract.mjs index fa66a22..f18eeab 100644 --- a/scripts/docs-contract.mjs +++ b/scripts/docs-contract.mjs @@ -189,7 +189,11 @@ export function comparisonBaselineContractFailures(baseline, expectedVersions) { } for (const library of chartLibraries) { const manifestVersion = expectedVersions[library.id] - if (baseline.packageVersions?.[library.id] !== manifestVersion) { + const baselineVersion = baseline.packageVersions?.[library.id] + if ( + !baselineVersion || + (library.id !== 'tanstack' && baselineVersion !== manifestVersion) + ) { failures.push( `comparison bundle baseline version is stale for ${library.label}: expected ${manifestVersion}`, ) diff --git a/scripts/docs-contract.test.mjs b/scripts/docs-contract.test.mjs index 410517a..6435898 100644 --- a/scripts/docs-contract.test.mjs +++ b/scripts/docs-contract.test.mjs @@ -156,6 +156,12 @@ Body } expect(comparisonBaselineContractFailures(baseline, versions)).toEqual([]) + expect( + comparisonBaselineContractFailures(baseline, { + ...versions, + tanstack: '0.0.2', + }), + ).toEqual([]) const stale = structuredClone(baseline) stale.packageVersions.chartjs = '4.5.0' diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index ff6c544..b1b1499 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -28,6 +28,9 @@ const changesetConfig = JSON.parse( 'utf8', ), ) +const packageManifest = JSON.parse( + await readFile(resolve(import.meta.dirname, '../package.json'), 'utf8'), +) describe('release workflow contract', () => { test('versions only the fixed public package set', () => { @@ -48,6 +51,13 @@ describe('release workflow contract', () => { ]) }) + test('synchronizes changelogs and release-facing docs in version pull requests', () => { + assert.equal( + packageManifest.scripts['changeset:version'], + 'changeset version && node scripts/sync-release-changelog.mjs && node scripts/sync-release-version.mjs && pnpm docs:sync && pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile && pnpm format', + ) + }) + test('orchestrates versions and tags only from successful main CI', () => { assert.match( workflow, diff --git a/scripts/sync-release-version.mjs b/scripts/sync-release-version.mjs new file mode 100644 index 0000000..28fe56f --- /dev/null +++ b/scripts/sync-release-version.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { readReleasePackages } from './release-package-config.mjs' + +export const releaseVersionSources = [ + { path: 'README.md', references: 1 }, + { path: 'MARKETING.md', references: 6 }, + { path: 'docs/overview.md', references: 1 }, + { path: 'docs/installation.md', references: 1 }, + { path: 'docs/comparison.md', references: 5 }, +] + +export function changelogVersions(source) { + return [ + ...source.matchAll(/^## (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\s|$)/gm), + ] + .map((match) => match[1]) + .filter((version, index, versions) => versions.indexOf(version) === index) +} + +export function syncReleaseVersionReference( + source, + previousVersion, + version, + path, + expectedReferences, +) { + const previousReferences = source.split(previousVersion).length - 1 + const currentReferences = source.split(version).length - 1 + const expectedCount = `${expectedReferences} ${ + expectedReferences === 1 ? 'reference' : 'references' + }` + if (previousReferences === 0) { + assert.equal( + currentReferences, + expectedReferences, + `${path} must contain ${expectedCount} to release ${version}`, + ) + return source + } + + assert.equal( + previousReferences, + expectedReferences, + `${path} must contain ${expectedCount} to release ${previousVersion}`, + ) + assert.equal( + currentReferences, + 0, + `${path} mixes release versions ${previousVersion} and ${version}`, + ) + return source.replaceAll(previousVersion, version) +} + +export async function syncReleaseVersion({ + repositoryRoot = resolve(import.meta.dirname, '..'), +} = {}) { + const packages = await readReleasePackages(repositoryRoot) + const version = packages[0].manifest.version + const rootChangelog = await readFile( + resolve(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ) + const versions = changelogVersions(rootChangelog) + assert.equal( + versions[0], + version, + `Root changelog must begin with release ${version}`, + ) + const previousVersion = versions.find((entry) => entry !== version) + assert.ok(previousVersion, `Root changelog has no release before ${version}`) + + await Promise.all( + releaseVersionSources.map(async ({ path, references }) => { + const target = resolve(repositoryRoot, path) + const source = await readFile(target, 'utf8') + const next = syncReleaseVersionReference( + source, + previousVersion, + version, + path, + references, + ) + if (next !== source) await writeFile(target, next) + }), + ) +} + +const entrypoint = process.argv[1] +if (entrypoint && import.meta.url === pathToFileURL(resolve(entrypoint)).href) { + await syncReleaseVersion() +} diff --git a/scripts/sync-release-version.test.mjs b/scripts/sync-release-version.test.mjs new file mode 100644 index 0000000..9ae6016 --- /dev/null +++ b/scripts/sync-release-version.test.mjs @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + changelogVersions, + releaseVersionSources, + syncReleaseVersionReference, +} from './sync-release-version.mjs' + +describe('release version synchronization', () => { + it('tracks every current release-facing reference', async () => { + const repositoryRoot = resolve(import.meta.dirname, '..') + const releaseManifest = JSON.parse( + await readFile( + resolve(repositoryRoot, 'packages/charts-core/package.json'), + 'utf8', + ), + ) + expect(releaseManifest.version).toMatch(/^\d+\.\d+\.\d+/) + + for (const { path, references } of releaseVersionSources) { + const source = await readFile(resolve(repositoryRoot, path), 'utf8') + expect( + syncReleaseVersionReference( + source, + '__previous_release__', + releaseManifest.version, + path, + references, + ), + ).toBe(source) + } + }) + + it('reads release headings without treating subsections as releases', () => { + expect( + changelogVersions(`# Changelog + +## 0.0.2 (2026-07-31) + +## Verification + +## 0.0.1 +`), + ).toEqual(['0.0.2', '0.0.1']) + }) + + it('advances every release-facing reference and remains idempotent', () => { + const source = + 'TanStack Charts `0.0.1` is pre-alpha. Evidence: /blob/v0.0.1/README.md' + const synced = syncReleaseVersionReference( + source, + '0.0.1', + '0.0.2', + 'docs/example.md', + 2, + ) + + expect(synced).toBe( + 'TanStack Charts `0.0.2` is pre-alpha. Evidence: /blob/v0.0.2/README.md', + ) + expect( + syncReleaseVersionReference( + synced, + '0.0.1', + '0.0.2', + 'docs/example.md', + 2, + ), + ).toBe(synced) + }) + + it('rejects a release-facing file with no recognized version', () => { + expect(() => + syncReleaseVersionReference( + 'No release marker.', + '0.0.1', + '0.0.2', + 'README.md', + 1, + ), + ).toThrow('README.md must contain 1 reference to release 0.0.2') + }) + + it('rejects incomplete and mixed release references', () => { + expect(() => + syncReleaseVersionReference( + 'Current `0.0.1`, but another reference was omitted.', + '0.0.1', + '0.0.2', + 'MARKETING.md', + 2, + ), + ).toThrow('MARKETING.md must contain 2 references to release 0.0.1') + expect(() => + syncReleaseVersionReference( + 'Current `0.0.1`; future `0.0.2`.', + '0.0.1', + '0.0.2', + 'MARKETING.md', + 1, + ), + ).toThrow('MARKETING.md mixes release versions 0.0.1 and 0.0.2') + }) +})