Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/compatibility-fixtures.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 9 additions & 1 deletion docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions scripts/check-compatibility-fixtures.mjs
Original file line number Diff line number Diff line change
@@ -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 <directory> <directory>\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 ")}`]
}
101 changes: 101 additions & 0 deletions test/check-compatibility-fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>

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)
}
Loading