diff --git a/README.md b/README.md index b9aec77..680405d 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ abstract contract MyDeploySuites is RainDeploySuitesBase { // script/Deploy.sol contract Deploy is MyDeploySuites, RainDeployBroadcast {} +// script/Build.sol +contract Build is MyDeploySuites, BuildScript { + function regenerateSnapshots() internal override; + function regenerateLibs() internal override; + function snapshotContractNames() internal view override returns (string[] memory); +} + // test/src/abstract/MyDeploySnapshot.t.sol contract MyDeploySnapshotTest is MyDeploySuites, RainDeployVerifySnapshot {} @@ -76,6 +83,12 @@ can be true — not because something checks for it, but because there is nothin for it to disagree with. A repo that wrote its suites out twice would have that bug available to it; this one does not. +`BuildScript` carries both build entry points concrete. `run()` regenerates the +generated sources and freezes nothing; `cutRelease()` regenerates, freezes the +release as `src/generated//`, then regenerates from the record that now +holds it. Neither is `virtual`, so the entry point CI runs on every push has no +way to cut a release. + Suites are a **registry the abstract iterates**, not a chain of `else if`. Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports the valid keys built from that same array, so the error cannot fall behind the @@ -391,8 +404,8 @@ codehashes consumers pin, so releases are **manual `sol-v*` tags**, not merges. `[external.package].version` is the version of the LAST Soldeer publish, and only a release moves it. A release cut under this lifecycle also names the frozen `src/generated//` record `cutRelease()` wrote for it. Every version -published under the previous merge-driven lifecycle predates that record and -has none, so `src/generated/` holds no directory for it; those versions stay +published under the previous merge-driven lifecycle predates that record and has +none, so `src/generated/` holds no directory for it; those versions stay published, and consumers pin exact versions and are unaffected. ## Install diff --git a/script/Build.sol b/script/Build.sol index 70a5132..1cacd9c 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Script} from "forge-std-1.16.2/src/Script.sol"; +import {BuildScript} from "../src/abstract/BuildScript.sol"; import {DeployCandidate} from "../src/abstract/RainDeploySuitesBase.sol"; import {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; @@ -23,22 +23,15 @@ struct GeneratedContract { /// @title Build /// @notice Generates the deterministic-deploy pins for every contract this repo -/// deploys. -/// -/// - `run()` rewrites the rolling snapshots under `src/generated/candidate/`, -/// the alias libs pointing at them, the released-suites libs and the -/// aggregate over them. -/// - `cutRelease()` does the same, freezing the rolling snapshots as -/// `src/generated//` in between. +/// deploys. `run()` and `cutRelease()` are inherited from `BuildScript`. /// /// Alias libs always point at `candidate`, so `LibAddressRegistry` and /// `LibMigrationRegistry` resolve against what this repo currently compiles. /// The frozen `/` directories are what /// `RegistryDeploySuites.releasedSuites()` enumerates. /// -/// `generatedContracts()` is the only list, read by the regeneration, all three -/// lib writers and the freeze. -contract Build is Script, RegistryDeploySuites { +/// `generatedContracts()` is the only list, read by every hook below. +contract Build is BuildScript, RegistryDeploySuites { /// Every contract this repo generates deploy pins for. /// @return The generated contracts. function generatedContracts() internal pure returns (GeneratedContract[] memory) { @@ -54,10 +47,10 @@ contract Build is Script, RegistryDeploySuites { return contracts; } - /// Every generated contract's name, in declaration order — the order the - /// aggregate emits its entries in. Read by the freeze and the aggregate. - /// @return The contract names. - function generatedContractNames() internal pure returns (string[] memory) { + /// @inheritdoc BuildScript + /// @dev In declaration order — the order the aggregate emits its entries + /// in. Read by the freeze and the aggregate. + function snapshotContractNames() internal pure override returns (string[] memory) { GeneratedContract[] memory contracts = generatedContracts(); string[] memory names = new string[](contracts.length); for (uint256 i = 0; i < contracts.length; i++) { @@ -66,41 +59,24 @@ contract Build is Script, RegistryDeploySuites { return names; } - /// @notice Regenerate the rolling snapshots, their alias libs, the - /// released-suites libs and the aggregate over them. - function run() external { - regenerateCandidates(); - regenerateLibs(); - } - - /// @notice Regenerate the rolling snapshots, freeze them as - /// `src/generated//`, then rewrite the libs from the record, so the - /// release being cut is in them. - function cutRelease() external { - LibRainDeploySnapshot.freeze( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, regenerateCandidates, generatedContractNames() - ); - regenerateLibs(); - } - - /// @notice Rewrite every alias lib, every released-suites lib and the - /// aggregate over them. - function regenerateLibs() internal { + /// @inheritdoc BuildScript + /// @dev Every alias lib, every released-suites lib and the aggregate over + /// them. + function regenerateLibs() internal override { GeneratedContract[] memory contracts = generatedContracts(); for (uint256 i = 0; i < contracts.length; i++) { LibRainDeploySnapshot.writeAliasLib( vm, contracts[i].contractName, contracts[i].constantPrefix, LibRainDeploySnapshot.CANDIDATE ); LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, contracts[i].contractName, contracts[i].candidate.snapshot + vm, recordRoot(), contracts[i].contractName, contracts[i].candidate.snapshot ); } - LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, LibRainDeploySnapshot.LIB_DIR, generatedContractNames()); + LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, LibRainDeploySnapshot.LIB_DIR, snapshotContractNames()); } - /// @notice Rewrite every `src/generated/candidate/` snapshot from what this - /// repo currently compiles. - function regenerateCandidates() internal { + /// @inheritdoc BuildScript + function regenerateSnapshots() internal override { GeneratedContract[] memory contracts = generatedContracts(); for (uint256 i = 0; i < contracts.length; i++) { LibRainDeploySnapshot.writeSnapshot( diff --git a/slither.config.json b/slither.config.json index 747d60d..b25a906 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|RegistryDeploySuites)\\.sol", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|RegistryDeploySuites|BuildScript)\\.sol", "detectors_to_exclude": "assembly" } diff --git a/src/abstract/BuildScript.sol b/src/abstract/BuildScript.sol new file mode 100644 index 0000000..92f5626 --- /dev/null +++ b/src/abstract/BuildScript.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Script} from "forge-std-1.16.2/src/Script.sol"; +import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; + +/// @title BuildScript +/// @notice The two entry points of a deploy repo's `script/Build.sol`, concrete +/// here: +/// +/// - `run()` regenerates and freezes nothing. This is the one CI runs. +/// - `cutRelease()` regenerates, freezes the rolling snapshots as +/// `//`, then regenerates the libs from the record that +/// now holds the release being cut. +/// +/// Neither is `virtual`, so a repo inheriting this implements the hooks below +/// and has no entry point to cut a release from other than `cutRelease()`. +abstract contract BuildScript is Script { + /// Rewrite the rolling `candidate/` snapshots from what this repo currently + /// compiles. Run by `cutRelease()` inside `freeze`, after its guards and + /// before it copies anything. + function regenerateSnapshots() internal virtual; + + /// Rewrite every file generated from the snapshots and from the frozen + /// record. Run last, after a `cutRelease()` freeze has written the record, + /// so a file emitted from that record holds the release just cut. + function regenerateLibs() internal virtual; + + /// The contracts whose rolling snapshots a release freezes. + /// @return The contract names. + function snapshotContractNames() internal view virtual returns (string[] memory); + + /// The record root a release is frozen into, and the one the rolling + /// snapshots are read from. + /// + /// Overridable so a `cutRelease()` can be exercised against a record other + /// than the repo's own, which is append-only and cannot hold a test's + /// release. + /// @return The record root. + function recordRoot() internal view virtual returns (string memory) { + return LibRainDeploySnapshot.LIB_FS_ROOT; + } + + /// @notice Regenerate everything this repo generates. Freezes nothing. + function run() external { + regenerateSnapshots(); + regenerateLibs(); + } + + /// @notice Regenerate the rolling snapshots, freeze them as this release's + /// record, then regenerate the libs from the record. + function cutRelease() external { + LibRainDeploySnapshot.freeze(vm, recordRoot(), regenerateSnapshots, snapshotContractNames()); + regenerateLibs(); + } +} diff --git a/test/concrete/BuildHarness.sol b/test/concrete/BuildHarness.sol index f80d38a..4061a81 100644 --- a/test/concrete/BuildHarness.sol +++ b/test/concrete/BuildHarness.sol @@ -20,10 +20,10 @@ contract BuildHarness is Build { return generatedContracts(); } - /// The name list the freeze and the aggregate are emitted from. - /// @return The generated contract names. - function externalGeneratedContractNames() external pure returns (string[] memory) { - return generatedContractNames(); + /// The names a release cut from this script freezes. + /// @return The snapshot contract names. + function externalSnapshotContractNames() external pure returns (string[] memory) { + return snapshotContractNames(); } /// The deploy declaration's list, through the same guarded reader every diff --git a/test/concrete/BuildScriptHarness.sol b/test/concrete/BuildScriptHarness.sol new file mode 100644 index 0000000..2b764b8 --- /dev/null +++ b/test/concrete/BuildScriptHarness.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {BuildScript} from "../../src/abstract/BuildScript.sol"; +import {LibRainDeploySnapshot} from "../../src/lib/LibRainDeploySnapshot.sol"; + +/// @title BuildScriptHarness +/// @notice A `BuildScript` whose hooks write markers into a fixture record +/// instead of real generated sources, so `run()` and `cutRelease()` can be +/// called and what each one wrote — and what the record held when it wrote it — +/// read back. +/// +/// The markers are comment-only `.sol` files, because a failing test leaves its +/// fixture behind and `forge test` compiles everything under `test/`. +contract BuildScriptHarness is BuildScript { + /// The fixture record root. Empty defers to `BuildScript`'s own. + string internal sRoot; + + /// The single contract this fixture release freezes. + string internal sContractName; + + /// @param root The fixture record root, or empty for `BuildScript`'s own. + /// @param contractName The contract the fixture snapshot describes. + constructor(string memory root, string memory contractName) { + sRoot = root; + sContractName = contractName; + } + + /// @inheritdoc BuildScript + function recordRoot() internal view override returns (string memory) { + return bytes(sRoot).length > 0 ? sRoot : super.recordRoot(); + } + + /// The root a release cut from this harness is frozen into. + /// @return The record root. + function externalRecordRoot() external view returns (string memory) { + return recordRoot(); + } + + /// Where `regenerateSnapshots` writes. + /// @return The rolling snapshot path. + function rollingPath() public view returns (string memory) { + return LibRainDeploySnapshot.pathForSnapshot(recordRoot(), LibRainDeploySnapshot.CANDIDATE, sContractName); + } + + /// Where the release freezes that snapshot to. + /// @return The frozen snapshot path. + function frozenPath() external view returns (string memory) { + return LibRainDeploySnapshot.pathForSnapshot(recordRoot(), LibRainDeploySnapshot.deployTag(vm), sContractName); + } + + /// Where `regenerateLibs` writes. Directly under the root, so the record + /// walk — which reads tag directories — never sees it. + /// @return The lib marker path. + function libsPath() public view returns (string memory) { + return string.concat(recordRoot(), "/libs.sol"); + } + + /// A fixture file's content. + /// @param body What distinguishes this marker from the others. + /// @return The marker. + function marker(string memory body) public pure returns (string memory) { + // Split so `reuse lint` reads this as a fixture rather than as this + // file's own license declaration. + return string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n// ", body, "\n"); + } + + /// What `regenerateSnapshots` writes over whatever was there. + /// @return The regenerated rolling snapshot. + function regeneratedSnapshot() public pure returns (string memory) { + return marker("regenerated"); + } + + /// What `regenerateLibs` writes: the record it could see, and whether the + /// rolling snapshot had been regenerated, at the moment it ran. + /// @param frozenCount Frozen record files visible to it. + /// @param rollingExists Whether the rolling snapshot existed. + /// @return The lib marker. + function libsMarker(uint256 frozenCount, bool rollingExists) public pure returns (string memory) { + return + marker( + string.concat("frozen ", vm.toString(frozenCount), " rolling ", rollingExists ? "present" : "absent") + ); + } + + /// @inheritdoc BuildScript + function snapshotContractNames() internal view override returns (string[] memory) { + string[] memory contractNames = new string[](1); + contractNames[0] = sContractName; + return contractNames; + } + + /// @inheritdoc BuildScript + function regenerateSnapshots() internal override { + writeFixture(rollingPath(), regeneratedSnapshot()); + } + + /// @inheritdoc BuildScript + function regenerateLibs() internal override { + writeFixture( + libsPath(), + libsMarker(LibRainDeploySnapshot.frozenSnapshotPaths(vm, recordRoot()).length, vm.exists(rollingPath())) + ); + } + + /// Writes a marker, creating the directories above it. + /// @param path The file to write. + /// @param content The marker to write there. + function writeFixture(string memory path, string memory content) internal { + string[] memory components = vm.split(path, "/"); + string memory dir = components[0]; + for (uint256 i = 1; i < components.length - 1; i++) { + dir = string.concat(dir, "/", components[i]); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(dir, true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, content); + } +} diff --git a/test/script/Build.t.sol b/test/script/Build.t.sol index 3e6f1b1..b65039c 100644 --- a/test/script/Build.t.sol +++ b/test/script/Build.t.sol @@ -8,6 +8,7 @@ import {DeployCandidate} from "../../src/abstract/RainDeploySuitesBase.sol"; import {LibRainDeploySnapshot} from "../../src/lib/LibRainDeploySnapshot.sol"; import {LibReleasedSuitesAggregate} from "../lib/LibReleasedSuitesAggregate.sol"; import {BuildHarness} from "../concrete/BuildHarness.sol"; +import {LibStringSet} from "../lib/LibStringSet.sol"; /// @title BuildTest /// @notice `script/Build.sol`'s own declaration. @@ -100,6 +101,28 @@ contract BuildTest is Test { } } + /// PROPERTY: the names a release freezes are EXACTLY the generator's + /// contracts. + /// + /// `snapshotContractNames()` is the list `cutRelease()` hands `freeze`, and + /// it is the only thing that decides what a release records. A generated + /// contract missing from it is regenerated on every push and then absent + /// from the frozen tag, which `SnapshotAlreadyFrozen` makes unrepairable. + function testSnapshotContractNamesAreTheGeneratedContracts() external view { + GeneratedContract[] memory generated = sBuild.externalGeneratedContracts(); + string[] memory names = sBuild.externalSnapshotContractNames(); + + assertEq( + names.length, generated.length, "a generated contract is not frozen, or a frozen name is not generated" + ); + for (uint256 i = 0; i < generated.length; i++) { + assertTrue( + LibStringSet.holds(names, generated[i].contractName), + string.concat("generated contract is not frozen by a release: ", generated[i].contractName) + ); + } + } + /// PROPERTY: `contractName` is the name the snapshot path and both /// generated libs are built from, and it MUST be the contract the /// candidate's artifact path names. A disagreement writes one contract's @@ -150,7 +173,7 @@ contract BuildTest is Test { /// `generatedContracts()`'s ORDER, not merely as a set. /// /// Declaration order is claimed twice — the emitted library documents its - /// entries as being "in declaration order" and `generatedContractNames()` + /// entries as being "in declaration order" and `snapshotContractNames()` /// documents itself as giving "the order the aggregate emits its entries /// in" — and nothing else pins it. /// `testTheCommittedAggregateIsWhatTheGeneratorEmits` takes the contract @@ -185,7 +208,7 @@ contract BuildTest is Test { } } - /// PROPERTY: `generatedContractNames()` is every `generatedContracts()` + /// PROPERTY: `snapshotContractNames()` is every `generatedContracts()` /// entry's `contractName`, positionally. /// /// It is the list `cutRelease` freezes and the list the aggregate is @@ -195,9 +218,9 @@ contract BuildTest is Test { /// and every other assertion here is still green: the tests above read /// `generatedContracts()` and the committed file, neither of which this /// list passes through. - function testGeneratedContractNamesAreTheDeclarationInOrder() external view { + function testSnapshotContractNamesAreTheDeclarationInOrder() external view { GeneratedContract[] memory generated = sBuild.externalGeneratedContracts(); - string[] memory names = sBuild.externalGeneratedContractNames(); + string[] memory names = sBuild.externalSnapshotContractNames(); assertEq(names.length, generated.length, "a different number of names than generated contracts"); diff --git a/test/src/abstract/BuildScript.t.sol b/test/src/abstract/BuildScript.t.sol new file mode 100644 index 0000000..e91aff5 --- /dev/null +++ b/test/src/abstract/BuildScript.t.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; +import {BuildScriptHarness} from "../../concrete/BuildScriptHarness.sol"; + +/// @title BuildScriptTest +/// @notice The split between the two entry points every deploy repo inherits. +/// +/// The contract this repo compiles is not what these run against: a +/// `cutRelease()` here cuts THIS repo's tag, and `src/generated/` is +/// append-only, so each test drives a harness over a fixture record of its own. +/// A shared root would have the second test refused as a re-cut of the first. +contract BuildScriptTest is Test { + /// The contract the fixture snapshots describe. + string constant FIXTURE_CONTRACT = "Fixture"; + + /// Where the `run()` fixture's record is built. + string constant RUN_FIXTURE_ROOT = "test/generated-buildscript-run"; + + /// Where the freeze fixture's record is built. + string constant CUT_FIXTURE_ROOT = "test/generated-buildscript-cut"; + + /// Where the lib-ordering fixture's record is built. + string constant LIBS_FIXTURE_ROOT = "test/generated-buildscript-libs"; + + /// Clears a fixture record an earlier failure left behind. + /// + /// A cheatcode write is not undone by a revert, so a failing test leaves + /// its markers on disk and the next run reads THOSE — an assertion about + /// the previous run rather than about this one. + /// @param root The fixture record root to clear. + function resetFixture(string memory root) internal { + if (vm.exists(root)) { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(root, true); + } + } + + /// PROPERTY: `run()` regenerates everything and freezes NOTHING. + /// + /// This is the entry point CI calls on every push. A `run()` that cut a + /// release would freeze whatever a branch happened to compile under the + /// repo's current tag, and that tag can then never be cut again for real. + /// + /// The lib marker also carries the order: the libs are written after the + /// snapshots, from a record that holds no release. + function testRunRegeneratesAndFreezesNothing() external { + resetFixture(RUN_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(RUN_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.run(); + + // Read while the fixture is still there, asserted once it is gone. + string memory rolling = vm.readFile(harness.rollingPath()); + string memory libs = vm.readFile(harness.libsPath()); + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, RUN_FIXTURE_ROOT); + bool frozenExists = vm.exists(harness.frozenPath()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RUN_FIXTURE_ROOT, true); + + assertEq(rolling, harness.regeneratedSnapshot()); + assertEq(libs, harness.libsMarker(0, true)); + assertEq(record.length, 0); + assertFalse(frozenExists); + } + + /// PROPERTY: `cutRelease()` freezes the snapshot its OWN regeneration + /// wrote, not the one that was on disk when it was called. + /// + /// The regeneration reaches `freeze` as an internal function pointer taken + /// in the base, so what a release records is what the DERIVED hook writes. + /// A pointer that resolved anywhere else freezes the stale bytes, and a + /// release recording bytes its own deploy did not produce is silent + /// afterwards — the immutability guard only fires on a re-cut. + function testCutReleaseFreezesTheRegeneratedSnapshot() external { + resetFixture(CUT_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(CUT_FIXTURE_ROOT, FIXTURE_CONTRACT); + string memory stale = harness.marker("stale"); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(LibRainDeploySnapshot.dirForSnapshot(CUT_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE), true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(harness.rollingPath(), stale); + + harness.cutRelease(); + + // Read while the fixture is still there, asserted once it is gone. + bool frozenExists = vm.exists(harness.frozenPath()); + string memory frozen = frozenExists ? vm.readFile(harness.frozenPath()) : ""; + string memory rolling = vm.readFile(harness.rollingPath()); + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, CUT_FIXTURE_ROOT); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(CUT_FIXTURE_ROOT, true); + + assertTrue(frozenExists); + assertEq(frozen, harness.regeneratedSnapshot()); + assertNotEq(frozen, stale); + assertEq(rolling, harness.regeneratedSnapshot()); + assertEq(record.length, 1); + } + + /// PROPERTY: `cutRelease()` regenerates the libs AFTER the freeze, so a lib + /// emitted from the record holds the release being cut. + /// + /// Libs written before the freeze describe the record as it was one release + /// ago, and the release publishes a declaration that omits itself — which + /// every check downstream then reads as a release nobody ever made. + function testCutReleaseRegeneratesLibsFromTheRecordJustCut() external { + resetFixture(LIBS_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(LIBS_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.cutRelease(); + + // Read while the fixture is still there, asserted once it is gone. + string memory libs = vm.readFile(harness.libsPath()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(LIBS_FIXTURE_ROOT, true); + + assertEq(libs, harness.libsMarker(1, true)); + } + + /// PROPERTY: a repo that overrides nothing freezes into its OWN record. + /// + /// The root is overridable only so a release can be cut somewhere a test + /// may leave one. The default is the tree `RegistryDeploySuites` reads its + /// releases from, and a default pointing anywhere else writes releases + /// nothing enumerates. + function testRecordRootDefaultsToTheRepoRecord() external { + BuildScriptHarness harness = new BuildScriptHarness("", FIXTURE_CONTRACT); + assertEq(harness.externalRecordRoot(), LibRainDeploySnapshot.LIB_FS_ROOT); + } +}