Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-worlds-migrate.md
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 121 additions & 0 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
@@ -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}"
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/set-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
}
Expand Down
132 changes: 132 additions & 0 deletions packages/cli/src/deploy/ensureFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading