diff --git a/.changeset/calm-worlds-migrate.md b/.changeset/calm-worlds-migrate.md new file mode 100644 index 0000000000..3c0411dd6e --- /dev/null +++ b/.changeset/calm-worlds-migrate.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/cli": patch +--- + +Add selector System-ID reconciliation for existing Worlds. Deployments now update only the `FunctionSelectors.systemId` field when the configured and registered System function selectors match exactly, and reject ambiguous routing conflicts before writing. The deployer must have Store access to the `FunctionSelectors` table; otherwise deployment stops with one-time grant instructions. diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000..16e7c33ae0 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,121 @@ +name: Fork package release + +on: + push: + tags: + - "v*-floki.*" + +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_OPTIONS: "--max-old-space-size=4096" + +jobs: + build: + name: Build and validate CLI release + if: github.repository == 'Floki-Inu/mud' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + version: ${{ steps.release.outputs.version }} + steps: + - name: Checkout tagged source + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Derive package version from tag + id: release + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + if [[ "v${version}" != "${GITHUB_REF_NAME}" ]]; then + echo "Release tag must begin with v." >&2 + exit 1 + fi + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Test release packager + run: pnpm release:test-fork + + - name: Build CLI and its workspace dependencies + shell: bash + run: pnpm exec turbo run build --filter=@latticexyz/cli... --force + + - name: Test forked CLI package + run: pnpm --filter @latticexyz/cli test + + - name: Package and validate release assets + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: >- + pnpm release:pack-fork -- + --version "${RELEASE_VERSION}" + --tag "${GITHUB_REF_NAME}" + --output release-assets + + - name: Install and smoke test packaged CLI + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + consumer_dir="${RUNNER_TEMP}/mud-fork-consumer" + mkdir -p "${consumer_dir}" + cd "${consumer_dir}" + npm init --yes + pnpm add --save-exact "${GITHUB_WORKSPACE}/release-assets/latticexyz-cli-${RELEASE_VERSION}.tgz" + pnpm exec mud --help + + - name: Upload validated release assets + uses: actions/upload-artifact@v4 + with: + name: fork-release-${{ steps.release.outputs.version }} + path: | + release-assets/latticexyz-cli-${{ steps.release.outputs.version }}.tgz + release-assets/SHA256SUMS + if-no-files-found: error + retention-days: 1 + + release: + name: Publish CLI release + needs: build + if: github.repository == 'Floki-Inu/mud' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: write + steps: + - name: Download validated release assets + uses: actions/download-artifact@v4 + with: + name: fork-release-${{ needs.build.outputs.version }} + path: release-assets + + - name: Verify downloaded release assets + working-directory: release-assets + run: sha256sum --check SHA256SUMS + + - name: Create GitHub Release + shell: bash + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.build.outputs.version }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + "release-assets/latticexyz-cli-${RELEASE_VERSION}.tgz" \ + "release-assets/SHA256SUMS" \ + --verify-tag \ + --prerelease \ + --generate-notes \ + --title "MUD ${RELEASE_VERSION}" diff --git a/package.json b/package.json index af2cd1d3f4..4b8cb5fbec 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "prettier": "prettier --write '**/*.{ts,tsx,css,md,mdx,sol}'", "prettier:check": "prettier --check '**/*.{ts,tsx,css,md,mdx,sol}'", "release:check": "changeset status --verbose --since=origin/main", + "release:pack-fork": "node scripts/package-fork-release.mjs", "release:publish": "pnpm install && pnpm build && changeset publish", + "release:test-fork": "node --test scripts/package-fork-release.test.mjs", "release:version": "changeset version && pnpm install --lockfile-only && pnpm run changelog:generate", "test": "with-anvil turbo run test --concurrency=100%", "test:ci": "with-anvil turbo run test:ci --concurrency=100%", diff --git a/packages/cli/package.json b/packages/cli/package.json index ef90f88616..8ff5284a69 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "clean:test-tables": "shx rm -rf src/**/codegen", "dev": "tsup --watch", "lint": "eslint . --ext .ts", - "test": "tsc --noEmit && forge test", + "test": "tsc --noEmit && vitest --run --passWithNoTests && forge test", "test:ci": "pnpm run test" }, "dependencies": { diff --git a/packages/cli/src/commands/set-version.ts b/packages/cli/src/commands/set-version.ts index 71776ec822..b6e85df3f6 100644 --- a/packages/cli/src/commands/set-version.ts +++ b/packages/cli/src/commands/set-version.ts @@ -4,9 +4,9 @@ import path from "path"; import type { CommandModule } from "yargs"; import { MUDError } from "@latticexyz/common/errors"; import { logError } from "../utils/errors"; -import localPackageJson from "../../package.json" with { type: "json" }; import { globSync } from "glob"; import { mudPackages } from "../mudPackages"; +import { cliPackageInfo } from "../version"; type Options = { backup?: boolean; @@ -88,7 +88,7 @@ async function resolveVersion(options: Options) { let npmResult: any; try { console.log(chalk.blue(`Fetching available versions`)); - npmResult = await (await fetch(`https://registry.npmjs.org/${localPackageJson.name}`)).json(); + npmResult = await (await fetch(`https://registry.npmjs.org/${cliPackageInfo.name}`)).json(); } catch (e) { throw new MUDError(`Could not fetch available MUD versions`); } diff --git a/packages/cli/src/deploy/ensureFunctions.test.ts b/packages/cli/src/deploy/ensureFunctions.test.ts new file mode 100644 index 0000000000..2649677c35 --- /dev/null +++ b/packages/cli/src/deploy/ensureFunctions.test.ts @@ -0,0 +1,132 @@ +import { padHex, toFunctionSelector, toHex, zeroAddress } from "viem"; +import { describe, expect, it, vi } from "vitest"; +import { resourceToHex } from "@latticexyz/common"; +import worldConfig from "@latticexyz/world/mud.config"; +import type { CommonDeployOptions, WorldFunction } from "./common"; +import { + assertFunctionSelectorsWriteAccess, + assertTargetSystemActive, + getFunctionReconciliationAction, + getFunctionSystemIdWrite, + getLatestWorldDeploy, +} from "./ensureFunctions"; + +const sourceSystemId = `0x${"11".repeat(32)}` as const; + +function worldFunction(namespace: string): WorldFunction { + const systemFunctionSignature = "run()"; + const signature = namespace === "" ? systemFunctionSignature : `${namespace}__${systemFunctionSignature}`; + return { + signature, + selector: toFunctionSelector(signature), + systemId: resourceToHex({ type: "system", namespace, name: "Runner" }), + systemFunctionSignature, + systemFunctionSelector: toFunctionSelector(systemFunctionSignature), + }; +} + +describe("FunctionSelectors System ID reconciliation", () => { + it("bypasses viem's block-number cache for immediately consecutive latest reads", async () => { + const request = vi.fn().mockResolvedValueOnce(toHex(100n)).mockResolvedValueOnce(toHex(101n)); + const client = { + uid: "ensure-functions-latest-block-test", + cacheTime: 10_000, + request, + } as unknown as CommonDeployOptions["client"]; + const worldDeploy: CommonDeployOptions["worldDeploy"] = { + address: zeroAddress, + worldVersion: "2.0.2", + storeVersion: "2.0.2", + deployBlock: 1n, + stateBlock: 99n, + }; + + const first = await getLatestWorldDeploy({ client, worldDeploy }); + const second = await getLatestWorldDeploy({ client, worldDeploy }); + + expect(first.stateBlock).toBe(100n); + expect(second.stateBlock).toBe(101n); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("encodes a write to field zero and the exact world-selector key", () => { + const func = worldFunction("valhalla"); + + expect(getFunctionSystemIdWrite(func)).toMatchObject({ + keyTuple: [padHex(func.selector, { dir: "right", size: 32 })], + fieldIndex: 0, + data: func.systemId, + }); + }); + + it("accepts either namespace-level or table-level Store access", () => { + const caller = "0x1111111111111111111111111111111111111111"; + const tableId = worldConfig.namespaces.world.tables.FunctionSelectors.tableId; + + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: true, tableAccess: false }), + ).not.toThrow(); + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: false, tableAccess: true }), + ).not.toThrow(); + expect(() => + assertFunctionSelectorsWriteAccess({ caller, namespaceAccess: false, tableAccess: false }), + ).toThrowError( + [ + `Deployer ${caller} cannot reconcile World function routes because it lacks Store write access to the FunctionSelectors table (${tableId}).`, + `The owner of the \`world\` namespace must grant ${caller} access to that table once.`, + ].join("\n"), + ); + }); + + it("requires an active destination System", () => { + const func = worldFunction("valhalla"); + + expect(() => assertTargetSystemActive(func.systemId, "0x2222222222222222222222222222222222222222")).not.toThrow(); + expect(() => assertTargetSystemActive(func.systemId, zeroAddress)).toThrowError("inactive target System"); + }); + + it("writes only while the latest route is still the planned exact source", () => { + const func = worldFunction("valhalla"); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + const reconciliation = { func, expectedRoute }; + + expect(getFunctionReconciliationAction({ reconciliation, currentRoute: expectedRoute })).toBe("write"); + expect( + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemId: func.systemId }, + }), + ).toBe("skip"); + }); + + it("fails closed on an absent, selector-changed, or System-ID-drifted latest route", () => { + const func = worldFunction("valhalla"); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + const reconciliation = { func, expectedRoute }; + + expect(() => getFunctionReconciliationAction({ reconciliation, currentRoute: undefined })).toThrowError( + "unknown current route", + ); + expect(() => + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemFunctionSelector: "0xaaaaaaaa" }, + }), + ).toThrowError("changed System function selector"); + expect(() => + getFunctionReconciliationAction({ + reconciliation, + currentRoute: { ...expectedRoute, systemId: `0x${"33".repeat(32)}` }, + }), + ).toThrowError("changed after deployment planning"); + }); +}); diff --git a/packages/cli/src/deploy/ensureFunctions.ts b/packages/cli/src/deploy/ensureFunctions.ts index 74bc4db0c7..8bf3161938 100644 --- a/packages/cli/src/deploy/ensureFunctions.ts +++ b/packages/cli/src/deploy/ensureFunctions.ts @@ -1,83 +1,319 @@ -import { Hex } from "viem"; -import { hexToResource, writeContract } from "@latticexyz/common"; -import { getFunctions } from "@latticexyz/store-sync/world"; +import { AbortError } from "p-retry"; +import { getAddress, type Address, type Hex, zeroAddress } from "viem"; +import { getBlockNumber } from "viem/actions"; +import { getKeyTuple } from "@latticexyz/protocol-parser/internal"; +import { hexToResource, resourceToHex, writeContract } from "@latticexyz/common"; +import { waitForTransactions } from "@latticexyz/common/internal"; +import { isDefined } from "@latticexyz/common/utils"; +import worldConfig from "@latticexyz/world/mud.config"; import { CommonDeployOptions, WorldFunction, worldAbi } from "./common"; import { debug } from "./debug"; import pRetry from "p-retry"; +import { assertFunctionPlanApplied, FunctionRegistrationPlan, planFunctionRegistrations } from "./functionPlan"; +import { getFunctionRoutes } from "./getFunctionRoutes"; +import { getRecord } from "./getRecord"; + +type FunctionReconciliation = FunctionRegistrationPlan["toReconcile"][number]; + +function sameHex(a: Hex, b: Hex): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +export function getFunctionSystemIdWrite(func: WorldFunction) { + const table = worldConfig.namespaces.world.tables.FunctionSelectors; + return { + tableId: table.tableId, + keyTuple: getKeyTuple(table, { worldFunctionSelector: func.selector }), + fieldIndex: 0, + data: func.systemId, + } as const; +} + +export function assertFunctionSelectorsWriteAccess({ + caller, + namespaceAccess, + tableAccess, +}: { + readonly caller: Address; + readonly namespaceAccess: boolean; + readonly tableAccess: boolean; +}): void { + if (namespaceAccess || tableAccess) return; + + const tableId = worldConfig.namespaces.world.tables.FunctionSelectors.tableId; + throw new Error( + [ + `Deployer ${caller} cannot reconcile World function routes because it lacks Store write access to the FunctionSelectors table (${tableId}).`, + `The owner of the \`world\` namespace must grant ${caller} access to that table once.`, + ].join("\n"), + ); +} + +export function assertTargetSystemActive(systemId: Hex, system: Address): void { + if (getAddress(system) !== zeroAddress) return; + throw new Error(`Cannot reconcile a World function route to inactive target System ${systemId}.`); +} + +export function getFunctionReconciliationAction({ + reconciliation, + currentRoute, +}: { + readonly reconciliation: FunctionReconciliation; + readonly currentRoute: Parameters[1][number] | undefined; +}): "skip" | "write" { + const { func, expectedRoute } = reconciliation; + if (currentRoute == null || !sameHex(currentRoute.selector, func.selector)) { + throw new Error( + `World function ${func.signature} (${func.selector}) has an unknown current route; refusing to write.`, + ); + } + + if ( + sameHex(currentRoute.systemId, func.systemId) && + sameHex(currentRoute.systemFunctionSelector, func.systemFunctionSelector) + ) { + return "skip"; + } + + if (!sameHex(currentRoute.systemFunctionSelector, func.systemFunctionSelector)) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) changed System function selector before reconciliation.`, + `Expected: ${func.systemFunctionSelector}`, + `Current: ${currentRoute.systemFunctionSelector}`, + "Refusing to update its System ID.", + ].join("\n"), + ); + } + + if ( + !sameHex(currentRoute.systemId, expectedRoute.systemId) || + !sameHex(currentRoute.systemFunctionSelector, expectedRoute.systemFunctionSelector) + ) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) changed after deployment planning.`, + `Expected source System ID: ${expectedRoute.systemId}`, + `Current System ID: ${currentRoute.systemId}`, + "Refusing to overwrite the newer route.", + ].join("\n"), + ); + } + + return "write"; +} + +export async function getLatestWorldDeploy({ + client, + worldDeploy, +}: Pick): Promise { + return { ...worldDeploy, stateBlock: await getBlockNumber(client, { cacheTime: 0 }) }; +} + +async function assertReconciliationTargetsActive({ + client, + worldDeploy, + reconciliations, +}: Pick & { + readonly reconciliations: readonly FunctionReconciliation[]; +}): Promise { + const systemsTable = worldConfig.namespaces.world.tables.Systems; + const uniqueTargets = [ + ...new Map(reconciliations.map(({ func }) => [func.systemId.toLowerCase(), func.systemId])).values(), + ]; + await Promise.all( + uniqueTargets.map(async (systemId) => { + const record = await getRecord({ client, worldDeploy, table: systemsTable, key: { systemId } }); + assertTargetSystemActive(systemId, record.system); + }), + ); +} + +async function assertFunctionSelectorsAccess({ + client, + worldDeploy, +}: Pick): Promise { + const functionSelectorsTable = worldConfig.namespaces.world.tables.FunctionSelectors; + const resourceAccessTable = worldConfig.namespaces.world.tables.ResourceAccess; + const namespaceId = resourceToHex({ + type: "namespace", + namespace: hexToResource(functionSelectorsTable.tableId).namespace, + name: "", + }); + const caller = client.account.address; + const [namespaceAccess, tableAccess] = await Promise.all([ + getRecord({ + client, + worldDeploy, + table: resourceAccessTable, + key: { resourceId: namespaceId, caller }, + }), + getRecord({ + client, + worldDeploy, + table: resourceAccessTable, + key: { resourceId: functionSelectorsTable.tableId, caller }, + }), + ]); + assertFunctionSelectorsWriteAccess({ + caller, + namespaceAccess: namespaceAccess.access, + tableAccess: tableAccess.access, + }); +} + +async function assertReconciliationPreconditions({ + client, + worldDeploy, + reconciliations, +}: Pick & { + readonly reconciliations: readonly FunctionReconciliation[]; +}): Promise { + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + await Promise.all([ + assertReconciliationTargetsActive({ client, worldDeploy: latestWorldDeploy, reconciliations }), + assertFunctionSelectorsAccess({ client, worldDeploy: latestWorldDeploy }), + ]); + return latestWorldDeploy; +} + +async function reconcileFunctionSystemId({ + client, + worldDeploy, + reconciliation, +}: Pick & { + readonly reconciliation: FunctionReconciliation; +}): Promise { + // Legacy Worlds expose no compare-and-swap Store write. Re-read immediately + // before each attempt and verify after mining; deploys must still be serialized. + return pRetry( + async () => { + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const [currentRoute] = await getFunctionRoutes({ + client, + worldDeploy: latestWorldDeploy, + selectors: [reconciliation.func.selector], + }); + + let action: "skip" | "write"; + try { + action = getFunctionReconciliationAction({ reconciliation, currentRoute }); + } catch (error) { + throw new AbortError(error instanceof Error ? error : new Error(String(error))); + } + if (action === "skip") return undefined; + + const write = getFunctionSystemIdWrite(reconciliation.func); + return writeContract(client, { + chain: client.chain ?? null, + address: worldDeploy.address, + abi: worldAbi, + functionName: "setField", + args: [write.tableId, write.keyTuple, write.fieldIndex, write.data], + }); + }, + { + retries: 3, + onFailedAttempt: () => + debug(`failed to reconcile function ${reconciliation.func.signature}, re-reading route before retry`), + }, + ); +} + +async function getFunctionPlan({ + client, + worldDeploy, + functions, +}: Pick & { + readonly functions: readonly WorldFunction[]; +}): Promise { + const registeredRoutes = await getFunctionRoutes({ + client, + worldDeploy, + selectors: functions.map((func) => func.selector), + }); + return planFunctionRegistrations(functions, registeredRoutes); +} export async function ensureFunctions({ client, worldDeploy, functions, - indexerUrl, - chainId, }: CommonDeployOptions & { readonly functions: readonly WorldFunction[]; }): Promise { - const worldFunctions = await getFunctions({ - client, - worldAddress: worldDeploy.address, - fromBlock: worldDeploy.deployBlock, - toBlock: worldDeploy.stateBlock, - indexerUrl, - chainId, - }); - const worldSelectorToFunction = Object.fromEntries(worldFunctions.map((func) => [func.selector, func])); - - const toSkip = functions.filter((func) => worldSelectorToFunction[func.selector]); - const toAdd = functions.filter((func) => !toSkip.includes(func)); - - if (toSkip.length) { - debug("functions already registered:", toSkip.map((func) => func.signature).join(", ")); - const wrongSystem = toSkip.filter((func) => func.systemId !== worldSelectorToFunction[func.selector]?.systemId); - if (wrongSystem.length) { - console.warn( - "found", - wrongSystem.length, - "functions already registered but pointing at a different system ID:", - wrongSystem.map((func) => func.signature).join(", "), - ); - } + const planningWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const plan = await getFunctionPlan({ client, worldDeploy: planningWorldDeploy, functions }); + + if (plan.toSkip.length) { + debug("functions already registered:", plan.toSkip.map((func) => func.signature).join(", ")); } - if (!toAdd.length) return []; - - debug("registering functions:", toAdd.map((func) => func.signature).join(", ")); - - return Promise.all( - toAdd.map((func) => { - const { namespace } = hexToResource(func.systemId); - - // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) - const params = - namespace === "" - ? ({ - functionName: "registerRootFunctionSelector", - args: [ - func.systemId, - // use system function signature as world signature - func.systemFunctionSignature, - func.systemFunctionSignature, - ], - } as const) - : ({ - functionName: "registerFunctionSelector", - args: [func.systemId, func.systemFunctionSignature], - } as const); - - return pRetry( - () => - writeContract(client, { - chain: client.chain ?? null, - address: worldDeploy.address, - abi: worldAbi, - ...params, - }), - { - retries: 3, - onFailedAttempt: () => debug(`failed to register function ${func.signature}, retrying...`), - }, - ); - }), - ); + if (!plan.toAdd.length && !plan.toReconcile.length) return []; + + if (plan.toReconcile.length) { + debug("reconciling function System IDs:", plan.toReconcile.map(({ func }) => func.signature).join(", ")); + await assertReconciliationPreconditions({ client, worldDeploy, reconciliations: plan.toReconcile }); + } + + if (plan.toAdd.length) { + debug("registering functions:", plan.toAdd.map((func) => func.signature).join(", ")); + } + + const hashes = ( + await Promise.all([ + ...plan.toAdd.map((func) => { + const { namespace } = hexToResource(func.systemId); + + // TODO: replace with batchCall (https://github.com/latticexyz/mud/issues/1645) + const params = + namespace === "" + ? ({ + functionName: "registerRootFunctionSelector", + args: [ + func.systemId, + // use system function signature as world signature + func.systemFunctionSignature, + func.systemFunctionSignature, + ], + } as const) + : ({ + functionName: "registerFunctionSelector", + args: [func.systemId, func.systemFunctionSignature], + } as const); + + return pRetry( + () => + writeContract(client, { + chain: client.chain ?? null, + address: worldDeploy.address, + abi: worldAbi, + ...params, + }), + { + retries: 3, + onFailedAttempt: () => debug(`failed to register function ${func.signature}, retrying...`), + }, + ); + }), + ...plan.toReconcile.map((reconciliation) => reconcileFunctionSystemId({ client, worldDeploy, reconciliation })), + ]) + ).filter(isDefined); + + if (hashes.length > 0) { + await waitForTransactions({ client, hashes, debugLabel: "function registrations and System ID reconciliation" }); + } + + const latestWorldDeploy = await getLatestWorldDeploy({ client, worldDeploy }); + const finalPlan = await getFunctionPlan({ client, worldDeploy: latestWorldDeploy, functions }); + assertFunctionPlanApplied(finalPlan); + if (plan.toReconcile.length > 0) { + await assertReconciliationTargetsActive({ + client, + worldDeploy: latestWorldDeploy, + reconciliations: plan.toReconcile, + }); + } + + return hashes; } diff --git a/packages/cli/src/deploy/functionPlan.test.ts b/packages/cli/src/deploy/functionPlan.test.ts new file mode 100644 index 0000000000..1057b051df --- /dev/null +++ b/packages/cli/src/deploy/functionPlan.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { Hex, toFunctionSelector, zeroHash } from "viem"; +import { resourceToHex } from "@latticexyz/common"; +import { WorldFunction } from "./common"; +import { assertFunctionPlanApplied, planFunctionRegistrations } from "./functionPlan"; + +const sourceSystemId = `0x${"11".repeat(32)}` as Hex; +const targetSystemId = `0x${"22".repeat(32)}` as Hex; + +function worldFunction(overrides: Partial = {}): WorldFunction { + return { + signature: "app__run()", + selector: "0x12345678", + systemId: targetSystemId, + systemFunctionSignature: "run()", + systemFunctionSelector: "0x87654321", + ...overrides, + }; +} + +describe("planFunctionRegistrations", () => { + it("adds missing routes", () => { + const func = worldFunction(); + + expect(planFunctionRegistrations([func], [])).toEqual({ toAdd: [func], toSkip: [], toReconcile: [] }); + }); + + it("skips routes only when the complete tuple matches", () => { + const func = worldFunction(); + + expect( + planFunctionRegistrations( + [func], + [ + { + selector: func.selector, + systemId: func.systemId, + systemFunctionSelector: func.systemFunctionSelector, + }, + ], + ), + ).toEqual({ toAdd: [], toSkip: [func], toReconcile: [] }); + }); + + it("reconciles a selector registered to another System when the System function selector is exact", () => { + const func = worldFunction(); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + + expect(planFunctionRegistrations([func], [expectedRoute])).toEqual({ + toAdd: [], + toSkip: [], + toReconcile: [{ func, expectedRoute }], + }); + }); + + it("rejects a selector registered to another system function", () => { + const func = worldFunction(); + + expect(() => + planFunctionRegistrations( + [func], + [ + { + selector: func.selector, + systemId: func.systemId, + systemFunctionSelector: "0xaaaaaaaa", + }, + ], + ), + ).toThrowError(`Registered: systemId=${targetSystemId}, systemFunctionSelector=0xaaaaaaaa`); + }); + + it("rejects malformed partial routes instead of treating them as reconcilable", () => { + const func = worldFunction(); + + expect(() => + planFunctionRegistrations( + [func], + [{ selector: func.selector, systemId: zeroHash, systemFunctionSelector: func.systemFunctionSelector }], + ), + ).toThrowError("has a malformed registered route"); + }); + + it("plans the two real PlayerStash selector repairs", () => { + const legacySystemId = resourceToHex({ type: "system", namespace: "valhalla", name: "PlayerStashSyste" }); + const desiredSystemId = resourceToHex({ type: "system", namespace: "valhalla", name: "p_stash" }); + const systemSignatures = [ + "exchangeItems(uint64[],uint64[],uint64[],uint64[])", + "exchangeAmulets(uint64[],uint64[])", + ]; + const functions = systemSignatures.map( + (systemFunctionSignature): WorldFunction => ({ + signature: `valhalla__${systemFunctionSignature}`, + selector: toFunctionSelector(`valhalla__${systemFunctionSignature}`), + systemId: desiredSystemId, + systemFunctionSignature, + systemFunctionSelector: toFunctionSelector(systemFunctionSignature), + }), + ); + const routes = functions.map((func) => ({ + selector: func.selector, + systemId: legacySystemId, + systemFunctionSelector: func.systemFunctionSelector, + })); + + expect(planFunctionRegistrations(functions, routes)).toEqual({ + toAdd: [], + toSkip: [], + toReconcile: functions.map((func, index) => ({ func, expectedRoute: routes[index] })), + }); + }); + + it("rejects conflicting configured functions", () => { + const first = worldFunction(); + const second = worldFunction({ + signature: "app__other()", + systemFunctionSignature: "other()", + systemFunctionSelector: "0xaaaaaaaa", + }); + + expect(() => planFunctionRegistrations([first, second], [])).toThrowError( + `Configured functions collide on world selector ${first.selector}.`, + ); + }); + + it("deduplicates exact configured functions", () => { + const func = worldFunction(); + + expect(planFunctionRegistrations([func, func], [])).toEqual({ toAdd: [func], toSkip: [], toReconcile: [] }); + }); +}); + +describe("assertFunctionPlanApplied", () => { + it("rejects routes that remain missing", () => { + const func = worldFunction(); + + expect(() => assertFunctionPlanApplied({ toAdd: [func], toSkip: [], toReconcile: [] })).toThrowError( + `Function route verification failed after deployment`, + ); + }); + + it("rejects routes that still need reconciliation", () => { + const func = worldFunction(); + const expectedRoute = { + selector: func.selector, + systemId: sourceSystemId, + systemFunctionSelector: func.systemFunctionSelector, + }; + + expect(() => + assertFunctionPlanApplied({ toAdd: [], toSkip: [], toReconcile: [{ func, expectedRoute }] }), + ).toThrowError("Routes still pointing at stale System IDs"); + }); +}); diff --git a/packages/cli/src/deploy/functionPlan.ts b/packages/cli/src/deploy/functionPlan.ts new file mode 100644 index 0000000000..a59070d67f --- /dev/null +++ b/packages/cli/src/deploy/functionPlan.ts @@ -0,0 +1,150 @@ +import { Hex, zeroHash } from "viem"; +import { WorldFunction } from "./common"; + +export type FunctionRoute = { + readonly selector: Hex; + readonly systemId: Hex; + readonly systemFunctionSelector: Hex; +}; + +export type FunctionRegistrationPlan = { + readonly toAdd: readonly WorldFunction[]; + readonly toSkip: readonly WorldFunction[]; + /** Existing routes whose System ID can be reconciled without changing their function selector. */ + readonly toReconcile: readonly { + readonly func: WorldFunction; + readonly expectedRoute: FunctionRoute; + }[]; +}; + +const emptyFunctionSelector = "0x00000000"; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +function routesMatch( + expected: Pick, + actual: Pick, +): boolean { + return ( + normalizeHex(expected.systemId) === normalizeHex(actual.systemId) && + normalizeHex(expected.systemFunctionSelector) === normalizeHex(actual.systemFunctionSelector) + ); +} + +function sameHex(a: Hex, b: Hex): boolean { + return normalizeHex(a) === normalizeHex(b); +} + +export function isMalformedFunctionRoute(route: FunctionRoute): boolean { + const hasSystemId = !sameHex(route.systemId, zeroHash); + const hasSystemFunctionSelector = !sameHex(route.systemFunctionSelector, emptyFunctionSelector); + return hasSystemId !== hasSystemFunctionSelector; +} + +function formatRoute(route: Pick): string { + return `systemId=${route.systemId}, systemFunctionSelector=${route.systemFunctionSelector}`; +} + +function uniqueConfiguredFunctions(functions: readonly WorldFunction[]): readonly WorldFunction[] { + const bySelector = new Map(); + + for (const func of functions) { + const selector = normalizeHex(func.selector); + const existing = bySelector.get(selector); + if (existing == null) { + bySelector.set(selector, func); + continue; + } + + const isExactDuplicate = + existing.signature === func.signature && + existing.systemFunctionSignature === func.systemFunctionSignature && + routesMatch(existing, func); + if (isExactDuplicate) continue; + + throw new Error( + [ + `Configured functions collide on world selector ${func.selector}.`, + `First: ${existing.signature} (${formatRoute(existing)})`, + `Second: ${func.signature} (${formatRoute(func)})`, + "Each world selector must have exactly one configured route.", + ].join("\n"), + ); + } + + return [...bySelector.values()]; +} + +/** + * Build a write plan from configured functions and a read-only snapshot of registered routes. + * An existing selector may be reconciled only when its System function selector is already exact. + * This permits correcting a stale System ID with the World's narrow Store write API without + * changing which function is dispatched inside the destination System. + */ +export function planFunctionRegistrations( + functions: readonly WorldFunction[], + registeredRoutes: readonly FunctionRoute[], +): FunctionRegistrationPlan { + const routesBySelector = new Map(registeredRoutes.map((route) => [normalizeHex(route.selector), route])); + const toAdd: WorldFunction[] = []; + const toSkip: WorldFunction[] = []; + const toReconcile: { func: WorldFunction; expectedRoute: FunctionRoute }[] = []; + + for (const func of uniqueConfiguredFunctions(functions)) { + const registered = routesBySelector.get(normalizeHex(func.selector)); + if (registered == null) { + toAdd.push(func); + continue; + } + + if (routesMatch(func, registered)) { + toSkip.push(func); + continue; + } + + if (isMalformedFunctionRoute(registered)) { + throw new Error( + [ + `World function ${func.signature} (${func.selector}) has a malformed registered route.`, + `Registered: ${formatRoute(registered)}`, + "Refusing to infer or overwrite an unknown selector state.", + ].join("\n"), + ); + } + + if (sameHex(func.systemFunctionSelector, registered.systemFunctionSelector)) { + toReconcile.push({ func, expectedRoute: registered }); + continue; + } + + throw new Error( + [ + `World function ${func.signature} (${func.selector}) is already registered with a different route.`, + `Configured: ${formatRoute(func)}`, + `Registered: ${formatRoute(registered)}`, + "Refusing to change the System function selector through deployment reconciliation.", + ].join("\n"), + ); + } + + return { toAdd, toSkip, toReconcile }; +} + +export function assertFunctionPlanApplied(plan: FunctionRegistrationPlan): void { + if (plan.toAdd.length === 0 && plan.toReconcile.length === 0) return; + + throw new Error( + [ + "Function route verification failed after deployment:", + ...(plan.toAdd.length > 0 ? ["Missing routes:"] : []), + ...plan.toAdd.map((func) => `- ${func.signature} (${func.selector}): ${formatRoute(func)}`), + ...(plan.toReconcile.length > 0 ? ["Routes still pointing at stale System IDs:"] : []), + ...plan.toReconcile.map( + ({ func, expectedRoute }) => + `- ${func.signature} (${func.selector}): expected ${formatRoute(func)}, current ${formatRoute(expectedRoute)}`, + ), + ].join("\n"), + ); +} diff --git a/packages/cli/src/deploy/getFunctionRoutes.ts b/packages/cli/src/deploy/getFunctionRoutes.ts new file mode 100644 index 0000000000..9eb2b5befa --- /dev/null +++ b/packages/cli/src/deploy/getFunctionRoutes.ts @@ -0,0 +1,49 @@ +import type { Hex } from "viem"; +import { zeroHash } from "viem"; +import worldConfig from "@latticexyz/world/mud.config"; +import type { CommonDeployOptions } from "./common"; +import type { FunctionRoute } from "./functionPlan"; +import { getRecord } from "./getRecord"; + +const emptyFunctionSelector = "0x00000000"; + +function normalizeHex(value: Hex): string { + return value.toLowerCase(); +} + +export function isEmptyFunctionRoute(route: Pick): boolean { + return ( + normalizeHex(route.systemId) === zeroHash && normalizeHex(route.systemFunctionSelector) === emptyFunctionSelector + ); +} + +/** Read exact route tuples from the World at its introspection block. */ +export async function getFunctionRoutes({ + client, + worldDeploy, + selectors, +}: Pick & { + readonly selectors: readonly Hex[]; +}): Promise { + const uniqueSelectors = [...new Map(selectors.map((selector) => [normalizeHex(selector), selector])).values()]; + const functionSelectorsTable = worldConfig.namespaces.world.tables.FunctionSelectors; + + const routes = await Promise.all( + uniqueSelectors.map(async (selector): Promise => { + const record = await getRecord({ + client, + worldDeploy, + table: functionSelectorsTable, + key: { worldFunctionSelector: selector }, + }); + + return { + selector, + systemId: record.systemId, + systemFunctionSelector: record.systemFunctionSelector, + }; + }), + ); + + return routes.filter((route) => !isEmptyFunctionRoute(route)); +} diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index 1ee7df7d18..26c373f99a 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -1,6 +1,5 @@ import path from "node:path"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import packageJson from "../package.json"; import { InferredOptionTypes, Options } from "yargs"; import { deploy } from "./deploy/deploy"; import { createWalletClient, http, Hex, isHex, stringToHex } from "viem"; @@ -20,6 +19,7 @@ import { configToModules } from "./deploy/configToModules"; import { findContractArtifacts } from "@latticexyz/world/node"; import { enableAutomine } from "./utils/enableAutomine"; import { defaultChains } from "./defaultChains"; +import { cliPackageInfo } from "./version"; export const deployOptions = { configPath: { type: "string", desc: "Path to the MUD config file" }, @@ -72,7 +72,7 @@ export async function runDeploy(opts: DeployOptions): Promise { const config = (await loadConfig(configPath)) as WorldConfig; const rootDir = path.dirname(configPath); - console.log(chalk.green(`\nUsing ${packageJson.name}@${packageJson.version}`)); + console.log(chalk.green(`\nUsing ${cliPackageInfo.name}@${cliPackageInfo.version}`)); if (opts.printConfig) { console.log(chalk.green("\nResolved config:\n"), JSON.stringify(config, null, 2)); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000000..4db98f6a3d --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; + +export type CliPackageInfo = { + readonly name: string; + readonly version: string; +}; + +/** + * Read package metadata at runtime so staged release manifests and the CLI's + * reported version cannot drift apart after the bundle is built. + */ +export const cliPackageInfo = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as CliPackageInfo; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index c307577d6c..72fdbeb400 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -20,7 +20,7 @@ const mudPackages: MudPackages = Object.fromEntries( export default defineConfig((opts) => ({ ...baseConfig(opts), - entry: ["src/index.ts", "src/mud.ts"], + entry: ["src/index.ts", "src/mud.ts", "src/version.ts"], env: { MUD_PACKAGES: JSON.stringify(mudPackages), }, diff --git a/packages/world/test/FunctionSelectorSystemIdRepair.t.sol b/packages/world/test/FunctionSelectorSystemIdRepair.t.sol new file mode 100644 index 0000000000..e70b3fd586 --- /dev/null +++ b/packages/world/test/FunctionSelectorSystemIdRepair.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; + +import { IBaseWorld } from "../src/codegen/interfaces/IBaseWorld.sol"; +import { FunctionSelectors } from "../src/codegen/tables/FunctionSelectors.sol"; +import { IWorldErrors } from "../src/IWorldErrors.sol"; +import { System } from "../src/System.sol"; +import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "../src/WorldResourceId.sol"; +import { RESOURCE_SYSTEM } from "../src/worldResourceTypes.sol"; +import { createWorld } from "./createWorld.sol"; + +contract StaleRouteSystem is System { + function routedValue() external pure returns (uint256) { + return 1; + } +} + +contract TargetRouteSystem is System { + function routedValue() external pure returns (uint256) { + return 2; + } +} + +contract FunctionSelectorSystemIdRepairTest is Test { + using WorldResourceIdInstance for ResourceId; + + IBaseWorld internal world; + ResourceId internal staleSystemId; + ResourceId internal targetSystemId; + bytes4 internal worldFunctionSelector; + + function setUp() public { + world = createWorld(); + StoreSwitch.setStoreAddress(address(world)); + + staleSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "repair", name: "stale" }); + targetSystemId = WorldResourceIdLib.encode({ typeId: RESOURCE_SYSTEM, namespace: "repair", name: "target" }); + + world.registerNamespace(staleSystemId.getNamespaceId()); + world.registerSystem(staleSystemId, new StaleRouteSystem(), true); + world.registerSystem(targetSystemId, new TargetRouteSystem(), true); + worldFunctionSelector = world.registerFunctionSelector(staleSystemId, "routedValue()"); + } + + function testWorldOwnerCanRepairOnlyFunctionSelectorSystemId() public { + assertEq(world.worldVersion(), bytes32("2.0.2")); + assertEq(_callRoutedValue(), 1); + + bytes4 systemFunctionSelector = FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector); + assertEq(ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), ResourceId.unwrap(staleSystemId)); + assertEq(systemFunctionSelector, StaleRouteSystem.routedValue.selector); + + bytes32[] memory keyTuple = _functionSelectorKeyTuple(); + bytes memory targetSystemIdData = abi.encodePacked(ResourceId.unwrap(targetSystemId)); + + address unauthorizedCaller = address(0xBEEF); + vm.prank(unauthorizedCaller); + vm.expectRevert( + abi.encodeWithSelector( + IWorldErrors.World_AccessDenied.selector, + FunctionSelectors._tableId.toString(), + unauthorizedCaller + ) + ); + world.setField(FunctionSelectors._tableId, keyTuple, 0, targetSystemIdData); + + assertEq(ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), ResourceId.unwrap(staleSystemId)); + assertEq(FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector), systemFunctionSelector); + + world.setField(FunctionSelectors._tableId, keyTuple, 0, targetSystemIdData); + + assertEq( + ResourceId.unwrap(FunctionSelectors.getSystemId(worldFunctionSelector)), + ResourceId.unwrap(targetSystemId) + ); + assertEq(FunctionSelectors.getSystemFunctionSelector(worldFunctionSelector), systemFunctionSelector); + assertEq(_callRoutedValue(), 2); + } + + function _functionSelectorKeyTuple() internal view returns (bytes32[] memory keyTuple) { + keyTuple = new bytes32[](1); + keyTuple[0] = bytes32(worldFunctionSelector); + } + + function _callRoutedValue() internal returns (uint256 value) { + (bool success, bytes memory returnData) = address(world).call(abi.encodePacked(worldFunctionSelector)); + assertTrue(success, "World dispatch failed"); + value = abi.decode(returnData, (uint256)); + } +} diff --git a/scripts/package-fork-release.mjs b/scripts/package-fork-release.mjs new file mode 100644 index 0000000000..21088230ef --- /dev/null +++ b/scripts/package-fork-release.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + constants as fsConstants, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const baseVersion = "2.2.23"; +export const forkRepository = { + type: "git", + url: "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/Floki-Inu/mud.git", + directory: "packages/cli", +}; + +const dependencySections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; +const packageDefinitions = [ + { + name: "@latticexyz/cli", + sourceDirectory: "packages/cli", + requiredFiles: ["bin/mud.js", "dist/index.js", "dist/mud.js", "dist/version.js"], + }, +]; + +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const safeTagPattern = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; + +function compareStrings(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function usage() { + return `Usage: + pnpm release:pack-fork -- --version --tag --output + +Options: + --version Fork package version, for example 2.2.24-floki.1 + --tag GitHub Release tag; must equal v + --output New or existing output directory (target assets must not exist) + --help Show this help +`; +} + +export function parseArguments(argv) { + const values = {}; + const allowed = new Set(["version", "tag", "output"]); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--") continue; + if (argument === "--help" || argument === "-h") return { help: true }; + if (!argument.startsWith("--")) throw new Error(`Unexpected argument: ${argument}`); + + const separatorIndex = argument.indexOf("="); + const key = argument.slice(2, separatorIndex === -1 ? undefined : separatorIndex); + if (!allowed.has(key)) throw new Error(`Unknown option: --${key}`); + if (values[key] != null) throw new Error(`Option --${key} was provided more than once.`); + + const value = separatorIndex === -1 ? argv[++index] : argument.slice(separatorIndex + 1); + if (value == null || value === "" || value.startsWith("--")) { + throw new Error(`Option --${key} requires a value.`); + } + values[key] = value; + } + + for (const required of ["version", "tag", "output"]) { + if (values[required] == null) throw new Error(`Missing required option: --${required}`); + } + + if (!semverPattern.test(values.version)) throw new Error(`Invalid semantic version: ${values.version}`); + if (!values.version.includes("-floki.")) { + throw new Error(`Fork version must include the "-floki." prerelease identifier: ${values.version}`); + } + if (!safeTagPattern.test(values.tag)) throw new Error(`Unsafe GitHub Release tag: ${values.tag}`); + if (values.tag !== `v${values.version}`) { + throw new Error(`GitHub Release tag must be exactly v${values.version}; received ${values.tag}.`); + } + return { + help: false, + version: values.version, + tag: values.tag, + output: resolve(values.output), + }; +} + +export function packageTarballName(packageName, version) { + return `${packageName.replace(/^@/, "").replaceAll("/", "-")}-${version}.tgz`; +} + +export function stageManifest({ manifest, packageName, version }) { + if (manifest.name !== packageName) { + throw new Error(`Expected raw package ${packageName}, received ${String(manifest.name)}.`); + } + if (manifest.version !== baseVersion) { + throw new Error( + `Expected ${packageName} raw package version ${baseVersion}, received ${String(manifest.version)}. ` + + "Update the fork packaging baseVersion deliberately when rebasing.", + ); + } + + const staged = structuredClone(manifest); + staged.version = version; + staged.repository = structuredClone(forkRepository); + for (const section of dependencySections) { + if (staged[section] == null) continue; + for (const dependencyName of Object.keys(staged[section])) { + if (!dependencyName.startsWith("@latticexyz/")) continue; + staged[section][dependencyName] = baseVersion; + } + staged[section] = Object.fromEntries( + Object.entries(staged[section]).sort(([left], [right]) => compareStrings(left, right)), + ); + } + return staged; +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error != null) throw result.error; + if (result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(`${command} ${arguments_.join(" ")} failed${details ? `:\n${details}` : "."}`); + } + return result.stdout.trim(); +} + +function parsePackResult(output, packageName) { + let result; + try { + result = JSON.parse(output); + } catch { + throw new Error(`Could not parse pnpm pack output for ${packageName}.`); + } + const pack = Array.isArray(result) ? result[0] : result; + if (pack?.filename == null) throw new Error(`pnpm pack did not return a filename for ${packageName}.`); + return resolve(pack.filename); +} + +function packDirectory(directory, destination, packageName) { + mkdirSync(destination, { recursive: true }); + return parsePackResult( + run("corepack", ["pnpm", "--dir", directory, "pack", "--pack-destination", destination, "--json"]), + packageName, + ); +} + +function extractArchive(archive, destination) { + mkdirSync(destination, { recursive: true }); + const entries = run("tar", ["-tzf", archive]).split("\n").filter(Boolean); + if (entries.length === 0 || entries.some((entry) => entry !== "package" && !entry.startsWith("package/"))) { + throw new Error(`Archive ${basename(archive)} contains an unexpected path.`); + } + if (entries.some((entry) => entry.split("/").includes(".."))) { + throw new Error(`Archive ${basename(archive)} contains a parent-directory path.`); + } + run("tar", ["-xzf", archive, "-C", destination]); + return join(destination, "package"); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function assertFile(path, label) { + if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Missing ${label}: ${path}`); +} + +function readCliRuntimePackageInfo(packageRoot) { + const moduleUrl = pathToFileURL(join(packageRoot, "dist/version.js")).href; + const output = run(process.execPath, [ + "--input-type=module", + "--eval", + `const { cliPackageInfo } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(JSON.stringify(cliPackageInfo));`, + ]); + try { + return JSON.parse(output); + } catch { + throw new Error("The packaged CLI runtime did not return valid package metadata."); + } +} + +export function validateStagedManifest({ manifest, packageName, version }) { + if (manifest.name !== packageName) throw new Error(`Unexpected package name: ${String(manifest.name)}`); + if (manifest.version !== version) { + throw new Error(`${packageName} has version ${String(manifest.version)} instead of ${version}.`); + } + + for (const section of dependencySections) { + for (const [dependencyName, dependencyVersion] of Object.entries(manifest[section] ?? {})) { + if (String(dependencyVersion).startsWith("workspace:")) { + throw new Error(`${packageName} ${section}.${dependencyName} still uses a workspace dependency.`); + } + if (!dependencyName.startsWith("@latticexyz/")) continue; + if (dependencyVersion !== baseVersion) { + throw new Error( + `${packageName} ${section}.${dependencyName} must be exactly ${baseVersion}, received ${String( + dependencyVersion, + )}.`, + ); + } + } + } + + if (packageName === "@latticexyz/cli" && manifest.dependencies?.["@latticexyz/world"] !== baseVersion) { + throw new Error(`The CLI package must depend on official @latticexyz/world ${baseVersion}.`); + } + if ( + packageName === "@latticexyz/cli" && + (manifest.repository?.type !== forkRepository.type || + manifest.repository?.url !== forkRepository.url || + manifest.repository?.directory !== forkRepository.directory) + ) { + throw new Error(`The CLI package repository must be ${forkRepository.url}.`); + } +} + +function validateArchive({ archive, definition, version, verificationRoot }) { + const packageRoot = extractArchive(archive, join(verificationRoot, definition.name.replaceAll("/", "-"))); + const manifest = readJson(join(packageRoot, "package.json")); + validateStagedManifest({ manifest, packageName: definition.name, version }); + + for (const relativePath of definition.requiredFiles) { + assertFile(join(packageRoot, relativePath), `${definition.name} package content ${relativePath}`); + } + + if (definition.name === "@latticexyz/cli") { + if (manifest.bin?.mud !== "./bin/mud.js") throw new Error("The CLI manifest does not expose ./bin/mud.js."); + const executable = join(packageRoot, "bin/mud.js"); + if ((statSync(executable).mode & 0o111) === 0) throw new Error("The packaged mud executable has no execute bit."); + if (!readFileSync(executable, "utf8").startsWith("#!/usr/bin/env node\n")) { + throw new Error("The packaged mud executable is missing its Node.js shebang."); + } + const runtimePackageInfo = readCliRuntimePackageInfo(packageRoot); + if (runtimePackageInfo.name !== manifest.name || runtimePackageInfo.version !== manifest.version) { + throw new Error( + `The packaged CLI runtime reports ${String(runtimePackageInfo.name)}@${String( + runtimePackageInfo.version, + )}, but its manifest is ${manifest.name}@${manifest.version}.`, + ); + } + } +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function copyReleaseAssets({ archives, output }) { + mkdirSync(output, { recursive: true }); + const checksumPath = join(output, "SHA256SUMS"); + const targets = [...archives.map((archive) => join(output, basename(archive))), checksumPath]; + const existing = targets.filter(existsSync); + if (existing.length > 0) { + throw new Error(`Refusing to overwrite existing release asset(s): ${existing.join(", ")}`); + } + + for (const archive of archives) { + copyFileSync(archive, join(output, basename(archive)), fsConstants.COPYFILE_EXCL); + } + const checksums = archives + .map((archive) => ({ filename: basename(archive), digest: sha256(archive) })) + .sort((left, right) => compareStrings(left.filename, right.filename)); + writeFileSync(checksumPath, `${checksums.map(({ digest, filename }) => `${digest} ${filename}`).join("\n")}\n`, { + flag: "wx", + }); + for (const { digest, filename } of checksums) { + if (sha256(join(output, filename)) !== digest) throw new Error(`Copied release asset failed SHA-256: ${filename}`); + } + return checksums; +} + +function ensureBuiltPackages(repositoryRoot) { + for (const definition of packageDefinitions) { + const packageRoot = join(repositoryRoot, definition.sourceDirectory); + const sourceManifest = readJson(join(packageRoot, "package.json")); + if (sourceManifest.version !== baseVersion) { + throw new Error( + `${definition.name} source version must be ${baseVersion}; received ${String(sourceManifest.version)}.`, + ); + } + for (const relativePath of definition.requiredFiles) { + assertFile(join(packageRoot, relativePath), `built ${definition.name} file ${relativePath}`); + } + } +} + +export function packageForkRelease(options) { + const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + ensureBuiltPackages(repositoryRoot); + + const temporaryRoot = mkdtempSync(join(tmpdir(), "mud-fork-release-")); + try { + const rawRoot = join(temporaryRoot, "raw"); + const stagedRoot = join(temporaryRoot, "staged"); + const repackedRoot = join(temporaryRoot, "repacked"); + const verificationRoot = join(temporaryRoot, "verified"); + const archives = []; + + for (const definition of packageDefinitions) { + const rawArchive = packDirectory(join(repositoryRoot, definition.sourceDirectory), rawRoot, definition.name); + const packageRoot = extractArchive(rawArchive, join(stagedRoot, definition.name.replaceAll("/", "-"))); + const manifestPath = join(packageRoot, "package.json"); + const manifest = stageManifest({ + manifest: readJson(manifestPath), + packageName: definition.name, + version: options.version, + }); + writeJson(manifestPath, manifest); + + const archive = packDirectory(packageRoot, repackedRoot, definition.name); + const expectedFilename = packageTarballName(definition.name, options.version); + if (basename(archive) !== expectedFilename) { + throw new Error(`Expected ${definition.name} asset ${expectedFilename}, received ${basename(archive)}.`); + } + validateArchive({ + archive, + definition, + version: options.version, + verificationRoot, + }); + archives.push(archive); + } + + const checksums = copyReleaseAssets({ archives, output: options.output }); + return { archives: archives.map((archive) => join(options.output, basename(archive))), checksums }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage()); + return; + } + const result = packageForkRelease(options); + for (const { digest, filename } of result.checksums) process.stdout.write(`${digest} ${filename}\n`); + process.stdout.write(`Wrote ${result.archives.length} packages and SHA256SUMS to ${options.output}\n`); +} + +const invokedAsScript = process.argv[1] != null && pathToFileURL(resolve(process.argv[1])).href === import.meta.url; +if (invokedAsScript) { + try { + main(); + } catch (error) { + process.stderr.write(`Fork release packaging failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/package-fork-release.test.mjs b/scripts/package-fork-release.test.mjs new file mode 100644 index 0000000000..403187df4c --- /dev/null +++ b/scripts/package-fork-release.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + baseVersion, + forkRepository, + packageTarballName, + parseArguments, + stageManifest, + validateStagedManifest, +} from "./package-fork-release.mjs"; + +const version = "2.2.24-floki.1"; +const tag = `v${version}`; + +describe("fork release packaging", () => { + it("parses and validates the required release arguments", () => { + assert.deepEqual(parseArguments(["--", "--version", version, "--tag", tag, "--output", "release"]), { + help: false, + version, + tag, + output: new URL("../release", import.meta.url).pathname, + }); + assert.throws(() => parseArguments(["--version", "2.2.24", "--tag", "v2.2.24", "--output", "release"]), /-floki\./); + assert.throws( + () => parseArguments(["--version", version, "--tag", "refs/tags/unsafe", "--output", "release"]), + /Unsafe GitHub Release tag/, + ); + assert.throws( + () => parseArguments(["--version", version, "--tag", "v2.2.24-floki.2", "--output", "release"]), + /must be exactly v2\.2\.24-floki\.1/, + ); + }); + + it("uses a stable CLI package filename", () => { + assert.equal(packageTarballName("@latticexyz/cli", version), `latticexyz-cli-${version}.tgz`); + }); + + it("pins every internal dependency, including World, to the official base version", () => { + const staged = stageManifest({ + manifest: { + name: "@latticexyz/cli", + version: baseVersion, + repository: { + type: "git", + url: "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/latticexyz/mud.git", + directory: "packages/cli", + }, + dependencies: { + "@latticexyz/common": "workspace:*", + "@latticexyz/world": "workspace:*", + viem: "2.35.1", + }, + devDependencies: { + "@latticexyz/abi-ts": "workspace:^", + }, + }, + packageName: "@latticexyz/cli", + version, + }); + + assert.equal(staged.version, version); + assert.deepEqual(staged.repository, forkRepository); + assert.equal(staged.dependencies["@latticexyz/world"], baseVersion); + assert.equal(staged.dependencies["@latticexyz/common"], baseVersion); + assert.equal(staged.devDependencies["@latticexyz/abi-ts"], baseVersion); + assert.equal(staged.dependencies.viem, "2.35.1"); + assert.deepEqual(Object.keys(staged.dependencies), ["@latticexyz/common", "@latticexyz/world", "viem"]); + assert.doesNotThrow(() => + validateStagedManifest({ + manifest: staged, + packageName: "@latticexyz/cli", + version, + }), + ); + }); + + it("fails closed on the wrong base version or unresolved internal dependency", () => { + assert.throws( + () => + stageManifest({ + manifest: { name: "@latticexyz/cli", version: "2.2.22" }, + packageName: "@latticexyz/cli", + version, + }), + /Expected @latticexyz\/cli raw package version 2\.2\.23/, + ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/cli", + version, + dependencies: { + "@latticexyz/store": "workspace:*", + "@latticexyz/world": baseVersion, + }, + }, + packageName: "@latticexyz/cli", + version, + }), + /still uses a workspace dependency/, + ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/cli", + version, + dependencies: { "@latticexyz/world": version }, + }, + packageName: "@latticexyz/cli", + version, + }), + /@latticexyz\/world must be exactly 2\.2\.23/, + ); + assert.throws( + () => + validateStagedManifest({ + manifest: { + name: "@latticexyz/cli", + version, + repository: { + type: "git", + url: "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/latticexyz/mud.git", + directory: "packages/cli", + }, + dependencies: { "@latticexyz/world": baseVersion }, + }, + packageName: "@latticexyz/cli", + version, + }), + /repository must be https:\/\/github\.com\/Floki-Inu\/mud\.git/, + ); + }); +}); diff --git a/scripts/render-api-docs.ts b/scripts/render-api-docs.ts index c5650a31b7..0cc52ce46d 100755 --- a/scripts/render-api-docs.ts +++ b/scripts/render-api-docs.ts @@ -398,7 +398,7 @@ function formatHeadings(content: string) { } function fixGithubLinks(content: string, packageName: string) { - const pattern = /https:\/\/github.com\/latticexyz\/mud\/blob\/[^/]+\/(.*)/g; + const pattern = /https:\/\/github\.com\/[^/]+\/mud\/blob\/[^/]+\/(.*)/g; const replacement = `https://github.com/latticexyz/mud/blob/main/packages/${packageName}/$1`; return content.replace(pattern, replacement); }