Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/workflows/Build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@ jobs:
run: pnpm -r --filter='!native-widgets' --filter=$(if [ "${{ steps.files.outputs.global_files }}" = "" ] && ${{ github.event_name == 'pull_request' }}; then echo '...[origin/mx/11.12.x]'; else echo '**'; fi) run build
env:
NODE_OPTIONS: --max_old_space_size=8192
- name: "Validating manifest format"
run: pnpm run validate-manifest-format
- name: "Validating native dependencies"
run: pnpm run validate-native-dependencies
env:
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message || github.event.pull_request.title }}
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
. "$(dirname "$0")/_/husky.sh"

npx --no -- commitlint --edit "$1"
pnpm run validate-native-dependencies "$1"
10 changes: 8 additions & 2 deletions configs/jsactions/rollup-plugin-collect-dependencies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ async function resolvePackage(target, sourceDir, optional = false) {
}

async function hasNativeCode(dir) {
return (await fg(["**/{android,ios}/*", "**/*.podspec"], { cwd: dir })).length > 0;
return (await fg(
["**/{android,ios}/*", "**/*.podspec"],
{
cwd: dir,
ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**"]
}
)).length > 0;
}

async function getTransitiveDependencies(packagePath, isExternal) {
Expand Down Expand Up @@ -187,7 +193,7 @@ export async function copyJsModule(moduleSourcePath, to) {

// Skip certain directories
if (
relativePath.match(/[\\/](android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)[\\/]/)
relativePath.match(/(^|[\\/])(android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)([\\/]|$)/)
) {
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"build-mpk": "ts-node --project ./scripts/tsconfig.json ./scripts/release/build-mpk.ts",
"version": "ts-node --project ./scripts/tsconfig.json ./scripts/release/BumpVersion.ts",
"validate-staged-widget-versions": "node scripts/validation/validate-versions-staged-files.js",
"validate-manifest-format": "node scripts/validation/validate-manifest-format.js",
"validate-native-dependencies": "node scripts/validation/validate-native-dependencies.js",
"setup-mobile": "pnpm setup-android && pnpm setup-ios",
"build:widgets": "node ./scripts/widget/buildWidgets.js",
"test_widgets:maestro:ios": "bash maestro/run_maestro_widget_tests.sh ios",
Expand Down
21 changes: 18 additions & 3 deletions patches/@mendix__pluggable-widgets-tools@11.12.0.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/configs/rollup-plugin-collect-dependencies.mjs b/configs/rollup-plugin-collect-dependencies.mjs
index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b695892f162a8 100644
index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9f5ae6f6adf3e41220d897ba1aa074c7da458314 100644
--- a/configs/rollup-plugin-collect-dependencies.mjs
+++ b/configs/rollup-plugin-collect-dependencies.mjs
@@ -1,8 +1,7 @@
Expand All @@ -21,7 +21,22 @@ index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b6958
);
}
}
@@ -169,14 +168,28 @@ async function copyJsModule(moduleSourcePath, to) {
@@ -125,7 +124,13 @@ async function resolvePackage(target, sourceDir, optional = false) {
}

async function hasNativeCode(dir) {
- return (await fg(["**/{android,ios}/*", "**/*.podspec"], { cwd: dir })).length > 0;
+ return (await fg(
+ ["**/{android,ios}/*", "**/*.podspec"],
+ {
+ cwd: dir,
+ ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**"]
+ }
+ )).length > 0;
}

async function getTransitiveDependencies(packagePath, isExternal) {
@@ -169,14 +174,28 @@ async function copyJsModule(moduleSourcePath, to) {
if (existsSync(to)) {
return;
}
Expand All @@ -45,7 +60,7 @@ index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b6958
+ filter: src => {
+ const relativePath = src.replace(actualSourcePath, "").replace(/^[\\/]/, "");
+
+ if (relativePath.match(/[\\/](android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)[\\/]/)) {
+ if (relativePath.match(/(^|[\\/])(android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)([\\/]|$)/)) {
+ return false;
+ }
+
Expand Down
2,850 changes: 1,553 additions & 1,297 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

107 changes: 107 additions & 0 deletions scripts/validation/validate-manifest-format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const { readFileSync, existsSync, readdirSync } = require("fs");
const { join } = require("path");

/**
* CI Check: Prevent Empty Manifest Objects
*
* Purpose: Ensure JS-only widgets don't emit empty {} manifests.
* Studio Pro's JSON parser uses MissingMemberHandling.Error with [JsonRequired],
* so an empty object would fail the entire manifest file parse.
*
* Expected behavior:
* - Widgets WITH native dependencies: emit {"nativeDependencies": {...}}
* - Widgets WITHOUT native dependencies: emit NO FILE at all
*/

try {
validateManifestFormat();
} catch (error) {
console.error(error);
process.exit(1);
}

function validateManifestFormat() {
const violations = [];
const widgetsDir = join(process.cwd(), "packages/pluggableWidgets");

if (!existsSync(widgetsDir)) {
console.log("✅ No widgets directory found");
return;
}

const widgets = readdirSync(widgetsDir);
let checkedCount = 0;

for (const widget of widgets) {
const manifestDir = join(widgetsDir, widget, "dist/tmp/widgets");

if (!existsSync(manifestDir)) continue;

const files = readdirSync(manifestDir).filter(f => f.endsWith(".json") && f !== "package.json");

for (const file of files) {
checkedCount++;
const fullPath = join(manifestDir, file);

try {
const content = readFileSync(fullPath, "utf-8");

// Check if file is empty (not even valid JSON)
if (!content.trim()) {
violations.push({
widget,
file,
reason: "File is completely empty (not even valid JSON)"
});
continue;
}

const manifest = JSON.parse(content);

// Check for empty manifest or empty nativeDependencies object
const isEmpty =
Object.keys(manifest).length === 0 ||
(manifest.nativeDependencies && Object.keys(manifest.nativeDependencies).length === 0);

if (isEmpty) {
violations.push({
widget,
file,
reason:
Object.keys(manifest).length === 0
? "Manifest is completely empty: {}"
: "nativeDependencies object is empty: {}"
});
}
} catch (error) {
// Invalid JSON is also a violation
violations.push({
widget,
file,
reason: `Invalid JSON: ${error.message}`
});
}
}
}

if (violations.length > 0) {
console.error("\n❌ EMPTY MANIFEST VIOLATION DETECTED\n");
console.error("The following widgets have empty manifest files:\n");

for (const violation of violations) {
console.error(` Widget: ${violation.widget}`);
console.error(` File: ${violation.file}`);
console.error(` Issue: ${violation.reason}\n`);
}

console.error("⚠️ Empty manifests break Studio Pro's JSON parser!");
console.error(" Expected behavior:");
console.error(" - Widgets WITH native deps: emit {nativeDependencies: {...}}");
console.error(" - Widgets WITHOUT native deps: emit NO FILE at all\n");
console.error("This is a bug in the build tooling. Check writeNativeDependenciesJson().\n");

throw new Error("Manifest format validation failed");
}

console.log(`✅ All manifests valid (checked ${checkedCount} manifest files)`);
}
153 changes: 153 additions & 0 deletions scripts/validation/validate-native-dependencies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
const { existsSync, readdirSync, readFileSync } = require("fs");
const { join } = require("path");
const { execSync } = require("child_process");
const fg = require("fast-glob");

/**
* CI Check: Detect New Native Dependencies
*
* Purpose: Warn when a widget adds a new dependency that contains native code.
* This helps catch when developers accidentally add native dependencies,
* which forces customers to rebuild their mobile apps.
*
* How it works:
* - Compares package.json dependencies against previous commit (HEAD)
* - For any NEW dependencies, checks if they contain native code
* - Warns if the new dependency has ios/android folders
*
* Bypass: Include "NATIVE_DEPENDENCY_APPROVED" in the commit message.
*/

try {
validateNativeDependencies();
} catch (error) {
console.error(error);
process.exit(1);
}

async function validateNativeDependencies() {
// Check for bypass approval
// In CI: read from environment variable
// In commit-msg hook: read from file path passed as argument
let commitMessage = process.env.CI_COMMIT_MESSAGE || "";

const commitMsgFile = process.argv[2]; // Path from commit-msg hook
if (commitMsgFile && existsSync(commitMsgFile)) {
commitMessage = readFileSync(commitMsgFile, "utf-8");
}

const isApproved = commitMessage.includes("NATIVE_DEPENDENCY_APPROVED");

if (isApproved) {
console.log("✅ Native dependency changes approved via commit message");
return;
}

const violations = [];
const widgetsDir = join(process.cwd(), "packages/pluggableWidgets");

if (!existsSync(widgetsDir)) {
console.log("✅ No widgets directory found");
return;
}

const widgets = readdirSync(widgetsDir);
let checkedCount = 0;

for (const widget of widgets) {
const widgetPath = join(widgetsDir, widget);
const packageJsonPath = join(widgetPath, "package.json");

if (!existsSync(packageJsonPath)) continue;

checkedCount++;

try {
// Get current package.json
const currentPackageJson = require(packageJsonPath);
const currentDeps = {
...currentPackageJson.dependencies,
...currentPackageJson.peerDependencies
};

// Get previous package.json from git (HEAD)
let previousDeps = {};
try {
const relativePath = join("packages/pluggableWidgets", widget, "package.json");
const previousContent = execSync(`git show HEAD:${relativePath}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"]
});
const previousPackageJson = JSON.parse(previousContent);
previousDeps = {
...previousPackageJson.dependencies,
...previousPackageJson.peerDependencies
};
} catch (error) {
// package.json doesn't exist in previous commit (new widget)
// We'll check all dependencies
}

// Find NEW dependencies (in current but not in previous)
const newDeps = Object.keys(currentDeps).filter(dep => !previousDeps[dep]);

if (newDeps.length === 0) continue;

// Check each new dependency for native code
for (const depName of newDeps) {
const depPath = join(widgetPath, "node_modules", depName);

if (!existsSync(depPath)) continue;

const hasNative = await hasNativeCode(depPath);

if (hasNative) {
violations.push({
widget,
dependency: `${depName}@${currentDeps[depName]}`,
path: depPath
});
}
}
} catch (error) {
console.warn(`⚠️ Could not check ${widget}: ${error.message}`);
}
}

if (violations.length > 0) {
console.error("\n❌ NEW NATIVE DEPENDENCY DETECTED\n");
console.error("The following widgets added dependencies with native code:\n");

for (const violation of violations) {
console.error(` Widget: ${violation.widget}`);
console.error(` New Dependency: ${violation.dependency}`);
console.error(` (Contains ios/android folders)\n`);
}

console.error("⚠️ Adding native dependencies is a BREAKING CHANGE:");
console.error(" - Forces customers to rebuild their mobile apps");
console.error(" - Requires resubmission to App Store / Play Store");
console.error(" - Can take days/weeks for customer deployment\n");
console.error("To approve this change, include 'NATIVE_DEPENDENCY_APPROVED' in your commit message.\n");

throw new Error("Native dependency validation failed");
}

console.log(`✅ No new native dependencies detected (checked ${checkedCount} widgets)`);
}

/**
* Check if a dependency contains native code
* Same logic as the patched hasNativeCode function
*/
async function hasNativeCode(dir) {
try {
const files = await fg(["**/{android,ios}/*", "**/*.podspec"], {
cwd: dir,
ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**"]
});
return files.length > 0;
} catch (error) {
return false;
}
}
Loading