Skip to content
Merged
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/feat-skip-contract-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"playground-cli": minor
---

Add `--no-contract-build` flag to `dot deploy`. When set alongside `--contracts`, the deploy uses pre-existing contract artifacts (foundry `out/`, hardhat `artifacts/contracts/`, cdm `target/<crate>.release.polkavm`) instead of running the build toolchain. Useful for CI environments where `forge` / `cargo-contract` aren't installed.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` run

The publish step is always signed by the user so the registry contract records their address as the app owner — this is what drives the Playground "my apps" view.

#### CI / automation flags for `dot deploy`

These flags are designed for non-interactive (CI) usage. Combining all four of `--signer`, `--domain`, `--buildDir`, and `--playground` triggers fully headless mode; the flags below layer on top.

| Flag | Effect |
|---|---|
| `--signer dev` | Use the dev signer (no phone QR scan). Requires `--suri`. |
| `--suri <suri>` | Secret URI for the user signer (e.g. `//Alice` or a full mnemonic). Required with `--signer dev`. |
| `--domain <name>` | DotNS domain. Skips interactive selection. |
| `--buildDir <path>` | Path to existing built frontend assets. |
| `--no-build` | Skip the **frontend** build (`pnpm build`). Use pre-built `dist/`. |
| `--no-contract-build` | Skip the **contract** compile (`forge build --resolc`, `cargo-contract build`, `npx hardhat compile`). Use pre-built `out/` / `target/<crate>.release.polkavm` / `artifacts/contracts/`. Requires `--contracts`. |
| `--no-modable` | Don't push source to GitHub even if `--modable` would normally apply. |
| `--playground` | Publish to the playground registry. Required with all the above to trigger the fully-headless code path. |

### `dot mod`

Pull a modable playground app's source into a fresh local project so you can customise and re-deploy it. The interactive picker only shows apps that opted into modable at deploy time; non-modable apps surface a clear "this app is not modable" error if you target them by domain.
Expand Down
20 changes: 20 additions & 0 deletions src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ interface DeployOpts {
* a `--no-foo` option is declared.
*/
build?: boolean;
/**
* Commander's auto-negated boolean: defaults to `true`; `--no-contract-build` flips it to `false`.
* When false, the contract compile step (forge/hardhat/cargo-contract) is skipped and
* pre-existing artifacts on disk are used instead. CI-friendly for environments without
* the contract toolchains installed.
*/
contractBuild?: boolean;
/** Deploy the project's contracts alongside the frontend. Defaults to false. */
contracts?: boolean;
/** Publish the source repo so others can `dot mod` it. Commander auto-negates: `--no-modable` ⇒ false. */
Expand All @@ -68,6 +75,10 @@ export const deployCommand = new Command("deploy")
"--contracts",
"Also deploy any contracts detected in the project (foundry/hardhat/cdm)",
)
.option(
"--no-contract-build",
"Skip the contract compile step (forge/hardhat/cargo-contract) and deploy existing pre-built artifacts. Requires --contracts. Useful for CI environments without the contract toolchains installed.",
)
.option("--playground", "Publish to the playground registry")
.option(
"--private",
Expand Down Expand Up @@ -143,6 +154,13 @@ export const deployCommand = new Command("deploy")

try {
const nonInteractive = isFullySpecified(opts);

if (opts.contractBuild === false && opts.contracts && !nonInteractive) {
throw new Error(
"--no-contract-build requires headless mode (combine with --signer, --domain, --buildDir, --playground).",
);
}

if (nonInteractive) {
await runHeadless({ projectDir, env, userSigner, opts });
} else {
Expand Down Expand Up @@ -261,6 +279,7 @@ async function runHeadless(ctx: {
const buildDir = ctx.opts.buildDir as string;
const skipBuild = ctx.opts.build === false;
const deployContracts = Boolean(ctx.opts.contracts);
const skipContractBuild = ctx.opts.contractBuild === false;
const contractsType = safeDetectContractsType(ctx.projectDir);
if (deployContracts && contractsType === null) {
throw new Error(
Expand Down Expand Up @@ -369,6 +388,7 @@ async function runHeadless(ctx: {
modable,
repositoryUrl,
deployContracts,
skipContractBuild,
contractsFundingNeeded,
userSigner: ctx.userSigner,
plan: availability.plan,
Expand Down
148 changes: 147 additions & 1 deletion src/utils/deploy/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
import { describe, it, expect } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Buffer } from "node:buffer";
import { extractFoundryBytecode, extractHardhatBytecode, hexToBytes } from "./contracts.js";

// ── skipBuild mocks ──────────────────────────────────────────────────────────
//
// These mocks exist purely for the skipBuild integration tests at the bottom of
// this file. The pure-helper tests above do not need them.

const { runStreamedMock, deployBatchMock } = vi.hoisted(() => ({
runStreamedMock: vi.fn(async () => {}),
deployBatchMock: vi.fn(async () => ({
addresses: ["0xdeadbeef"] as `0x${string}`[],
chunkCount: 1,
})),
}));

vi.mock("../process.js", () => ({
runStreamed: runStreamedMock,
}));

vi.mock("@dotdm/contracts", async (importOriginal) => {
const real = await importOriginal<typeof import("@dotdm/contracts")>();
return {
...real,
buildContracts: vi.fn(async () => ({
contracts: [{ crate: "flipper", pvmPath: "/tmp/flipper.polkavm" }],
totalDurationMs: 1,
})),
detectContracts: vi.fn(() => [{ name: "flipper" }]),
ContractDeployer: class MockContractDeployer {
deployBatch = deployBatchMock;
},
};
});

describe("hexToBytes", () => {
it("decodes a 0x-prefixed hex string", () => {
expect(hexToBytes("0x50564d00")).toEqual(new Uint8Array([0x50, 0x56, 0x4d, 0x00]));
Expand Down Expand Up @@ -110,3 +145,114 @@ describe("extractHardhatBytecode", () => {
expect(extractHardhatBytecode(42)).toBeNull();
});
});

// ── skipBuild integration tests ───────────────────────────────────────────────
//
// These tests verify that the compile step (runStreamed) is NOT called when
// skipBuild is true, and that the discovery loop reads pre-existing artifacts.
// They use a real tmp dir so the fs checks in compileFoundry/compileHardhat
// work correctly; ContractDeployer.deployBatch is mocked to avoid chain I/O.

import { runContractsPhase } from "./contracts.js";

function makeOpts(projectDir: string, contractsType: "foundry" | "hardhat", skipBuild: boolean) {
return {
projectDir,
contractsType,
skipBuild,
// ContractDeployer is mocked — provide the minimal shape that the
// constructor call `new ContractDeployer(signer, origin, raw.assetHub,
// assetHub)` accesses before handing off to the mock.
client: { raw: { assetHub: {} }, assetHub: {} } as any,
signer: {} as any,
origin: "5FakeAddress" as any,
onEvent: () => {},
};
}

describe("runContractsPhase skipBuild=true (foundry)", () => {
let dir: string;

beforeEach(() => {
runStreamedMock.mockClear();
deployBatchMock.mockClear();
// Fresh tmp dir per test so artifact presence/absence is controlled.
dir = mkdtempSync(join(tmpdir(), "contracts-test-foundry-"));
});

it("uses existing foundry artifacts without spawning forge", async () => {
// Set up out/Counter.sol/Counter.json with real bytecode.
const solDir = join(dir, "out", "Counter.sol");
mkdirSync(solDir, { recursive: true });
writeFileSync(
join(solDir, "Counter.json"),
JSON.stringify({ bytecode: { object: "0x6080604052" } }),
);

const result = await runContractsPhase(makeOpts(dir, "foundry", true));

expect(runStreamedMock).not.toHaveBeenCalled();
expect(result.deployed).toHaveLength(1);
expect(result.deployed[0].name).toBe("Counter");
});

it("throws with a clear message when out/ is missing", async () => {
// No out/ directory — simulates a project where forge was never run.
mkdirSync(dir, { recursive: true });

await expect(runContractsPhase(makeOpts(dir, "foundry", true))).rejects.toThrow(
/no pre-built contract artifacts found at/,
);
expect(runStreamedMock).not.toHaveBeenCalled();
});

it("throws with a clear message when out/ exists but has no valid artifacts", async () => {
// out/ exists but is empty — forge ran but produced nothing usable.
mkdirSync(join(dir, "out"), { recursive: true });

await expect(runContractsPhase(makeOpts(dir, "foundry", true))).rejects.toThrow(
/no pre-built contract artifacts found at/,
);
});
});

describe("runContractsPhase skipBuild=true (hardhat)", () => {
let dir: string;

beforeEach(() => {
runStreamedMock.mockClear();
deployBatchMock.mockClear();
dir = mkdtempSync(join(tmpdir(), "contracts-test-hardhat-"));
});

it("uses existing hardhat artifacts without spawning npx hardhat compile", async () => {
// Set up artifacts/contracts/Lock.sol/Lock.json with real bytecode.
const solDir = join(dir, "artifacts", "contracts", "Lock.sol");
mkdirSync(solDir, { recursive: true });
writeFileSync(join(solDir, "Lock.json"), JSON.stringify({ bytecode: "0x6080604052" }));

const result = await runContractsPhase(makeOpts(dir, "hardhat", true));

expect(runStreamedMock).not.toHaveBeenCalled();
expect(result.deployed).toHaveLength(1);
expect(result.deployed[0].name).toBe("Lock");
});

it("throws with a clear message when artifacts/contracts/ is missing", async () => {
mkdirSync(dir, { recursive: true });

await expect(runContractsPhase(makeOpts(dir, "hardhat", true))).rejects.toThrow(
/no pre-built contract artifacts found at/,
);
expect(runStreamedMock).not.toHaveBeenCalled();
});

it("throws with a clear message when artifacts/contracts/ has no valid artifacts", async () => {
// Directory exists but contains no .sol subdirs with valid JSON.
mkdirSync(join(dir, "artifacts", "contracts"), { recursive: true });

await expect(runContractsPhase(makeOpts(dir, "hardhat", true))).rejects.toThrow(
/no pre-built contract artifacts found at/,
);
});
});
Loading
Loading