feat(cli): add --locks-required flag to wmill lint and sync push (#8026)

Add a --locks-required flag that fails if scripts or inline scripts
that need locks have no locks. Checks standalone scripts, flow inline
scripts, app inline scripts, and raw app backend scripts.

The flag can be set via CLI (--locks-required) or wmill.yaml config
(locksRequired: true). On sync push, verification runs before any
push operations to fail early.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-20 07:47:47 +01:00
committed by GitHub
parent adfd8b4df0
commit 4abe589397
6 changed files with 1200 additions and 6 deletions
+512 -1
View File
@@ -1,4 +1,11 @@
import { colors, Command, log, path, SEP } from "../../../deps.ts";
import {
colors,
Command,
log,
path,
SEP,
yamlParseFile,
} from "../../../deps.ts";
import { GlobalOptions } from "../../types.ts";
import { mergeConfigWithConfigFile } from "../../core/conf.ts";
import {
@@ -11,10 +18,23 @@ import {
type ValidationTarget,
WindmillYamlValidator,
} from "npm:windmill-yaml-validator@1.1.1";
import {
inferContentTypeFromFilePath,
languageNeedsLock,
ScriptLanguage,
} from "../../utils/script_common.ts";
import {
isFlowInlineScriptPath,
isAppInlineScriptPath,
isRawAppPath,
getFolderSuffix,
} from "../../utils/resource_folders.ts";
import { exts } from "../script/script.ts";
interface LintOptions extends GlobalOptions {
json?: boolean;
failOnWarn?: boolean;
locksRequired?: boolean;
}
interface FileIssue {
@@ -101,6 +121,487 @@ function formatYamlDiagnostics(parsed: { diagnostics?: Array<{ message?: string
return diagnostics.map((d) => d?.message || "Invalid YAML document");
}
/**
* Check if a lock value represents an actually resolved lock.
* Returns true if the lock is present and valid, false if missing.
* For `!inline` references, checks that the referenced file exists and is non-empty.
*/
async function isLockResolved(
lockValue: string | string[] | undefined,
baseDir: string,
): Promise<boolean> {
if (lockValue === undefined) return false;
// Array lock (v2 format) - if non-empty, locks are present
if (Array.isArray(lockValue)) {
const joined = lockValue.join("\n");
if (joined === "") return false;
if (joined.startsWith("!inline ")) {
return await checkInlineFile(joined.substring("!inline ".length), baseDir);
}
return true;
}
if (lockValue === "") return false;
// Inline file reference
if (lockValue.startsWith("!inline ")) {
return await checkInlineFile(lockValue.substring("!inline ".length), baseDir);
}
// Embedded lock content
return true;
}
async function checkInlineFile(
relativePath: string,
baseDir: string,
): Promise<boolean> {
const fullPath = path.join(baseDir, relativePath.trim());
try {
const stat = await Deno.stat(fullPath);
return stat.size > 0;
} catch {
return false;
}
}
/**
* Recursively find rawscript modules in a flow's module tree.
*/
function findRawScriptsInModules(
modules: any[],
): { language: string; lock: any; id: string }[] {
const results: { language: string; lock: any; id: string }[] = [];
if (!modules || !Array.isArray(modules)) return results;
for (const m of modules) {
if (!m?.value?.type) continue;
if (m.value.type === "rawscript") {
results.push({
language: m.value.language,
lock: m.value.lock,
id: m.id ?? "unknown",
});
} else if (
m.value.type === "forloopflow" ||
m.value.type === "whileloopflow"
) {
results.push(...findRawScriptsInModules(m.value.modules));
} else if (m.value.type === "branchall") {
for (const b of m.value.branches ?? []) {
results.push(...findRawScriptsInModules(b.modules));
}
} else if (m.value.type === "branchone") {
for (const b of m.value.branches ?? []) {
results.push(...findRawScriptsInModules(b.modules));
}
if (m.value.default) {
results.push(...findRawScriptsInModules(m.value.default));
}
} else if (m.value.type === "aiagent") {
for (const tool of m.value.tools ?? []) {
const toolValue = tool.value;
if (
toolValue?.tool_type === "flowmodule" &&
toolValue?.type === "rawscript"
) {
results.push({
language: toolValue.language,
lock: toolValue.lock,
id: tool.id ?? "unknown",
});
}
}
}
}
return results;
}
/**
* Recursively find inlineScript objects in a normal app's value structure.
* Follows the same traversal as traverseAndProcessInlineScripts in app_metadata.ts.
*/
function findInlineScriptsInApp(
obj: any,
currentPath: string[] = [],
): { language: string; lock: any; path: string }[] {
const results: { language: string; lock: any; path: string }[] = [];
if (!obj || typeof obj !== "object") return results;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
results.push(
...findInlineScriptsInApp(obj[i], [...currentPath, `[${i}]`]),
);
}
return results;
}
for (const [key, value] of Object.entries(obj)) {
if (key === "inlineScript" && typeof value === "object" && value !== null) {
const script = value as Record<string, any>;
if (script.language) {
results.push({
language: script.language,
lock: script.lock,
path: [...currentPath, key].join("."),
});
}
} else {
results.push(
...findInlineScriptsInApp(value, [...currentPath, key]),
);
}
}
return results;
}
/**
* Check raw app backend runnables for missing locks.
* Reads YAML config files and code files from the backend/ folder.
*/
async function checkRawAppRunnables(
backendDir: string,
rawAppYamlPath: string,
defaultTs: "bun" | "deno" | undefined,
): Promise<FileIssue[]> {
const issues: FileIssue[] = [];
const allFiles: string[] = [];
for await (const entry of Deno.readDir(backendDir)) {
if (entry.isFile) {
allFiles.push(entry.name);
}
}
// Track processed IDs to avoid duplicates
const processedIds = new Set<string>();
// Process YAML files (explicit config)
for (const fileName of allFiles) {
if (!fileName.endsWith(".yaml")) continue;
const runnableId = fileName.replace(".yaml", "");
processedIds.add(runnableId);
const filePath = path.join(backendDir, fileName);
let runnable: Record<string, any>;
try {
runnable = (await yamlParseFile(filePath)) as Record<string, any>;
} catch {
continue;
}
// Only inline runnables need lock checking
if (runnable?.type !== "inline") continue;
// Find the content file to determine language
let language: string | null = null;
for (const codeFile of allFiles) {
if (
codeFile.endsWith(".yaml") || codeFile.endsWith(".lock") ||
!codeFile.startsWith(runnableId + ".")
) continue;
language = inferContentTypeFromFilePath(codeFile, defaultTs);
break;
}
if (!language || !languageNeedsLock(language)) continue;
// Check for lock file
const lockFile = path.join(backendDir, `${runnableId}.lock`);
let hasLock = false;
try {
const stat = await Deno.stat(lockFile);
hasLock = stat.size > 0;
} catch {
// No lock file
}
// Also check if the runnable YAML has inlineScript.lock
if (!hasLock && runnable.inlineScript?.lock) {
hasLock = await isLockResolved(runnable.inlineScript.lock, backendDir);
}
if (!hasLock) {
issues.push({
path: rawAppYamlPath,
target: "raw_app_inline_script",
errors: [
`Missing lock for ${language} runnable '${runnableId}'. Run 'wmill app generate-locks' to generate locks.`,
],
});
}
}
// Auto-detect code files without YAML config
for (const fileName of allFiles) {
if (fileName.endsWith(".yaml") || fileName.endsWith(".lock")) continue;
// Extract runnableId from code file
let runnableId: string | null = null;
try {
const lang = inferContentTypeFromFilePath(fileName, defaultTs);
if (lang) {
// The runnableId is the filename without the extension portion
// We need to find which extension matches
for (const ext of exts) {
if (fileName.endsWith(ext)) {
runnableId = fileName.slice(0, -ext.length);
break;
}
}
}
} catch {
continue;
}
if (!runnableId || processedIds.has(runnableId)) continue;
processedIds.add(runnableId);
let language: string;
try {
language = inferContentTypeFromFilePath(fileName, defaultTs);
} catch {
continue;
}
if (!languageNeedsLock(language)) continue;
const lockFile = path.join(backendDir, `${runnableId}.lock`);
let hasLock = false;
try {
const stat = await Deno.stat(lockFile);
hasLock = stat.size > 0;
} catch {
// No lock file
}
if (!hasLock) {
issues.push({
path: rawAppYamlPath,
target: "raw_app_inline_script",
errors: [
`Missing lock for ${language} runnable '${runnableId}'. Run 'wmill app generate-locks' to generate locks.`,
],
});
}
}
return issues;
}
/**
* Check for missing lock files across scripts, flow inline scripts,
* app inline scripts, and raw app backend scripts.
* Returns a list of issues for scripts/inline scripts that should have locks but don't.
*/
export async function checkMissingLocks(
opts: GlobalOptions & { defaultTs?: "bun" | "deno" },
directory?: string,
): Promise<FileIssue[]> {
const initialCwd = Deno.cwd();
const targetDirectory = directory
? path.resolve(initialCwd, directory)
: Deno.cwd();
const { ...syncOpts } = opts;
const mergedOpts = await mergeConfigWithConfigFile(syncOpts);
const ignore = await ignoreF(mergedOpts);
const root = await FSFSElement(targetDirectory, [], false);
const issues: FileIssue[] = [];
const defaultTs = mergedOpts.defaultTs;
const flowSuffix = getFolderSuffix("flow");
const appSuffix = getFolderSuffix("app");
const rawAppSuffix = getFolderSuffix("raw_app");
// Collect all file paths and categorize them
const scriptYamls: string[] = [];
const flowYamls: { normalizedPath: string; fullPath: string }[] = [];
const appYamls: { normalizedPath: string; fullPath: string }[] = [];
const rawAppYamls: { normalizedPath: string; fullPath: string }[] = [];
for await (const entry of readDirRecursiveWithIgnore(ignore, root)) {
if (entry.isDirectory || entry.ignored) continue;
const normalizedPath = normalizePath(entry.path);
// Standalone script metadata files (not inside flow/app folders)
if (
normalizedPath.endsWith(".script.yaml") &&
!isFlowInlineScriptPath(normalizedPath) &&
!isAppInlineScriptPath(normalizedPath)
) {
scriptYamls.push(normalizedPath);
}
// Flow definition files
if (
normalizedPath.endsWith("/flow.yaml") &&
normalizedPath.includes(flowSuffix + "/")
) {
flowYamls.push({
normalizedPath,
fullPath: path.join(targetDirectory, entry.path),
});
}
// Normal app definition files
if (
normalizedPath.endsWith("/app.yaml") &&
normalizedPath.includes(appSuffix + "/")
) {
appYamls.push({
normalizedPath,
fullPath: path.join(targetDirectory, entry.path),
});
}
// Raw app definition files
if (
normalizedPath.endsWith("/raw_app.yaml") &&
normalizedPath.includes(rawAppSuffix + "/")
) {
rawAppYamls.push({
normalizedPath,
fullPath: path.join(targetDirectory, entry.path),
});
}
}
// Check standalone scripts
for (const yamlPath of scriptYamls) {
const basePath = yamlPath.replace(/\.script\.yaml$/, "");
// Find the content file to determine language
let language: ScriptLanguage | null = null;
for (const ext of exts) {
try {
await Deno.stat(path.join(targetDirectory, basePath + ext));
language = inferContentTypeFromFilePath(basePath + ext, defaultTs);
break;
} catch {
// Content file with this extension doesn't exist, try next
}
}
if (language && languageNeedsLock(language)) {
// Read the metadata to check the lock field
try {
const metadata = (await yamlParseFile(
path.join(targetDirectory, yamlPath),
)) as { lock?: string | string[] };
const lockResolved = await isLockResolved(
metadata?.lock,
targetDirectory,
);
if (!lockResolved) {
issues.push({
path: yamlPath,
target: "script",
errors: [
`Missing lock for ${language} script. Run 'wmill script generate-metadata' to generate locks.`,
],
});
}
} catch (e) {
log.debug(`Failed to parse ${yamlPath}: ${e}`);
}
}
}
// Check flow inline scripts
for (const { normalizedPath: flowYamlPath, fullPath } of flowYamls) {
const flowDir = path.dirname(fullPath);
try {
const flowFile = (await yamlParseFile(fullPath)) as {
value?: { modules?: any[] };
};
if (!flowFile?.value?.modules) continue;
const rawScripts = findRawScriptsInModules(flowFile.value.modules);
for (const script of rawScripts) {
if (!languageNeedsLock(script.language as ScriptLanguage)) continue;
const lockResolved = await isLockResolved(script.lock, flowDir);
if (!lockResolved) {
issues.push({
path: flowYamlPath,
target: "flow_inline_script",
errors: [
`Missing lock for ${script.language} inline script '${script.id}'. Run 'wmill flow generate-locks' to generate locks.`,
],
});
}
}
} catch (e) {
log.debug(`Failed to parse flow ${flowYamlPath}: ${e}`);
}
}
// Check normal app inline scripts
for (const { normalizedPath: appYamlPath, fullPath } of appYamls) {
const appDir = path.dirname(fullPath);
try {
const appFile = (await yamlParseFile(fullPath)) as { value?: any };
if (!appFile?.value) continue;
const inlineScripts = findInlineScriptsInApp(appFile.value);
for (const script of inlineScripts) {
if (!languageNeedsLock(script.language)) continue;
const lockResolved = await isLockResolved(script.lock, appDir);
if (!lockResolved) {
issues.push({
path: appYamlPath,
target: "app_inline_script",
errors: [
`Missing lock for ${script.language} inline script at '${script.path}'. Run 'wmill app generate-locks' to generate locks.`,
],
});
}
}
} catch (e) {
log.debug(`Failed to parse app ${appYamlPath}: ${e}`);
}
}
// Check raw app backend scripts
for (const { normalizedPath: rawAppYamlPath, fullPath } of rawAppYamls) {
const rawAppDir = path.dirname(fullPath);
const backendDir = path.join(rawAppDir, "backend");
try {
await Deno.stat(backendDir);
} catch {
continue; // No backend folder
}
try {
const runnableIssues = await checkRawAppRunnables(
backendDir,
rawAppYamlPath,
defaultTs,
);
issues.push(...runnableIssues);
} catch (e) {
log.debug(`Failed to check raw app runnables ${rawAppYamlPath}: ${e}`);
}
}
return issues;
}
export async function runLint(
opts: LintOptions,
directory?: string,
@@ -178,6 +679,12 @@ export async function runLint(
}
}
// Check for missing locks if --locks-required is set
if (opts.locksRequired) {
const lockIssues = await checkMissingLocks(opts, explicitTargetDirectory);
issues.push(...lockIssues);
}
const invalidFiles = issues.length;
const shouldFail = invalidFiles > 0 ||
(!!opts.failOnWarn && warnings.length > 0);
@@ -268,6 +775,10 @@ const command = new Command()
.arguments("[directory:string]")
.option("--json", "Output results in JSON format")
.option("--fail-on-warn", "Exit with code 1 when warnings are emitted")
.option(
"--locks-required",
"Fail if scripts or flow inline scripts that need locks have no locks",
)
.action(lint as any);
export default command;
+24 -1
View File
@@ -25,7 +25,7 @@ import {
extractNativeTriggerInfo,
} from "../../types.ts";
import { downloadZip } from "./pull.ts";
import { runLint, printReport } from "../lint/lint.ts";
import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts";
import {
exts,
@@ -2221,6 +2221,25 @@ export async function push(
}
}
if (opts.locksRequired) {
log.info("Checking for missing locks...");
const lockIssues = await checkMissingLocks(opts);
if (lockIssues.length > 0) {
for (const issue of lockIssues) {
for (const error of issue.errors) {
log.error(colors.red(` ${issue.path}: ${error}`));
}
}
log.error(
colors.red(
`\nPush aborted: ${lockIssues.length} script(s) missing locks.`,
),
);
Deno.exit(1);
}
log.info(colors.green("All scripts have valid locks."));
}
const codebases = await listSyncCodebases(opts);
if (opts.raw) {
log.info("--raw is now the default, you can remove it as a flag");
@@ -3090,6 +3109,10 @@ const command = new Command()
"Override the current git branch (works even outside a git repository)",
)
.option("--lint", "Run lint validation before pushing")
.option(
"--locks-required",
"Fail if scripts or flow inline scripts that need locks have no locks",
)
// deno-lint-ignore no-explicit-any
.action(push as any);
+1
View File
@@ -97,6 +97,7 @@ export interface SyncOptions {
};
promotion?: string;
lint?: boolean;
locksRequired?: boolean;
}
export interface Codebase {
+13
View File
@@ -39,6 +39,19 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [
{ language: "go", filename: "go.mod" },
] as const;
/**
* Returns true if a script in the given language requires a lock file.
* Matches the condition in updateScriptLock (metadata.ts).
*/
export function languageNeedsLock(language: ScriptLanguage | string): boolean {
return (
workspaceDependenciesLanguages.some((l) => l.language === language) ||
language === "deno" ||
language === "rust" ||
language === "ansible"
);
}
export function inferContentTypeFromFilePath(
contentPath: string,
defaultTs: "bun" | "deno" | undefined