From 227d533f1f0d8aa5ee7fd8fef4940c681bdc81b3 Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Tue, 25 Aug 2026 23:26:06 +1000 Subject: [PATCH 1/2] fix(scenarios): make Synapse source runs consumer-equivalent Install only the production dependency closure and resolve peers under Synapse's pnpm security policy. Add EIP-2612 support to MockUSDFC so fresh devnet accounts can fund uploads through Synapse. https://github.com/FilOzone/foc-devnet/pull/182 didn't quite work in resolving https://github.com/FilOzone/foc-devnet/issues/179 --- ci/README.md | 2 +- contracts/MockUSDFC/src/MockUSDFC.sol | 9 ++- contracts/MockUSDFC/test/MockUSDFC.t.sol | 36 +++++++++++ scenarios/synapse-e2e/source-runtime.mjs | 30 ++++++--- scenarios/synapse_runtime.py | 68 ++++++++++++++++----- scripts/tests/test_scenario_dependencies.py | 53 +++++++++++++--- 6 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 contracts/MockUSDFC/test/MockUSDFC.t.sol diff --git a/ci/README.md b/ci/README.md index 6b5821f6..d5905e5e 100644 --- a/ci/README.md +++ b/ci/README.md @@ -192,7 +192,7 @@ override is applied. The resolver does not infer overrides from package metadata Overrides are currently allowed only for npm-installed `synapse-sdk` and `filecoin-pin` selections. They are written to the temporary consumer -`package.json`; source profiles use the checkout's committed lockfile. +`package.json`. Current consumers write npm overrides to the temporary `package.json` used by their scenario. diff --git a/contracts/MockUSDFC/src/MockUSDFC.sol b/contracts/MockUSDFC/src/MockUSDFC.sol index 03bd08d5..e4b1334c 100644 --- a/contracts/MockUSDFC/src/MockUSDFC.sol +++ b/contracts/MockUSDFC/src/MockUSDFC.sol @@ -2,19 +2,20 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; /** * @title MockUSDFC * @dev Mock USDC token for testing FOC warm storage services */ -contract MockUSDFC is ERC20 { +contract MockUSDFC is ERC20, ERC20Permit { uint8 private _decimals; /** * @dev Constructor that gives msg.sender all of initial supply. * @param initialSupply The initial supply of tokens (in wei, accounting for decimals) */ - constructor(uint256 initialSupply) ERC20("Mock USDC", "USDFC") { + constructor(uint256 initialSupply) ERC20("Mock USDC", "USDFC") ERC20Permit("Mock USDC") { _decimals = 18; _mint(msg.sender, initialSupply); } @@ -26,6 +27,10 @@ contract MockUSDFC is ERC20 { return _decimals; } + function version() external pure returns (string memory) { + return "1"; + } + /** * @dev Mint new tokens (for testing purposes) * @param to Address to receive the tokens diff --git a/contracts/MockUSDFC/test/MockUSDFC.t.sol b/contracts/MockUSDFC/test/MockUSDFC.t.sol new file mode 100644 index 00000000..3d08b1ec --- /dev/null +++ b/contracts/MockUSDFC/test/MockUSDFC.t.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/MockUSDFC.sol"; + +contract MockUSDFCTest is Test { + bytes32 private constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + MockUSDFC private token; + uint256 private ownerKey; + address private owner; + + function setUp() public { + token = new MockUSDFC(0); + ownerKey = 0xA11CE; + owner = vm.addr(ownerKey); + token.mint(owner, 100 ether); + } + + function testPermit() public { + address spender = makeAddr("spender"); + uint256 value = 25 ether; + uint256 deadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, 0, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest); + + token.permit(owner, spender, value, deadline, v, r, s); + + assertEq(token.allowance(owner, spender), value); + assertEq(token.nonces(owner), 1); + assertEq(token.version(), "1"); + } +} diff --git a/scenarios/synapse-e2e/source-runtime.mjs b/scenarios/synapse-e2e/source-runtime.mjs index b34bd543..b0d38803 100644 --- a/scenarios/synapse-e2e/source-runtime.mjs +++ b/scenarios/synapse-e2e/source-runtime.mjs @@ -1,11 +1,11 @@ /** * Runs foc-devnet scenarios against Synapse TypeScript source on Node 24+. * - * Source profiles install Synapse's production dependency closure, then preload - * this module with `node --import`. Public `@filoz/synapse-sdk` and - * `@filoz/synapse-core` imports resolve to their source counterparts instead of - * the packages' compiled `dist` targets. All other imports use Node's normal - * resolver. + * Source profiles install Synapse's production dependencies and peer runtime, + * then preload this module with `node --import`. Public + * `@filoz/synapse-sdk` and `@filoz/synapse-core` imports resolve to their source + * counterparts instead of the packages' compiled `dist` targets. Peer imports + * resolve from the temporary consumer; all other imports use Node's resolver. * * Mappings come from each package's export map, keeping the scenario on the * public API and avoiding a hard-coded list that drifts as exports change. @@ -16,10 +16,14 @@ import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import { registerHooks } from 'node:module' import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' const sourceRoot = process.env.SYNAPSE_SDK_SOURCE_DIR assert(sourceRoot, 'SYNAPSE_SDK_SOURCE_DIR must name the Synapse checkout when using source-runtime.mjs') +const runtimePackageUrl = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'package.json')).href +const runtimeDependencies = new Set( + Object.keys(JSON.parse(readFileSync(fileURLToPath(runtimePackageUrl), 'utf8')).dependencies ?? {}) +) // Export entries may be strings or nested condition objects. Prefer the // conditions used by this ESM runtime, then inspect package-specific branches. @@ -62,10 +66,20 @@ const sourceMappings = new Map([ ...sourceExports('@filoz/synapse-core', 'packages/synapse-core'), ]) -// Short-circuit exact public package matches; delegate everything else. +function packageName(specifier) { + if (specifier.startsWith('@')) return specifier.split('/', 2).join('/') + return specifier.split('/', 1)[0] +} + +// Short-circuit public Synapse exports and resolve peer dependencies from the +// temporary consumer. Source package dependencies use Node's normal resolver. registerHooks({ resolve(specifier, context, nextResolve) { const sourceUrl = sourceMappings.get(specifier) - return sourceUrl == null ? nextResolve(specifier, context) : { url: sourceUrl, shortCircuit: true } + if (sourceUrl != null) return { url: sourceUrl, shortCircuit: true } + if (runtimeDependencies.has(packageName(specifier))) { + return nextResolve(specifier, { ...context, parentURL: runtimePackageUrl }) + } + return nextResolve(specifier, context) }, }) diff --git a/scenarios/synapse_runtime.py b/scenarios/synapse_runtime.py index 585dd156..cd6978d0 100755 --- a/scenarios/synapse_runtime.py +++ b/scenarios/synapse_runtime.py @@ -110,6 +110,32 @@ def _write_manifest(work_dir: Path, dependency: dict) -> None: (work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") +def _write_source_manifest(work_dir: Path, source_dir: Path) -> None: + dependencies: dict[str, str] = {} + for package in ("synapse-sdk", "synapse-core"): + package_json = json.loads( + (source_dir / "packages" / package / "package.json").read_text() + ) + for name, version in package_json.get("peerDependencies", {}).items(): + existing = dependencies.get(name) + if existing is not None and existing != version: + raise RuntimeError( + f"Synapse source packages disagree on {name}: {existing} != {version}" + ) + dependencies[name] = version + manifest = { + "name": "foc-devnet-synapse-source-e2e", + "private": True, + "type": "module", + "dependencies": dependencies, + } + (work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") + policy = source_dir / "pnpm-workspace.yaml" + if not policy.is_file(): + raise RuntimeError(f"Synapse source has no pnpm workspace policy: {policy}") + shutil.copyfile(policy, work_dir / policy.name) + + def _copy_scenarios(work_dir: Path) -> None: source = _scenario_dir() if not source.is_dir(): @@ -126,6 +152,11 @@ def _source_pnpm_version(source_dir: Path) -> str: return package_manager.removeprefix("pnpm@") +def _has_source_package_closure(source_dir: Path) -> bool: + node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules" + return (node_modules / "@filoz" / "synapse-core").is_dir() + + def _source_commit(source_dir: Path) -> str: result = subprocess.run( ["git", "-C", str(source_dir), "rev-parse", "HEAD"], @@ -197,34 +228,41 @@ def prepare_synapse_runtime(work_dir: Path) -> SynapseRuntime: pnpm_version = _source_pnpm_version(source_dir) source_node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules" - has_runtime_closure = (source_node_modules / "viem").is_dir() and ( - source_node_modules / "@filoz" / "synapse-core" - ).is_dir() - if not local_source or not has_runtime_closure: + has_package_closure = _has_source_package_closure(source_dir) + if not local_source or not has_package_closure: if not run_cmd( [ "pnpm", "install", - "--frozen-lockfile", + "--no-frozen-lockfile", "--prod", "--ignore-scripts", "--filter", "@filoz/synapse-sdk...", ], cwd=str(source_dir), - label=f"install Synapse production runtime (pnpm@{pnpm_version})", + label=f"install Synapse production dependencies (pnpm@{pnpm_version})", ): - raise RuntimeError("failed to install Synapse production runtime") - if not source_node_modules.is_dir(): - raise RuntimeError( - f"Synapse production install has no SDK node_modules: {source_node_modules}" - ) - runtime_node_modules = work_dir / "node_modules" - if runtime_node_modules.exists() or runtime_node_modules.is_symlink(): + raise RuntimeError("failed to install Synapse production dependencies") + if not _has_source_package_closure(source_dir): raise RuntimeError( - f"Synapse runtime node_modules already exists: {runtime_node_modules}" + f"Synapse source install has no package closure: {source_node_modules}" ) - runtime_node_modules.symlink_to(source_node_modules, target_is_directory=True) + _write_source_manifest(work_dir, source_dir) + if not run_cmd( + [ + "pnpm", + "install", + "--no-frozen-lockfile", + "--prod", + "--ignore-scripts", + ], + cwd=str(work_dir), + label="install Synapse peer runtime", + ): + raise RuntimeError("failed to install Synapse peer runtime") + if not (work_dir / "node_modules" / "viem").is_dir(): + raise RuntimeError("Synapse peer runtime has no viem installation") provenance = f"{provenance} (pnpm@{pnpm_version})" runtime = SynapseRuntime(work_dir, "source", provenance, source_dir) else: diff --git a/scripts/tests/test_scenario_dependencies.py b/scripts/tests/test_scenario_dependencies.py index d7ae81c3..5a6ce346 100644 --- a/scripts/tests/test_scenario_dependencies.py +++ b/scripts/tests/test_scenario_dependencies.py @@ -149,18 +149,32 @@ def test_npm_runtime_resolves_fallback_consumer_dependencies( "commit": "deadbeef", }, ) - def test_source_runtime_checks_out_and_installs_production_closure( + def test_source_runtime_installs_production_and_peer_closures( self, _component, run_cmd, _source_commit, _copy_scenarios ): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "synapse-sdk" source.mkdir() - (source / "packages" / "synapse-sdk" / "node_modules").mkdir(parents=True) + source_node_modules = source / "packages" / "synapse-sdk" / "node_modules" + (source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True) + (Path(directory) / "node_modules" / "viem").mkdir(parents=True) (source / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') + (source / "pnpm-workspace.yaml").write_text( + "minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n" + ) + for package in ("synapse-sdk", "synapse-core"): + package_dir = source / "packages" / package + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "package.json").write_text( + '{"peerDependencies":{"viem":"2.x"}}' + ) runtime = prepare_synapse_runtime(Path(directory)) + self.assertFalse((runtime.work_dir / "node_modules").is_symlink()) self.assertEqual( - (runtime.work_dir / "node_modules").resolve(), - source / "packages" / "synapse-sdk" / "node_modules", + json.loads((runtime.work_dir / "package.json").read_text())[ + "dependencies" + ], + {"viem": "2.x"}, ) commands = [call.args[0] for call in run_cmd.call_args_list] @@ -169,7 +183,7 @@ def test_source_runtime_checks_out_and_installs_production_closure( [ "pnpm", "install", - "--frozen-lockfile", + "--no-frozen-lockfile", "--prod", "--ignore-scripts", "--filter", @@ -177,6 +191,16 @@ def test_source_runtime_checks_out_and_installs_production_closure( ], commands, ) + self.assertIn( + [ + "pnpm", + "install", + "--no-frozen-lockfile", + "--prod", + "--ignore-scripts", + ], + commands, + ) @patch("scenarios.synapse_runtime._copy_scenarios") @patch("scenarios.synapse_runtime._source_commit", return_value="localcommit") @@ -199,9 +223,18 @@ def test_local_source_runtime_uses_declared_pnpm( source_node_modules = ( source_dir / "packages" / "synapse-sdk" / "node_modules" ) - (source_node_modules / "viem").mkdir(parents=True) (source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True) + (work_dir / "node_modules" / "viem").mkdir(parents=True) (source_dir / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') + (source_dir / "pnpm-workspace.yaml").write_text( + "minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n" + ) + for package in ("synapse-sdk", "synapse-core"): + package_dir = source_dir / "packages" / package + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "package.json").write_text( + '{"peerDependencies":{"viem":"2.x"}}' + ) with patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": str(source_dir)}): runtime = prepare_synapse_runtime(work_dir) @@ -210,7 +243,13 @@ def test_local_source_runtime_uses_declared_pnpm( ) commands = [call.args[0] for call in run_cmd.call_args_list] self.assertFalse(any(command[:2] == ["git", "clone"] for command in commands)) - self.assertFalse(any(command[0] == "pnpm" for command in commands)) + source_commands = [ + call.args[0] + for call in run_cmd.call_args_list + if call.kwargs.get("cwd") == str(source_dir) + ] + self.assertFalse(any(command[0] == "pnpm" for command in source_commands)) + self.assertTrue(any(command[:2] == ["pnpm", "install"] for command in commands)) @patch("scenarios.synapse_runtime.ok") @patch("scenarios.synapse_runtime.info") From af15655cf05f5e20a5cc25edc0788dbaa588a75e Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Wed, 26 Aug 2026 12:19:36 +1000 Subject: [PATCH 2/2] fix(scenarios): use pkg.pr.new for synapse edge npm package installs --- .github/workflows/ci_run.yml | 2 +- ci/README.md | 23 +- ci/dependency-profiles.json | 2 +- scenarios/synapse-e2e/source-runtime.mjs | 85 ------- scenarios/synapse_runtime.py | 213 ++---------------- scripts/resolve-ci-dependencies.py | 36 +++ scripts/tests/test_resolve_ci_dependencies.py | 51 +++++ scripts/tests/test_scenario_dependencies.py | 160 +++---------- 8 files changed, 167 insertions(+), 405 deletions(-) delete mode 100644 scenarios/synapse-e2e/source-runtime.mjs diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index e1d8a83d..54b66960 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -122,7 +122,7 @@ jobs: rm -rf target/ df -h - # Dependency profile resolution queries npm release metadata. + # Dependency profile resolution queries Git refs and npm release metadata. - name: "EXEC: {Setup Node.js}, independent" uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/ci/README.md b/ci/README.md index d5905e5e..210a6f6d 100644 --- a/ci/README.md +++ b/ci/README.md @@ -47,8 +47,8 @@ exist unless added there. Top-level component fields: - `repository`: Git repository URL. -- `npm_package`: npm package name, for components that are resolved through npm - metadata. +- `npm_package`: npm package name, for components resolved through npm metadata + or pkg.pr.new previews. - `default`, `stability`, `frontier`: component profile selections. Profile selections always have a `strategy`. Some strategies require additional @@ -166,6 +166,23 @@ For `synapse-sdk`, npm resolution also records exact compatible versions of `@filoz/synapse-core` and the `viem` peer dependency. The scenario installs that set into a temporary consumer project. +### `pkg_pr_new` + +Resolve a branch head to an immutable commit, then install the packages built +for that commit by pkg.pr.new: + +```json +{ + "strategy": "pkg_pr_new", + "branch": "master" +} +``` + +This strategy is used for Synapse frontier runs. The resolver records the exact +commit and constructs commit-pinned preview URLs for `@filoz/synapse-sdk` and +`@filoz/synapse-core`. The scenario installs those built packages in the same +temporary npm consumer used for released versions. + ## Overrides Some profile selections can include an optional `overrides` object. Each entry @@ -206,7 +223,7 @@ Installation currently lives in three places (which consume the resolved metadata): - `foc-devnet init`: Lotus, Curio, filecoin-services, and optionally PDP. -- `scenarios/synapse_runtime.py`: published or source Synapse scenario runtime. +- `scenarios/synapse_runtime.py`: released or preview Synapse packages. - `scenarios/test_multi_copy_upload.py`: filecoin-pin scenario dependency. This split is intentional, but it is not necessarily the final shape. Resolution diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index ef01dc06..33b67fb4 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -106,7 +106,7 @@ "version": "latest" }, "frontier": { - "strategy": "git_branch", + "strategy": "pkg_pr_new", "branch": "master" } }, diff --git a/scenarios/synapse-e2e/source-runtime.mjs b/scenarios/synapse-e2e/source-runtime.mjs deleted file mode 100644 index b0d38803..00000000 --- a/scenarios/synapse-e2e/source-runtime.mjs +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Runs foc-devnet scenarios against Synapse TypeScript source on Node 24+. - * - * Source profiles install Synapse's production dependencies and peer runtime, - * then preload this module with `node --import`. Public - * `@filoz/synapse-sdk` and `@filoz/synapse-core` imports resolve to their source - * counterparts instead of the packages' compiled `dist` targets. Peer imports - * resolve from the temporary consumer; all other imports use Node's resolver. - * - * Mappings come from each package's export map, keeping the scenario on the - * public API and avoiding a hard-coded list that drifts as exports change. - * Wildcard exports are deliberately excluded because one wildcard can expose - * paths with no TypeScript source equivalent. - */ -import assert from 'node:assert/strict' -import { existsSync, readFileSync } from 'node:fs' -import { registerHooks } from 'node:module' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -const sourceRoot = process.env.SYNAPSE_SDK_SOURCE_DIR -assert(sourceRoot, 'SYNAPSE_SDK_SOURCE_DIR must name the Synapse checkout when using source-runtime.mjs') -const runtimePackageUrl = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'package.json')).href -const runtimeDependencies = new Set( - Object.keys(JSON.parse(readFileSync(fileURLToPath(runtimePackageUrl), 'utf8')).dependencies ?? {}) -) - -// Export entries may be strings or nested condition objects. Prefer the -// conditions used by this ESM runtime, then inspect package-specific branches. -function exportedTarget(entry) { - if (typeof entry === 'string') return entry - if (entry == null || typeof entry !== 'object') return undefined - for (const condition of ['node', 'import', 'default']) { - const target = exportedTarget(entry[condition]) - if (target != null) return target - } - for (const target of Object.values(entry)) { - const resolved = exportedTarget(target) - if (resolved != null) return resolved - } - return undefined -} - -// Convert concrete public dist exports to source files only when the matching -// TypeScript file exists. Dependencies and private paths remain untouched. -function sourceExports(packageName, packageDirectory) { - const packagePath = join(sourceRoot, packageDirectory, 'package.json') - const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) - const mappings = new Map() - for (const [subpath, entry] of Object.entries(packageJson.exports ?? {})) { - if (subpath.includes('*')) continue - const target = exportedTarget(entry) - if (target == null) continue - const sourcePath = resolve( - dirname(packagePath), - target.replace(/^\.\/dist\/src\//, './src/').replace(/\.js$/, '.ts') - ) - if (!existsSync(sourcePath)) continue - mappings.set(subpath === '.' ? packageName : `${packageName}/${subpath.slice(2)}`, pathToFileURL(sourcePath).href) - } - return mappings -} - -const sourceMappings = new Map([ - ...sourceExports('@filoz/synapse-sdk', 'packages/synapse-sdk'), - ...sourceExports('@filoz/synapse-core', 'packages/synapse-core'), -]) - -function packageName(specifier) { - if (specifier.startsWith('@')) return specifier.split('/', 2).join('/') - return specifier.split('/', 1)[0] -} - -// Short-circuit public Synapse exports and resolve peer dependencies from the -// temporary consumer. Source package dependencies use Node's normal resolver. -registerHooks({ - resolve(specifier, context, nextResolve) { - const sourceUrl = sourceMappings.get(specifier) - if (sourceUrl != null) return { url: sourceUrl, shortCircuit: true } - if (runtimeDependencies.has(packageName(specifier))) { - return nextResolve(specifier, { ...context, parentURL: runtimePackageUrl }) - } - return nextResolve(specifier, context) - }, -}) diff --git a/scenarios/synapse_runtime.py b/scenarios/synapse_runtime.py index cd6978d0..a01caafc 100755 --- a/scenarios/synapse_runtime.py +++ b/scenarios/synapse_runtime.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prepare and run the Synapse E2E consumer or source runtime.""" +"""Prepare and run the Synapse E2E consumer runtime.""" from __future__ import annotations @@ -16,7 +16,6 @@ STATE_FORK_ERROR = "refusing explicit call due to state fork at epoch" UPLOAD_RETRY_DELAYS_SECS = (5, 10, 15, 30) -RUNTIME_MARKER = ".synapse-runtime.json" @dataclass(frozen=True) @@ -24,17 +23,12 @@ class SynapseRuntime: work_dir: Path source: str provenance: str - source_dir: Path | None = None def _scenario_dir() -> Path: return Path(__file__).with_name("synapse-e2e") -def _runtime_marker(work_dir: Path) -> Path: - return work_dir / RUNTIME_MARKER - - def _npm_view(package: str, version: str, *fields: str): result = subprocess.run( ["npm", "view", f"{package}@{version}", *fields, "--json"], @@ -110,32 +104,6 @@ def _write_manifest(work_dir: Path, dependency: dict) -> None: (work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") -def _write_source_manifest(work_dir: Path, source_dir: Path) -> None: - dependencies: dict[str, str] = {} - for package in ("synapse-sdk", "synapse-core"): - package_json = json.loads( - (source_dir / "packages" / package / "package.json").read_text() - ) - for name, version in package_json.get("peerDependencies", {}).items(): - existing = dependencies.get(name) - if existing is not None and existing != version: - raise RuntimeError( - f"Synapse source packages disagree on {name}: {existing} != {version}" - ) - dependencies[name] = version - manifest = { - "name": "foc-devnet-synapse-source-e2e", - "private": True, - "type": "module", - "dependencies": dependencies, - } - (work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") - policy = source_dir / "pnpm-workspace.yaml" - if not policy.is_file(): - raise RuntimeError(f"Synapse source has no pnpm workspace policy: {policy}") - shutil.copyfile(policy, work_dir / policy.name) - - def _copy_scenarios(work_dir: Path) -> None: source = _scenario_dir() if not source.is_dir(): @@ -143,160 +111,36 @@ def _copy_scenarios(work_dir: Path) -> None: shutil.copytree(source, work_dir, dirs_exist_ok=True) -def _source_pnpm_version(source_dir: Path) -> str: - package_manager = json.loads((source_dir / "package.json").read_text()).get( - "packageManager" - ) - if not isinstance(package_manager, str) or not package_manager.startswith("pnpm@"): - raise RuntimeError(f"Synapse source has no declared pnpm version: {source_dir}") - return package_manager.removeprefix("pnpm@") - - -def _has_source_package_closure(source_dir: Path) -> bool: - node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules" - return (node_modules / "@filoz" / "synapse-core").is_dir() - - -def _source_commit(source_dir: Path) -> str: - result = subprocess.run( - ["git", "-C", str(source_dir), "rev-parse", "HEAD"], - text=True, - capture_output=True, - ) - if result.returncode: - raise RuntimeError(f"Cannot determine Synapse source commit at {source_dir}") - return result.stdout.strip() - - -def _load_runtime(work_dir: Path) -> SynapseRuntime: - marker = _runtime_marker(work_dir) - if not marker.is_file(): - raise RuntimeError(f"SYNAPSE_RUNTIME_DIR is not a prepared runtime: {work_dir}") - data = json.loads(marker.read_text()) - source_dir = data.get("source_dir") - return SynapseRuntime( - work_dir=work_dir, - source=data["source"], - provenance=data["provenance"], - source_dir=Path(source_dir) if source_dir else None, - ) - - def prepare_synapse_runtime(work_dir: Path) -> SynapseRuntime: - """Prepare one reusable runtime and return its executable scenario location.""" - reused_dir = os.environ.get("SYNAPSE_RUNTIME_DIR") - if reused_dir: - return _load_runtime(Path(reused_dir).resolve()) - + """Install Synapse into a temporary consumer project.""" work_dir = work_dir.resolve() work_dir.mkdir(parents=True, exist_ok=True) dependency = component("synapse-sdk") - local_source = os.environ.get("SYNAPSE_SDK_SOURCE_DIR") - - if local_source or dependency.get("source") != "npm": - source_dir = ( - Path(local_source).resolve() if local_source else work_dir / "synapse-sdk" - ) - if local_source: - if not source_dir.is_dir(): - raise RuntimeError( - f"SYNAPSE_SDK_SOURCE_DIR is not a directory: {source_dir}" - ) - provenance = f"local:{source_dir}@{_source_commit(source_dir)}" - else: - checkout = dependency.get("commit") or dependency.get("ref") - if not checkout: - raise RuntimeError("resolved Synapse source has no commit or ref") - if not run_cmd( - ["git", "clone", dependency["repository"], str(source_dir)], - label="clone synapse-sdk", - ): - raise RuntimeError("failed to clone synapse-sdk") - if not run_cmd( - ["git", "checkout", "--detach", checkout], - cwd=str(source_dir), - label=f"checkout synapse-sdk {checkout}", - ): - raise RuntimeError("failed to checkout synapse-sdk") - actual_commit = _source_commit(source_dir) - expected_commit = dependency.get("commit") - if expected_commit and actual_commit != expected_commit: - raise RuntimeError( - f"synapse-sdk checkout is {actual_commit}, expected {expected_commit}" - ) - provenance = f"git:{dependency['repository']}@{actual_commit}" + source = dependency.get("source") + if source not in {"npm", "pkg_pr_new"}: + raise RuntimeError(f"Unsupported Synapse package source: {source!r}") - pnpm_version = _source_pnpm_version(source_dir) - source_node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules" - has_package_closure = _has_source_package_closure(source_dir) - if not local_source or not has_package_closure: - if not run_cmd( - [ - "pnpm", - "install", - "--no-frozen-lockfile", - "--prod", - "--ignore-scripts", - "--filter", - "@filoz/synapse-sdk...", - ], - cwd=str(source_dir), - label=f"install Synapse production dependencies (pnpm@{pnpm_version})", - ): - raise RuntimeError("failed to install Synapse production dependencies") - if not _has_source_package_closure(source_dir): - raise RuntimeError( - f"Synapse source install has no package closure: {source_node_modules}" - ) - _write_source_manifest(work_dir, source_dir) - if not run_cmd( - [ - "pnpm", - "install", - "--no-frozen-lockfile", - "--prod", - "--ignore-scripts", - ], - cwd=str(work_dir), - label="install Synapse peer runtime", - ): - raise RuntimeError("failed to install Synapse peer runtime") - if not (work_dir / "node_modules" / "viem").is_dir(): - raise RuntimeError("Synapse peer runtime has no viem installation") - provenance = f"{provenance} (pnpm@{pnpm_version})" - runtime = SynapseRuntime(work_dir, "source", provenance, source_dir) - else: - _write_manifest(work_dir, dependency) - if not run_cmd( - [ - "npm", - "install", - "--omit=dev", - "--ignore-scripts", - "--package-lock=false", - ], - cwd=str(work_dir), - label="install Synapse consumer runtime", - ): - raise RuntimeError("failed to install Synapse consumer runtime") - runtime = SynapseRuntime( - work_dir, + _write_manifest(work_dir, dependency) + if not run_cmd( + [ "npm", - f"npm:{dependency['package']}@{dependency['version']}", - ) - - _copy_scenarios(work_dir) - _runtime_marker(work_dir).write_text( - json.dumps( - { - "source": runtime.source, - "provenance": runtime.provenance, - "source_dir": str(runtime.source_dir) if runtime.source_dir else None, - }, - indent=2, - ) - + "\n" + "install", + "--omit=dev", + "--ignore-scripts", + "--package-lock=false", + ], + cwd=str(work_dir), + label="install Synapse consumer runtime", + ): + raise RuntimeError("failed to install Synapse consumer runtime") + + identifier = dependency.get("commit") or dependency["version"] + runtime = SynapseRuntime( + work_dir, + source, + f"{source}:{dependency['package']}@{identifier}", ) + _copy_scenarios(work_dir) info(f"Synapse runtime: {runtime.provenance}") return runtime @@ -314,15 +158,8 @@ def run_node_script( if not script.is_file(): raise RuntimeError(f"Synapse scenario entrypoint not found: {script}") - cmd = ["node"] + cmd = ["node", str(script), *(args or [])] process_env = {**os.environ, **(env or {})} - if runtime.source_dir: - loader = runtime.work_dir / "source-runtime.mjs" - if not loader.is_file(): - raise RuntimeError(f"Synapse source runtime hook not found: {loader}") - cmd.extend(["--import", str(loader)]) - process_env["SYNAPSE_SDK_SOURCE_DIR"] = str(runtime.source_dir) - cmd.extend([str(script), *(args or [])]) max_attempts = len(UPLOAD_RETRY_DELAYS_SECS) + 1 for attempt in range(1, max_attempts + 1): diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index c9d2cc69..e29faa2e 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -24,6 +24,8 @@ VERSION_RE = re.compile(r"^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$") STABLE_VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$") COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +PKG_PR_NEW_BASE_URL = "https://pkg.pr.new" +SYNAPSE_PKG_PR_NEW_REPOSITORY = "FilOzone/synapse-sdk" class ResolutionError(RuntimeError): @@ -292,6 +294,25 @@ def npm_runtime_dependencies( return {"@filoz/synapse-core": core, "viem": viem} +def pkg_pr_new_url(package: str, commit: str) -> str: + return ( + f"{PKG_PR_NEW_BASE_URL}/{SYNAPSE_PKG_PR_NEW_REPOSITORY}/" + f"{package}@{commit[:7]}" + ) + + +def synapse_preview_runtime_dependencies( + commit: str, runner=run_command +) -> dict[str, str]: + return { + "@filoz/synapse-core": pkg_pr_new_url( + "@filoz/synapse-core", + commit, + ), + "viem": npm_metadata("viem", "2.x", runner)["version"], + } + + def read_gitlink(repository: str, commit: str, path: str, runner=run_command) -> str: with tempfile.TemporaryDirectory(prefix="foc-devnet-ci-deps-") as directory: repo_dir = Path(directory) / "repo" @@ -376,6 +397,21 @@ def resolve_component( branch = selection["branch"] commit = resolve_ref(repository, f"refs/heads/{branch}", runner) resolved.update(source="git", ref_type="branch", ref=branch, commit=commit) + elif strategy == "pkg_pr_new": + if name != "synapse-sdk": + raise ResolutionError("pkg_pr_new is only supported for synapse-sdk") + branch = selection["branch"] + commit = resolve_ref(repository, f"refs/heads/{branch}", runner) + package = component["npm_package"] + resolved.update( + source="pkg_pr_new", + ref_type="branch", + ref=branch, + commit=commit, + package=package, + version=pkg_pr_new_url(package, commit), + runtime_dependencies=synapse_preview_runtime_dependencies(commit, runner), + ) elif strategy == "git_submodule": parent_repository = selection["repository"] tag = selection["tag"] diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index 71c15712..2fc17ac9 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -531,6 +531,57 @@ def test_synapse_npm_resolution_includes_exact_runtime_dependencies(self): {"@filoz/synapse-core": "1.1.1", "viem": "2.52.0"}, ) + def test_synapse_preview_resolves_commit_pinned_packages(self): + commit = "a" * 40 + component = { + "repository": "https://example.test/synapse.git", + "npm_package": "@filoz/synapse-sdk", + "frontier": {"strategy": "pkg_pr_new", "branch": "master"}, + } + runner = FakeRunner( + { + ( + "git", + "ls-remote", + "https://example.test/synapse.git", + "refs/heads/master", + ): f"{commit}\trefs/heads/master", + ("npm", "view", "viem@2.x", "version", "--json"): '"2.52.0"', + ( + "npm", + "view", + "viem@2.52.0", + "gitHead", + "--json", + ): '""', + } + ) + + resolved = resolver.resolve_component( + "synapse-sdk", component, "frontier", runner + ) + + self.assertEqual(resolved["source"], "pkg_pr_new") + self.assertEqual(resolved["ref"], "master") + self.assertEqual(resolved["commit"], commit) + self.assertEqual( + resolved["version"], + ( + "https://pkg.pr.new/FilOzone/synapse-sdk/" + f"@filoz/synapse-sdk@{commit[:7]}" + ), + ) + self.assertEqual( + resolved["runtime_dependencies"], + { + "@filoz/synapse-core": ( + "https://pkg.pr.new/FilOzone/synapse-sdk/" + f"@filoz/synapse-core@{commit[:7]}" + ), + "viem": "2.52.0", + }, + ) + def test_profile_overrides_are_copied_to_resolved_component(self): component = { "repository": "https://example.test/filecoin-pin.git", diff --git a/scripts/tests/test_scenario_dependencies.py b/scripts/tests/test_scenario_dependencies.py index 5a6ce346..e424c831 100644 --- a/scripts/tests/test_scenario_dependencies.py +++ b/scripts/tests/test_scenario_dependencies.py @@ -137,119 +137,57 @@ def test_npm_runtime_resolves_fallback_consumer_dependencies( ), ) - @patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": ""}, clear=False) @patch("scenarios.synapse_runtime._copy_scenarios") - @patch("scenarios.synapse_runtime._source_commit", return_value="deadbeef") @patch("scenarios.synapse_runtime.run_cmd", return_value=True) @patch( "scenarios.synapse_runtime.component", return_value={ - "source": "git", - "repository": "https://example.test/synapse.git", + "source": "pkg_pr_new", + "package": "@filoz/synapse-sdk", + "version": "https://pkg.pr.new/@filoz/synapse-sdk@deadbeef", "commit": "deadbeef", + "runtime_dependencies": { + "@filoz/synapse-core": ( + "https://pkg.pr.new/@filoz/synapse-core@deadbeef" + ), + "viem": "2.52.0", + }, }, ) - def test_source_runtime_installs_production_and_peer_closures( - self, _component, run_cmd, _source_commit, _copy_scenarios + def test_preview_runtime_installs_immutable_consumer_manifest( + self, _component, run_cmd, _copy_scenarios ): with tempfile.TemporaryDirectory() as directory: - source = Path(directory) / "synapse-sdk" - source.mkdir() - source_node_modules = source / "packages" / "synapse-sdk" / "node_modules" - (source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True) - (Path(directory) / "node_modules" / "viem").mkdir(parents=True) - (source / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') - (source / "pnpm-workspace.yaml").write_text( - "minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n" - ) - for package in ("synapse-sdk", "synapse-core"): - package_dir = source / "packages" / package - package_dir.mkdir(parents=True, exist_ok=True) - (package_dir / "package.json").write_text( - '{"peerDependencies":{"viem":"2.x"}}' - ) runtime = prepare_synapse_runtime(Path(directory)) - self.assertFalse((runtime.work_dir / "node_modules").is_symlink()) - self.assertEqual( - json.loads((runtime.work_dir / "package.json").read_text())[ - "dependencies" - ], - {"viem": "2.x"}, - ) + manifest = json.loads((Path(directory) / "package.json").read_text()) - commands = [call.args[0] for call in run_cmd.call_args_list] - self.assertIn(["git", "checkout", "--detach", "deadbeef"], commands) - self.assertIn( - [ - "pnpm", - "install", - "--no-frozen-lockfile", - "--prod", - "--ignore-scripts", - "--filter", - "@filoz/synapse-sdk...", - ], - commands, + self.assertEqual(runtime.source, "pkg_pr_new") + self.assertEqual( + runtime.provenance, + "pkg_pr_new:@filoz/synapse-sdk@deadbeef", ) - self.assertIn( + self.assertEqual( + manifest["dependencies"], + { + "@filoz/synapse-sdk": ( + "https://pkg.pr.new/@filoz/synapse-sdk@deadbeef" + ), + "@filoz/synapse-core": ( + "https://pkg.pr.new/@filoz/synapse-core@deadbeef" + ), + "viem": "2.52.0", + }, + ) + self.assertEqual( + run_cmd.call_args.args[0], [ - "pnpm", + "npm", "install", - "--no-frozen-lockfile", - "--prod", + "--omit=dev", "--ignore-scripts", + "--package-lock=false", ], - commands, - ) - - @patch("scenarios.synapse_runtime._copy_scenarios") - @patch("scenarios.synapse_runtime._source_commit", return_value="localcommit") - @patch("scenarios.synapse_runtime.run_cmd", return_value=True) - @patch( - "scenarios.synapse_runtime.component", - return_value={ - "source": "npm", - "package": "@filoz/synapse-sdk", - "version": "1.1.1", - }, - ) - def test_local_source_runtime_uses_declared_pnpm( - self, _component, run_cmd, _source_commit, _copy_scenarios - ): - with tempfile.TemporaryDirectory() as directory: - work_dir = Path(directory) / "runtime" - source_dir = Path(directory) / "synapse-source" - source_dir.mkdir() - source_node_modules = ( - source_dir / "packages" / "synapse-sdk" / "node_modules" - ) - (source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True) - (work_dir / "node_modules" / "viem").mkdir(parents=True) - (source_dir / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') - (source_dir / "pnpm-workspace.yaml").write_text( - "minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n" - ) - for package in ("synapse-sdk", "synapse-core"): - package_dir = source_dir / "packages" / package - package_dir.mkdir(parents=True, exist_ok=True) - (package_dir / "package.json").write_text( - '{"peerDependencies":{"viem":"2.x"}}' - ) - with patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": str(source_dir)}): - runtime = prepare_synapse_runtime(work_dir) - - self.assertEqual( - runtime.provenance, f"local:{source_dir}@localcommit (pnpm@11.5.3)" ) - commands = [call.args[0] for call in run_cmd.call_args_list] - self.assertFalse(any(command[:2] == ["git", "clone"] for command in commands)) - source_commands = [ - call.args[0] - for call in run_cmd.call_args_list - if call.kwargs.get("cwd") == str(source_dir) - ] - self.assertFalse(any(command[0] == "pnpm" for command in source_commands)) - self.assertTrue(any(command[:2] == ["pnpm", "install"] for command in commands)) @patch("scenarios.synapse_runtime.ok") @patch("scenarios.synapse_runtime.info") @@ -279,38 +217,6 @@ def test_run_node_script_uses_consumer_cwd_and_env(self, run, _info, ok): self.assertEqual(kwargs["timeout"], 30) ok.assert_called_once_with("run smoke") - @patch("scenarios.synapse_runtime.ok") - @patch("scenarios.synapse_runtime.info") - @patch("scenarios.synapse_runtime.subprocess.run") - def test_run_node_script_uses_source_runtime_hook(self, run, _info, _ok): - run.return_value = subprocess.CompletedProcess( - ["node"], 0, stdout="", stderr="" - ) - with tempfile.TemporaryDirectory() as directory: - work_dir = Path(directory) - source_dir = work_dir / "synapse-sdk" - source_dir.mkdir() - (work_dir / "source-runtime.mjs").touch() - (work_dir / "system-e2e.ts").touch() - run_node_script( - SynapseRuntime(work_dir, "source", "git:example@deadbeef", source_dir), - "system-e2e.ts", - "run system e2e", - ) - - self.assertEqual( - run.call_args.args[0], - [ - "node", - "--import", - str(work_dir / "source-runtime.mjs"), - str(work_dir / "system-e2e.ts"), - ], - ) - self.assertEqual( - run.call_args.kwargs["env"]["SYNAPSE_SDK_SOURCE_DIR"], str(source_dir) - ) - @patch("scenarios.synapse_runtime.time.sleep") @patch("scenarios.synapse_runtime.ok") @patch("scenarios.synapse_runtime.info")