diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 65e6e7403e..3795eee28f 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -27,6 +27,9 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v7 with: + cache: yarn node-version: 22 - - run: node --test scripts/changelog.test.js + # The scripts import semver. Package builds aren't needed for them. + - run: yarn install --immutable --mode=skip-build + - run: node --test scripts/changelog.test.js scripts/magicNumbers.test.js - run: node scripts/changelog.js check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fde0d4343a..a93e200c24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -584,7 +584,7 @@ The repository's `npm-release` GitHub environment should have required reviewers To build a new version and release it on NPM, follow these steps: 1. Verify that the version number is already set correctly for the release. (It should have been incremented after releasing the previous version.) -1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. If more fragments are merged before publishing, the `Publish` workflow refuses to run; repeat this step to add them to the same version. Don't run `yarn changelog:release` while a `Publish` run is in progress: it could add notes for changes that the run is not publishing. +1. Run `yarn changelog:preview` to review pending notes, then `yarn changelog:release` to collect fragments into `CHANGELOG.md`, remove "(Unreleased)", and delete consumed fragments. It also bumps the `.cmi`, `.cmt` and `.ast` magic numbers in `compiler/ext/config.ml` when their sources changed since the previous release tag, so fetch the tags first (`git fetch --tags`). Create a PR containing these changes, review the combined release notes, and apply the `changelog:release` label. Existing unreleased entries are preserved. If more fragments are merged before publishing, the `Publish` workflow refuses to run; repeat this step to add them to the same version. Don't run `yarn changelog:release` while a `Publish` run is in progress: it could add notes for changes that the run is not publishing. 1. Once that PR is merged and built successfully, run the `Publish` workflow from the GitHub Actions tab. Use the "Run workflow" branch dropdown to select the release branch (normally `master`, or the relevant maintenance branch) and indicate whether this is the current stable release. The version is read from that branch's `package.json`, and the npm tag is derived automatically: - A current stable release is published as `latest`. - A stable maintenance release for an older major is published as `latest-`, for example `latest-12`. diff --git a/changes/README.md b/changes/README.md index 0bfdb45720..59cf033ef9 100644 --- a/changes/README.md +++ b/changes/README.md @@ -23,7 +23,7 @@ A bullet needs no PR link: `yarn changelog:release` appends the link of the pull `yarn changelog:preview` prints pending fragments grouped by category, newest PR first, without changing files. It does not include entries already in `CHANGELOG.md`. -During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: as long as Publish has not created the version's `v` tag, it adds them to the finalized version. Once the tag exists, it refuses, and the fragments wait for the next version after `yarn changelog:open`. With no fragments left, running it again fails. Don't run it while a Publish run is in progress, because the tag only appears once that run gets to tagging, and the notes could then describe changes the run is not publishing. +During release preparation, run `yarn changelog:release`. It verifies that the first version heading matches `package.json` and is unreleased, validates every fragment before writing, adds fragments above the existing entries of each section, newest PR first, removes `(Unreleased)`, and deletes consumed files. It also bumps the magic numbers in `compiler/ext/config.ml` whose sources changed since the previous release tag, unless they already differ from that tag, so running it again does not bump them twice. CI rejects pull requests without the `changelog:release` label that bump one. Review and commit all changes in the release PR. Historical entries and existing unreleased notes are preserved. Releases with no fragments can still finalize existing notes. The publish workflow refuses to release while fragments remain in `changes/`. If fragments are merged after the release PR but before publishing, run `yarn changelog:release` again: as long as Publish has not created the version's `v` tag, it adds them to the finalized version. Once the tag exists, it refuses, and the fragments wait for the next version after `yarn changelog:open`. With no fragments left, running it again fails. Don't run it while a Publish run is in progress, because the tag only appears once that run gets to tagging, and the notes could then describe changes the run is not publishing. After publishing, run `yarn changelog:open` once `package.json` has the next version. It adds an empty unreleased section with every category heading. diff --git a/changes/release-magic-numbers.breaking.md b/changes/release-magic-numbers.breaking.md new file mode 100644 index 0000000000..fe4b8de9d0 --- /dev/null +++ b/changes/release-magic-numbers.breaking.md @@ -0,0 +1 @@ +- Give `.cmi` and `.cmt` files ReScript magic numbers (`ResCmi…`, `ResCmt…`) instead of OCaml-style ones, and bump all magic numbers at release time instead of in pull requests. diff --git a/compiler/AGENTS.md b/compiler/AGENTS.md index 47e28e3167..c9c92b19cf 100644 --- a/compiler/AGENTS.md +++ b/compiler/AGENTS.md @@ -11,6 +11,8 @@ commands below are relative to the repository root unless stated otherwise. - Add a source fixture exercising the new syntax to `tests/syntax_tests/data/ast-mapping/`. Every file there is run through `res_parser -test-ast-conversion` (which round-trips the parsetree through the frozen v0 AST before printing) as part of `make test-syntax`; the printed output must match the snapshot in its `expected/` directory. - For exact-identity invariants (locations, attributes) or the v0 wire shape itself, add cases to `tests/ounit_tests/ounit_ast_mapper0_tests.ml`, which tests `ast_mapper_to0`/`ast_mapper_from0` directly. +- **Don't bump magic numbers** - Changing a serialized type (parsetree, typed tree, `.cmi` signature) needs no edit of the magic numbers in `compiler/ext/config.ml`. `yarn changelog:release` bumps them when `compiler/ml`, `compiler/ext` or `compiler/flow_parser` changed since the previous release, and the changelog check rejects pull requests that bump them. + - **Missing test coverage** - Always add tests for syntax, lambda, and end-to-end behavior - **Test early and often** - Add tests immediately after modifying each compiler layer to catch problems early, rather than waiting until all changes are complete diff --git a/compiler/ext/config.ml b/compiler/ext/config.ml index a3d5904c21..202ba84895 100644 --- a/compiler/ext/config.ml +++ b/compiler/ext/config.ml @@ -1,4 +1,9 @@ -let cmi_magic_number = "Caml1999I040" +(* Pull requests never change the magic numbers below: [yarn changelog:release] + bumps their trailing counters when the format sources changed since the + previous release (see scripts/magicNumbers.js). The .cmi and .cmt numbers + must have the same length, since a .cmt reader compares its first bytes with + both. *) +let cmi_magic_number = "ResCmi040" (* Magic numbers for marshaled values of the *current* parsetree, whose layout changes across compiler versions. *) @@ -13,7 +18,7 @@ and ast0_impl_magic_number = "Caml1999M022" and ast0_intf_magic_number = "Caml1999N022" -and cmt_magic_number = "Caml1999T048" +and cmt_magic_number = "ResCmt048" let get_load_path () = (Compiler_request_state.current ()).load_path let set_load_path path = (Compiler_request_state.current ()).load_path <- path diff --git a/compiler/ml/cmi_format.ml b/compiler/ml/cmi_format.ml index 46a4a0594f..5f3201a4f9 100644 --- a/compiler/ml/cmi_format.ml +++ b/compiler/ml/cmi_format.ml @@ -35,6 +35,9 @@ let input_cmi ic = let flags = input_value ic in {cmi_name = name; cmi_sign = sign; cmi_crcs = crcs; cmi_flags = flags} +(* Earlier compilers wrote OCaml-style magic numbers *) +let legacy_magic_prefix = "Caml1999I" + let read_cmi_channel filename ic = try let buffer = @@ -50,6 +53,8 @@ let read_cmi_channel filename ic = if buffer < Config.cmi_magic_number then "an older" else "a newer" in raise (Error (Wrong_version_interface (filename, msg))) + else if String.starts_with ~prefix:legacy_magic_prefix buffer then + raise (Error (Wrong_version_interface (filename, "an older"))) else raise (Error (Not_an_interface filename))); let cmi = input_cmi ic in cmi @@ -111,8 +116,8 @@ let report_error ppf = function filename | Wrong_version_interface (filename, older_newer) -> fprintf ppf - "%a@ is not a compiled interface for this version of OCaml.@.It seems to \ - be for %s version of OCaml." + "%a@ is not a compiled interface for this version of ReScript.@.It seems \ + to be for %s version of ReScript." Location.print_filename filename older_newer | Corrupted_interface filename -> fprintf ppf "Corrupted compiled interface@ %a" Location.print_filename diff --git a/package.json b/package.json index 09612a20c3..547cdade22 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "changelog:preview": "node scripts/changelog.js preview", "changelog:release": "node scripts/changelog.js release", "changelog:open": "node scripts/changelog.js open", - "test-changelog": "node --test scripts/changelog.test.js", + "test-changelog": "node --test scripts/changelog.test.js scripts/magicNumbers.test.js", "test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js" }, "files": [ diff --git a/scripts/changelog.js b/scripts/changelog.js index 33e814f393..30e1ca017d 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -2,6 +2,12 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + checkMagicNumbers, + configPath, + releaseMagicNumbers, + showAt, +} from "./magicNumbers.js"; export const categories = { breaking: "#### :boom: Breaking Change", @@ -280,6 +286,7 @@ export async function run( command, root = process.cwd(), pullRequestOf = name => addedByPullRequest(root, name), + magicNumbersFor = version => releaseMagicNumbers(root, version), ) { if (!["check", "preview", "release", "open"].includes(command)) { throw new Error( @@ -308,6 +315,11 @@ export async function run( files.push({ status: diff[i], name: diff[i + 1] }); } checkPullRequest(files, pullRequest); + checkMagicNumbers( + showAt(root, pullRequest.base.sha, configPath), + await fs.readFile(path.join(root, configPath), "utf8"), + pullRequest.labels.map(label => label.name), + ); } else if (process.env.GITHUB_EVENT_NAME === "push") { // Once merged, every fragment must resolve to its pull request, so a // merge without "(#N)" in its subject fails here rather than at release. @@ -339,10 +351,12 @@ export async function run( linked.length > 0 && isFinalized(changelog, version) && releaseTagExists(repository.url, version); - await fs.writeFile( - target, - prepareRelease(changelog, version, linked, { tagged }), - ); + const released = prepareRelease(changelog, version, linked, { tagged }); + // The release bumps the magic numbers of the formats that changed, so that + // pull requests never touch them. + const config = await magicNumbersFor(version); + await fs.writeFile(target, released); + if (config !== null) await fs.writeFile(path.join(root, configPath), config); for (const fragment of fragments) { await fs.unlink(path.join(root, "changes", fragment.name)); } diff --git a/scripts/changelog.test.js b/scripts/changelog.test.js index 0a4ed21d67..edab46d29b 100644 --- a/scripts/changelog.test.js +++ b/scripts/changelog.test.js @@ -22,6 +22,8 @@ import { const note = "- Fix recovery. https://github.com/rescript-lang/rescript/pull/8667"; const historical = "# 12.0.0\n\nOld notes.\n"; +// Bumping the magic numbers needs a checkout of the repository. +const noMagicNumbers = async () => null; const changelog = `# Changelog\n\n# 13.0.0 (Unreleased)\n\n${Object.values(categories).join("\n\n")}\n\n${historical}`; test("validates category names and bullet content", () => { @@ -203,7 +205,7 @@ test("validates before mutation, sorts fragments, consumes only on release", asy await write("z.fix.md", note); await write("a.fix.md", note.replace("recovery", "formatting")); await write("bad.unknown.md", note); - await assert.rejects(run("release", root)); + await assert.rejects(run("release", root, undefined, noMagicNumbers)); assert.equal( await fs.readFile(path.join(root, "CHANGELOG.md"), "utf8"), changelog, @@ -216,9 +218,9 @@ test("validates before mutation, sorts fragments, consumes only on release", asy (await readFragments(root)).map(fragment => fragment.name), ["a.fix.md", "z.fix.md"], ); - await run("release", root); + await run("release", root, undefined, noMagicNumbers); assert.deepEqual(await fs.readdir(path.join(root, "changes")), ["README.md"]); - await assert.rejects(run("release", root)); + await assert.rejects(run("release", root, undefined, noMagicNumbers)); }); test("links bullets without a link to the pull request that added them", () => { @@ -285,7 +287,7 @@ test("releases fragments without a link with their pull request's link", async t await fs.writeFile(path.join(root, "changes", "lost.fix.md"), "- Lost."); const numbers = { "new.fix.md": 9000, "lost.fix.md": null }; await assert.rejects( - run("release", root, name => numbers[name]), + run("release", root, name => numbers[name], noMagicNumbers), /changes\/lost\.fix\.md/, ); assert.equal( @@ -293,7 +295,20 @@ test("releases fragments without a link with their pull request's link", async t changelog, ); await fs.unlink(path.join(root, "changes", "lost.fix.md")); - await run("release", root, name => numbers[name]); + await fs.mkdir(path.join(root, "compiler", "ext"), { recursive: true }); + await run( + "release", + root, + name => numbers[name], + async version => { + assert.equal(version, "13.0.0"); + return "bumped"; + }, + ); + assert.equal( + await fs.readFile(path.join(root, "compiler", "ext", "config.ml"), "utf8"), + "bumped", + ); assert.ok( (await fs.readFile(path.join(root, "CHANGELOG.md"), "utf8")).includes( `${categories.fix}\n\n- New fix. https://github.com/rescript-lang/rescript/pull/9000\n${note}\n`, diff --git a/scripts/magicNumbers.js b/scripts/magicNumbers.js new file mode 100644 index 0000000000..13d1c5d8da --- /dev/null +++ b/scripts/magicNumbers.js @@ -0,0 +1,144 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import semver from "semver"; + +export const configPath = "compiler/ext/config.ml"; + +// The libraries that define the serialized parsetree and typed tree types, +// and everything those types refer to: `ml` and the libraries it depends on. +export const formatSources = [ + "compiler/ml", + "compiler/ext", + "compiler/flow_parser", +]; + +export const magicNumbers = [ + "cmi_magic_number", + "cmt_magic_number", + "ast_impl_magic_number", + "ast_intf_magic_number", +]; + +const definition = name => new RegExp(`\\b${name} = "([^"]*)"`); + +export function readMagicNumber(config, name) { + return definition(name).exec(config)?.[1] ?? null; +} + +// Increments the trailing counter and keeps its width: ResCmi040 -> ResCmi041. +export function incrementMagicNumber(value) { + const match = /^(.*?)(\d+)$/.exec(value); + if (!match) throw new Error(`${value}: expected a trailing counter`); + const next = String(Number(match[2]) + 1); + if (next.length > match[2].length) { + throw new Error(`${value}: the counter is out of digits`); + } + return match[1] + next.padStart(match[2].length, "0"); +} + +// Bumps every magic number that is still the one of the previous release, +// when the format sources changed since then. One that differs was already +// bumped, by an earlier run for the same release or by hand. +export function bumpMagicNumbers(config, previousConfig, sourcesChanged) { + if (!sourcesChanged) return config; + let output = config; + for (const name of magicNumbers) { + const value = readMagicNumber(config, name); + if (value === null) throw new Error(`${configPath}: ${name} not found`); + if (readMagicNumber(previousConfig ?? "", name) !== value) continue; + output = output.replace( + definition(name), + `${name} = "${incrementMagicNumber(value)}"`, + ); + } + return output; +} + +// The highest release tag below the version being released. The nearest tag +// is not enough: a maintenance branch merged into master brings its newer +// tags, such as v12.3.2 after v13.0.0-alpha.6, with that line's magic numbers. +export function previousRelease(tags, version) { + let previous = null; + for (const tag of tags) { + const parsed = tag.startsWith("v") ? semver.parse(tag.slice(1)) : null; + if (parsed === null || !semver.lt(parsed, version)) continue; + if (previous === null || semver.gt(parsed, previous.parsed)) { + previous = { tag, parsed }; + } + } + return previous?.tag ?? null; +} + +// The previous release tag among the tags reachable from HEAD. +export function previousReleaseTag(root, version) { + const result = spawnSync("git", ["tag", "--merged", "HEAD", "--list", "v*"], { + cwd: root, + encoding: "utf8", + }); + const tag = + result.status === 0 + ? previousRelease(result.stdout.split("\n").filter(Boolean), version) + : null; + if (tag === null) { + throw new Error( + `No previous release tag found; fetch the tags (git fetch --tags) first.\n${result.stderr}`, + ); + } + return tag; +} + +function changedSince(root, tag) { + const result = spawnSync( + "git", + ["diff", "--quiet", tag, "HEAD", "--", ...formatSources], + { cwd: root }, + ); + if (result.status === 0) return false; + if (result.status === 1) return true; + throw new Error(`git diff ${tag} HEAD failed: ${result.stderr}`); +} + +export function showAt(root, revision, file) { + const result = spawnSync("git", ["show", `${revision}:${file}`], { + cwd: root, + encoding: "utf8", + }); + return result.status === 0 ? result.stdout : null; +} + +const counterPrefix = value => /^(.*?)\d*$/.exec(value)[1]; + +// A pull request may switch a magic number to another prefix, but only the +// release preparation bumps a counter. +export function checkMagicNumbers(baseConfig, config, labels) { + if (baseConfig === null || labels.includes("changelog:release")) return; + const bumped = magicNumbers.filter(name => { + const before = readMagicNumber(baseConfig, name); + const after = readMagicNumber(config, name); + return ( + before !== null && + after !== null && + before !== after && + counterPrefix(before) === counterPrefix(after) + ); + }); + if (bumped.length) { + throw new Error( + `${configPath}: don't bump ${bumped.join(", ")}; yarn changelog:release bumps the magic numbers when a release is prepared.`, + ); + } +} + +// The new contents of config.ml for the release, or null when no magic +// number needs a bump. +export async function releaseMagicNumbers(root, version) { + const tag = previousReleaseTag(root, version); + const config = await fs.readFile(path.join(root, configPath), "utf8"); + const output = bumpMagicNumbers( + config, + showAt(root, tag, configPath), + changedSince(root, tag), + ); + return output === config ? null : output; +} diff --git a/scripts/magicNumbers.test.js b/scripts/magicNumbers.test.js new file mode 100644 index 0000000000..2ee15905b3 --- /dev/null +++ b/scripts/magicNumbers.test.js @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + bumpMagicNumbers, + checkMagicNumbers, + configPath, + incrementMagicNumber, + previousRelease, + previousReleaseTag, + readMagicNumber, + releaseMagicNumbers, +} from "./magicNumbers.js"; + +const config = (cmi, cmt, ast) => `let cmi_magic_number = "ResCmi${cmi}" + +and ast_impl_magic_number = "ResImpl${ast}" + +and ast_intf_magic_number = "ResIntf${ast}" + +and ast0_impl_magic_number = "Caml1999M022" + +and cmt_magic_number = "ResCmt${cmt}" +`; + +test("compares with the highest release below the version", () => { + const tags = [ + "v12.3.1", + "v13.0.0-alpha.6", + "v12.3.2", + "v13.0.0-alpha.7", + "vnext", + "13.0.0", + ]; + assert.equal(previousRelease(tags, "13.0.0-alpha.7"), "v13.0.0-alpha.6"); + assert.equal(previousRelease(tags, "13.0.0"), "v13.0.0-alpha.7"); + assert.equal(previousRelease(tags, "12.3.3"), "v12.3.2"); + assert.equal(previousRelease(tags, "12.3.1"), null); +}); + +test("increments the trailing counter and keeps its width", () => { + assert.equal(incrementMagicNumber("ResCmi040"), "ResCmi041"); + assert.equal(incrementMagicNumber("ResCmi099"), "ResCmi100"); + assert.equal(incrementMagicNumber("ResImpl01317"), "ResImpl01318"); + assert.throws(() => incrementMagicNumber("ResCmi999"), /out of digits/); + assert.throws(() => incrementMagicNumber("ResCmi"), /trailing counter/); +}); + +test("reads magic numbers by name", () => { + const current = config("040", "048", "01317"); + assert.equal(readMagicNumber(current, "cmi_magic_number"), "ResCmi040"); + assert.equal( + readMagicNumber(current, "ast_impl_magic_number"), + "ResImpl01317", + ); + assert.equal(readMagicNumber(current, "missing_magic_number"), null); +}); + +test("bumps the magic numbers of the previous release when the formats changed", () => { + const previous = config("040", "048", "01317"); + assert.equal(bumpMagicNumbers(previous, previous, false), previous); + assert.equal( + bumpMagicNumbers(previous, previous, true), + config("041", "049", "01318"), + ); + // Leaves the frozen Parsetree0 numbers alone. + assert.ok( + bumpMagicNumbers(previous, previous, true).includes('"Caml1999M022"'), + ); + // A number that differs from the previous release was already bumped. + const bumped = config("041", "048", "01317"); + assert.equal( + bumpMagicNumbers(bumped, previous, true), + config("041", "049", "01318"), + ); + assert.equal( + bumpMagicNumbers(config("041", "049", "01318"), previous, true), + config("041", "049", "01318"), + ); + // So is every number when the previous release had other ones or no + // config.ml at all. + const legacy = previous.replace('"ResCmi040"', '"Caml1999I040"'); + assert.equal( + bumpMagicNumbers(previous, legacy, true), + config("040", "049", "01318"), + ); + assert.equal(bumpMagicNumbers(previous, null, true), previous); + assert.throws( + () => + bumpMagicNumbers( + previous.replace("cmt_magic_number", "other"), + previous, + true, + ), + /cmt_magic_number not found/, + ); +}); + +test("bumps the magic numbers once per release when format sources changed", async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rescript-magic-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const git = (...args) => + execFileSync("git", args, { + cwd: root, + env: { + ...process.env, + GIT_AUTHOR_NAME: "a", + GIT_AUTHOR_EMAIL: "a@example.com", + GIT_COMMITTER_NAME: "a", + GIT_COMMITTER_EMAIL: "a@example.com", + }, + }); + const write = async (file, text) => { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), text); + }; + const commit = async (file, text) => { + await write(file, text); + git("add", "."); + git("commit", "-q", "-m", file); + }; + git("init", "-q"); + await commit(configPath, config("040", "048", "01317")); + assert.throws(() => previousReleaseTag(root, "1.1.0"), /git fetch --tags/); + await commit("compiler/ml/types.ml", "type t = A\n"); + git("tag", "v1.0.0"); + + assert.equal(await releaseMagicNumbers(root, "1.1.0"), null); + await commit("compiler/syntax/printer.ml", "let print = ()\n"); + assert.equal(await releaseMagicNumbers(root, "1.1.0"), null); + // A merged maintenance branch brings a newer tag of an older line, with + // other magic numbers. + git("checkout", "-q", "-b", "maintenance", "v1.0.0"); + await commit(configPath, config("030", "038", "01200")); + git("tag", "v0.9.9"); + git("checkout", "-q", "-"); + git("merge", "-q", "-s", "ours", "-m", "Merge maintenance", "maintenance"); + assert.equal(previousReleaseTag(root, "1.1.0"), "v1.0.0"); + // Only committed changes count. + await write("compiler/ml/types.ml", "type t = A | B\n"); + assert.equal(await releaseMagicNumbers(root, "1.1.0"), null); + await commit("compiler/ml/types.ml", "type t = A | B\n"); + const bumped = await releaseMagicNumbers(root, "1.1.0"); + assert.equal(bumped, config("041", "049", "01318")); + + // A later run for the same release keeps the bumped numbers, also when the + // tag of the version being released already exists. + await commit(configPath, bumped); + git("tag", "v1.1.0"); + assert.equal(previousReleaseTag(root, "1.1.0"), "v1.0.0"); + await commit("compiler/ext/ident.ml", "type t = string\n"); + assert.equal(await releaseMagicNumbers(root, "1.1.0"), null); + // The next release compares with the one before it. + assert.equal( + await releaseMagicNumbers(root, "1.2.0"), + config("042", "050", "01319"), + ); +}); + +test("rejects pull requests that bump a magic number", () => { + const base = config("040", "048", "01317"); + checkMagicNumbers(base, base, []); + checkMagicNumbers(null, base, []); + assert.throws( + () => checkMagicNumbers(base, config("040", "049", "01317"), []), + /don't bump cmt_magic_number;/, + ); + assert.throws( + () => checkMagicNumbers(base, config("041", "048", "01318"), []), + /cmi_magic_number, ast_impl_magic_number, ast_intf_magic_number/, + ); + checkMagicNumbers(base, config("041", "049", "01318"), ["changelog:release"]); + // Switching to another prefix is no bump. + checkMagicNumbers(base.replace('"ResCmi040"', '"Caml1999I040"'), base, [ + "changelog:skip", + ]); +}); diff --git a/tests/build_tests/cmi_magic_number/input.js b/tests/build_tests/cmi_magic_number/input.js new file mode 100644 index 0000000000..8f78b7c963 --- /dev/null +++ b/tests/build_tests/cmi_magic_number/input.js @@ -0,0 +1,53 @@ +// @ts-check + +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { setup } from "#dev/process"; + +const { bsc } = setup(import.meta.dirname); + +const generatedDir = await fs.mkdtemp( + path.join(import.meta.dirname, "generated-"), +); +/** @param {string} file */ +const compile = file => + bsc(["-nostdlib", "-nopervasives", "-I", generatedDir, file], { + cwd: generatedDir, + }); + +try { + await fs.writeFile(path.join(generatedDir, "A.res"), "let x = 1\n"); + await fs.writeFile(path.join(generatedDir, "B.res"), "let y = A.x\n"); + const compiled = await compile("A.res"); + assert.equal(compiled.status, 0, compiled.stderr); + const cmi = path.join(generatedDir, "A.cmi"); + const contents = await fs.readFile(cmi); + const magic = contents.subarray(0, 9).toString("latin1"); + assert.match(magic, /^ResCmi\d{3}$/); + + /** + * Replaces the magic number of A.cmi and compiles a module that reads it. + * @param {string} header + */ + const readWith = async header => { + await fs.writeFile( + cmi, + Buffer.concat([Buffer.from(header, "latin1"), contents.subarray(9)]), + ); + const result = await compile("B.res"); + assert.notEqual(result.status, 0); + return result.stderr; + }; + + const older = "is not a compiled interface for this version of ReScript"; + // The OCaml-style magic numbers of earlier compilers are reported as older. + assert.match(await readWith("Caml1999I040"), /an older version of ReScript/); + assert.match(await readWith("ResCmi000"), /an older version of ReScript/); + assert.match(await readWith("ResCmi999"), /a newer version of ReScript/); + const unrelated = await readWith("NotACmi!!"); + assert.match(unrelated, /is not a compiled interface/); + assert.doesNotMatch(unrelated, new RegExp(older)); +} finally { + await fs.rm(generatedDir, { recursive: true, force: true }); +}