diff --git a/.github/workflows/compatibility-fixtures.yml b/.github/workflows/compatibility-fixtures.yml new file mode 100644 index 0000000..9055cb9 --- /dev/null +++ b/.github/workflows/compatibility-fixtures.yml @@ -0,0 +1,29 @@ +name: Compatibility fixtures + +on: + push: + branches: + - main + schedule: + - cron: "23 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: cardmagic/solid-objects-ruby + ref: main + path: solid-objects-ruby + sparse-checkout: compatibility + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: "24.15.0" + - run: node scripts/check-compatibility-fixtures.mjs compatibility solid-objects-ruby/compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index f69e33c..9b52921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +- Add `scripts/check-compatibility-fixtures.mjs` and a workflow with the name + `Compatibility fixtures`. The script compares the `*.json` files in two + directories by name and by bytes. It fails with one line for each file that + is missing on one side or different. The workflow runs the script on this + repository's `compatibility/` directory and on the `compatibility/` directory + of solid-objects-ruby at `main`. It runs on each push to `main`, one time + each day, and on demand. Before this change, no job compared the shared + fixtures. +- `docs/parity.md` now cites Ruby `solid_objects` 0.17.3. That Ruby release + adds guides, tests, and one development dependency, and does not change the + runtime, so the ledger has no new row. + ## 0.17.6 - 2026-10-09 - Add three problem guides in `docs/guides/`. Each one reproduces a failure, diff --git a/docs/parity.md b/docs/parity.md index 05a6749..26e2080 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem. Parity preserves a capability and its correctness or security boundary. It does not copy a Rails API into Node. -Reference: Ruby `solid_objects` 0.17.2. The JavaScript package began at the Ruby +Reference: Ruby `solid_objects` 0.17.3. The JavaScript package began at the Ruby design's `0.12` capability generation; that version number did not imply earlier JavaScript releases. @@ -284,6 +284,14 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it found (the optional `arguments` default) is fixed and pinned by the shared fixture. +The `Compatibility fixtures` workflow compares each `compatibility/*.json` file +with the same file in solid-objects-ruby at `main`, byte for byte. It runs on +each push to `main`, one time each day, and on demand. It does not run on a +pull request, because the other repository holds the old fixture until the two +pull requests of a paired change merge. Run the same check on a local clone of +the Ruby repository with +`node scripts/check-compatibility-fixtures.mjs compatibility ../solid-objects-ruby/compatibility`. + ## JavaScript-only: Cloudflare hosting The experimental `solid-objects/cloudflare` backend hosts each actor identity diff --git a/scripts/check-compatibility-fixtures.mjs b/scripts/check-compatibility-fixtures.mjs new file mode 100644 index 0000000..18da5a5 --- /dev/null +++ b/scripts/check-compatibility-fixtures.mjs @@ -0,0 +1,53 @@ +import fs from "node:fs" +import path from "node:path" +import process from "node:process" + +const directories = process.argv.slice(2) + +if (directories.length !== 2) { + process.stderr.write( + "usage: node scripts/check-compatibility-fixtures.mjs \n", + ) + process.exit(1) +} + +const absentDirectories = directories.filter( + (directory) => fs.statSync(directory, { throwIfNoEntry: false })?.isDirectory() !== true, +) + +if (absentDirectories.length > 0) { + process.stderr.write( + `${absentDirectories.map((directory) => `${directory} is not a directory`).join("\n")}\n`, + ) + process.exit(1) +} + +const fixtureNames = [ + ...new Set( + directories.flatMap((directory) => + fs.readdirSync(directory).filter((name) => name.endsWith(".json")), + ), + ), +].sort() +const differences = fixtureNames.flatMap(findDifferences) + +if (differences.length > 0) { + process.stderr.write(`${differences.join("\n")}\n`) + process.exitCode = 1 +} + +function findDifferences(name) { + const directoriesWithoutFile = directories.filter( + (directory) => !fs.existsSync(path.join(directory, name)), + ) + if (directoriesWithoutFile.length > 0) { + return directoriesWithoutFile.map((directory) => `${name} is missing from ${directory}`) + } + + const [first, second] = directories.map((directory) => + fs.readFileSync(path.join(directory, name)), + ) + if (first.equals(second)) return [] + + return [`${name} is different in ${directories.join(" and ")}`] +} diff --git a/test/check-compatibility-fixtures.test.ts b/test/check-compatibility-fixtures.test.ts new file mode 100644 index 0000000..71687ce --- /dev/null +++ b/test/check-compatibility-fixtures.test.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +const scriptPath = path.resolve("scripts/check-compatibility-fixtures.mjs") + +type Fixtures = Record + +const fixtures: Fixtures = { + "json-values.json": '{"cases":[]}\n', + "transmit-envelopes.json": '{"version":1}\n', +} + +let workspace: string | undefined + +afterEach(() => { + if (workspace !== undefined) fs.rmSync(workspace, { recursive: true, force: true }) + workspace = undefined +}) + +describe("compatibility fixture check", () => { + it("passes for equal sets", () => { + const result = compare({ javascript: fixtures, ruby: fixtures }) + + expect(result.stderr).toBe("") + expect(result.status).toBe(0) + }) + + it("fails for a changed byte", () => { + const result = compare({ + javascript: fixtures, + ruby: { ...fixtures, "json-values.json": '{"cases":[]}' }, + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain("json-values.json is different") + expect(result.stderr).not.toContain("transmit-envelopes.json") + }) + + it("fails for a file present on one side only", () => { + const result = compare({ + javascript: { ...fixtures, "sync-timeout.json": "{}\n" }, + ruby: { ...fixtures, "telemetry-events.json": "{}\n" }, + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(`sync-timeout.json is missing from ${directoryFor("ruby")}`) + expect(result.stderr).toContain( + `telemetry-events.json is missing from ${directoryFor("javascript")}`, + ) + }) + + it("fails for a missing directory", () => { + writeFixtures({ name: "javascript", files: fixtures }) + const result = runScript([directoryFor("javascript"), directoryFor("ruby")]) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(`${directoryFor("ruby")} is not a directory`) + }) + + it("ignores a file that is not JSON", () => { + const result = compare({ javascript: { ...fixtures, "README.md": "notes\n" }, ruby: fixtures }) + + expect(result.stderr).toBe("") + expect(result.status).toBe(0) + }) + + it("fails when a directory is not given", () => { + const result = runScript([]) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain("usage:") + }) +}) + +function compare({ javascript, ruby }: { javascript: Fixtures; ruby: Fixtures }) { + writeFixtures({ name: "javascript", files: javascript }) + writeFixtures({ name: "ruby", files: ruby }) + + return runScript([directoryFor("javascript"), directoryFor("ruby")]) +} + +function runScript(directories: string[]) { + return spawnSync(process.execPath, [scriptPath, ...directories], { encoding: "utf8" }) +} + +function writeFixtures({ name, files }: { name: string; files: Fixtures }) { + const directory = directoryFor(name) + fs.mkdirSync(directory) + for (const [fileName, content] of Object.entries(files)) { + fs.writeFileSync(path.join(directory, fileName), content) + } +} + +function directoryFor(name: string) { + workspace ??= fs.mkdtempSync(path.join(os.tmpdir(), "compatibility-fixtures-")) + + return path.join(workspace, name) +}