From b791ebd430fb2e5fca7a7dd345b04587223ad666 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Tue, 15 Sep 2026 19:50:53 +0000 Subject: [PATCH 1/9] Generate the network config from the roster instead of comparing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LibRainDeploy.supportedNetworkConfigs()` becomes the single statement of the supported network set — name, chain id, explorer url and default endpoint. `LibRainDeployConfig` emits `[rpc_endpoints]`, `[etherscan]` and the `.env.example` endpoint variables from it and splices each between its markers, and `BuildScript.run()` writes both files. `supportedNetworks()` is now the roster's names. `testSupportedNetworksAreFullyConfigured` goes: with both sides written from one list there is nothing left for it to compare. What generation cannot settle is whether a declared chain id is the one the bound endpoint reports, so `RainDeployVerifyChain` gains `testSupportedNetworkChainIdsAreBound`, which forks every supported network and checks `block.chainid`. Every generated `[etherscan]` entry states `chain`, which carries #229's requirement across as a property of the generator rather than an assertion about a hand-written file. KNOWN BLOCKER, unresolved: foundry refuses every fs cheatcode write to the project-root `foundry.toml` — `ensure_not_foundry_toml`, "access to `foundry.toml` is not allowed" — regardless of `fs_permissions`. `writeFile`, `writeLine` and `copyFile` are all refused, under every path spelling (`foundry.toml`, `./foundry.toml`, `src/../foundry.toml`, absolute, absolute with `..`). So `forge script ./script/Build.sol` reverts, and the `Git is clean` job that runs it goes red. The `.env.example` half writes fine. The suite does not see this because `BuildScriptHarness` points `configPath()` at a fixture root, where the guard does not apply. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- .env.example | 5 + README.md | 103 +++-- foundry.toml | 57 +-- src/abstract/BuildScript.sol | 39 ++ src/abstract/RainDeployVerifyChain.sol | 63 ++- src/abstract/RainDeployVerifySnapshot.sol | 84 +--- src/lib/LibRainDeploy.sol | 95 +++- src/lib/LibRainDeployConfig.sol | 244 +++++++++++ test/concrete/BuildScriptHarness.sol | 54 +++ test/script/Deploy.t.sol | 9 +- test/src/abstract/BuildScript.t.sol | 82 ++++ test/src/abstract/RainDeployVerifyChain.t.sol | 69 ++- test/src/lib/LibRainDeploy.t.sol | 53 ++- test/src/lib/LibRainDeployConfig.t.sol | 408 ++++++++++++++++++ 14 files changed, 1210 insertions(+), 155 deletions(-) create mode 100644 src/lib/LibRainDeployConfig.sol create mode 100644 test/src/lib/LibRainDeployConfig.t.sol diff --git a/.env.example b/.env.example index f88a536..8a26393 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ # `foundry.toml` has to resolve — not just the one for the network under test. # # These are public endpoints and they rate-limit. CI holds its own. +# +# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`, +# between the markers and nowhere else. +# rain-deploy:generated:env:begin ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc BASE_RPC_URL=https://mainnet.base.org BASE_SEPOLIA_RPC_URL=https://sepolia.base.org @@ -14,3 +18,4 @@ FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc HYPEREVM_RPC_URL=https://rpc.hyperliquid.xyz/evm POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com ROBINHOOD_RPC_URL=https://rpc.mainnet.chain.robinhood.com +# rain-deploy:generated:env:end diff --git a/README.md b/README.md index 25209aa..4d50c3b 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,12 @@ 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. +generated sources and the network config, and freezes nothing; `cutRelease()` +regenerates, freezes the release as `src/generated//`, then regenerates +from the record that now holds it. The config is written by `run()` alone: it is +not part of a release record, and `cutRelease()` is the one operation that +cannot be repeated. 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 @@ -121,16 +123,15 @@ Deriving the pins at broadcast time would make that comparison derived-against-derived, and a guard that compares a value to itself is not a guard. -Five groups, sorted by what each is anchored to and therefore by what each can +Four groups, sorted by what each is anchored to and therefore by what each can catch: -| Group | Anchored to | Catches | Cannot catch | -| -------- | ---------------------- | ------------------------------------- | -------------------------------- | -| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract | -| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain | -| Record | the frozen record | a release the declaration missed | what a declared suite records | -| Chain | the networks | never deployed, or not there any more | anything about a candidate | -| Config | `foundry.toml` | a network it cannot fork or verify on | anything about a suite | +| Group | Anchored to | Catches | Cannot catch | +| -------- | ---------------------- | -------------------------------------------------- | -------------------------------- | +| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract | +| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain | +| Record | the frozen record | a release the declaration missed | what a declared suite records | +| Chain | the networks | a missing deployment, or an alias on another chain | anything about a candidate | The internal group's blind spot is not a gap to close there: every check in it asks the recorded bytes to agree with each other, and the wrong contract's bytes @@ -179,22 +180,34 @@ chain: deploying through Zoltu buys address predictability, and such a constructor spends it. So a per-chain difference fails hard, naming the chain and both hashes, and there is deliberately no per-chain code hash to record. -The config group is the only one whose subject is the CONSUMER's own -`foundry.toml` rather than its suites. `supportedNetworks()` is what the deploy -broadcasts to and what the chain group forks, `[rpc_endpoints]` is what makes an -alias forkable and `[etherscan]` is what makes `--verify` resolve, so the three -lists are one list and drift between them is a defect in either direction: a -supported network missing from a section broadcasts and then fails after the gas -is spent, and a section entry no supported network names is config nothing ever -reads. An `[etherscan]` entry carrying neither `chain` nor `url` under an alias -foundry cannot resolve is worse than missing — it takes verification down for -every entry in the section, not only its own. - -It reads the raw file rather than forge's resolved config, because the values -are `${VAR}` interpolations that only exist in CI while the KEYS are the whole -contract, and the keys are in the text. So it needs no RPC and fails on the pull -request that drifts rather than at dispatch time. Reading the file at all is -what a consumer has to allow: see [Install](#install). +**The network config is generated, not compared.** +`LibRainDeploy.supportedNetworkConfigs()` is the single statement of the set — +each network's name, chain id, explorer url and default endpoint — and +`BuildScript.run()` writes `foundry.toml`'s `[rpc_endpoints]` and `[etherscan]` +sections and `.env.example`'s endpoint variables from it. `Git is clean` is the +enforcement, the same mechanism already holding `src/generated/`: a tree whose +config has drifted from the roster it pins fails the job every push runs. + +That is what a config group used to be for, and comparing is the weaker half of +it. A comparison keeps both statements, so every assertion in it is one somebody +had to think of and the prose around them drifts silently; generation leaves one +statement and there is nothing left to compare. It also settles what a +comparison could only report: every generated `[etherscan]` entry states +`chain`, so an entry carrying neither `chain` nor `url` under an alias foundry +cannot resolve — which takes verification down for every entry in the section +and not only its own — is no longer a state a consumer can be in. + +The roster is deliberately not overridable. A repo able to narrow it would +deploy to and verify fewer chains with nothing red, so a network arrives in a +consumer's config by a version bump and by nothing else. + +The one thing generation cannot settle is whether a declared chain id is the one +the bound endpoint reports. That is a claim about the world rather than about +the text, and it is what `--verify` submits, so it sits in the chain group: +`testSupportedNetworkChainIdsAreBound` forks every supported network and +compares `block.chainid` against the roster. A wrong id there is config that +resolves, passes everything that reads the file, and verifies a deployment +against the wrong explorer. ## Address registry @@ -632,17 +645,37 @@ The versions have to match: the import paths are version-qualified, which is deliberate — it is what stops a consumer's incompatible copy from silently satisfying these imports. -The config group reads the CONSUMING repo's `foundry.toml`, so that repo has to -allow it and has to have the sections to be read: +`BuildScript.run()` writes the CONSUMING repo's own `foundry.toml` and +`.env.example`, so that repo has to allow it: ```toml -fs_permissions = [{ access = "read", path = "./foundry.toml" }] +fs_permissions = [ + { access = "read-write", path = "./foundry.toml" }, + { access = "read-write", path = "./.env.example" }, +] ``` -`[rpc_endpoints]` and `[etherscan]` then have to name exactly the networks in -`supportedNetworks()`. Missing permission fails the check rather than skipping -it, which is the intended direction: a repo that cannot read its own config is a -repo whose config nothing has checked. +Both files then need the markers the generated blocks are spliced between, once +each and the begin before the end. A file carrying neither is refused, naming +the file and the block, rather than having the section appended to it — which is +a duplicate key at the next forge startup: + +```toml +# rain-deploy:generated:rpc_endpoints:begin +# rain-deploy:generated:rpc_endpoints:end + +# rain-deploy:generated:etherscan:begin +# rain-deploy:generated:etherscan:end +``` + +```sh +# .env.example +# rain-deploy:generated:env:begin +# rain-deploy:generated:env:end +``` + +Everything outside the markers is the consumer's, and the build neither reads +nor moves it. ## Develop diff --git a/foundry.toml b/foundry.toml index 5578371..e821b78 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,11 +36,17 @@ bytecode_hash = "none" # plain `forge test` produces — not only under an explicit `--ast`. ast = true -# Build reads the version from foundry.toml and writes the generated -# per-tag snapshots + the current-pin lib under src/. Nothing else in this repo -# touches the filesystem. +# Build reads the version from foundry.toml, writes the generated network +# sections back into it and into .env.example, and writes the generated per-tag +# snapshots + the current-pin lib under src/. Nothing else in this repo touches +# the filesystem. fs_permissions = [ - { access = "read", path = "./foundry.toml" }, + # Read-write because `BuildScript.regenerateConfig` writes the generated + # network sections here. Only `script/Build.sol` does: nothing under + # `forge test` points a config writer at this file, because the tests that + # read it would race the write. + { access = "read-write", path = "./foundry.toml" }, + { access = "read-write", path = "./.env.example" }, { access = "read-write", path = "./src" }, # LibRainDeploySnapshotTest builds a record tree of its own under # test/generated to drive the frozen-record walk. NOT src/generated: the @@ -74,6 +80,12 @@ recursive_deps = false # _RPC_URL to a candidate that is reachable at the time of the run, # rather than to one URL that may be dead, so these names are the contract with # it. +# +# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`, +# between the markers and nowhere else. Adding a network is an edit to the +# roster followed by a build, and `Git is clean` fails a tree where the two have +# come apart. +# rain-deploy:generated:rpc_endpoints:begin [rpc_endpoints] arbitrum = "${ARBITRUM_RPC_URL}" base = "${BASE_RPC_URL}" @@ -84,36 +96,31 @@ flare = "${FLARE_RPC_URL}" hyperevm = "${HYPEREVM_RPC_URL}" polygon = "${POLYGON_RPC_URL}" robinhood = "${ROBINHOOD_RPC_URL}" +# rain-deploy:generated:rpc_endpoints:end # `rainix-manual-sol-artifacts` passes `--verify` by default and exports exactly # these variable names, so a deploy without this section broadcasts and then # fails with no API key configured for the chain — after spending the gas. One # entry per `[rpc_endpoints]` alias, because the deploy goes to all of them. # -# Both sections are checked against `LibRainDeploy.supportedNetworks()`, in both -# directions, by `testSupportedNetworksAreFullyConfigured`. Adding a network is -# an edit to all three or a red test, not a broadcast that discovers it. +# `chain` is stated on every entry, from the roster's own chain id. An entry +# with neither `chain` nor `url` under an alias foundry does not itself resolve +# is not a missing key, it is a config error — "At least one of `url` or `chain` +# must be present for Etherscan config with unknown alias" — raised while +# resolving the section, so it takes down verification for every network in it +# and not only its own. # -# `chain` is stated on the entries whose alias foundry does not itself resolve -# to a chain. An entry with neither `chain` nor `url` under such an alias is not -# a missing key, it is a config error — "At least one of `url` or `chain` must -# be present for Etherscan config with unknown alias" — raised while resolving -# the section, so it takes down verification for every network in it and not -# only its own. +# Generated with `[rpc_endpoints]`, from the same roster, so the two cannot name +# different networks. +# rain-deploy:generated:etherscan:begin [etherscan] -arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" } -base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" } -base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" } +arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}", chain = 42161 } +base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}", chain = 8453 } +base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}", chain = 84532 } bsc = { key = "${CI_DEPLOY_BSC_ETHERSCAN_API_KEY}", chain = 56 } ethereum = { key = "${CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY}", chain = 1 } -flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" } +flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}", chain = 14 } hyperevm = { key = "${CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY}", chain = 999 } -polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" } -# Robinhood Chain (4663) is not indexed by Etherscan V2. Its Blockscout -# explorer speaks the Etherscan API, so the entry points there; the key is -# whatever `CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY` carries (Blockscout ignores -# it). Blockscout sits behind a browser challenge that has rejected non-browser -# clients, so if `--verify` fails on this network after a broadcast, verify -# afterwards through Sourcify (which supports 4663 and which Blockscout -# imports): `forge verify-contract --verifier sourcify --chain 4663 ...`. +polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}", chain = 137 } robinhood = { key = "${CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY}", chain = 4663, url = "https://robinhoodchain.blockscout.com/api" } +# rain-deploy:generated:etherscan:end diff --git a/src/abstract/BuildScript.sol b/src/abstract/BuildScript.sol index 92f5626..7dcaa39 100644 --- a/src/abstract/BuildScript.sol +++ b/src/abstract/BuildScript.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.25; import {Script} from "forge-std-1.16.2/src/Script.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeployConfig} from "../lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// @title BuildScript @@ -16,6 +18,12 @@ import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// /// 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()`. +/// +/// The network config is generated here rather than through a hook a repo +/// implements: it comes out of `LibRainDeploy.supportedNetworkConfigs()`, this +/// package's own constant, so a version bump is how a network arrives in a +/// consumer's `foundry.toml` and `.env.example`. A repo able to narrow that +/// list would deploy to and verify fewer chains with nothing red. 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 @@ -42,8 +50,39 @@ abstract contract BuildScript is Script { return LibRainDeploySnapshot.LIB_FS_ROOT; } + /// The `foundry.toml` whose network sections are generated. + /// + /// Overridable for the same reason `recordRoot` is, and with the same + /// hazard: a writer that can only be pointed at the committed tree can only + /// be exercised by overwriting it, and overwriting this one under `forge + /// test` races every test that reads the config. A repo pointing it + /// somewhere other than its own root generates config nothing reads, and + /// `Git is clean` then sees a tree that never drifts because nothing + /// regenerates it. + /// @return The config path. + function configPath() internal view virtual returns (string memory) { + return LibRainDeployConfig.CONFIG_PATH; + } + + /// The `.env.example` whose endpoint variables are generated. + /// @return The `.env.example` path. + function envExamplePath() internal view virtual returns (string memory) { + return LibRainDeployConfig.ENV_EXAMPLE_PATH; + } + + /// Rewrite the delimited network config blocks from this package's roster. + /// + /// Run by `run()` and not by `cutRelease()`: the config is not part of a + /// release record, and `run()` is what `Git is clean` calls on every push, + /// so a tree whose config has drifted from the roster it pins fails there. + function regenerateConfig() internal { + LibRainDeployConfig.writeNetworkConfig(vm, configPath(), LibRainDeploy.supportedNetworkConfigs()); + LibRainDeployConfig.writeEnvExample(vm, envExamplePath(), LibRainDeploy.supportedNetworkConfigs()); + } + /// @notice Regenerate everything this repo generates. Freezes nothing. function run() external { + regenerateConfig(); regenerateSnapshots(); regenerateLibs(); } diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol index dea2a87..761f861 100644 --- a/src/abstract/RainDeployVerifyChain.sol +++ b/src/abstract/RainDeployVerifyChain.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; -import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} from "../lib/LibRainDeploy.sol"; /// Thrown when a version's derived address has no code on a network. Either it /// never deployed there, or it is not there any more. @@ -29,6 +29,16 @@ error CodeHashMismatchOnNetwork( string network, string suite, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash ); +/// Thrown when the chain id a network's roster entry declares is not the chain +/// id the endpoint bound to its `[rpc_endpoints]` alias reports. Either the +/// declaration is wrong — and `--verify` submits it — or the alias is bound to +/// a different network than the one it names, and everything checked through it +/// was checked somewhere else. +/// @param network The network name, as configured in `[rpc_endpoints]`. +/// @param declared The chain id the roster states. +/// @param reported The chain id the endpoint answers with. +error NetworkChainIdMismatch(string network, uint256 declared, uint256 reported); + /// @title RainDeployVerifyChain /// @notice The only deploy-pin assertions anchored to something outside the /// repo: across every network in `LibRainDeploy.supportedNetworks()`, every @@ -134,4 +144,55 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { function testSuitesLiveOnEverySupportedNetwork() external { checkDeployedOnSupportedNetworks(deriveDeployments(releasedSuites())); } + + /// Checks one network's declared chain id against whichever chain id the + /// currently selected fork reports. + /// @param network The network name, for the error only. + /// @param declared The chain id the roster states. + /// @param reported The chain id the bound endpoint answers with. + function checkNetworkChainId(string memory network, uint256 declared, uint256 reported) internal pure { + if (declared != reported) { + revert NetworkChainIdMismatch(network, declared, reported); + } + } + + /// Checks every network's declared chain id against the endpoint bound to + /// its `[rpc_endpoints]` alias. + /// @param networks The roster to check. + function checkNetworkChainIds(SupportedNetwork[] memory networks) internal { + // An empty roster is a repo that deploys nowhere, not a repo with + // nothing to check, and it would pass here having forked nothing. + if (networks.length == 0) { + revert LibRainDeploy.NoNetworks(); + } + + string[] memory names = new string[](networks.length); + for (uint256 i = 0; i < networks.length; i++) { + names[i] = networks[i].name; + } + + uint256[] memory forkIds = LibRainDeploy.createForks(vm, names); + for (uint256 i = 0; i < networks.length; i++) { + vm.selectFork(forkIds[i]); + checkNetworkChainId(networks[i].name, networks[i].chainId, block.chainid); + } + } + + /// Every supported network's declared chain id MUST be the one the endpoint + /// bound to its alias reports. + /// + /// The one thing about the config that generating it cannot settle. The + /// sections are written from the roster, so an alias missing from one of + /// them is a tree `Git is clean` fails rather than an assertion anything + /// makes — but which chain an endpoint actually serves is a claim about the + /// world, and `chain` is what `--verify` submits to. A wrong id there is + /// config that resolves, passes every check that reads the text, and + /// verifies a deployment against the wrong explorer. + /// + /// Here rather than beside the roster because the subject is the endpoint: + /// this is the contract that already forks every supported network, and the + /// snapshot half is the one a credential-free job binds. + function testSupportedNetworkChainIdsAreBound() external { + checkNetworkChainIds(LibRainDeploy.supportedNetworkConfigs()); + } } diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index 3de5d1d..c564f87 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -3,25 +3,21 @@ pragma solidity ^0.8.25; import {RainDeployVerifySnapshotBase} from "./RainDeployVerifySnapshotBase.sol"; -import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; -import {LibMemoryKV, MemoryKV, MemoryKVKey, MemoryKVVal} from "rain-lib-memkv-0.1.5/src/lib/LibMemoryKV.sol"; /// @title RainDeployVerifySnapshot /// @notice What a deploy repo inherits: every assertion that needs no network, /// bound to that repo. `RainDeployVerifySnapshotBase` is where the three -/// deploy-pin groups are defined and documented; this adds the tests whose -/// subject is the repo's own state on disk — its frozen record, and its -/// `foundry.toml` — rather than anything the inheriting contract declares. +/// deploy-pin groups are defined and documented; this adds the test whose +/// subject is the repo's own state on disk — its frozen record — rather than +/// anything the inheriting contract declares. /// -/// The split is which contract carries those tests, and nothing else. A -/// consumer inherits this and gets all three groups, exactly as it does when -/// they are one contract. The base is for a contract whose declaration is a -/// FIXTURE — the record is not its subject, and see the base for why asking it -/// about the record asserts something false. +/// The split is which contract carries that test, and nothing else. A consumer +/// inherits this and gets all three groups, exactly as it does when they are +/// one contract. The base is for a contract whose declaration is a FIXTURE — +/// the record is not its subject, and see the base for why asking it about the +/// record asserts something false. abstract contract RainDeployVerifySnapshot is RainDeployVerifySnapshotBase { - using LibMemoryKV for MemoryKV; - /// Every release in the frozen record MUST be declared, so that the set the /// chain group checks is every release this repo has ever cut rather than /// the ones somebody remembered to list. @@ -61,68 +57,4 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifySnapshotBase { function testEveryFrozenSnapshotIsReleased() external view { checkFrozenSnapshotsReleased(LibRainDeploySnapshot.frozenSnapshotPaths(vm), releasedSuites()); } - - /// `[rpc_endpoints]` and `[etherscan]` in the binding repo's `foundry.toml` - /// MUST be EXACTLY `supportedNetworks()`, which makes the three lists one - /// list. - /// - /// The deploy forks by the first and `--verify` resolves the second, so a - /// supported network missing from either broadcasts and then fails after - /// the gas is spent, and a section entry no supported network names is - /// config nothing ever reads. Both are the same defect — the lists having - /// drifted — so both directions are asserted, by membership: containment - /// one way alone passes for a section carrying an alias nothing deploys - /// to, and the other way alone passes for a network with no config at all. - /// Membership rather than position, because a config section is keyed - /// rather than ordered and there is no order in it to assert. - /// - /// This is what makes the `[etherscan]` half enforced at all. The RPC half - /// is enforced only incidentally, by the fork tests, and only forwards. - /// - /// The raw file is read rather than forge's resolved config because the - /// values are `${VAR}` interpolations that exist only in CI. The KEYS are - /// the whole contract here, and they are in the text — so this needs no - /// RPC and fails on the PR that drifts rather than at dispatch time. - /// - /// `vm.readFile` resolves against the project root of whatever runs it, so - /// the file read is the binder's own and the networks are this package's. - /// A binding repo therefore needs `{ access = "read", path = - /// "./foundry.toml" }` in `fs_permissions`, and one without it fails here - /// rather than passing on a file it never opened. - function testSupportedNetworksAreFullyConfigured() external view { - string memory config = vm.readFile("foundry.toml"); - string[] memory networks = LibRainDeploy.supportedNetworks(); - - MemoryKV networkSet = MemoryKV.wrap(0); - for (uint256 i = 0; i < networks.length; i++) { - networkSet = networkSet.set(MemoryKVKey.wrap(keccak256(bytes(networks[i]))), MemoryKVVal.wrap(0)); - } - - for (uint256 i = 0; i < networks.length; i++) { - assertTrue( - vm.keyExistsToml(config, string.concat(".rpc_endpoints.", networks[i])), - string.concat("supported network has no [rpc_endpoints] alias: ", networks[i]) - ); - assertTrue( - vm.keyExistsToml(config, string.concat(".etherscan.", networks[i])), - string.concat("supported network has no [etherscan] key: ", networks[i]) - ); - } - - string[] memory rpcAliases = vm.parseTomlKeys(config, ".rpc_endpoints"); - for (uint256 i = 0; i < rpcAliases.length; i++) { - assertTrue( - networkSet.has(MemoryKVKey.wrap(keccak256(bytes(rpcAliases[i])))), - string.concat("[rpc_endpoints] alias is not a supported network: ", rpcAliases[i]) - ); - } - - string[] memory etherscanKeys = vm.parseTomlKeys(config, ".etherscan"); - for (uint256 i = 0; i < etherscanKeys.length; i++) { - assertTrue( - networkSet.has(MemoryKVKey.wrap(keccak256(bytes(etherscanKeys[i])))), - string.concat("[etherscan] key is not a supported network: ", etherscanKeys[i]) - ); - } - } } diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index e73e002..5ff7922 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -5,6 +5,28 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.2/src/Vm.sol"; import {console2} from "forge-std-1.16.2/src/console2.sol"; +/// One network the Rain org deploys to, as everything generated from the roster +/// needs it. The roster is the single statement of the set: `foundry.toml`'s +/// `[rpc_endpoints]` and `[etherscan]` sections and `.env.example` are written +/// from it by `BuildScript`, so a network arrives everywhere by being added +/// here. +/// @param name The `[rpc_endpoints]` alias, the `[etherscan]` key, and the stem +/// of both the `_RPC_URL` and `CI_DEPLOY__ETHERSCAN_API_KEY` +/// variables the generated entries interpolate. +/// @param chainId The chain id the bound endpoint reports. Generation cannot +/// settle this one — it is a claim about the world, checked by +/// `RainDeployVerifyChain` against `block.chainid` on a fork. +/// @param explorerUrl The explorer API `--verify` posts to, or empty for the +/// one Etherscan V2 resolves from `chainId`. +/// @param defaultRpcUrl The public endpoint `.env.example` binds for a local +/// run. CI binds its own, so this is a convenience rather than a contract. +struct SupportedNetwork { + string name; + uint256 chainId; + string explorerUrl; + string defaultRpcUrl; +} + /// @title LibRainDeploy /// Library for deploying contracts via the Zoltu factory across all the networks /// currently supported by Rain by default. The Rain contracts can be deployed @@ -303,19 +325,70 @@ library LibRainDeploy { return forkIds; } - /// Returns the list of networks currently supported by Rain deployments. + /// The networks currently supported by Rain deployments, with everything + /// the generated config states about each. + /// + /// The ONE list. `supportedNetworks()` is its names, `LibRainDeployConfig` + /// writes the config sections from it, `RainDeployBroadcast` deploys to it + /// and `RainDeployVerifyChain` forks it — so the config cannot disagree + /// with the roster without `Git is clean` failing the tree that says so, + /// and there is nothing left for a test to compare. + /// + /// Deriving it from the config instead would be the same list spelled once + /// as well, and wrong: a repo that deleted an alias would deploy to and + /// verify fewer chains, green, because the thing that would notice reads + /// the same file. + /// @return The supported networks. + function supportedNetworkConfigs() internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](9); + networks[0] = SupportedNetwork({ + name: ARBITRUM_ONE, chainId: 42161, explorerUrl: "", defaultRpcUrl: "https://arb1.arbitrum.io/rpc" + }); + networks[1] = + SupportedNetwork({name: BASE, chainId: 8453, explorerUrl: "", defaultRpcUrl: "https://mainnet.base.org"}); + networks[2] = SupportedNetwork({ + name: BASE_SEPOLIA, chainId: 84532, explorerUrl: "", defaultRpcUrl: "https://sepolia.base.org" + }); + networks[3] = SupportedNetwork({ + name: BSC, chainId: 56, explorerUrl: "", defaultRpcUrl: "https://bsc-dataseed.binance.org" + }); + networks[4] = SupportedNetwork({ + name: ETHEREUM, chainId: 1, explorerUrl: "", defaultRpcUrl: "https://eth-pokt.nodies.app" + }); + networks[5] = SupportedNetwork({ + name: FLARE, chainId: 14, explorerUrl: "", defaultRpcUrl: "https://flare-api.flare.network/ext/C/rpc" + }); + networks[6] = SupportedNetwork({ + name: HYPEREVM, chainId: 999, explorerUrl: "", defaultRpcUrl: "https://rpc.hyperliquid.xyz/evm" + }); + networks[7] = SupportedNetwork({ + name: POLYGON, chainId: 137, explorerUrl: "", defaultRpcUrl: "https://polygon-bor-rpc.publicnode.com" + }); + // Robinhood Chain is not indexed by Etherscan V2, so `--verify` is + // pointed at its Blockscout explorer, which speaks the Etherscan API + // and ignores the key. Blockscout sits behind a browser challenge that + // has rejected non-browser clients, so if `--verify` fails on this + // network after a broadcast, verify afterwards through Sourcify (which + // supports 4663 and which Blockscout imports): + // `forge verify-contract --verifier sourcify --chain 4663 ...`. + networks[8] = SupportedNetwork({ + name: ROBINHOOD, + chainId: 4663, + explorerUrl: "https://robinhoodchain.blockscout.com/api", + defaultRpcUrl: "https://rpc.mainnet.chain.robinhood.com" + }); + return networks; + } + + /// The names of the networks currently supported by Rain deployments, in + /// roster order. /// @return The list of supported network names. function supportedNetworks() internal pure returns (string[] memory) { - string[] memory networks = new string[](9); - networks[0] = ARBITRUM_ONE; - networks[1] = BASE; - networks[2] = BASE_SEPOLIA; - networks[3] = BSC; - networks[4] = ETHEREUM; - networks[5] = FLARE; - networks[6] = HYPEREVM; - networks[7] = POLYGON; - networks[8] = ROBINHOOD; + SupportedNetwork[] memory configs = supportedNetworkConfigs(); + string[] memory networks = new string[](configs.length); + for (uint256 i = 0; i < configs.length; i++) { + networks[i] = configs[i].name; + } return networks; } diff --git a/src/lib/LibRainDeployConfig.sol b/src/lib/LibRainDeployConfig.sol new file mode 100644 index 0000000..8cf5281 --- /dev/null +++ b/src/lib/LibRainDeployConfig.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Vm} from "forge-std-1.16.2/src/Vm.sol"; +import {SupportedNetwork} from "./LibRainDeploy.sol"; + +/// Thrown when the file a generated block is written into does not carry that +/// block's markers exactly once each, the begin before the end. Refused rather +/// than appended: a file with no markers is a file whose config is hand +/// written, and appending a second copy of a section to a TOML file is a +/// duplicate key error at the next forge startup. +/// @param path The file the block was to be written into. +/// @param name The block name, as it appears in the markers. +error GeneratedBlockMalformed(string path, string name); + +/// Thrown when a roster entry states no chain id. A generated `[etherscan]` +/// entry states `chain` from this field and nothing else, and `chain = 0` is an +/// entry `--verify` resolves to no chain at all — so the roster cannot be +/// silent about it. +/// @param network The roster entry with no chain id. +error NoChainId(string network); + +/// Thrown when the roster is empty. Both sections would be generated with no +/// entries, which is a repo that deploys nowhere and verifies nothing while +/// every check that reads them stays green. +error EmptyRoster(); + +/// @title LibRainDeployConfig +/// @notice Writes the network config a deploy repo cannot state twice: the +/// `[rpc_endpoints]` and `[etherscan]` sections of `foundry.toml`, and the +/// `_RPC_URL` lines of `.env.example`, all of them from +/// `LibRainDeploy.supportedNetworkConfigs()`. +/// +/// Generated rather than compared. A comparison keeps both statements and +/// checks them, so the prose around them drifts silently and every assertion is +/// one somebody has to have thought of; generation leaves one statement, and +/// the enforcement is `Git is clean` — the same mechanism already holding +/// `src/generated/` and `src/lib/`. +/// +/// Delimited rather than whole-file, because everything else in `foundry.toml` +/// is hand written and a `.env.example` carries prose a generator has no way to +/// know. Each block is replaced between its markers and nothing outside them is +/// read or moved. +library LibRainDeployConfig { + /// The repo-root `foundry.toml` the sections are written into. Relative, so + /// it resolves against the project root of whatever runs the build — the + /// consuming repo's own config rather than this package's. + string constant CONFIG_PATH = "foundry.toml"; + + /// The repo-root `.env.example` the endpoint variables are written into. + string constant ENV_EXAMPLE_PATH = ".env.example"; + + /// The `[rpc_endpoints]` block, as it is named in its markers. + string constant RPC_ENDPOINTS_BLOCK = "rpc_endpoints"; + + /// The `[etherscan]` block, as it is named in its markers. + string constant ETHERSCAN_BLOCK = "etherscan"; + + /// The `.env.example` endpoint block, as it is named in its markers. + string constant ENV_BLOCK = "env"; + + /// What both markers start with. A `#` comment in TOML and in a dotenv + /// file alike, so one marker shape serves both. + string constant MARKER_PREFIX = "# rain-deploy:generated:"; + + /// The line a generated block starts after. + /// @param name The block name. + /// @return The begin marker, including the newline it ends the line with. + function beginMarker(string memory name) internal pure returns (string memory) { + return string.concat(MARKER_PREFIX, name, ":begin\n"); + } + + /// The line a generated block ends before. + /// @param name The block name. + /// @return The end marker, without a newline: whatever followed it stays. + function endMarker(string memory name) internal pure returns (string memory) { + return string.concat(MARKER_PREFIX, name, ":end"); + } + + /// The environment variable a network's `[rpc_endpoints]` alias + /// interpolates, and the one `.env.example` declares. + /// @param vm The Vm instance, for the case conversion only. + /// @param network The network name. + /// @return The variable name. + function rpcUrlVar(Vm vm, string memory network) internal pure returns (string memory) { + return string.concat(vm.toUppercase(network), "_RPC_URL"); + } + + /// The environment variable a network's `[etherscan]` entry interpolates + /// its key from. `rainix-manual-sol-artifacts` exports exactly these names. + /// @param vm The Vm instance, for the case conversion only. + /// @param network The network name. + /// @return The variable name. + function etherscanKeyVar(Vm vm, string memory network) internal pure returns (string memory) { + return string.concat("CI_DEPLOY_", vm.toUppercase(network), "_ETHERSCAN_API_KEY"); + } + + /// The `[rpc_endpoints]` section, header and all. + /// @param vm The Vm instance, for the case conversion only. + /// @param networks The roster. + /// @return The section text, newline terminated. + function rpcEndpointsSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = "[rpc_endpoints]\n"; + for (uint256 i = 0; i < networks.length; i++) { + section = string.concat(section, networks[i].name, ' = "${', rpcUrlVar(vm, networks[i].name), '}"', "\n"); + } + return section; + } + + /// The `[etherscan]` section, header and all. + /// + /// Every entry states `chain`, and `url` as well wherever the roster gives + /// one. That is foundry's own condition for an entry it can resolve — "At + /// least one of `url` or `chain` must be present for Etherscan config with + /// unknown alias" is raised while resolving the SECTION, so one entry that + /// cannot be resolved takes verification down for every network in it and + /// not only its own. Stating the chain an alias already resolves to + /// resolves it to the same chain, so stating it on all of them cannot go + /// wrong when foundry adds or renames an alias. + /// @param vm The Vm instance, for the case conversion and `chain`. + /// @param networks The roster. + /// @return The section text, newline terminated. + function etherscanSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = "[etherscan]\n"; + for (uint256 i = 0; i < networks.length; i++) { + if (networks[i].chainId == 0) { + revert NoChainId(networks[i].name); + } + string memory entry = string.concat( + networks[i].name, + ' = { key = "${', + etherscanKeyVar(vm, networks[i].name), + '}", chain = ', + vm.toString(networks[i].chainId) + ); + if (bytes(networks[i].explorerUrl).length > 0) { + entry = string.concat(entry, ', url = "', networks[i].explorerUrl, '"'); + } + section = string.concat(section, entry, " }\n"); + } + return section; + } + + /// The `_RPC_URL` lines of `.env.example`. + /// @param vm The Vm instance, for the case conversion only. + /// @param networks The roster. + /// @return The block text, newline terminated. + function envExampleSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = ""; + for (uint256 i = 0; i < networks.length; i++) { + section = string.concat(section, rpcUrlVar(vm, networks[i].name), "=", networks[i].defaultRpcUrl, "\n"); + } + return section; + } + + /// Replaces whatever sits between a block's markers with `body`, leaving + /// the markers and everything outside them where they are. + /// + /// Both markers MUST appear exactly once, the begin before the end. Two + /// begin markers is a file with two claims about where the block is and no + /// rule for choosing between them that does not silently keep one of the + /// copies; none at all is a file nothing here has ever written, which is + /// what a consumer that has not adopted this looks like. + /// @param vm The Vm instance, for the string search only. + /// @param path The file the content came from, for the error only. + /// @param content The whole file. + /// @param name The block name. + /// @param body The block's new content, newline terminated. + /// @return The whole file, with the block replaced. + function spliceBlock(Vm vm, string memory path, string memory content, string memory name, string memory body) + internal + pure + returns (string memory) + { + string memory begin = beginMarker(name); + string memory end = endMarker(name); + + // `split` yields one more part than there are occurrences of the + // delimiter, so this counts them rather than finding the first and + // hoping it is the only one. + string[] memory beginParts = vm.split(content, begin); + if (beginParts.length != 2) { + revert GeneratedBlockMalformed(path, name); + } + string[] memory endParts = vm.split(beginParts[1], end); + if (endParts.length != 2) { + revert GeneratedBlockMalformed(path, name); + } + // An end marker BEFORE the begin marker is still in the prefix, and + // splicing would move it to the other side of the block. + if (vm.contains(beginParts[0], end)) { + revert GeneratedBlockMalformed(path, name); + } + return string.concat(beginParts[0], begin, body, end, endParts[1]); + } + + /// Writes both `foundry.toml` sections from the roster. + /// + /// `foundry.toml` is read by forge at startup and never re-read, so a + /// script rewriting it does not move anything under itself. A TEST that + /// reads it does race this, which is why nothing under `forge test` points + /// this at the repo's own config. + /// @param vm The Vm instance for file operations. + /// @param path The config to write — `CONFIG_PATH` for a repo's own. + /// @param networks The roster. + /// @return The path written. + function writeNetworkConfig(Vm vm, string memory path, SupportedNetwork[] memory networks) + internal + returns (string memory) + { + string memory content = vm.readFile(path); + content = spliceBlock(vm, path, content, RPC_ENDPOINTS_BLOCK, rpcEndpointsSection(vm, networks)); + content = spliceBlock(vm, path, content, ETHERSCAN_BLOCK, etherscanSection(vm, networks)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, content); + return path; + } + + /// Writes the `.env.example` endpoint block from the roster. + /// @param vm The Vm instance for file operations. + /// @param path The file to write — `ENV_EXAMPLE_PATH` for a repo's own. + /// @param networks The roster. + /// @return The path written. + function writeEnvExample(Vm vm, string memory path, SupportedNetwork[] memory networks) + internal + returns (string memory) + { + string memory content = vm.readFile(path); + content = spliceBlock(vm, path, content, ENV_BLOCK, envExampleSection(vm, networks)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, content); + return path; + } +} diff --git a/test/concrete/BuildScriptHarness.sol b/test/concrete/BuildScriptHarness.sol index 2b764b8..45f9d63 100644 --- a/test/concrete/BuildScriptHarness.sol +++ b/test/concrete/BuildScriptHarness.sol @@ -38,6 +38,60 @@ contract BuildScriptHarness is BuildScript { return recordRoot(); } + /// @inheritdoc BuildScript + /// @dev Under the fixture root, never the repo's own `foundry.toml`: forge + /// reads that file once at startup and a script rewriting it is safe, but a + /// TEST rewriting it races every other test that reads it. + function configPath() internal view override returns (string memory) { + return string.concat(sRoot, "/foundry.toml"); + } + + /// @inheritdoc BuildScript + function envExamplePath() internal view override returns (string memory) { + return string.concat(sRoot, "/.env.example"); + } + + /// Where `regenerateConfig` writes the network sections. + /// @return The fixture config path. + function externalConfigPath() external view returns (string memory) { + return configPath(); + } + + /// Where `regenerateConfig` writes the endpoint variables. + /// @return The fixture `.env.example` path. + function externalEnvExamplePath() external view returns (string memory) { + return envExamplePath(); + } + + /// A fixture file carrying both `foundry.toml` blocks, with a stale body in + /// each and hand-written text around them. + /// @return The seed config. + function configSeed() public pure returns (string memory) { + return string.concat( + "# hand written\n", + "# rain-deploy:generated:rpc_endpoints:begin\n", + "STALE\n", + "# rain-deploy:generated:rpc_endpoints:end\n", + "# rain-deploy:generated:etherscan:begin\n", + "STALE\n", + "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// A fixture file carrying the `.env.example` block. + /// @return The seed `.env.example`. + function envExampleSeed() public pure returns (string memory) { + return string.concat( + "# hand written\n", "# rain-deploy:generated:env:begin\n", "STALE=1\n", "# rain-deploy:generated:env:end\n" + ); + } + + /// Writes both config fixtures, so `run()` has markers to splice into. + function seedConfig() external { + writeFixture(configPath(), configSeed()); + writeFixture(envExamplePath(), envExampleSeed()); + } + /// Where `regenerateSnapshots` writes. /// @return The rolling snapshot path. function rollingPath() public view returns (string memory) { diff --git a/test/script/Deploy.t.sol b/test/script/Deploy.t.sol index bef7db7..a156991 100644 --- a/test/script/Deploy.t.sol +++ b/test/script/Deploy.t.sol @@ -36,11 +36,10 @@ contract DeployTest is Test { /// Matched against the config rather than against `supportedNetworks()`, /// which is the value the script's inherited body returns: an assertion /// written against that would be the implementation compared to itself and - /// would pass for any override that happened to call it. The config is a - /// separate statement of the same set, and - /// `testSupportedNetworksAreFullyConfigured` is what holds the two together - /// — so a narrowing here fails, and a network added to one place and not - /// the other fails there. + /// would pass for any override that happened to call it. The config is + /// GENERATED from the roster, so what the file says is the roster as of the + /// last build — which makes reading it here a read of the committed tree + /// and not of the constant, and a narrowing in the script fails against it. /// /// Both directions, because containment one way passes for a script that /// deploys to a subset and the other way for a config carrying an alias diff --git a/test/src/abstract/BuildScript.t.sol b/test/src/abstract/BuildScript.t.sol index e91aff5..7ead3eb 100644 --- a/test/src/abstract/BuildScript.t.sol +++ b/test/src/abstract/BuildScript.t.sol @@ -3,6 +3,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeployConfig} from "../../../src/lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; import {BuildScriptHarness} from "../../concrete/BuildScriptHarness.sol"; @@ -26,6 +28,15 @@ contract BuildScriptTest is Test { /// Where the lib-ordering fixture's record is built. string constant LIBS_FIXTURE_ROOT = "test/generated-buildscript-libs"; + /// Where the `run()` config fixture's record is built. + string constant RUN_CONFIG_FIXTURE_ROOT = "test/generated-buildscript-run-config"; + + /// Where the `cutRelease()` config fixture's record is built. Its own root, + /// like every other fixture here: forge runs the tests in a contract in + /// parallel, so a root two of them share is one deleting the tree the other + /// is midway through reading. + string constant CUT_CONFIG_FIXTURE_ROOT = "test/generated-buildscript-cut-config"; + /// Clears a fixture record an earlier failure left behind. /// /// A cheatcode write is not undone by a revert, so a failing test leaves @@ -50,6 +61,7 @@ contract BuildScriptTest is Test { function testRunRegeneratesAndFreezesNothing() external { resetFixture(RUN_FIXTURE_ROOT); BuildScriptHarness harness = new BuildScriptHarness(RUN_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); harness.run(); // Read while the fixture is still there, asserted once it is gone. @@ -122,6 +134,76 @@ contract BuildScriptTest is Test { assertEq(libs, harness.libsMarker(1, true)); } + /// PROPERTY: `run()` rewrites both `foundry.toml` network blocks and the + /// `.env.example` block from the roster, leaving everything outside the + /// markers where it was. + /// + /// This is the wiring, not the emission: what the sections SAY is pinned + /// against string literals in `LibRainDeployConfigTest`, over a fixture + /// roster no real network is named in. What is asserted here is that the + /// entry point CI runs on every push reaches the config at all — without + /// it the sections would exist, be correct, and be written nowhere, and + /// `Git is clean` would pass a tree whose config had drifted from the + /// roster it pins. + function testRunRegeneratesTheNetworkConfig() external { + resetFixture(RUN_CONFIG_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(RUN_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); + harness.run(); + + // Read while the fixture is still there, asserted once it is gone. + string memory config = vm.readFile(harness.externalConfigPath()); + string memory envExample = vm.readFile(harness.externalEnvExamplePath()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RUN_CONFIG_FIXTURE_ROOT, true); + + assertEq( + config, + string.concat( + "# hand written\n", + "# rain-deploy:generated:rpc_endpoints:begin\n", + LibRainDeployConfig.rpcEndpointsSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:rpc_endpoints:end\n", + "# rain-deploy:generated:etherscan:begin\n", + LibRainDeployConfig.etherscanSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:etherscan:end\n" + ) + ); + assertEq( + envExample, + string.concat( + "# hand written\n", + "# rain-deploy:generated:env:begin\n", + LibRainDeployConfig.envExampleSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:env:end\n" + ) + ); + } + + /// PROPERTY: `cutRelease()` leaves the config exactly as it found it. + /// + /// The config is not part of a release record. A `cutRelease()` that + /// rewrote it would put a config change inside the one operation that can + /// never be repeated, where `run()` is the entry point every push already + /// runs and the only one `Git is clean` currency checks. + function testCutReleaseLeavesTheConfigAlone() external { + resetFixture(CUT_CONFIG_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(CUT_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); + harness.cutRelease(); + + // Read while the fixture is still there, asserted once it is gone. + string memory config = vm.readFile(harness.externalConfigPath()); + string memory envExample = vm.readFile(harness.externalEnvExamplePath()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(CUT_CONFIG_FIXTURE_ROOT, true); + + assertEq(config, harness.configSeed()); + assertEq(envExample, harness.envExampleSeed()); + } + /// 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 diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 6f9147d..9961280 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -5,10 +5,11 @@ pragma solidity =0.8.25; import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; import { CodeHashMismatchOnNetwork, + NetworkChainIdMismatch, NotDeployedOnNetwork, RainDeployVerifyChain } from "../../../src/abstract/RainDeployVerifyChain.sol"; -import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { @@ -362,4 +363,70 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain (bool extraFork,) = address(vm).call(abi.encodeWithSignature("selectFork(uint256)", networkCount)); assertFalse(extraFork, "the run opened a fork that is not one of the supported networks"); } + + /// External wrapper for `checkNetworkChainId` so `vm.expectRevert` works at + /// the correct call depth. + /// @param network The network name, for the error only. + /// @param declared The chain id the roster states. + /// @param reported The chain id the endpoint answers with. + function externalCheckNetworkChainId(string memory network, uint256 declared, uint256 reported) external pure { + checkNetworkChainId(network, declared, reported); + } + + /// External wrapper for `checkNetworkChainIds` so `vm.expectRevert` works + /// at the correct call depth. + /// @param networks The roster to check. + function externalCheckNetworkChainIds(SupportedNetwork[] memory networks) external { + checkNetworkChainIds(networks); + } + + /// PROPERTY: a declared chain id that is not the reported one fails hard, + /// naming the network and BOTH ids. + /// + /// Exact values rather than a bare revert: the two ids are what tells a + /// reader whether the declaration is wrong or the alias is bound to a + /// different network, and those are opposite fixes. + function testChainIdMismatchReverts() external { + vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, "alpha", 11, 22)); + this.externalCheckNetworkChainId("alpha", 11, 22); + } + + /// PROPERTY: a declared chain id that IS the reported one passes. + /// + /// The other half of the discrimination: without it a check that reverted + /// on every pair would satisfy the case above. + function testChainIdMatchPasses() external view { + this.externalCheckNetworkChainId("alpha", 11, 11); + } + + /// PROPERTY: the chain id read is the one the ENDPOINT reports, on a fork + /// that really was created. + /// + /// The roster's own pass is the inherited + /// `testSupportedNetworkChainIdsAreBound`, which cannot say where the + /// number came from — a check reading `block.chainid` off the unforked + /// 31337 EVM would fail there for every network, but so would one reading + /// it off the wrong fork, and neither is distinguishable from the other by + /// a green run. This declares a chain id no network has for an alias that + /// really resolves, and the id in the revert is the one that endpoint + /// answers with: `1`, which is Ethereum's and not 31337. + function testChainIdIsReadFromTheForkedEndpoint() external { + SupportedNetwork[] memory networks = new SupportedNetwork[](1); + networks[0] = + SupportedNetwork({name: LibRainDeploy.ETHEREUM, chainId: 987654, explorerUrl: "", defaultRpcUrl: ""}); + + vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, LibRainDeploy.ETHEREUM, 987654, 1)); + this.externalCheckNetworkChainIds(networks); + } + + /// PROPERTY: an empty roster fails rather than passing having forked + /// nothing. + /// + /// The one shape of roster that satisfies every loop here without a + /// subject, and the shape a repo that narrowed the list all the way down + /// would be in. + function testChainIdEmptyRosterReverts() external { + vm.expectRevert(LibRainDeploy.NoNetworks.selector); + this.externalCheckNetworkChainIds(new SupportedNetwork[](0)); + } } diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index ee40f16..c46f13f 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; -import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; import {MockAddressRevertingFactory} from "../../concrete/MockAddressRevertingFactory.sol"; @@ -209,6 +209,57 @@ contract LibRainDeployTest is Test { assertEq(networks[8], LibRainDeploy.ROBINHOOD); } + /// `supportedNetworks` MUST be the names of `supportedNetworkConfigs`, in + /// the same order and with nothing dropped. + /// + /// The two are one list or they are two lists, and two lists is what this + /// whole arrangement removes: the config sections are written from the + /// roster while the deploy and the fork matrix read the names. + function testSupportedNetworksAreTheRosterNames() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + string[] memory networks = LibRainDeploy.supportedNetworks(); + assertEq(networks.length, configs.length); + for (uint256 i = 0; i < configs.length; i++) { + assertEq(networks[i], configs[i].name); + } + } + + /// Every roster entry MUST state the chain id its network really has. + /// + /// Spelled out here rather than looped, because a loop over the roster + /// comparing it to itself asserts nothing. These nine numbers came from + /// `cast chain-id` against the endpoints `.env.example` binds, and they are + /// what `--verify` submits as `chain`. + function testSupportedNetworkChainIds() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + assertEq(configs.length, 9); + assertEq(configs[0].chainId, 42161); + assertEq(configs[1].chainId, 8453); + assertEq(configs[2].chainId, 84532); + assertEq(configs[3].chainId, 56); + assertEq(configs[4].chainId, 1); + assertEq(configs[5].chainId, 14); + assertEq(configs[6].chainId, 999); + assertEq(configs[7].chainId, 137); + assertEq(configs[8].chainId, 4663); + } + + /// Only Robinhood Chain states an explorer url, because it is the only + /// supported network Etherscan V2 does not index. + /// + /// A url stated on a network Etherscan does index would point `--verify` at + /// that url instead, silently, for that network alone. + function testSupportedNetworkExplorerUrls() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + for (uint256 i = 0; i < configs.length; i++) { + if (keccak256(bytes(configs[i].name)) == keccak256(bytes(LibRainDeploy.ROBINHOOD))) { + assertEq(configs[i].explorerUrl, "https://robinhoodchain.blockscout.com/api"); + } else { + assertEq(configs[i].explorerUrl, "", configs[i].name); + } + } + } + /// `ZOLTU_FACTORY_CODEHASH` MUST match the actual codehash of the Zoltu /// factory on every supported network. Every name in `supportedNetworks` /// MUST also be a configured fork alias, otherwise it cannot be deployed diff --git a/test/src/lib/LibRainDeployConfig.t.sol b/test/src/lib/LibRainDeployConfig.t.sol new file mode 100644 index 0000000..96a8985 --- /dev/null +++ b/test/src/lib/LibRainDeployConfig.t.sol @@ -0,0 +1,408 @@ +// 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 {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; +import { + EmptyRoster, + GeneratedBlockMalformed, + LibRainDeployConfig, + NoChainId +} from "../../../src/lib/LibRainDeployConfig.sol"; + +/// @title LibRainDeployConfigTest +/// @notice The generator that replaces the comparison. What a comparison +/// asserted about two lists, this has to EMIT, so the expectations here are +/// string literals rather than anything the library builds — an expectation +/// concatenated the way the source concatenates it would pass for any spelling +/// the source happens to use, including a broken one. +/// +/// The fixture roster is `alpha`/`beta`/`gamma`, which no real network is +/// named, so nothing here can pass against this repo's own config by accident. +contract LibRainDeployConfigTest is Test { + /// Where the `writeNetworkConfig` fixture is built. Under `test/`, which is + /// the root the config grants read-write; `foundry.toml` itself is written + /// only by `script/Build.sol`, never from here. + /// + /// One root per test, because forge runs the tests in a contract in + /// parallel: a shared root is one test deleting the tree another is + /// midway through reading. + string constant CONFIG_FIXTURE_ROOT = "test/generated-config-write"; + + /// Where the `writeEnvExample` fixture is built. + string constant ENV_FIXTURE_ROOT = "test/generated-config-env"; + + /// Where the fixture carrying no markers is built. + string constant NO_MARKERS_FIXTURE_ROOT = "test/generated-config-no-markers"; + + /// A roster of three, one of which carries an explorer url, so both + /// `[etherscan]` entry shapes are emitted by one call. + /// @return The fixture roster. + function fixtureRoster() internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](3); + networks[0] = + SupportedNetwork({name: "alpha", chainId: 11, explorerUrl: "", defaultRpcUrl: "https://alpha.example"}); + networks[1] = SupportedNetwork({ + name: "beta", chainId: 22, explorerUrl: "https://beta.example/api", defaultRpcUrl: "https://beta.example" + }); + networks[2] = + SupportedNetwork({name: "gamma", chainId: 33, explorerUrl: "", defaultRpcUrl: "https://gamma.example"}); + return networks; + } + + /// A roster of one, for the cases where three would say nothing extra. + /// @param name The network name. + /// @param chainId The chain id it declares. + /// @return The roster. + function singleRoster(string memory name, uint256 chainId) internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](1); + networks[0] = SupportedNetwork({name: name, chainId: chainId, explorerUrl: "", defaultRpcUrl: "https://one"}); + return networks; + } + + /// Clears a fixture tree an earlier failure left behind. A cheatcode write + /// is not undone by a revert, so a failing test leaves its files on disk + /// and the next run reads THOSE. + /// @param root The fixture root to clear. + function resetFixtures(string memory root) internal { + if (vm.exists(root)) { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(root, true); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(root, true); + } + + /// Writes a fixture file, creating the root above it. + /// @param root The fixture root the file sits under. + /// @param path The file to write. + /// @param content What to write there. + function writeFixture(string memory root, string memory path, string memory content) internal { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(root, true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, content); + } + + /// External wrappers, so `vm.expectRevert` sees a call at a lower depth + /// than its own. An internal library call reverts at this contract's depth + /// and the cheatcode refuses it. + /// @param networks The roster to render. + /// @return The section. + function externalRpcEndpointsSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.rpcEndpointsSection(vm, networks); + } + + /// @param networks The roster to render. + /// @return The section. + function externalEtherscanSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.etherscanSection(vm, networks); + } + + /// @param networks The roster to render. + /// @return The block. + function externalEnvExampleSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.envExampleSection(vm, networks); + } + + /// @param path The file the content came from, for the error only. + /// @param content The whole file. + /// @param name The block name. + /// @param body The block's new content. + /// @return The whole file, with the block replaced. + function externalSpliceBlock(string memory path, string memory content, string memory name, string memory body) + external + pure + returns (string memory) + { + return LibRainDeployConfig.spliceBlock(vm, path, content, name, body); + } + + /// @param path The config to write. + /// @param networks The roster. + /// @return The path written. + function externalWriteNetworkConfig(string memory path, SupportedNetwork[] memory networks) + external + returns (string memory) + { + return LibRainDeployConfig.writeNetworkConfig(vm, path, networks); + } + + /// PROPERTY: the `[rpc_endpoints]` section is the roster, in roster order, + /// each alias interpolating its own `_RPC_URL`. + function testRpcEndpointsSectionIsTheRoster() external pure { + assertEq( + LibRainDeployConfig.rpcEndpointsSection(vm, fixtureRoster()), + "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' 'beta = "${BETA_RPC_URL}"\n' + 'gamma = "${GAMMA_RPC_URL}"\n' + ); + } + + /// PROPERTY: the `[etherscan]` section states `chain` on every entry, and + /// `url` as well on the entries the roster gives one for. + /// + /// This is #192's closing assertion, carried across the change that removed + /// the test which made it. What was asserted about a hand-written section + /// is emitted here, so a section carrying an entry with neither is no + /// longer a state this repo or a consumer can be in. + function testEtherscanSectionStatesChainOnEveryEntry() external pure { + assertEq( + LibRainDeployConfig.etherscanSection(vm, fixtureRoster()), + "[etherscan]\n" 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + 'beta = { key = "${CI_DEPLOY_BETA_ETHERSCAN_API_KEY}", chain = 22, url = "https://beta.example/api" }\n' + 'gamma = { key = "${CI_DEPLOY_GAMMA_ETHERSCAN_API_KEY}", chain = 33 }\n' + ); + } + + /// PROPERTY: what is emitted parses as the section it claims to be, and + /// every entry in it satisfies foundry's own condition for an entry it can + /// resolve. + /// + /// Asked of foundry's TOML parser rather than of the emitted text, so the + /// oracle is the file format and not the concatenation under test. The + /// literal above pins the bytes; this pins what they MEAN. + function testEtherscanSectionEntriesAreResolvable() external view { + string memory section = LibRainDeployConfig.etherscanSection(vm, fixtureRoster()); + string[] memory entries = vm.parseTomlKeys(section, ".etherscan"); + assertEq(entries.length, 3); + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".chain")) + || vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".url")), + string.concat("[etherscan] entry states neither chain nor url: ", entries[i]) + ); + } + } + + /// PROPERTY: the same holds of the section generated from the REAL roster, + /// which is the one every consumer's config is written from. + function testEtherscanSectionOfTheSupportedNetworksIsResolvable() external view { + SupportedNetwork[] memory networks = LibRainDeploy.supportedNetworkConfigs(); + string memory section = LibRainDeployConfig.etherscanSection(vm, networks); + string[] memory entries = vm.parseTomlKeys(section, ".etherscan"); + assertEq(entries.length, networks.length); + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".chain")) + || vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".url")), + string.concat("[etherscan] entry states neither chain nor url: ", entries[i]) + ); + } + } + + /// PROPERTY: a roster entry with no chain id is refused rather than emitted + /// as `chain = 0`, which resolves to no chain at all. + function testEtherscanSectionZeroChainIdReverts() external { + SupportedNetwork[] memory networks = fixtureRoster(); + networks[1].chainId = 0; + vm.expectRevert(abi.encodeWithSelector(NoChainId.selector, "beta")); + this.externalEtherscanSection(networks); + } + + /// PROPERTY: the `.env.example` block declares each network's endpoint + /// variable bound to the roster's default for it. + function testEnvExampleSectionIsTheRosterDefaults() external pure { + assertEq( + LibRainDeployConfig.envExampleSection(vm, fixtureRoster()), + "ALPHA_RPC_URL=https://alpha.example\n" "BETA_RPC_URL=https://beta.example\n" + "GAMMA_RPC_URL=https://gamma.example\n" + ); + } + + /// PROPERTY: a network name that is not already uppercase reaches the + /// variable names uppercased, and an underscore survives it. + /// + /// Every real network name is lowercase, so a generator that passed the + /// name through unchanged would emit `base_sepolia = "${base_sepolia_RPC_URL}"` + /// and every endpoint would be unbound at the same time. + function testVariableNamesAreUppercased() external pure { + assertEq(LibRainDeployConfig.rpcUrlVar(vm, "base_sepolia"), "BASE_SEPOLIA_RPC_URL"); + assertEq(LibRainDeployConfig.etherscanKeyVar(vm, "base_sepolia"), "CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY"); + } + + /// PROPERTY: an empty roster is refused by every emitter rather than + /// written as an empty section. + /// + /// An empty `[rpc_endpoints]` is a repo that deploys nowhere and forks + /// nothing, with every check that reads it green for want of a subject. + function testEmptyRosterReverts() external { + SupportedNetwork[] memory networks = new SupportedNetwork[](0); + + vm.expectRevert(EmptyRoster.selector); + this.externalRpcEndpointsSection(networks); + + vm.expectRevert(EmptyRoster.selector); + this.externalEtherscanSection(networks); + + vm.expectRevert(EmptyRoster.selector); + this.externalEnvExampleSection(networks); + } + + /// PROPERTY: a splice replaces what is between the markers and moves + /// nothing outside them. + /// + /// The whole reason the blocks are delimited: everything else in a + /// `foundry.toml` is hand written, and a generator that owned the file + /// would take the profile with it. + function testSpliceReplacesOnlyBetweenTheMarkers() external pure { + string memory before = "# leading prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" + "# rain-deploy:generated:env:end\n" "# trailing prose\n"; + + assertEq( + LibRainDeployConfig.spliceBlock(vm, "fixture", before, "env", "FRESH=2\n"), + "# leading prose\n" "# rain-deploy:generated:env:begin\n" "FRESH=2\n" "# rain-deploy:generated:env:end\n" + "# trailing prose\n" + ); + } + + /// PROPERTY: splicing the same body twice is the same file, so a build that + /// changed nothing leaves nothing for `Git is clean` to see. + function testSpliceIsIdempotent() external pure { + string memory before = "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n"; + string memory once = LibRainDeployConfig.spliceBlock(vm, "fixture", before, "env", "FRESH=2\n"); + assertEq(LibRainDeployConfig.spliceBlock(vm, "fixture", once, "env", "FRESH=2\n"), once); + } + + /// PROPERTY: a file carrying more than one block has the named one replaced + /// and the others left where they are. + function testSpliceLeavesTheOtherBlocksAlone() external pure { + string memory before = "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" + "# rain-deploy:generated:etherscan:end\n"; + + assertEq( + LibRainDeployConfig.spliceBlock(vm, "fixture", before, "etherscan", "[etherscan]\n"), + "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" + "[etherscan]\n" "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// PROPERTY: a file with no begin marker is refused, naming the file and + /// the block. + /// + /// This is what a consumer that has not adopted the generated blocks looks + /// like, and it MUST be loud: appending the section instead would be a + /// duplicate TOML key at the next forge startup, and skipping it silently + /// would leave a hand-written config nothing regenerates and nothing + /// compares any more. + function testSpliceMissingBeginMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", "# nothing generated here\n# rain-deploy:generated:env:end\n", "env", "FRESH=2\n" + ); + } + + /// PROPERTY: a file with a begin marker and no end marker is refused. + function testSpliceMissingEndMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock("fixture", "# rain-deploy:generated:env:begin\nSTALE=1\n", "env", "FRESH=2\n"); + } + + /// PROPERTY: a second begin marker is refused rather than one of the two + /// blocks being picked. + function testSpliceDuplicateBeginMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" + "# rain-deploy:generated:env:begin\n" "ALSO_STALE=1\n" "# rain-deploy:generated:env:end\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: a second end marker is refused. + function testSpliceDuplicateEndMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" + "# rain-deploy:generated:env:end\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: an end marker BEFORE the begin marker is refused rather than + /// spliced around, which would move the marker to the other side of the + /// block and leave a file that never splices again. + function testSpliceEndBeforeBeginReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:end\n" "STALE=1\n" "# rain-deploy:generated:env:begin\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: the config writer replaces BOTH sections of the file it is + /// pointed at, and nothing else in it. + function testWriteNetworkConfigWritesBothSections() external { + resetFixtures(CONFIG_FIXTURE_ROOT); + string memory path = string.concat(CONFIG_FIXTURE_ROOT, "/foundry.toml"); + writeFixture( + CONFIG_FIXTURE_ROOT, + path, + "[profile.default]\n" "src = \"src\"\n" "\n" "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" + "# rain-deploy:generated:etherscan:end\n" + ); + + LibRainDeployConfig.writeNetworkConfig(vm, path, singleRoster("alpha", 11)); + + string memory written = vm.readFile(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(CONFIG_FIXTURE_ROOT, true); + + assertEq( + written, + "[profile.default]\n" "src = \"src\"\n" "\n" "# rain-deploy:generated:rpc_endpoints:begin\n" + "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' "# rain-deploy:generated:rpc_endpoints:end\n" "\n" + "# rain-deploy:generated:etherscan:begin\n" "[etherscan]\n" + 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// PROPERTY: the `.env.example` writer replaces its block and nothing else. + function testWriteEnvExampleWritesTheBlock() external { + resetFixtures(ENV_FIXTURE_ROOT); + string memory path = string.concat(ENV_FIXTURE_ROOT, "/.env.example"); + writeFixture( + ENV_FIXTURE_ROOT, + path, + "# prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" + "HAND_WRITTEN=1\n" + ); + + LibRainDeployConfig.writeEnvExample(vm, path, singleRoster("alpha", 11)); + + string memory written = vm.readFile(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(ENV_FIXTURE_ROOT, true); + + assertEq( + written, + "# prose\n" "# rain-deploy:generated:env:begin\n" "ALPHA_RPC_URL=https://one\n" + "# rain-deploy:generated:env:end\n" "HAND_WRITTEN=1\n" + ); + } + + /// PROPERTY: the writer names the FILE it was pointed at when that file + /// carries no markers, rather than the file it would have been pointed at + /// by default. + function testWriteNetworkConfigWithoutMarkersReverts() external { + resetFixtures(NO_MARKERS_FIXTURE_ROOT); + string memory path = string.concat(NO_MARKERS_FIXTURE_ROOT, "/foundry.toml"); + writeFixture(NO_MARKERS_FIXTURE_ROOT, path, "[profile.default]\n"); + + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, path, "rpc_endpoints")); + this.externalWriteNetworkConfig(path, singleRoster("alpha", 11)); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(NO_MARKERS_FIXTURE_ROOT, true); + } +} From 8e02c8b352a3015a756fac179d9d063e52841cd0 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 13:04:49 +0000 Subject: [PATCH 2/9] Stage the generated network config for the build hook to install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundry refuses every filesystem cheatcode write to the project root's own `foundry.toml` — "access to `foundry.toml` is not allowed", a guard on the path that no `fs_permissions` grant and no spelling of the path gets past, and that refuses `writeFile`, `writeLine` and `copyFile` alike. So `forge script ./script/Build.sol` reverted, and the `Git is clean` job that runs it went red, while the suite stayed green because the harness pointed the writer at a fixture root the guard does not apply to. Reads are allowed, which is what makes this possible. `run()` now reads each file, splices its blocks and writes the result to `.staged-config/` under the same name; `script/build.sh` copies each staged file onto the file of that name at the root and removes the directory. That hook is rainix's own consumer hook: `rainix-copy-artifacts` runs it outside any devshell, after the regeneration and before the `git diff` that fails a stale tree. It needs no forge, no nix and no `--ffi` — the alternative, and not taken, because the invocation that matters passes no `--ffi` and granting it there would hand FFI to every consumer's build. `.env.example` is staged too, though foundry would allow that one written directly, so which file foundry happens to guard is not something the design depends on. Staging also retires the hazard the direct write carried: nothing under `forge test` can race a rewrite of the config every other test reads. A repo with no `script/build.sh` is refused — `BuildHookMissing` — because nothing else installs a staged file, and generating for such a repo would write the roster where nothing reads it while the config went on saying whatever it said, green. Presence is the same condition `rainix-copy-artifacts` runs the hook on. `configPath()` and `envExamplePath()` collapse into one `configRoot()` hook, and `fs_permissions` on both generated files goes to READ. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- .gitignore | 1 + .soldeerignore | 1 + CLAUDE.md | 7 ++ README.md | 47 +++++++- foundry.toml | 26 +++-- script/build.sh | 51 +++++++++ src/abstract/BuildScript.sol | 42 +++---- src/lib/LibRainDeployConfig.sol | 143 +++++++++++++++++++---- test/concrete/BuildScriptHarness.sol | 47 +++++--- test/src/abstract/BuildScript.t.sol | 27 ++++- test/src/lib/LibRainDeployConfig.t.sol | 152 ++++++++++++++++++++----- 11 files changed, 439 insertions(+), 105 deletions(-) create mode 100755 script/build.sh diff --git a/.gitignore b/.gitignore index d194c7a..02f7a46 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ out .pre-commit-config.yaml fixture-lib target +.staged-config diff --git a/.soldeerignore b/.soldeerignore index 38c6da4..7a10590 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -23,3 +23,4 @@ CLAUDE.md /slither.config.json /soldeer.lock /test +/.staged-config diff --git a/CLAUDE.md b/CLAUDE.md index f0dfb02..269add6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,3 +37,10 @@ relocated. deployment. The deploy and the chain matrix create every fork before selecting any, so such a failure takes the whole run before any network is checked rather than stopping partway down the list. +- **Foundry REFUSES a cheatcode write to the project root's own + `foundry.toml`.** "access to `foundry.toml` is not allowed", from a guard on + the path — no `fs_permissions` grant and no spelling of the path gets past it, + and `writeFile`, `writeLine` and `copyFile` are all refused. Reads are + allowed. That is why `BuildScript.run()` stages the spliced config under + `.staged-config/` and `script/build.sh` installs it, and why regenerating by + hand is `forge script ./script/Build.sol` FOLLOWED BY `./script/build.sh`. diff --git a/README.md b/README.md index 4d50c3b..a0b71bb 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,14 @@ sections and `.env.example`'s endpoint variables from it. `Git is clean` is the enforcement, the same mechanism already holding `src/generated/`: a tree whose config has drifted from the roster it pins fails the job every push runs. +It reaches `foundry.toml` in two steps, because foundry refuses a filesystem +cheatcode write to the project root's own config whatever `fs_permissions` says. +`run()` stages the spliced files under `.staged-config/` and `script/build.sh` +installs them — see [Install](#install). Reads are allowed, which is what makes +the splice possible at all, and staging removes the hazard the direct write +would have carried: nothing under `forge test` can race a rewrite of the config +every other test reads, because nothing rewrites it. + That is what a config group used to be for, and comparing is the weaker half of it. A comparison keeps both statements, so every assertion in it is one somebody had to think of and the prose around them drifts silently; generation leaves one @@ -645,16 +653,41 @@ The versions have to match: the import paths are version-qualified, which is deliberate — it is what stops a consumer's incompatible copy from silently satisfying these imports. -`BuildScript.run()` writes the CONSUMING repo's own `foundry.toml` and +`BuildScript.run()` regenerates the CONSUMING repo's own `foundry.toml` and `.env.example`, so that repo has to allow it: ```toml fs_permissions = [ - { access = "read-write", path = "./foundry.toml" }, - { access = "read-write", path = "./.env.example" }, + { access = "read", path = "./foundry.toml" }, + { access = "read", path = "./.env.example" }, + { access = "read-write", path = "./.staged-config" }, + { access = "read", path = "./script/build.sh" }, ] ``` +Read, not read-write, on the two generated files: foundry refuses every +filesystem cheatcode write to the project root's own `foundry.toml` — the guard +is on the path, so no `fs_permissions` grant and no spelling of the path gets +past it. `run()` therefore reads each file, splices its blocks, and writes the +result to `.staged-config/` under the same name. + +What installs it is `script/build.sh`, the hook rainix's `rainix-copy-artifacts` +runs after `forge script ./script/Build.sol` and before the `git diff` that +fails a stale tree. Copy this repo's — it needs no forge, no nix and no `--ffi`, +which is the other way a script could reach a shell and is not taken: the +invocation that matters passes no `--ffi`, and granting it there would hand FFI +to every consumer's build rather than to one step. + +```sh +# .gitignore +.staged-config +``` + +A repo with no `script/build.sh` is REFUSED rather than staged for, because +nothing else moves a staged file into place: generating for it would write the +roster where nothing reads it while the config went on saying whatever it said, +and the build would report success. + Both files then need the markers the generated blocks are spliced between, once each and the begin before the end. A file carrying neither is refused, naming the file and the block, rather than having the section appended to it — which is @@ -688,6 +721,14 @@ forge soldeer install # install deps declared in foundry.toml forge test ``` +Regenerating what this repo generates is two commands, and the second is not +optional — the first only stages the network config: + +```sh +nix develop -c forge script ./script/Build.sol +./script/build.sh +``` + Three of the CI jobs are rainix reusable workflows, not commands in the shell, and the fourth is repo-local. What each of them runs, which is what reproduces it locally: diff --git a/foundry.toml b/foundry.toml index e821b78..63b4e32 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,17 +36,23 @@ bytecode_hash = "none" # plain `forge test` produces — not only under an explicit `--ast`. ast = true -# Build reads the version from foundry.toml, writes the generated network -# sections back into it and into .env.example, and writes the generated per-tag -# snapshots + the current-pin lib under src/. Nothing else in this repo touches -# the filesystem. +# Build reads the version and the network sections from foundry.toml, stages the +# regenerated sections under .staged-config/ for script/build.sh to install, and +# writes the generated per-tag snapshots + the current-pin lib under src/. +# Nothing else in this repo touches the filesystem. fs_permissions = [ - # Read-write because `BuildScript.regenerateConfig` writes the generated - # network sections here. Only `script/Build.sol` does: nothing under - # `forge test` points a config writer at this file, because the tests that - # read it would race the write. - { access = "read-write", path = "./foundry.toml" }, - { access = "read-write", path = "./.env.example" }, + # READ, not read-write. `BuildScript.regenerateConfig` reads these two and + # writes the spliced result to .staged-config/: foundry refuses a cheatcode + # write to this file whatever is granted here, so the write it would need is + # not a permission this list can hand out. + { access = "read", path = "./foundry.toml" }, + { access = "read", path = "./.env.example" }, + # Where the spliced files are staged, for script/build.sh to install over the + # two above. Gitignored; the hook removes it once it has installed them. + { access = "read-write", path = "./.staged-config" }, + # `writeStagedConfig` refuses a repo with no build hook, which is a repo where + # nothing would install what it staged. + { access = "read", path = "./script/build.sh" }, { access = "read-write", path = "./src" }, # LibRainDeploySnapshotTest builds a record tree of its own under # test/generated to drive the frozen-record walk. NOT src/generated: the diff --git a/script/build.sh b/script/build.sh new file mode 100755 index 0000000..4c7755c --- /dev/null +++ b/script/build.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-DCL-1.0 +# SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +# +# Installs what `forge script ./script/Build.sol` staged. +# +# `BuildScript.run()` generates `foundry.toml`'s network sections and +# `.env.example`'s endpoint variables from `LibRainDeploy.supportedNetworkConfigs()` +# but CANNOT write the first of them: foundry refuses every filesystem cheatcode +# write to the project root's own `foundry.toml`, whatever `fs_permissions` +# says. So the script splices and writes to `.staged-config/`, and this moves +# each staged file onto the file of the same name at the root. +# +# This is `rainix-copy-artifacts`' own consumer hook. rainix runs it outside any +# devshell, after `forge script ./script/Build.sol` and before the `git diff` +# that fails a stale tree — so a tree whose config has drifted from the roster +# it pins is red there. Nothing here needs forge, nix or `--ffi`; granting +# `--ffi` to the build is the alternative, and it would be granted to every +# consumer's build rather than to this one step. +set -euo pipefail + +cd "$(dirname "$0")/.." + +staged=.staged-config + +# Absent or empty means the staging never happened, which is exactly the state +# in which copying nothing would leave the committed config stale and report +# success. The regeneration step runs before this one and fails on its own, so +# reaching here with nothing staged is a build that generated nothing. +if [ ! -d "$staged" ]; then + echo "::error::$staged/ does not exist. forge script ./script/Build.sol stages the generated config there; it did not run, or it wrote nothing." >&2 + exit 1 +fi + +installed=0 +while IFS= read -r -d '' file; do + cp "$file" "./$(basename "$file")" + installed=$((installed + 1)) +done < <(find "$staged" -mindepth 1 -maxdepth 1 -type f -print0) + +if [ "$installed" -eq 0 ]; then + echo "::error::$staged/ holds no files. forge script ./script/Build.sol stages the generated config there; it wrote nothing." >&2 + exit 1 +fi + +# Removed once installed, so the staging directory is never a place a stale +# generated file can sit: the next run stages fresh, and a bare re-run of this +# hook fails above rather than re-installing what a previous run left. +rm -rf "$staged" + +echo "Installed $installed generated config file(s) from $staged/." diff --git a/src/abstract/BuildScript.sol b/src/abstract/BuildScript.sol index 7dcaa39..d653ec1 100644 --- a/src/abstract/BuildScript.sol +++ b/src/abstract/BuildScript.sol @@ -24,6 +24,11 @@ import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// package's own constant, so a version bump is how a network arrives in a /// consumer's `foundry.toml` and `.env.example`. A repo able to narrow that /// list would deploy to and verify fewer chains with nothing red. +/// +/// `run()` stages those two files rather than writing them, because foundry +/// refuses a cheatcode write to the project root's own `foundry.toml`. A repo +/// inheriting this needs `script/build.sh` to install what was staged — see +/// `LibRainDeployConfig`. 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 @@ -50,24 +55,18 @@ abstract contract BuildScript is Script { return LibRainDeploySnapshot.LIB_FS_ROOT; } - /// The `foundry.toml` whose network sections are generated. + /// The root holding the `foundry.toml` and `.env.example` whose network + /// blocks are generated, and the parent of the staging directory they are + /// written to. /// - /// Overridable for the same reason `recordRoot` is, and with the same - /// hazard: a writer that can only be pointed at the committed tree can only - /// be exercised by overwriting it, and overwriting this one under `forge - /// test` races every test that reads the config. A repo pointing it - /// somewhere other than its own root generates config nothing reads, and - /// `Git is clean` then sees a tree that never drifts because nothing - /// regenerates it. - /// @return The config path. - function configPath() internal view virtual returns (string memory) { - return LibRainDeployConfig.CONFIG_PATH; - } - - /// The `.env.example` whose endpoint variables are generated. - /// @return The `.env.example` path. - function envExamplePath() internal view virtual returns (string memory) { - return LibRainDeployConfig.ENV_EXAMPLE_PATH; + /// Overridable for the same reason `recordRoot` is: a writer that can only + /// be pointed at the committed tree can only be exercised against it. The + /// hazard is the same too — a repo pointing this anywhere but its own root + /// generates config nothing reads, and `Git is clean` then sees a tree that + /// never drifts because nothing regenerates it. + /// @return The config root. + function configRoot() internal view virtual returns (string memory) { + return LibRainDeployConfig.CONFIG_ROOT; } /// Rewrite the delimited network config blocks from this package's roster. @@ -75,9 +74,14 @@ abstract contract BuildScript is Script { /// Run by `run()` and not by `cutRelease()`: the config is not part of a /// release record, and `run()` is what `Git is clean` calls on every push, /// so a tree whose config has drifted from the roster it pins fails there. + /// + /// This STAGES the files; `script/build.sh` installs them. See + /// `LibRainDeployConfig` for why a script cannot write `foundry.toml` + /// itself. function regenerateConfig() internal { - LibRainDeployConfig.writeNetworkConfig(vm, configPath(), LibRainDeploy.supportedNetworkConfigs()); - LibRainDeployConfig.writeEnvExample(vm, envExamplePath(), LibRainDeploy.supportedNetworkConfigs()); + LibRainDeployConfig.writeStagedConfig( + vm, configRoot(), LibRainDeployConfig.BUILD_HOOK_PATH, LibRainDeploy.supportedNetworkConfigs() + ); } /// @notice Regenerate everything this repo generates. Freezes nothing. diff --git a/src/lib/LibRainDeployConfig.sol b/src/lib/LibRainDeployConfig.sol index 8cf5281..cd84fc6 100644 --- a/src/lib/LibRainDeployConfig.sol +++ b/src/lib/LibRainDeployConfig.sol @@ -26,6 +26,14 @@ error NoChainId(string network); /// every check that reads them stays green. error EmptyRoster(); +/// Thrown when the repo has no `script/build.sh`. That hook is the only thing +/// that moves a staged config into place, so without it the generated blocks +/// are written somewhere nothing reads, `foundry.toml` never changes, and `Git +/// is clean` passes a tree whose config has drifted from the roster it pins — +/// the silent green this whole mechanism exists to remove. +/// @param path The hook that was looked for. +error BuildHookMissing(string path); + /// @title LibRainDeployConfig /// @notice Writes the network config a deploy repo cannot state twice: the /// `[rpc_endpoints]` and `[etherscan]` sections of `foundry.toml`, and the @@ -42,14 +50,50 @@ error EmptyRoster(); /// is hand written and a `.env.example` carries prose a generator has no way to /// know. Each block is replaced between its markers and nothing outside them is /// read or moved. +/// +/// ## Why the generated files are staged rather than written +/// +/// Foundry REFUSES every filesystem cheatcode write to the project root's own +/// `foundry.toml` — "vm.writeFile: access to `foundry.toml` is not allowed" — +/// and it is not an `fs_permissions` miss: the guard is on the path, so no +/// grant and no spelling of the path gets past it. Reads are allowed, which is +/// the whole reason this is possible at all. +/// +/// So the spliced file is written to `/.staged-config/` and installed by +/// `script/build.sh`, the hook rainix's `rainix-copy-artifacts` runs — outside +/// any devshell, after `forge script ./script/Build.sol` and before the `git +/// diff` that fails a stale tree. FFI is the other way to reach a shell from a +/// script, and is not taken: the invocation that matters passes no `--ffi`, and +/// granting it there would hand FFI to every consumer's build. +/// +/// `.env.example` is staged too, though foundry would allow that one written +/// directly. One mechanism, so which file foundry happens to guard is not +/// something the design depends on. +/// +/// Staging also removes the hazard the direct write carried: nothing under +/// `forge test` can race a rewrite of the config every other test reads, +/// because nothing rewrites it at all. library LibRainDeployConfig { - /// The repo-root `foundry.toml` the sections are written into. Relative, so - /// it resolves against the project root of whatever runs the build — the - /// consuming repo's own config rather than this package's. - string constant CONFIG_PATH = "foundry.toml"; + /// The project root, which is what a relative path resolves against — the + /// CONSUMING repo's root rather than this package's. + string constant CONFIG_ROOT = "."; + + /// The config whose sections are generated, as it is named under the root. + string constant CONFIG_NAME = "foundry.toml"; + + /// The file the endpoint variables are generated into, as it is named under + /// the root. + string constant ENV_EXAMPLE_NAME = ".env.example"; - /// The repo-root `.env.example` the endpoint variables are written into. - string constant ENV_EXAMPLE_PATH = ".env.example"; + /// The directory under the root that a generated file is staged in. Git + /// ignored: `script/build.sh` removes it once it has installed what is in + /// it, and a run that failed between the two must not leave a tracked file + /// behind. + string constant STAGED_DIR_NAME = ".staged-config"; + + /// The hook that installs what was staged. Matched exactly, because that is + /// the path `rainix-copy-artifacts` conditions its own step on. + string constant BUILD_HOOK_PATH = "script/build.sh"; /// The `[rpc_endpoints]` block, as it is named in its markers. string constant RPC_ENDPOINTS_BLOCK = "rpc_endpoints"; @@ -204,41 +248,90 @@ library LibRainDeployConfig { return string.concat(beginParts[0], begin, body, end, endParts[1]); } - /// Writes both `foundry.toml` sections from the roster. - /// - /// `foundry.toml` is read by forge at startup and never re-read, so a - /// script rewriting it does not move anything under itself. A TEST that - /// reads it does race this, which is why nothing under `forge test` points - /// this at the repo's own config. + /// The config under a root. + /// @param root The project root. + /// @return The path. + function configPath(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", CONFIG_NAME); + } + + /// The `.env.example` under a root. + /// @param root The project root. + /// @return The path. + function envExamplePath(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", ENV_EXAMPLE_NAME); + } + + /// The staging directory under a root. + /// @param root The project root. + /// @return The path. + function stagedDir(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", STAGED_DIR_NAME); + } + + /// Where a generated file is staged, named as it will be installed. + /// `script/build.sh` copies by name, so a staged file lands on the file of + /// the same name under the root and nowhere else. + /// @param root The project root. + /// @param name The file name under the root. + /// @return The path. + function stagedPath(string memory root, string memory name) internal pure returns (string memory) { + return string.concat(stagedDir(root), "/", name); + } + + /// Splices both `foundry.toml` sections from the roster, reading one file + /// and writing another. /// @param vm The Vm instance for file operations. - /// @param path The config to write — `CONFIG_PATH` for a repo's own. + /// @param path The config to read. + /// @param staged Where to write the spliced result. /// @param networks The roster. - /// @return The path written. - function writeNetworkConfig(Vm vm, string memory path, SupportedNetwork[] memory networks) + function writeNetworkConfig(Vm vm, string memory path, string memory staged, SupportedNetwork[] memory networks) internal - returns (string memory) { string memory content = vm.readFile(path); content = spliceBlock(vm, path, content, RPC_ENDPOINTS_BLOCK, rpcEndpointsSection(vm, networks)); content = spliceBlock(vm, path, content, ETHERSCAN_BLOCK, etherscanSection(vm, networks)); //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(path, content); - return path; + vm.writeFile(staged, content); } - /// Writes the `.env.example` endpoint block from the roster. + /// Splices the `.env.example` endpoint block from the roster, reading one + /// file and writing another. /// @param vm The Vm instance for file operations. - /// @param path The file to write — `ENV_EXAMPLE_PATH` for a repo's own. + /// @param path The `.env.example` to read. + /// @param staged Where to write the spliced result. /// @param networks The roster. - /// @return The path written. - function writeEnvExample(Vm vm, string memory path, SupportedNetwork[] memory networks) + function writeEnvExample(Vm vm, string memory path, string memory staged, SupportedNetwork[] memory networks) internal - returns (string memory) { string memory content = vm.readFile(path); content = spliceBlock(vm, path, content, ENV_BLOCK, envExampleSection(vm, networks)); //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(path, content); - return path; + vm.writeFile(staged, content); + } + + /// Stages every generated config file under a root, for the build hook to + /// install. + /// + /// Refuses a repo with no hook. Nothing else moves a staged file into + /// place, so generating for a repo that has none writes the roster to a + /// directory nothing reads while the config it is meant to hold goes on + /// saying whatever it said — green, from a build that regenerated nothing. + /// Presence is the same condition `rainix-copy-artifacts` runs the hook on, + /// so this is exactly the state in which the install would be skipped. + /// @param vm The Vm instance for file operations. + /// @param root The project root. + /// @param hookPath The hook that installs what this stages. + /// @param networks The roster. + function writeStagedConfig(Vm vm, string memory root, string memory hookPath, SupportedNetwork[] memory networks) + internal + { + if (!vm.exists(hookPath)) { + revert BuildHookMissing(hookPath); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(stagedDir(root), true); + writeNetworkConfig(vm, configPath(root), stagedPath(root, CONFIG_NAME), networks); + writeEnvExample(vm, envExamplePath(root), stagedPath(root, ENV_EXAMPLE_NAME), networks); } } diff --git a/test/concrete/BuildScriptHarness.sol b/test/concrete/BuildScriptHarness.sol index 45f9d63..a31e923 100644 --- a/test/concrete/BuildScriptHarness.sol +++ b/test/concrete/BuildScriptHarness.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {BuildScript} from "../../src/abstract/BuildScript.sol"; +import {LibRainDeployConfig} from "../../src/lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../../src/lib/LibRainDeploySnapshot.sol"; /// @title BuildScriptHarness @@ -39,28 +40,42 @@ contract BuildScriptHarness is BuildScript { } /// @inheritdoc BuildScript - /// @dev Under the fixture root, never the repo's own `foundry.toml`: forge - /// reads that file once at startup and a script rewriting it is safe, but a - /// TEST rewriting it races every other test that reads it. - function configPath() internal view override returns (string memory) { - return string.concat(sRoot, "/foundry.toml"); + /// @dev The fixture root, never the repo's own. A `run()` here would + /// otherwise read the committed config and stage a spliced copy of it, + /// which the next `script/build.sh` would install. + function configRoot() internal view override returns (string memory) { + return sRoot; } - /// @inheritdoc BuildScript - function envExamplePath() internal view override returns (string memory) { - return string.concat(sRoot, "/.env.example"); - } - - /// Where `regenerateConfig` writes the network sections. + /// The seeded `foundry.toml` `regenerateConfig` reads. /// @return The fixture config path. function externalConfigPath() external view returns (string memory) { - return configPath(); + return LibRainDeployConfig.configPath(configRoot()); } - /// Where `regenerateConfig` writes the endpoint variables. + /// The seeded `.env.example` `regenerateConfig` reads. /// @return The fixture `.env.example` path. function externalEnvExamplePath() external view returns (string memory) { - return envExamplePath(); + return LibRainDeployConfig.envExamplePath(configRoot()); + } + + /// Where `regenerateConfig` stages the spliced config. + /// @return The staged config path. + function externalStagedConfigPath() external view returns (string memory) { + return LibRainDeployConfig.stagedPath(configRoot(), LibRainDeployConfig.CONFIG_NAME); + } + + /// Where `regenerateConfig` stages the spliced `.env.example`. + /// @return The staged `.env.example` path. + function externalStagedEnvExamplePath() external view returns (string memory) { + return LibRainDeployConfig.stagedPath(configRoot(), LibRainDeployConfig.ENV_EXAMPLE_NAME); + } + + /// The staging directory, so a test can assert whether anything was staged + /// at all. + /// @return The staging directory. + function externalStagedDir() external view returns (string memory) { + return LibRainDeployConfig.stagedDir(configRoot()); } /// A fixture file carrying both `foundry.toml` blocks, with a stale body in @@ -88,8 +103,8 @@ contract BuildScriptHarness is BuildScript { /// Writes both config fixtures, so `run()` has markers to splice into. function seedConfig() external { - writeFixture(configPath(), configSeed()); - writeFixture(envExamplePath(), envExampleSeed()); + writeFixture(LibRainDeployConfig.configPath(configRoot()), configSeed()); + writeFixture(LibRainDeployConfig.envExamplePath(configRoot()), envExampleSeed()); } /// Where `regenerateSnapshots` writes. diff --git a/test/src/abstract/BuildScript.t.sol b/test/src/abstract/BuildScript.t.sol index 7ead3eb..01c6102 100644 --- a/test/src/abstract/BuildScript.t.sol +++ b/test/src/abstract/BuildScript.t.sol @@ -134,9 +134,9 @@ contract BuildScriptTest is Test { assertEq(libs, harness.libsMarker(1, true)); } - /// PROPERTY: `run()` rewrites both `foundry.toml` network blocks and the + /// PROPERTY: `run()` stages both `foundry.toml` network blocks and the /// `.env.example` block from the roster, leaving everything outside the - /// markers where it was. + /// markers where it was — and leaves the files it read untouched. /// /// This is the wiring, not the emission: what the sections SAY is pinned /// against string literals in `LibRainDeployConfigTest`, over a fixture @@ -145,13 +145,19 @@ contract BuildScriptTest is Test { /// it the sections would exist, be correct, and be written nowhere, and /// `Git is clean` would pass a tree whose config had drifted from the /// roster it pins. - function testRunRegeneratesTheNetworkConfig() external { + /// + /// The sources staying byte-identical is the half `script/build.sh` then + /// depends on: a `run()` that wrote them directly would be refused for + /// `foundry.toml` and would make the hook's copy a no-op for the other. + function testRunStagesTheNetworkConfig() external { resetFixture(RUN_CONFIG_FIXTURE_ROOT); BuildScriptHarness harness = new BuildScriptHarness(RUN_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); harness.seedConfig(); harness.run(); // Read while the fixture is still there, asserted once it is gone. + string memory stagedConfig = vm.readFile(harness.externalStagedConfigPath()); + string memory stagedEnvExample = vm.readFile(harness.externalStagedEnvExamplePath()); string memory config = vm.readFile(harness.externalConfigPath()); string memory envExample = vm.readFile(harness.externalEnvExamplePath()); @@ -159,7 +165,7 @@ contract BuildScriptTest is Test { vm.removeDir(RUN_CONFIG_FIXTURE_ROOT, true); assertEq( - config, + stagedConfig, string.concat( "# hand written\n", "# rain-deploy:generated:rpc_endpoints:begin\n", @@ -171,7 +177,7 @@ contract BuildScriptTest is Test { ) ); assertEq( - envExample, + stagedEnvExample, string.concat( "# hand written\n", "# rain-deploy:generated:env:begin\n", @@ -179,14 +185,21 @@ contract BuildScriptTest is Test { "# rain-deploy:generated:env:end\n" ) ); + assertEq(config, harness.configSeed()); + assertEq(envExample, harness.envExampleSeed()); } - /// PROPERTY: `cutRelease()` leaves the config exactly as it found it. + /// PROPERTY: `cutRelease()` stages nothing and leaves the config exactly as + /// it found it. /// /// The config is not part of a release record. A `cutRelease()` that /// rewrote it would put a config change inside the one operation that can /// never be repeated, where `run()` is the entry point every push already /// runs and the only one `Git is clean` currency checks. + /// + /// The staging directory being ABSENT is what says so now: a staged file + /// left behind by a release is one the next `script/build.sh` installs, + /// which is the config change happening anyway, one step later. function testCutReleaseLeavesTheConfigAlone() external { resetFixture(CUT_CONFIG_FIXTURE_ROOT); BuildScriptHarness harness = new BuildScriptHarness(CUT_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); @@ -196,12 +209,14 @@ contract BuildScriptTest is Test { // Read while the fixture is still there, asserted once it is gone. string memory config = vm.readFile(harness.externalConfigPath()); string memory envExample = vm.readFile(harness.externalEnvExamplePath()); + bool stagedAnything = vm.exists(harness.externalStagedDir()); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(CUT_CONFIG_FIXTURE_ROOT, true); assertEq(config, harness.configSeed()); assertEq(envExample, harness.envExampleSeed()); + assertFalse(stagedAnything); } /// PROPERTY: a repo that overrides nothing freezes into its OWN record. diff --git a/test/src/lib/LibRainDeployConfig.t.sol b/test/src/lib/LibRainDeployConfig.t.sol index 96a8985..a663319 100644 --- a/test/src/lib/LibRainDeployConfig.t.sol +++ b/test/src/lib/LibRainDeployConfig.t.sol @@ -5,6 +5,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; import { + BuildHookMissing, EmptyRoster, GeneratedBlockMalformed, LibRainDeployConfig, @@ -36,6 +37,12 @@ contract LibRainDeployConfigTest is Test { /// Where the fixture carrying no markers is built. string constant NO_MARKERS_FIXTURE_ROOT = "test/generated-config-no-markers"; + /// Where the `writeStagedConfig` fixture is built. + string constant STAGED_FIXTURE_ROOT = "test/generated-config-staged"; + + /// Where the fixture for a repo with no build hook is built. + string constant NO_HOOK_FIXTURE_ROOT = "test/generated-config-no-hook"; + /// A roster of three, one of which carries an explorer url, so both /// `[etherscan]` entry shapes are emitted by one call. /// @return The fixture roster. @@ -119,14 +126,22 @@ contract LibRainDeployConfigTest is Test { return LibRainDeployConfig.spliceBlock(vm, path, content, name, body); } - /// @param path The config to write. + /// @param path The config to read. + /// @param staged Where to write the spliced result. /// @param networks The roster. - /// @return The path written. - function externalWriteNetworkConfig(string memory path, SupportedNetwork[] memory networks) + function externalWriteNetworkConfig(string memory path, string memory staged, SupportedNetwork[] memory networks) + external + { + LibRainDeployConfig.writeNetworkConfig(vm, path, staged, networks); + } + + /// @param root The project root to stage under. + /// @param hookPath The build hook that would install what is staged. + /// @param networks The roster. + function externalWriteStagedConfig(string memory root, string memory hookPath, SupportedNetwork[] memory networks) external - returns (string memory) { - return LibRainDeployConfig.writeNetworkConfig(vm, path, networks); + LibRainDeployConfig.writeStagedConfig(vm, root, hookPath, networks); } /// PROPERTY: the `[rpc_endpoints]` section is the roster, in roster order, @@ -338,25 +353,30 @@ contract LibRainDeployConfigTest is Test { ); } - /// PROPERTY: the config writer replaces BOTH sections of the file it is - /// pointed at, and nothing else in it. + /// PROPERTY: the config writer replaces BOTH sections of the file it reads, + /// and nothing else in it, writing the result WHERE IT WAS TOLD rather than + /// back over what it read. + /// + /// The source staying untouched is the property: foundry refuses a + /// cheatcode write to the project root's own `foundry.toml`, so the only + /// thing that can put this result there is `script/build.sh`. function testWriteNetworkConfigWritesBothSections() external { resetFixtures(CONFIG_FIXTURE_ROOT); string memory path = string.concat(CONFIG_FIXTURE_ROOT, "/foundry.toml"); - writeFixture( - CONFIG_FIXTURE_ROOT, - path, - "[profile.default]\n" "src = \"src\"\n" "\n" "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" - "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" - "# rain-deploy:generated:etherscan:end\n" - ); + string memory staged = string.concat(CONFIG_FIXTURE_ROOT, "/staged-foundry.toml"); + string memory source = "[profile.default]\n" "src = \"src\"\n" "\n" + "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" "# rain-deploy:generated:rpc_endpoints:end\n" "\n" + "# rain-deploy:generated:etherscan:begin\n" "STALE\n" "# rain-deploy:generated:etherscan:end\n"; + writeFixture(CONFIG_FIXTURE_ROOT, path, source); - LibRainDeployConfig.writeNetworkConfig(vm, path, singleRoster("alpha", 11)); + LibRainDeployConfig.writeNetworkConfig(vm, path, staged, singleRoster("alpha", 11)); - string memory written = vm.readFile(path); + string memory written = vm.readFile(staged); + string memory read = vm.readFile(path); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(CONFIG_FIXTURE_ROOT, true); + assertEq(read, source); assertEq( written, "[profile.default]\n" "src = \"src\"\n" "\n" "# rain-deploy:generated:rpc_endpoints:begin\n" @@ -367,23 +387,24 @@ contract LibRainDeployConfigTest is Test { ); } - /// PROPERTY: the `.env.example` writer replaces its block and nothing else. + /// PROPERTY: the `.env.example` writer replaces its block and nothing else, + /// and leaves what it read where it was. function testWriteEnvExampleWritesTheBlock() external { resetFixtures(ENV_FIXTURE_ROOT); string memory path = string.concat(ENV_FIXTURE_ROOT, "/.env.example"); - writeFixture( - ENV_FIXTURE_ROOT, - path, - "# prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" - "HAND_WRITTEN=1\n" - ); + string memory staged = string.concat(ENV_FIXTURE_ROOT, "/staged.env.example"); + string memory source = "# prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" + "# rain-deploy:generated:env:end\n" "HAND_WRITTEN=1\n"; + writeFixture(ENV_FIXTURE_ROOT, path, source); - LibRainDeployConfig.writeEnvExample(vm, path, singleRoster("alpha", 11)); + LibRainDeployConfig.writeEnvExample(vm, path, staged, singleRoster("alpha", 11)); - string memory written = vm.readFile(path); + string memory written = vm.readFile(staged); + string memory read = vm.readFile(path); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(ENV_FIXTURE_ROOT, true); + assertEq(read, source); assertEq( written, "# prose\n" "# rain-deploy:generated:env:begin\n" "ALPHA_RPC_URL=https://one\n" @@ -397,12 +418,91 @@ contract LibRainDeployConfigTest is Test { function testWriteNetworkConfigWithoutMarkersReverts() external { resetFixtures(NO_MARKERS_FIXTURE_ROOT); string memory path = string.concat(NO_MARKERS_FIXTURE_ROOT, "/foundry.toml"); + string memory staged = string.concat(NO_MARKERS_FIXTURE_ROOT, "/staged-foundry.toml"); writeFixture(NO_MARKERS_FIXTURE_ROOT, path, "[profile.default]\n"); vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, path, "rpc_endpoints")); - this.externalWriteNetworkConfig(path, singleRoster("alpha", 11)); + this.externalWriteNetworkConfig(path, staged, singleRoster("alpha", 11)); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(NO_MARKERS_FIXTURE_ROOT, true); } + + /// PROPERTY: a staged file is named EXACTLY as the file it will be + /// installed over, and sits under the staging directory of the root it was + /// generated for. + /// + /// `script/build.sh` copies each staged file onto the file of the same name + /// at the root, so the name is the whole of the mapping between the two. + function testStagedPathsAreNamedAsTheFilesTheyInstallOver() external pure { + assertEq(LibRainDeployConfig.configPath("."), "./foundry.toml"); + assertEq(LibRainDeployConfig.envExamplePath("."), "./.env.example"); + assertEq(LibRainDeployConfig.stagedDir("."), "./.staged-config"); + assertEq(LibRainDeployConfig.stagedPath(".", LibRainDeployConfig.CONFIG_NAME), "./.staged-config/foundry.toml"); + assertEq( + LibRainDeployConfig.stagedPath(".", LibRainDeployConfig.ENV_EXAMPLE_NAME), "./.staged-config/.env.example" + ); + } + + /// PROPERTY: staging puts BOTH generated files in the staging directory and + /// changes neither of the files it read. + function testWriteStagedConfigStagesBothFiles() external { + resetFixtures(STAGED_FIXTURE_ROOT); + string memory configSource = "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" + "# rain-deploy:generated:etherscan:end\n"; + string memory envSource = "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n"; + writeFixture(STAGED_FIXTURE_ROOT, LibRainDeployConfig.configPath(STAGED_FIXTURE_ROOT), configSource); + writeFixture(STAGED_FIXTURE_ROOT, LibRainDeployConfig.envExamplePath(STAGED_FIXTURE_ROOT), envSource); + + LibRainDeployConfig.writeStagedConfig( + vm, STAGED_FIXTURE_ROOT, LibRainDeployConfig.BUILD_HOOK_PATH, singleRoster("alpha", 11) + ); + + string memory stagedConfig = + vm.readFile(LibRainDeployConfig.stagedPath(STAGED_FIXTURE_ROOT, LibRainDeployConfig.CONFIG_NAME)); + string memory stagedEnv = + vm.readFile(LibRainDeployConfig.stagedPath(STAGED_FIXTURE_ROOT, LibRainDeployConfig.ENV_EXAMPLE_NAME)); + string memory readConfig = vm.readFile(LibRainDeployConfig.configPath(STAGED_FIXTURE_ROOT)); + string memory readEnv = vm.readFile(LibRainDeployConfig.envExamplePath(STAGED_FIXTURE_ROOT)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(STAGED_FIXTURE_ROOT, true); + + assertEq(readConfig, configSource); + assertEq(readEnv, envSource); + assertEq( + stagedConfig, + "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' + "# rain-deploy:generated:rpc_endpoints:end\n" "# rain-deploy:generated:etherscan:begin\n" "[etherscan]\n" + 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + "# rain-deploy:generated:etherscan:end\n" + ); + assertEq( + stagedEnv, + "# rain-deploy:generated:env:begin\n" "ALPHA_RPC_URL=https://one\n" "# rain-deploy:generated:env:end\n" + ); + } + + /// PROPERTY: a repo with no build hook is refused, naming the hook, and + /// nothing is staged. + /// + /// The hook is the only thing that installs a staged file. Staging for a + /// repo that has none writes the roster where nothing reads it while the + /// config goes on saying whatever it said, and the build reports success — + /// the silent green this whole mechanism exists to remove. Presence is what + /// `rainix-copy-artifacts` conditions its own run of the hook on, so this + /// is exactly the state in which the install would be skipped. + function testWriteStagedConfigWithoutBuildHookReverts() external { + resetFixtures(NO_HOOK_FIXTURE_ROOT); + string memory missing = string.concat(NO_HOOK_FIXTURE_ROOT, "/build.sh"); + + vm.expectRevert(abi.encodeWithSelector(BuildHookMissing.selector, missing)); + this.externalWriteStagedConfig(NO_HOOK_FIXTURE_ROOT, missing, singleRoster("alpha", 11)); + + bool stagedAnything = vm.exists(LibRainDeployConfig.stagedDir(NO_HOOK_FIXTURE_ROOT)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(NO_HOOK_FIXTURE_ROOT, true); + + assertFalse(stagedAnything); + } } From eb92654e08e610daf16e6523ea1174aae298086e Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 5 Oct 2026 17:35:50 +0000 Subject: [PATCH 3/9] ci: retrigger under the fixed arbitrum rpc order From ca2ee20fa05778154226d988d4a7a90e73b0f82b Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 5 Oct 2026 18:17:03 +0000 Subject: [PATCH 4/9] ci: retrigger after a transient base endpoint rejection From 0ec87830860e5727d79ce6cd330c3eb805c88173 Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 5 Oct 2026 20:08:39 +0000 Subject: [PATCH 5/9] ci: retrigger with nodies removed from the arbitrum variable From 944cb82c47ed58f5e175cbfcb67889c4720cd772 Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 5 Oct 2026 20:24:59 +0000 Subject: [PATCH 6/9] ci: retrigger after a cancelled run From b9f58498fb7038b12c621d81167b78f8f65701b6 Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 5 Oct 2026 21:07:11 +0000 Subject: [PATCH 7/9] ci: retrigger to observe whether jobs start From 182b4ebf262a68999b1aeffc960d141ea2e03c6c Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 6 Oct 2026 06:31:38 +0000 Subject: [PATCH 8/9] ci: retrigger after a transient hyperevm block-height error From 61d2f4c2460238607356ecdc7570c23de8a82d3f Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 6 Oct 2026 13:57:22 +0000 Subject: [PATCH 9/9] ci: retrigger under the fixed hyperevm default