diff --git a/.github/workflows/publish-scope-catalog.yml b/.github/workflows/publish-scope-catalog.yml new file mode 100644 index 0000000..c13af71 --- /dev/null +++ b/.github/workflows/publish-scope-catalog.yml @@ -0,0 +1,255 @@ +name: Publish Scope Catalog + +on: + workflow_dispatch: + inputs: + confirmInitialPublish: + description: "Confirm Callum approved the first @opendatalabs/scope-catalog@1.0.0 publish" + required: true + type: boolean + default: false + push: + branches: [main] + paths: + - ".github/workflows/publish-scope-catalog.yml" + - "connectors/**/*-playwright.json" + - "connectors/**/schemas/*.json" + - "packages/scope-catalog/**" + - "registry.json" + - "schemas/manifest.schema.json" + - "schemas/scope-catalog.schema.json" + - "schemas/web-scope-capabilities.schema.json" + - "scope-catalog.json" + - "scopes/web-capabilities.json" + - "scripts/build-scope-catalog-package.mjs" + - "scripts/check-scope-catalog-package.mjs" + - "scripts/diff-scope-catalog-package.mjs" + - "scripts/generate-scope-catalog.mjs" + - "scripts/generate-scope-catalog.test.mjs" + - "scripts/scope-catalog-package.test.mjs" + - "package.json" + - "package-lock.json" + +permissions: + contents: read + id-token: write + +concurrency: + group: publish-scope-catalog + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24.4.0" + registry-url: https://registry.npmjs.org + + - name: Pin trusted-publishing-compatible npm + run: npm install --global npm@11.5.1 + + - name: Install dependencies + run: npm ci + + - name: Validate BUI-705 manifest and schema inputs + run: | + node scripts/validate-manifests.mjs + node scripts/normalize-manifests.mjs --check + node scripts/validate-scope-schemas.mjs + + - name: Test BUI-705 catalog contract + run: npm run scope-catalog:test + + - name: Check BUI-705 generated catalog + run: npm run scope-catalog:check + + - name: Resolve exact previous package + id: previous + env: + PACKAGE_NAME: "@opendatalabs/scope-catalog" + run: | + set -euo pipefail + previous_dir="$RUNNER_TEMP/scope-catalog-previous" + mkdir -p "$previous_dir/packed" "$previous_dir/unpacked" + + set +e + npm view "$PACKAGE_NAME" version --json > "$previous_dir/version.json" 2> "$previous_dir/view-error.log" + view_status=$? + set -e + + if [ "$view_status" -eq 0 ]; then + previous_version="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" "$previous_dir/version.json")" + npm pack "$PACKAGE_NAME@$previous_version" --pack-destination "$previous_dir/packed" --json > "$previous_dir/pack.json" + tarball="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))[0].filename" "$previous_dir/pack.json")" + tar -xzf "$previous_dir/packed/$tarball" -C "$previous_dir/unpacked" + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "version=$previous_version" >> "$GITHUB_OUTPUT" + echo "root=$previous_dir/unpacked/package" >> "$GITHUB_OUTPUT" + elif grep -q "E404" "$previous_dir/view-error.log"; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "version=" >> "$GITHUB_OUTPUT" + echo "root=" >> "$GITHUB_OUTPUT" + else + cat "$previous_dir/view-error.log" >&2 + exit "$view_status" + fi + + - name: Build package and semantic release metadata + env: + PACKAGE_EXISTS: ${{ steps.previous.outputs.exists }} + PREVIOUS_PACKAGE_ROOT: ${{ steps.previous.outputs.root }} + run: | + if [ "$PACKAGE_EXISTS" = "true" ]; then + npm run scope-catalog-package:build -- --previous-package "$PREVIOUS_PACKAGE_ROOT" + else + npm run scope-catalog-package:build + fi + + - name: Check deterministic package + env: + PACKAGE_EXISTS: ${{ steps.previous.outputs.exists }} + PREVIOUS_PACKAGE_ROOT: ${{ steps.previous.outputs.root }} + run: | + if [ "$PACKAGE_EXISTS" = "true" ]; then + npm run scope-catalog-package:check -- --previous-package "$PREVIOUS_PACKAGE_ROOT" + else + npm run scope-catalog-package:check + fi + + - name: Test package contract and release gates + run: npm run scope-catalog-package:test + + - name: Decide whether publication is allowed + id: release + env: + EVENT_NAME: ${{ github.event_name }} + INITIAL_CONFIRMED: ${{ github.event_name == 'workflow_dispatch' && inputs.confirmInitialPublish == true }} + PACKAGE_EXISTS: ${{ steps.previous.outputs.exists }} + run: | + set -euo pipefail + decision="$(node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + import { decideScopeCatalogPublication } from "./scripts/diff-scope-catalog-package.mjs"; + + const release = JSON.parse( + readFileSync("./packages/scope-catalog/release.json", "utf8"), + ); + const decision = decideScopeCatalogPublication({ + eventName: process.env.EVENT_NAME, + packageExists: process.env.PACKAGE_EXISTS === "true", + initialConfirmed: process.env.INITIAL_CONFIRMED === "true", + release, + }); + process.stdout.write(JSON.stringify({ ...decision, release })); + NODE + )" + impact="$(node -p "JSON.parse(process.argv[1]).release.impact" "$decision")" + version="$(node -p "JSON.parse(process.argv[1]).release.currentVersion" "$decision")" + should_publish="$(node -p "JSON.parse(process.argv[1]).shouldPublish" "$decision")" + authentication="$(node -p "JSON.parse(process.argv[1]).authentication" "$decision")" + reason="$(node -p "JSON.parse(process.argv[1]).reason" "$decision")" + + echo "impact=$impact" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT" + echo "authentication=$authentication" >> "$GITHUB_OUTPUT" + echo "reason=$reason" >> "$GITHUB_OUTPUT" + + { + echo "### Scope catalog package" + echo "- Version: \`$version\`" + echo "- Impact: \`$impact\`" + echo "- Publish: \`$should_publish\`" + echo "- Authentication: \`$authentication\`" + echo "- Reason: $reason" + if [ "$authentication" = "bootstrap-token" ]; then + echo "- After 1.0.0: configure npm trusted publishing, delete the NPM_TOKEN GitHub secret, and revoke the bootstrap token." + elif [ "$authentication" = "oidc-trusted-publishing" ]; then + echo "- NPM_TOKEN is not passed; npm trusted publishing must already be configured for this workflow." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Pack exact package contents + id: pack + run: | + set -euo pipefail + pack_dir="$RUNNER_TEMP/scope-catalog-pack" + mkdir -p "$pack_dir" + npm pack ./packages/scope-catalog --pack-destination "$pack_dir" --json > "$pack_dir/pack.json" + filename="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))[0].filename" "$pack_dir/pack.json")" + echo "tarball=$pack_dir/$filename" >> "$GITHUB_OUTPUT" + + - name: Smoke-test every package export + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + set -euo pipefail + consumer="$RUNNER_TEMP/scope-catalog-consumer" + mkdir -p "$consumer" + cd "$consumer" + npm init -y >/dev/null + npm install --ignore-scripts "$TARBALL" + node --input-type=module <<'NODE' + import { createRequire } from "node:module"; + + const require = createRequire(import.meta.url); + const catalog = require("@opendatalabs/scope-catalog/scope-catalog.json"); + require("@opendatalabs/scope-catalog/schemas/scope-catalog.schema.json"); + require("@opendatalabs/scope-catalog/release.json"); + require("@opendatalabs/scope-catalog/package.json"); + for (const scope of catalog.scopes) { + require(`@opendatalabs/scope-catalog/${scope.schema.path}`); + } + console.log(`Imported all required exports and ${catalog.scopes.length} payload schemas.`); + NODE + + - name: Publish initial package with bootstrap token + if: >- + steps.release.outputs.should_publish == 'true' && + steps.release.outputs.authentication == 'bootstrap-token' && + steps.previous.outputs.exists != 'true' && + github.event_name == 'workflow_dispatch' && + inputs.confirmInitialPublish == true + env: + CHECKED_OUT_SHA: ${{ github.sha }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + current_main_sha="$(git ls-remote --exit-code origin refs/heads/main | awk '{ print $1 }')" + if [ "$CHECKED_OUT_SHA" != "$current_main_sha" ]; then + { + echo "### Scope catalog publication skipped" + echo "- Reason: this run no longer represents current main." + echo "- Checked-out SHA: \`$CHECKED_OUT_SHA\`" + echo "- Current main SHA: \`$current_main_sha\`" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance + + - name: Publish later package with trusted publishing + if: >- + steps.release.outputs.should_publish == 'true' && + steps.release.outputs.authentication == 'oidc-trusted-publishing' && + steps.previous.outputs.exists == 'true' + env: + CHECKED_OUT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + current_main_sha="$(git ls-remote --exit-code origin refs/heads/main | awk '{ print $1 }')" + if [ "$CHECKED_OUT_SHA" != "$current_main_sha" ]; then + { + echo "### Scope catalog publication skipped" + echo "- Reason: this run no longer represents current main." + echo "- Checked-out SHA: \`$CHECKED_OUT_SHA\`" + echo "- Current main SHA: \`$current_main_sha\`" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance diff --git a/package.json b/package.json index a4cf082..9ed39fd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "scope-catalog:generate": "node ./scripts/generate-scope-catalog.mjs", "scope-catalog:check": "node ./scripts/generate-scope-catalog.mjs --check", "scope-catalog:test": "node --test ./scripts/generate-scope-catalog.test.mjs", + "scope-catalog-package:build": "node ./scripts/build-scope-catalog-package.mjs", + "scope-catalog-package:check": "node ./scripts/check-scope-catalog-package.mjs", + "scope-catalog-package:diff": "node ./scripts/diff-scope-catalog-package.mjs", + "scope-catalog-package:test": "node --test ./scripts/scope-catalog-package.test.mjs", "release:sign": "node ./scripts/sign-release-assets.mjs" }, "devDependencies": { diff --git a/packages/scope-catalog/.gitignore b/packages/scope-catalog/.gitignore new file mode 100644 index 0000000..efec0a1 --- /dev/null +++ b/packages/scope-catalog/.gitignore @@ -0,0 +1,7 @@ +# Generated by `npm run scope-catalog-package:build`. +/CHANGELOG.md +/connectors/ +/package.json +/release.json +/schemas/ +/scope-catalog.json diff --git a/packages/scope-catalog/README.md b/packages/scope-catalog/README.md new file mode 100644 index 0000000..ee14b70 --- /dev/null +++ b/packages/scope-catalog/README.md @@ -0,0 +1,43 @@ +# `@opendatalabs/scope-catalog` + +Versioned, JSON-first distribution of Vana's public source and scope contract. + +Install an exact version: + +```sh +npm install --save-exact @opendatalabs/scope-catalog@1.0.0 +``` + +Import the catalog and release metadata: + +```js +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const catalog = require("@opendatalabs/scope-catalog/scope-catalog.json"); +const release = require("@opendatalabs/scope-catalog/release.json"); +``` + +Each `scope.schema.path` is relative to the package root and is exported at the same package subpath. For example, resolve `connectors/github/schemas/github.profile.json` as: + +```js +const schema = require( + "@opendatalabs/scope-catalog/connectors/github/schemas/github.profile.json", +); +``` + +`catalogVersion` versions the catalog JSON format. The npm package version independently versions changes to the published public contract. + +## Exports + +- `@opendatalabs/scope-catalog/scope-catalog.json` +- `@opendatalabs/scope-catalog/schemas/scope-catalog.schema.json` +- `@opendatalabs/scope-catalog/connectors/*` +- `@opendatalabs/scope-catalog/release.json` +- `@opendatalabs/scope-catalog/package.json` + +## Release metadata + +`release.json` records the current and previous package versions and contract fingerprints, added and removed `[sourceId, scopeId]` pairs, per-pair old/current field changes, and the selected `major`, `minor`, `patch`, or `none` impact. + +The package supports Node.js 20 or later and npm-compatible package managers. The Dependabot dependency name is `@opendatalabs/scope-catalog`. diff --git a/packages/scope-catalog/package.template.json b/packages/scope-catalog/package.template.json new file mode 100644 index 0000000..799f43f --- /dev/null +++ b/packages/scope-catalog/package.template.json @@ -0,0 +1,32 @@ +{ + "name": "@opendatalabs/scope-catalog", + "description": "Versioned public Vana source and scope catalog", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/vana-com/data-connectors.git", + "directory": "packages/scope-catalog" + }, + "engines": { + "node": ">=20" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "connectors", + "release.json", + "schemas", + "scope-catalog.json" + ], + "exports": { + "./scope-catalog.json": "./scope-catalog.json", + "./schemas/scope-catalog.schema.json": "./schemas/scope-catalog.schema.json", + "./connectors/*": "./connectors/*", + "./release.json": "./release.json", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/scripts/build-scope-catalog-package.mjs b/scripts/build-scope-catalog-package.mjs new file mode 100644 index 0000000..a6794cf --- /dev/null +++ b/scripts/build-scope-catalog-package.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + compareScopeCatalogContracts, + readContractSnapshot, +} from "./diff-scope-catalog-package.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = join(scriptDir, ".."); +const defaultPackageRoot = join(defaultRepoRoot, "packages", "scope-catalog"); +const changelogPreamble = + "All notable public scope-catalog contract changes are recorded here.\n\n"; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function formatPairs(pairs) { + return pairs.length > 0 + ? pairs.map(([sourceId, scopeId]) => `\`${sourceId}/${scopeId}\``).join(", ") + : "None"; +} + +function renderChangelog(release, previousPackageRoot) { + if (release.impact === "none" && previousPackageRoot) { + const previousChangelogPath = join(previousPackageRoot, "CHANGELOG.md"); + if (!existsSync(previousChangelogPath)) { + throw new Error("Previous package is missing CHANGELOG.md"); + } + return readFileSync(previousChangelogPath, "utf8"); + } + + const entry = `## ${release.currentVersion}\n\n- Impact: ${release.impact}\n- Contract fingerprint: \`${release.currentContractFingerprint}\`\n- Added pairs: ${formatPairs(release.added)}\n- Removed pairs: ${formatPairs(release.removed)}\n- Changed pairs: ${release.changes.length}\n`; + if (!previousPackageRoot) { + return `# Changelog\n\n${changelogPreamble}${entry}`; + } + + const previousChangelogPath = join(previousPackageRoot, "CHANGELOG.md"); + if (!existsSync(previousChangelogPath)) { + throw new Error("Previous package is missing CHANGELOG.md"); + } + const previousChangelog = readFileSync(previousChangelogPath, "utf8"); + const prefix = "# Changelog\n"; + if (!previousChangelog.startsWith(prefix)) { + throw new Error("Previous package CHANGELOG.md must begin with # Changelog"); + } + const previousBody = previousChangelog.slice(prefix.length).trimStart(); + const previousEntries = previousBody.startsWith(changelogPreamble) + ? previousBody.slice(changelogPreamble.length) + : previousBody; + return `${prefix}\n${changelogPreamble}${entry}\n${previousEntries}`; +} + +export function buildScopeCatalogPackage({ + repoRoot = defaultRepoRoot, + packageRoot = defaultPackageRoot, + previousPackageRoot = null, +} = {}) { + const packageTemplatePath = join(packageRoot, "package.template.json"); + const packageJsonPath = join(packageRoot, "package.json"); + const readmePath = join(packageRoot, "README.md"); + if (!existsSync(packageTemplatePath) || !existsSync(readmePath)) { + throw new Error("Package root must contain authored package.template.json and README.md"); + } + const packageJson = readJson(packageTemplatePath); + if (packageJson.name !== "@opendatalabs/scope-catalog") { + throw new Error("Package name must remain @opendatalabs/scope-catalog"); + } + + const current = readContractSnapshot(repoRoot); + const previous = previousPackageRoot ? readContractSnapshot(previousPackageRoot) : null; + const release = compareScopeCatalogContracts({ current, previous }); + + for (const path of [ + "CHANGELOG.md", + "connectors", + "package.json", + "release.json", + "schemas", + "scope-catalog.json", + ]) { + rmSync(join(packageRoot, path), { force: true, recursive: true }); + } + + const { name, ...packageMetadata } = packageJson; + writeJson(packageJsonPath, { name, version: release.currentVersion, ...packageMetadata }); + copyFileSync(join(repoRoot, "scope-catalog.json"), join(packageRoot, "scope-catalog.json")); + mkdirSync(join(packageRoot, "schemas"), { recursive: true }); + copyFileSync( + join(repoRoot, current.catalog.catalogSchema.path), + join(packageRoot, current.catalog.catalogSchema.path), + ); + for (const path of current.payloadSchemas.keys()) { + mkdirSync(dirname(join(packageRoot, path)), { recursive: true }); + copyFileSync(join(repoRoot, path), join(packageRoot, path)); + } + writeJson(join(packageRoot, "release.json"), release); + writeFileSync( + join(packageRoot, "CHANGELOG.md"), + renderChangelog(release, previousPackageRoot), + ); + + const built = readContractSnapshot(packageRoot); + if (built.fingerprint !== current.fingerprint) { + throw new Error("Built package contract fingerprint differs from repository input"); + } + if (built.payloadSchemas.size !== current.payloadSchemas.size) { + throw new Error("Built package contains a different referenced payload-schema set"); + } + return release; +} + +function parseArgs(argv) { + const result = { previousPackageRoot: null }; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--previous-package" && argv[index + 1]) { + result.previousPackageRoot = resolve(argv[++index]); + } else { + throw new Error( + "Usage: node scripts/build-scope-catalog-package.mjs [--previous-package ]", + ); + } + } + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const { previousPackageRoot } = parseArgs(process.argv.slice(2)); + const release = buildScopeCatalogPackage({ previousPackageRoot }); + console.log( + `Built @opendatalabs/scope-catalog ${release.currentVersion} (${release.impact}, ${release.currentContractFingerprint}).`, + ); + } catch (error) { + console.error( + `[build-scope-catalog-package] ERROR: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/check-scope-catalog-package.mjs b/scripts/check-scope-catalog-package.mjs new file mode 100644 index 0000000..cb00b12 --- /dev/null +++ b/scripts/check-scope-catalog-package.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { + cpSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { buildScopeCatalogPackage } from "./build-scope-catalog-package.mjs"; +import { + compareScopeCatalogContracts, + readContractSnapshot, +} from "./diff-scope-catalog-package.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = join(scriptDir, ".."); +const defaultPackageRoot = join(defaultRepoRoot, "packages", "scope-catalog"); +const requiredExports = { + "./scope-catalog.json": "./scope-catalog.json", + "./schemas/scope-catalog.schema.json": "./schemas/scope-catalog.schema.json", + "./connectors/*": "./connectors/*", + "./release.json": "./release.json", + "./package.json": "./package.json", +}; +const requiredFiles = [ + "CHANGELOG.md", + "README.md", + "connectors", + "release.json", + "schemas", + "scope-catalog.json", +]; +const sourceOnlyFiles = new Set([".gitignore", "package.template.json"]); + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function listFiles(root, directory = root) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (sourceOnlyFiles.has(relative(root, path))) return []; + return entry.isDirectory() ? listFiles(root, path) : [relative(root, path)]; + }); +} + +function assertSameFileTree(actualRoot, expectedRoot) { + const actualFiles = listFiles(actualRoot).sort(); + const expectedFiles = listFiles(expectedRoot).sort(); + assert.deepEqual(actualFiles, expectedFiles, "package file set is not deterministic"); + for (const path of actualFiles) { + assert.ok( + readFileSync(join(actualRoot, path)).equals(readFileSync(join(expectedRoot, path))), + `package file differs from deterministic build: ${path}`, + ); + } +} + +export function checkScopeCatalogPackage({ + repoRoot = defaultRepoRoot, + packageRoot = defaultPackageRoot, + previousPackageRoot = null, +} = {}) { + const packageJson = readJson(join(packageRoot, "package.json")); + assert.equal(packageJson.name, "@opendatalabs/scope-catalog"); + assert.deepEqual(packageJson.files, requiredFiles); + assert.deepEqual(packageJson.exports, requiredExports); + assert.deepEqual(packageJson.publishConfig, { + access: "public", + registry: "https://registry.npmjs.org", + }); + assert.deepEqual(packageJson.repository, { + type: "git", + url: "https://github.com/vana-com/data-connectors.git", + directory: "packages/scope-catalog", + }); + + const packageSnapshot = readContractSnapshot(packageRoot); + const repoSnapshot = readContractSnapshot(repoRoot); + assert.equal(packageSnapshot.fingerprint, repoSnapshot.fingerprint); + const previousSnapshot = previousPackageRoot + ? readContractSnapshot(previousPackageRoot) + : null; + const expectedRelease = compareScopeCatalogContracts({ + current: repoSnapshot, + previous: previousSnapshot, + }); + assert.deepEqual(readJson(join(packageRoot, "release.json")), expectedRelease); + assert.equal(packageJson.version, expectedRelease.currentVersion); + + const expectedFiles = new Set([ + "CHANGELOG.md", + "README.md", + "package.json", + "release.json", + "scope-catalog.json", + packageSnapshot.catalog.catalogSchema.path, + ...packageSnapshot.payloadSchemas.keys(), + ]); + for (const path of listFiles(packageRoot)) { + if (!expectedFiles.has(path)) throw new Error(`unexpected package file: ${path}`); + } + for (const path of expectedFiles) { + if (!statSync(join(packageRoot, path)).isFile()) { + throw new Error(`missing package file: ${path}`); + } + } + + const expectedRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-check-")); + cpSync( + join(packageRoot, "package.template.json"), + join(expectedRoot, "package.template.json"), + ); + cpSync(join(packageRoot, "README.md"), join(expectedRoot, "README.md")); + buildScopeCatalogPackage({ + repoRoot, + packageRoot: expectedRoot, + previousPackageRoot, + }); + assertSameFileTree(packageRoot, expectedRoot); + return expectedRelease; +} + +function parseArgs(argv) { + const result = { previousPackageRoot: null }; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--previous-package" && argv[index + 1]) { + result.previousPackageRoot = resolve(argv[++index]); + } else { + throw new Error( + "Usage: node scripts/check-scope-catalog-package.mjs [--previous-package ]", + ); + } + } + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const { previousPackageRoot } = parseArgs(process.argv.slice(2)); + const release = checkScopeCatalogPackage({ previousPackageRoot }); + console.log( + `Scope catalog package is deterministic and complete (${release.currentVersion}, ${release.impact}).`, + ); + } catch (error) { + console.error( + `[check-scope-catalog-package] ERROR: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/diff-scope-catalog-package.mjs b/scripts/diff-scope-catalog-package.mjs new file mode 100644 index 0000000..948fcce --- /dev/null +++ b/scripts/diff-scope-catalog-package.mjs @@ -0,0 +1,506 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join, normalize, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = join(scriptDir, ".."); +const maturityRank = new Map([ + ["experimental", 0], + ["beta", 1], + ["stable", 2], +]); +const impactRank = new Map([ + ["none", 0], + ["patch", 1], + ["minor", 2], + ["major", 3], +]); + +function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort(compareCodeUnits) + .map((key) => [key, stableValue(value[key])]), + ); + } + return value; +} + +function stableJson(value) { + return JSON.stringify(stableValue(value)); +} + +function normalizeLimits(limits) { + if (!Array.isArray(limits)) return limits; + return limits + .map(stableValue) + .sort((left, right) => compareCodeUnits(stableJson(left), stableJson(right))); +} + +function normalizeDesktop(desktop) { + if (!desktop) return desktop; + const normalized = structuredClone(desktop); + normalized.limits = normalizeLimits(normalized.limits); + normalized.connectors = normalized.connectors?.map((connector) => ({ + ...connector, + limits: normalizeLimits(connector.limits), + })); + normalized.connectors?.sort((left, right) => + compareCodeUnits(`${left.id}\0${left.status}`, `${right.id}\0${right.status}`), + ); + return stableValue(normalized); +} + +function normalizeWeb(web) { + if (!web) return web; + return stableValue({ ...structuredClone(web), limits: normalizeLimits(web.limits) }); +} + +function assertPackageRelativePath(path, label) { + if ( + typeof path !== "string" || + path.length === 0 || + isAbsolute(path) || + normalize(path).startsWith("..") || + normalize(path) !== path + ) { + throw new Error(`${label} must be a normalized package-relative path: ${path}`); + } +} + +function normalizeCatalog(catalog) { + const normalized = structuredClone(catalog); + delete normalized.generatedFrom; + if (normalized.distribution) { + delete normalized.distribution.sourceCommit; + delete normalized.distribution.releaseTag; + } + for (const scope of normalized.scopes ?? []) { + delete scope.schema?.url; + if (scope.fulfillment) { + scope.fulfillment.desktop = normalizeDesktop(scope.fulfillment.desktop); + scope.fulfillment.web = normalizeWeb(scope.fulfillment.web); + } + } + normalized.scopes?.sort((left, right) => + compareCodeUnits( + `${left.sourceId}\0${left.scopeId}`, + `${right.sourceId}\0${right.scopeId}`, + ), + ); + return stableValue(normalized); +} + +function pair(scope) { + return [scope.sourceId, scope.scopeId]; +} + +function pairKey(scopeOrPair) { + return JSON.stringify(Array.isArray(scopeOrPair) ? scopeOrPair : pair(scopeOrPair)); +} + +function buffersEqual(left, right) { + return left.length === right.length && left.equals(right); +} + +function bytesFingerprint(bytes) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function valuesEqual(left, right) { + return stableJson(left) === stableJson(right); +} + +function maxImpact(current, candidate) { + return impactRank.get(candidate) > impactRank.get(current) ? candidate : current; +} + +function classifyMaturity(previous, current) { + const previousRank = maturityRank.get(previous); + const currentRank = maturityRank.get(current); + if (previousRank === undefined || currentRank === undefined) return "major"; + if (currentRank < previousRank) return "major"; + if (currentRank > previousRank) return "minor"; + return "none"; +} + +function connectorById(desktop) { + return new Map((desktop?.connectors ?? []).map((connector) => [connector.id, connector])); +} + +function classifyDesktop(previous, current) { + previous = normalizeDesktop(previous); + current = normalizeDesktop(current); + let impact = "none"; + if (previous?.status !== current?.status) { + if (previous?.status === "supported") impact = maxImpact(impact, "major"); + else if (current?.status === "supported") impact = maxImpact(impact, "minor"); + else impact = maxImpact(impact, "patch"); + } + if (!valuesEqual(previous?.limits, current?.limits)) { + const supportedPathAdded = previous?.status !== "supported" && current?.status === "supported"; + if (!supportedPathAdded) impact = maxImpact(impact, "major"); + } + + const previousConnectors = connectorById(previous); + const currentConnectors = connectorById(current); + for (const id of previousConnectors.keys()) { + if (!currentConnectors.has(id)) impact = maxImpact(impact, "major"); + } + for (const id of currentConnectors.keys()) { + if (!previousConnectors.has(id)) impact = maxImpact(impact, "minor"); + } + for (const [id, previousConnector] of previousConnectors) { + const currentConnector = currentConnectors.get(id); + if (!currentConnector) continue; + if (previousConnector.status !== currentConnector.status) { + impact = maxImpact( + impact, + classifyMaturity(previousConnector.status, currentConnector.status), + ); + } + if (!valuesEqual(previousConnector.limits, currentConnector.limits)) { + impact = maxImpact(impact, "major"); + } + const { status: _previousStatus, limits: _previousLimits, ...previousMetadata } = + previousConnector; + const { status: _currentStatus, limits: _currentLimits, ...currentMetadata } = + currentConnector; + if (!valuesEqual(previousMetadata, currentMetadata)) { + impact = maxImpact(impact, "patch"); + } + } + return impact; +} + +function classifyWeb(previous, current) { + previous = normalizeWeb(previous); + current = normalizeWeb(current); + let impact = "none"; + if (previous?.status !== current?.status) { + if (previous?.status === "supported") impact = maxImpact(impact, "major"); + else if (current?.status === "supported") impact = maxImpact(impact, "minor"); + else impact = maxImpact(impact, "patch"); + } + if (!valuesEqual(previous?.limits, current?.limits)) { + const supportedPathAdded = previous?.status !== "supported" && current?.status === "supported"; + if (!supportedPathAdded) impact = maxImpact(impact, "major"); + } + if (!valuesEqual(previous?.blocker, current?.blocker)) { + impact = maxImpact(impact, "patch"); + } + const { status: _previousStatus, limits: _previousLimits, blocker: _previousBlocker, ...previousMetadata } = + previous ?? {}; + const { status: _currentStatus, limits: _currentLimits, blocker: _currentBlocker, ...currentMetadata } = + current ?? {}; + if (!valuesEqual(previousMetadata, currentMetadata)) { + impact = maxImpact(impact, "patch"); + } + return impact; +} + +function bumpVersion(version, impact) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? ""); + if (!match) throw new Error(`Previous package version is not plain semver: ${version}`); + const [, majorText, minorText, patchText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + if (impact === "none") return version; + if (impact === "major") return `${major + 1}.0.0`; + if (impact === "minor") return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +export function readContractSnapshot(root) { + const catalogPath = join(root, "scope-catalog.json"); + if (!existsSync(catalogPath)) throw new Error(`Missing scope-catalog.json in ${root}`); + const catalog = readJson(catalogPath); + if (!Array.isArray(catalog.scopes)) throw new Error("scope-catalog.json must contain scopes"); + assertPackageRelativePath(catalog.catalogSchema?.path, "catalogSchema.path"); + + const scopes = new Map(); + const payloadSchemas = new Map(); + for (const scope of catalog.scopes) { + if (!scope?.sourceId || !scope?.scopeId) { + throw new Error("Every catalog scope must contain sourceId and scopeId"); + } + const key = pairKey(scope); + const connectorIds = new Set(); + const duplicateConnectorIds = new Set(); + for (const connector of scope.fulfillment?.desktop?.connectors ?? []) { + if (connectorIds.has(connector.id)) duplicateConnectorIds.add(connector.id); + connectorIds.add(connector.id); + } + if (duplicateConnectorIds.size > 0) { + const duplicates = [...duplicateConnectorIds].sort(compareCodeUnits); + throw new Error( + `Duplicate Desktop connector ID${duplicates.length === 1 ? "" : "s"} for catalog pair ${key}: ${duplicates.join(", ")}`, + ); + } + if (scopes.has(key)) throw new Error(`Duplicate catalog pair: ${key}`); + scopes.set(key, scope); + assertPackageRelativePath(scope.schema?.path, `${scope.scopeId} schema.path`); + if (payloadSchemas.has(scope.schema.path)) { + throw new Error(`Duplicate referenced payload schema: ${scope.schema.path}`); + } + const path = join(root, scope.schema.path); + if (!existsSync(path)) throw new Error(`Missing referenced payload schema: ${scope.schema.path}`); + payloadSchemas.set(scope.schema.path, readFileSync(path)); + } + + const catalogSchemaPath = join(root, catalog.catalogSchema.path); + if (!existsSync(catalogSchemaPath)) { + throw new Error(`Missing catalog schema: ${catalog.catalogSchema.path}`); + } + const packageJsonPath = join(root, "package.json"); + const version = existsSync(packageJsonPath) ? readJson(packageJsonPath).version ?? null : null; + const normalizedCatalog = normalizeCatalog(catalog); + const fingerprintInput = { + catalog: normalizedCatalog, + catalogSchema: { + path: catalog.catalogSchema.path, + bytes: readFileSync(catalogSchemaPath).toString("base64"), + }, + payloadSchemas: [...payloadSchemas] + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([path, bytes]) => ({ path, bytes: bytes.toString("base64") })), + }; + const fingerprint = `sha256:${createHash("sha256").update(stableJson(fingerprintInput)).digest("hex")}`; + + return { + root, + version, + catalog, + normalizedCatalog, + catalogSchemaBytes: readFileSync(catalogSchemaPath), + payloadSchemas, + scopes, + fingerprint, + }; +} + +function changedField(previous, current) { + return valuesEqual(previous, current) ? null : { previous, current }; +} + +function schemaReleaseValue(snapshot, scope) { + return { + path: scope.schema.path, + fingerprint: bytesFingerprint(snapshot.payloadSchemas.get(scope.schema.path)), + }; +} + +export function compareScopeCatalogContracts({ current, previous }) { + const added = [...current.scopes] + .filter(([key]) => !previous?.scopes.has(key)) + .map(([, scope]) => pair(scope)) + .sort((left, right) => compareCodeUnits(pairKey(left), pairKey(right))); + const removed = previous + ? [...previous.scopes] + .filter(([key]) => !current.scopes.has(key)) + .map(([, scope]) => pair(scope)) + .sort((left, right) => compareCodeUnits(pairKey(left), pairKey(right))) + : []; + const changes = []; + let impact = previous ? "none" : "minor"; + + if (previous && current.fingerprint !== previous.fingerprint) { + if (!buffersEqual(current.catalogSchemaBytes, previous.catalogSchemaBytes)) { + impact = maxImpact(impact, "major"); + } + if (removed.length > 0) impact = maxImpact(impact, "major"); + if (added.length > 0) impact = maxImpact(impact, "minor"); + + for (const [key, currentScope] of current.scopes) { + const previousScope = previous.scopes.get(key); + if (!previousScope) continue; + const change = { pair: pair(currentScope) }; + const description = changedField(previousScope.description, currentScope.description); + const previousSchemaMetadata = { ...previousScope.schema }; + const currentSchemaMetadata = { ...currentScope.schema }; + delete previousSchemaMetadata.url; + delete currentSchemaMetadata.url; + const schema = changedField(previousSchemaMetadata, currentSchemaMetadata); + const maturity = changedField(previousScope.maturity, currentScope.maturity); + const desktop = changedField( + normalizeDesktop(previousScope.fulfillment?.desktop), + normalizeDesktop(currentScope.fulfillment?.desktop), + ); + const web = changedField( + normalizeWeb(previousScope.fulfillment?.web), + normalizeWeb(currentScope.fulfillment?.web), + ); + if (description) { + change.description = description; + impact = maxImpact(impact, "patch"); + } + if (schema) { + change.schema = { + previous: schemaReleaseValue(previous, previousScope), + current: schemaReleaseValue(current, currentScope), + }; + impact = maxImpact(impact, "major"); + } + if (maturity) { + change.maturity = maturity; + impact = maxImpact( + impact, + classifyMaturity(previousScope.maturity, currentScope.maturity), + ); + } + if (desktop) { + change.desktop = desktop; + impact = maxImpact( + impact, + classifyDesktop( + previousScope.fulfillment?.desktop, + currentScope.fulfillment?.desktop, + ), + ); + } + if (web) { + change.web = web; + impact = maxImpact( + impact, + classifyWeb(previousScope.fulfillment?.web, currentScope.fulfillment?.web), + ); + } + const previousSchemaBytes = previous.payloadSchemas.get(previousScope.schema.path); + const currentSchemaBytes = current.payloadSchemas.get(currentScope.schema.path); + if (!buffersEqual(previousSchemaBytes, currentSchemaBytes)) { + impact = maxImpact(impact, "major"); + if (!change.schema) { + change.schema = { + previous: schemaReleaseValue(previous, previousScope), + current: schemaReleaseValue(current, currentScope), + }; + } + } + if (Object.keys(change).length > 1) changes.push(change); + } + + const previousTopLevel = structuredClone(previous.normalizedCatalog); + const currentTopLevel = structuredClone(current.normalizedCatalog); + delete previousTopLevel.scopes; + delete currentTopLevel.scopes; + if (!valuesEqual(previousTopLevel, currentTopLevel)) { + const breakingTopLevelChange = + previousTopLevel.catalogVersion !== currentTopLevel.catalogVersion || + !valuesEqual(previousTopLevel.catalogSchema, currentTopLevel.catalogSchema); + impact = maxImpact(impact, breakingTopLevelChange ? "major" : "patch"); + } + if (impact === "none") impact = "patch"; + } + + if (previous && current.fingerprint === previous.fingerprint) { + impact = "none"; + } + const previousVersion = previous?.version ?? null; + const currentVersion = previousVersion ? bumpVersion(previousVersion, impact) : "1.0.0"; + return { + schemaVersion: "1.0.0", + currentVersion, + previousVersion, + currentContractFingerprint: current.fingerprint, + previousContractFingerprint: previous?.fingerprint ?? null, + added, + removed, + changes, + impact, + }; +} + +export function decideScopeCatalogPublication({ + eventName, + packageExists, + initialConfirmed, + release, +}) { + if (!["push", "workflow_dispatch"].includes(eventName)) { + throw new Error(`Unsupported publication event: ${eventName}`); + } + if (!["major", "minor", "patch", "none"].includes(release?.impact)) { + throw new Error(`Unsupported release impact: ${release?.impact}`); + } + if (release.impact === "none") { + return { + shouldPublish: false, + authentication: "none", + reason: "Public contract fingerprint is unchanged.", + }; + } + if (packageExists) { + if (!release.previousVersion) { + throw new Error("A published predecessor requires previousVersion"); + } + return { + shouldPublish: true, + authentication: "oidc-trusted-publishing", + reason: `Published predecessor ${release.previousVersion} exists; release impact is ${release.impact}.`, + }; + } + if (eventName === "workflow_dispatch" && initialConfirmed) { + if (release.currentVersion !== "1.0.0" || release.previousVersion !== null) { + throw new Error("Initial publication must be 1.0.0 with no previous version"); + } + return { + shouldPublish: true, + authentication: "bootstrap-token", + reason: "Initial 1.0.0 publication explicitly confirmed by Callum.", + }; + } + return { + shouldPublish: false, + authentication: "none", + reason: + "Initial package does not exist; push and unconfirmed dispatch runs validate and pack but cannot publish.", + }; +} + +function parseArgs(argv) { + const result = { currentRoot: defaultRepoRoot, previousRoot: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--current-package" && argv[index + 1]) { + result.currentRoot = resolve(argv[++index]); + } else if (argument === "--previous-package" && argv[index + 1]) { + result.previousRoot = resolve(argv[++index]); + } else { + throw new Error( + "Usage: node scripts/diff-scope-catalog-package.mjs [--current-package ] [--previous-package ]", + ); + } + } + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const { currentRoot, previousRoot } = parseArgs(process.argv.slice(2)); + const release = compareScopeCatalogContracts({ + current: readContractSnapshot(currentRoot), + previous: previousRoot ? readContractSnapshot(previousRoot) : null, + }); + process.stdout.write(`${JSON.stringify(release, null, 2)}\n`); + } catch (error) { + console.error( + `[diff-scope-catalog-package] ERROR: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/scope-catalog-package.test.mjs b/scripts/scope-catalog-package.test.mjs new file mode 100644 index 0000000..c53c6d1 --- /dev/null +++ b/scripts/scope-catalog-package.test.mjs @@ -0,0 +1,764 @@ +import assert from "node:assert/strict"; +import { + cpSync, + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { buildScopeCatalogPackage } from "./build-scope-catalog-package.mjs"; +import { checkScopeCatalogPackage } from "./check-scope-catalog-package.mjs"; +import { + compareScopeCatalogContracts, + decideScopeCatalogPublication, + readContractSnapshot, +} from "./diff-scope-catalog-package.mjs"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function makeContractFixture() { + const root = mkdtempSync(join(tmpdir(), "scope-catalog-package-contract-")); + writeJson(join(root, "schemas", "scope-catalog.schema.json"), { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + }); + writeJson(join(root, "connectors", "alpha", "schemas", "alpha.profile.json"), { + $schema: "https://json-schema.org/draft/2020-12/schema", + scope: "alpha.profile", + type: "object", + }); + writeJson(join(root, "scope-catalog.json"), { + catalogVersion: "1.0.0", + catalogSchema: { path: "schemas/scope-catalog.schema.json" }, + distribution: { repository: "https://example.test/catalog" }, + generatedFrom: { manifests: ["connectors/alpha/alpha-playwright.json"] }, + scopes: [ + { + sourceId: "alpha", + scopeId: "alpha.profile", + description: "Alpha profile.", + schema: { path: "connectors/alpha/schemas/alpha.profile.json" }, + maturity: "beta", + fulfillment: { + desktop: { + status: "supported", + connectors: [{ id: "alpha-playwright", status: "beta" }], + }, + web: { status: "unsupported" }, + }, + }, + ], + }); + return root; +} + +function cloneFixture(root) { + const clone = mkdtempSync(join(tmpdir(), "scope-catalog-package-clone-")); + cpSync(root, clone, { recursive: true }); + return clone; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function compareFixtureMutation({ preparePrevious, mutateCurrent }) { + const previousRoot = makeContractFixture(); + writeJson(join(previousRoot, "package.json"), { version: "2.4.6" }); + preparePrevious?.(previousRoot); + const currentRoot = cloneFixture(previousRoot); + mutateCurrent(currentRoot); + return compareScopeCatalogContracts({ + current: readContractSnapshot(currentRoot), + previous: readContractSnapshot(previousRoot), + }); +} + +function mutateCatalog(root, mutate) { + const path = join(root, "scope-catalog.json"); + const catalog = readJson(path); + mutate(catalog); + writeJson(path, catalog); +} + +function readContractFingerprintInLocale(root, locale) { + const result = spawnSync( + process.execPath, + [ + join(repoRoot, "scripts", "diff-scope-catalog-package.mjs"), + "--current-package", + root, + ], + { + encoding: "utf8", + env: { ...process.env, LANG: locale, LC_ALL: locale }, + }, + ); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout).currentContractFingerprint; +} + +test("initial, unchanged, major, minor and patch contracts select deterministic versions", () => { + const initialRoot = makeContractFixture(); + const initial = compareScopeCatalogContracts({ + current: readContractSnapshot(initialRoot), + previous: null, + }); + assert.equal(initial.impact, "minor"); + assert.equal(initial.currentVersion, "1.0.0"); + assert.equal(initial.previousVersion, null); + assert.deepEqual(initial.added, [["alpha", "alpha.profile"]]); + + writeJson(join(initialRoot, "package.json"), { version: "2.4.6" }); + const unchangedRoot = cloneFixture(initialRoot); + const unchangedCatalog = readJson(join(unchangedRoot, "scope-catalog.json")); + unchangedCatalog.distribution.sourceCommit = "a".repeat(40); + unchangedCatalog.distribution.releaseTag = "connectors-aaaaaaaaaaaa"; + unchangedCatalog.generatedFrom.manifests = ["a-different-provenance-path.json"]; + unchangedCatalog.scopes[0].schema.url = + "https://raw.githubusercontent.com/example/repo/aaaaaaaa/schema.json"; + writeJson(join(unchangedRoot, "scope-catalog.json"), unchangedCatalog); + writeFileSync(join(unchangedRoot, "connector-implementation.js"), "changed only\n"); + const unchanged = compareScopeCatalogContracts({ + current: readContractSnapshot(unchangedRoot), + previous: readContractSnapshot(initialRoot), + }); + assert.equal(unchanged.impact, "none"); + assert.equal(unchanged.currentVersion, "2.4.6"); + + const majorRoot = cloneFixture(initialRoot); + writeJson( + join(majorRoot, "connectors", "alpha", "schemas", "alpha.profile.json"), + { scope: "alpha.profile", type: "object", required: ["id"] }, + ); + const major = compareScopeCatalogContracts({ + current: readContractSnapshot(majorRoot), + previous: readContractSnapshot(initialRoot), + }); + assert.equal(major.impact, "major"); + assert.equal(major.currentVersion, "3.0.0"); + assert.equal(major.changes[0].schema.previous.path, "connectors/alpha/schemas/alpha.profile.json"); + assert.match(major.changes[0].schema.previous.fingerprint, /^sha256:[a-f0-9]{64}$/); + assert.notEqual( + major.changes[0].schema.previous.fingerprint, + major.changes[0].schema.current.fingerprint, + ); + + const minorRoot = cloneFixture(initialRoot); + const minorCatalog = readJson(join(minorRoot, "scope-catalog.json")); + minorCatalog.scopes[0].fulfillment.web = { status: "supported" }; + writeJson(join(minorRoot, "scope-catalog.json"), minorCatalog); + const minor = compareScopeCatalogContracts({ + current: readContractSnapshot(minorRoot), + previous: readContractSnapshot(initialRoot), + }); + assert.equal(minor.impact, "minor"); + assert.equal(minor.currentVersion, "2.5.0"); + + const patchRoot = cloneFixture(initialRoot); + const patchCatalog = readJson(join(patchRoot, "scope-catalog.json")); + patchCatalog.scopes[0].description = "A clearer Alpha profile description."; + writeJson(join(patchRoot, "scope-catalog.json"), patchCatalog); + const patch = compareScopeCatalogContracts({ + current: readContractSnapshot(patchRoot), + previous: readContractSnapshot(initialRoot), + }); + assert.equal(patch.impact, "patch"); + assert.equal(patch.currentVersion, "2.4.7"); + assert.deepEqual(patch.changes, [ + { + pair: ["alpha", "alpha.profile"], + description: { + previous: "Alpha profile.", + current: "A clearer Alpha profile description.", + }, + }, + ]); +}); + +test("package build preserves package-relative paths and check rejects extra payload schemas", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-output-")); + cpSync( + join(repoRoot, "packages", "scope-catalog", "package.template.json"), + join(packageRoot, "package.template.json"), + ); + cpSync(join(repoRoot, "packages", "scope-catalog", "README.md"), join(packageRoot, "README.md")); + cpSync(join(repoRoot, "packages", "scope-catalog", ".gitignore"), join(packageRoot, ".gitignore")); + + buildScopeCatalogPackage({ repoRoot, packageRoot }); + const catalog = readJson(join(packageRoot, "scope-catalog.json")); + const release = readJson(join(packageRoot, "release.json")); + const packageJson = readJson(join(packageRoot, "package.json")); + + assert.equal(packageJson.name, "@opendatalabs/scope-catalog"); + assert.equal(packageJson.version, "1.0.0"); + assert.equal(release.currentVersion, "1.0.0"); + assert.equal(release.previousVersion, null); + assert.match(release.currentContractFingerprint, /^sha256:[a-f0-9]{64}$/); + assert.equal(catalog.catalogVersion, "1.0.0"); + for (const scope of catalog.scopes) { + assert.ok(readFileSync(join(packageRoot, scope.schema.path)).length > 0); + } + assert.doesNotThrow(() => checkScopeCatalogPackage({ repoRoot, packageRoot })); + + writeJson(join(packageRoot, "connectors", "extra", "schemas", "extra.json"), { + type: "object", + }); + assert.throws( + () => checkScopeCatalogPackage({ repoRoot, packageRoot }), + /unexpected package file: connectors\/extra\/schemas\/extra\.json/, + ); +}); + +test("conservative mechanical rules cover every major and minor contract category", () => { + const majorCases = [ + { + name: "pair removed", + mutateCurrent: (root) => mutateCatalog(root, (catalog) => catalog.scopes.pop()), + }, + { + name: "catalog-schema bytes changed", + mutateCurrent: (root) => + writeJson(join(root, "schemas", "scope-catalog.schema.json"), { + type: "object", + required: ["scopes"], + }), + }, + { + name: "Desktop connector removed", + preparePrevious: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors.push({ + id: "alpha-backup-playwright", + status: "experimental", + }); + }), + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors.pop(); + }), + }, + { + name: "Web support removed", + preparePrevious: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.web = { status: "supported" }; + }), + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.web = { status: "blocked" }; + }), + }, + { + name: "maturity decreases", + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].maturity = "experimental"; + }), + }, + { + name: "limit metadata changes", + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors[0].limits = [ + { + type: "maxItems", + value: 10, + unit: "items", + description: "At most ten items.", + }, + ]; + }), + }, + ]; + for (const fixture of majorCases) { + assert.equal(compareFixtureMutation(fixture).impact, "major", fixture.name); + } + + const minorCases = [ + { + name: "pair added", + mutateCurrent: (root) => { + writeJson(join(root, "connectors", "alpha", "schemas", "alpha.posts.json"), { + scope: "alpha.posts", + type: "array", + }); + mutateCatalog(root, (catalog) => { + catalog.scopes.push({ + ...structuredClone(catalog.scopes[0]), + scopeId: "alpha.posts", + description: "Alpha posts.", + schema: { path: "connectors/alpha/schemas/alpha.posts.json" }, + }); + }); + }, + }, + { + name: "connector added", + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors.push({ + id: "alpha-backup-playwright", + status: "experimental", + }); + }), + }, + { + name: "Desktop becomes supported with limits", + preparePrevious: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop = { + status: "unsupported", + connectors: [], + }; + }), + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop = { + status: "supported", + limits: [ + { + type: "maxItems", + value: 10, + unit: "items", + description: "At most ten items.", + }, + ], + connectors: [{ id: "alpha-playwright", status: "beta" }], + }; + }), + }, + { + name: "Web becomes supported", + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.web = { + status: "supported", + limits: [ + { + type: "maxItems", + value: 10, + unit: "items", + description: "At most ten items.", + }, + ], + }; + }), + }, + { + name: "maturity increases", + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].maturity = "stable"; + }), + }, + ]; + for (const fixture of minorCases) { + assert.equal(compareFixtureMutation(fixture).impact, "minor", fixture.name); + } + + const blockerEvidence = compareFixtureMutation({ + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.web = { + status: "blocked", + blocker: { id: "evidence-1", description: "Capture live evidence." }, + }; + }), + }); + assert.equal(blockerEvidence.impact, "patch"); +}); + +test("reordering equivalent Desktop and Web limits leaves the contract unchanged", () => { + const reordered = compareFixtureMutation({ + preparePrevious: (root) => + mutateCatalog(root, (catalog) => { + const limits = [ + { + type: "maxItems", + value: 10, + unit: "items", + description: "At most ten items.", + }, + { + type: "timeWindow", + value: 30, + unit: "days", + description: "The last thirty days.", + }, + ]; + catalog.scopes[0].fulfillment.desktop.connectors[0].limits = limits; + catalog.scopes[0].fulfillment.web = { + status: "supported", + limits: structuredClone(limits), + }; + }), + mutateCurrent: (root) => + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors[0].limits.reverse(); + catalog.scopes[0].fulfillment.web.limits.reverse(); + }), + }); + assert.equal(reordered.impact, "none"); + assert.deepEqual(reordered.changes, []); + assert.equal( + reordered.currentContractFingerprint, + reordered.previousContractFingerprint, + ); +}); + +test("contract fingerprints are identical across locale settings", () => { + const root = makeContractFixture(); + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors = [ + { id: "alpha.I", status: "beta" }, + { id: "alpha.i", status: "beta" }, + ]; + }); + + assert.equal( + readContractFingerprintInLocale(root, "en_US.UTF-8"), + readContractFingerprintInLocale(root, "tr_TR.UTF-8"), + ); +}); + +test("duplicate Desktop connector IDs are rejected deterministically", () => { + const root = makeContractFixture(); + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors = [ + { + id: "alpha-playwright", + status: "beta", + limits: [{ type: "maxItems", value: 10, unit: "items" }], + }, + { + id: "alpha-playwright", + status: "stable", + limits: [{ type: "maxItems", value: 20, unit: "items" }], + }, + ]; + }); + + function rejectionMessage() { + try { + readContractSnapshot(root); + } catch (error) { + assert.ok(error instanceof Error); + return error.message; + } + assert.fail("Expected duplicate Desktop connector IDs to be rejected"); + } + + const firstMessage = rejectionMessage(); + mutateCatalog(root, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors.reverse(); + }); + assert.equal(rejectionMessage(), firstMessage); + assert.equal( + firstMessage, + 'Duplicate Desktop connector ID for catalog pair ["alpha","alpha.profile"]: alpha-playwright', + ); +}); + +test("later builds bump from the published package and keep a cumulative changelog", () => { + const previousRepoRoot = makeContractFixture(); + const previousPackageRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-previous-")); + cpSync( + join(repoRoot, "packages", "scope-catalog", "package.template.json"), + join(previousPackageRoot, "package.template.json"), + ); + cpSync( + join(repoRoot, "packages", "scope-catalog", "README.md"), + join(previousPackageRoot, "README.md"), + ); + buildScopeCatalogPackage({ + repoRoot: previousRepoRoot, + packageRoot: previousPackageRoot, + }); + + const currentRepoRoot = cloneFixture(previousRepoRoot); + mutateCatalog(currentRepoRoot, (catalog) => { + catalog.scopes[0].description = "A clearer compatible description."; + }); + const currentPackageRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-current-")); + cpSync( + join(repoRoot, "packages", "scope-catalog", "package.template.json"), + join(currentPackageRoot, "package.template.json"), + ); + cpSync( + join(repoRoot, "packages", "scope-catalog", "README.md"), + join(currentPackageRoot, "README.md"), + ); + const release = buildScopeCatalogPackage({ + repoRoot: currentRepoRoot, + packageRoot: currentPackageRoot, + previousPackageRoot, + }); + const changelog = readFileSync(join(currentPackageRoot, "CHANGELOG.md"), "utf8"); + assert.equal(release.previousVersion, "1.0.0"); + assert.equal(release.currentVersion, "1.0.1"); + assert.equal(release.impact, "patch"); + assert.ok(changelog.indexOf("All notable") < changelog.indexOf("## 1.0.1")); + assert.ok(changelog.indexOf("## 1.0.1") < changelog.indexOf("## 1.0.0")); + assert.equal(changelog.match(/^# Changelog$/gm)?.length, 1); +}); + +test("later fulfillment changes without limits round-trip through release.json", () => { + const previousRepoRoot = makeContractFixture(); + const previousPackageRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-previous-")); + cpSync( + join(repoRoot, "packages", "scope-catalog", "package.template.json"), + join(previousPackageRoot, "package.template.json"), + ); + cpSync( + join(repoRoot, "packages", "scope-catalog", "README.md"), + join(previousPackageRoot, "README.md"), + ); + buildScopeCatalogPackage({ + repoRoot: previousRepoRoot, + packageRoot: previousPackageRoot, + }); + + const currentRepoRoot = cloneFixture(previousRepoRoot); + mutateCatalog(currentRepoRoot, (catalog) => { + catalog.scopes[0].fulfillment.desktop.connectors.push({ + id: "alpha-backup-playwright", + status: "experimental", + }); + catalog.scopes[0].fulfillment.web = { status: "supported" }; + }); + const currentPackageRoot = mkdtempSync(join(tmpdir(), "scope-catalog-package-current-")); + cpSync( + join(repoRoot, "packages", "scope-catalog", "package.template.json"), + join(currentPackageRoot, "package.template.json"), + ); + cpSync( + join(repoRoot, "packages", "scope-catalog", "README.md"), + join(currentPackageRoot, "README.md"), + ); + buildScopeCatalogPackage({ + repoRoot: currentRepoRoot, + packageRoot: currentPackageRoot, + previousPackageRoot, + }); + + assert.doesNotThrow(() => + checkScopeCatalogPackage({ + repoRoot: currentRepoRoot, + packageRoot: currentPackageRoot, + previousPackageRoot, + }), + ); +}); + +test("publication authorization prevents initial push publication and permits later releases", () => { + const initialRelease = { + currentVersion: "1.0.0", + previousVersion: null, + impact: "minor", + }; + assert.deepEqual( + decideScopeCatalogPublication({ + eventName: "push", + packageExists: false, + initialConfirmed: false, + release: initialRelease, + }), + { + shouldPublish: false, + authentication: "none", + reason: + "Initial package does not exist; push and unconfirmed dispatch runs validate and pack but cannot publish.", + }, + ); + assert.deepEqual( + decideScopeCatalogPublication({ + eventName: "workflow_dispatch", + packageExists: false, + initialConfirmed: false, + release: initialRelease, + }), + { + shouldPublish: false, + authentication: "none", + reason: + "Initial package does not exist; push and unconfirmed dispatch runs validate and pack but cannot publish.", + }, + ); + assert.deepEqual( + decideScopeCatalogPublication({ + eventName: "workflow_dispatch", + packageExists: false, + initialConfirmed: true, + release: initialRelease, + }), + { + shouldPublish: true, + authentication: "bootstrap-token", + reason: "Initial 1.0.0 publication explicitly confirmed by Callum.", + }, + ); + assert.deepEqual( + decideScopeCatalogPublication({ + eventName: "push", + packageExists: true, + initialConfirmed: false, + release: { currentVersion: "1.0.1", previousVersion: "1.0.0", impact: "patch" }, + }), + { + shouldPublish: true, + authentication: "oidc-trusted-publishing", + reason: "Published predecessor 1.0.0 exists; release impact is patch.", + }, + ); + assert.equal( + decideScopeCatalogPublication({ + eventName: "push", + packageExists: true, + initialConfirmed: false, + release: { currentVersion: "1.0.0", previousVersion: "1.0.0", impact: "none" }, + }).shouldPublish, + false, + ); + assert.throws( + () => + decideScopeCatalogPublication({ + eventName: "workflow_dispatch", + packageExists: false, + initialConfirmed: true, + release: { currentVersion: "2.0.0", previousVersion: null, impact: "major" }, + }), + /Initial publication must be 1\.0\.0/, + ); + + const workflow = readFileSync( + join(repoRoot, ".github", "workflows", "publish-scope-catalog.yml"), + "utf8", + ); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /confirmInitialPublish:/); + assert.match(workflow, /decideScopeCatalogPublication/); + assert.match(workflow, /node-version: "24\.4\.0"/); + assert.match(workflow, /npm install --global npm@11\.5\.1/); + assert.match(workflow, /Authentication: \\`\$authentication\\`/); + assert.match(workflow, /delete the NPM_TOKEN GitHub secret/); + assert.match(workflow, /npm trusted publishing must already be configured/); + assert.match(workflow, /npm publish .*--access public --provenance/); + assert.equal(workflow.match(/npm publish /g)?.length, 2); + assert.doesNotMatch(workflow, /consumer-update-prs|unity-surfaces|vana-data-app-starter/); +}); + +test("every publication requires the checked-out SHA to remain current main", () => { + const workflow = readFileSync( + join(repoRoot, ".github", "workflows", "publish-scope-catalog.yml"), + "utf8", + ); + function readPublishStep(name) { + const marker = `\n - name: ${name}`; + const start = workflow.indexOf(marker); + assert.notEqual(start, -1, `workflow must contain ${name}`); + const next = workflow.indexOf("\n - name:", start + marker.length); + return workflow.slice(start, next === -1 ? undefined : next); + } + + function readPublishScript(step) { + const runMarker = " run: |\n"; + const runStart = step.indexOf(runMarker); + assert.notEqual(runStart, -1, "publication freshness guard must run with publish"); + return step + .slice(runStart + runMarker.length) + .replace(/^ {10}/gm, "") + .replace("${{ steps.pack.outputs.tarball }}", "scope-catalog.tgz"); + } + + const initialStep = readPublishStep("Publish initial package with bootstrap token"); + const laterStep = readPublishStep("Publish later package with trusted publishing"); + const publishScripts = [readPublishScript(initialStep), readPublishScript(laterStep)]; + + function runPublishGate({ publishScript, checkedOutSha, currentMainSha }) { + const root = mkdtempSync(join(tmpdir(), "scope-catalog-publication-gate-")); + const npmCallsPath = join(root, "npm-calls"); + const summaryPath = join(root, "summary"); + writeFileSync(npmCallsPath, ""); + writeFileSync(summaryPath, ""); + const gitStub = `git() { + if [ "$*" != "ls-remote --exit-code origin refs/heads/main" ]; then + echo "unexpected git arguments: $*" >&2 + return 96 + fi + printf "%s\\trefs/heads/main\\n" "$CURRENT_MAIN_SHA" + }`; + const npmStub = 'npm() { printf "%s\\n" "$*" >> "$NPM_CALLS"; }'; + const result = spawnSync( + "bash", + ["-c", `${gitStub}\n${npmStub}\n${publishScript}`], + { + encoding: "utf8", + env: { + ...process.env, + CHECKED_OUT_SHA: checkedOutSha, + CURRENT_MAIN_SHA: currentMainSha, + GITHUB_STEP_SUMMARY: summaryPath, + NPM_CALLS: npmCallsPath, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + return { + npmCalls: readFileSync(npmCallsPath, "utf8"), + summary: readFileSync(summaryPath, "utf8"), + }; + } + + const oldSha = "1".repeat(40); + const currentSha = "2".repeat(40); + for (const publishScript of publishScripts) { + const stale = runPublishGate({ + publishScript, + checkedOutSha: oldSha, + currentMainSha: currentSha, + }); + assert.equal(stale.npmCalls, ""); + assert.match(stale.summary, /publication skipped/i); + assert.match(stale.summary, new RegExp(oldSha)); + assert.match(stale.summary, new RegExp(currentSha)); + + const current = runPublishGate({ + publishScript, + checkedOutSha: currentSha, + currentMainSha: currentSha, + }); + assert.equal( + current.npmCalls, + "publish scope-catalog.tgz --access public --provenance\n", + ); + assert.equal(current.summary, ""); + } + + assert.match( + initialStep, + /authentication == 'bootstrap-token'/, + ); + assert.match(initialStep, /github\.event_name == 'workflow_dispatch'/); + assert.match(initialStep, /inputs\.confirmInitialPublish == true/); + assert.match(initialStep, /NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/); + assert.match(laterStep, /authentication == 'oidc-trusted-publishing'/); + assert.doesNotMatch(laterStep, /NODE_AUTH_TOKEN|NPM_TOKEN/); + for (const step of [initialStep, laterStep]) { + assert.match(step, /CHECKED_OUT_SHA: \$\{\{ github\.sha \}\}/); + assert.doesNotMatch(step, /EVENT_NAME/); + } + assert.equal(workflow.match(/NODE_AUTH_TOKEN/g)?.length, 1); + assert.match(workflow, /concurrency:\n group: publish-scope-catalog\n cancel-in-progress: false/); +});