From faebb4874ca50b5460e25b907c5b8546cfdb2487 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 24 Feb 2026 11:45:02 +1100 Subject: [PATCH 1/4] ci: add maintainability workflow for repo health checks Runs 10 parallel checks on PRs to staging: compose validation, makefile dry-run, fork marker integrity, shellcheck, actionlint, hadolint, dockerfile COPY validation, env sync, cross-compose consistency, and plugin discovery integrity. --- .github/workflows/maintainability.yml | 293 ++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 .github/workflows/maintainability.yml diff --git a/.github/workflows/maintainability.yml b/.github/workflows/maintainability.yml new file mode 100644 index 00000000..11248be9 --- /dev/null +++ b/.github/workflows/maintainability.yml @@ -0,0 +1,293 @@ +name: Maintainability + +on: + pull_request: + branches: [staging] + +jobs: + compose-validation: + name: Docker Compose Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate docker-compose.yml + run: docker compose -f docker-compose.yml config --quiet + + - name: Validate docker-compose.test.yml + run: docker compose -f docker-compose.test.yml config --quiet + + makefile-dry-run: + name: Makefile Dry Run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Dry run core targets + run: | + failed=0 + for target in install build dev-up dev-down dev-logs dev-migrate test test-unit test-integration; do + if ! make -n "$target" > /dev/null 2>&1; then + echo "FAIL: make -n $target" + make -n "$target" 2>&1 + failed=1 + fi + done + exit $failed + + fork-markers: + name: Fork Marker Integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check balanced markers + run: | + failed=0 + for file in $(grep -rl "start custom keeperhub code" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" --include="*.mjs" --include="*.css" . 2>/dev/null | grep -v node_modules | grep -v .next); do + starts=$(grep -c "start custom keeperhub code" "$file") + ends=$(grep -c "end keeperhub code" "$file") + if [ "$starts" -ne "$ends" ]; then + echo "::warning file=$file::Imbalanced fork markers: $starts start(s), $ends end(s)" + failed=1 + fi + done + if [ "$failed" -eq 1 ]; then + echo "" + echo "Fork markers must be balanced. Every 'start custom keeperhub code' needs a matching 'end keeperhub code'." + exit 1 + fi + echo "All fork markers balanced." + + - name: Check unmarked custom code outside keeperhub/ + run: | + unmarked="" + for file in $(grep -rl "from.*[@/]keeperhub/" --include="*.ts" --include="*.tsx" app/ components/ lib/ plugins/ 2>/dev/null | grep -v node_modules | grep -v .next); do + if ! grep -q "start custom keeperhub code" "$file"; then + unmarked="$unmarked$file\n" + echo "::warning file=$file::Imports from keeperhub/ but has no fork markers" + fi + done + if [ -n "$unmarked" ]; then + count=$(echo -e "$unmarked" | grep -c .) + echo "" + echo "$count file(s) outside keeperhub/ import from keeperhub/ without fork markers." + echo "Either move these files into keeperhub/ with re-export wrappers, or add start/end markers." + fi + + shellcheck: + name: Shell Script Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run shellcheck + run: | + scripts=$(find . -name "*.sh" -not -path "./node_modules/*" -not -path "./.next/*" -not -path "./.git/*") + if [ -z "$scripts" ]; then + echo "No shell scripts found." + exit 0 + fi + echo "Checking: $scripts" + echo "$scripts" | xargs shellcheck --severity=warning --format=gcc + + actionlint: + name: GitHub Actions Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install actionlint + run: | + curl -sL "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_amd64.tar.gz" \ + | tar xz -C /usr/local/bin actionlint + + - name: Run actionlint (structural only) + run: | + output=$(actionlint -shellcheck="" 2>&1) || true + if [ -n "$output" ]; then + echo "$output" + echo "" + echo "Structural issues found in GitHub Actions workflows." + exit 1 + fi + echo "All workflows structurally valid." + + hadolint: + name: Dockerfile Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Lint Dockerfiles + run: | + failed=0 + for dockerfile in $(find . -name "Dockerfile" -not -path "./node_modules/*" -not -path "./.git/*"); do + echo "=== $dockerfile ===" + if ! docker run --rm -i hadolint/hadolint --failure-threshold warning < "$dockerfile"; then + failed=1 + fi + done + exit $failed + + dockerfile-sources: + name: Dockerfile COPY Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check COPY/ADD sources exist + run: | + failed=0 + for dockerfile in $(find . -name "Dockerfile" -not -path "./node_modules/*" -not -path "./.git/*"); do + dir=$(dirname "$dockerfile") + echo "=== $dockerfile (context: $dir) ===" + grep -E "^(COPY|ADD)" "$dockerfile" \ + | grep -v "\-\-from=" \ + | while read -r instruction; do + # Extract source (second token, skip flags like --chown) + src=$(echo "$instruction" | sed 's/^[A-Z]* //' | sed 's/--[a-z]*=[^ ]* //g' | awk '{print $1}') + # Skip wildcards and current directory + case "$src" in + .|./) continue ;; + *\**) continue ;; + esac + if [ ! -e "$dir/$src" ]; then + echo "::error file=$dockerfile::COPY/ADD source does not exist: $src" + failed=1 + fi + done + done + exit $failed + + env-sync: + name: Environment Variable Sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check .env.example completeness + run: | + # Extract documented vars + grep -oP '^[A-Z_][A-Z0-9_]*' .env.example 2>/dev/null | sort -u > /tmp/env-documented + + # Extract vars used in code (excluding node_modules, .next, test files) + grep -rhoP 'process\.env\.([A-Z_][A-Z0-9_]*)' \ + --include="*.ts" --include="*.tsx" --include="*.js" --include="*.mjs" \ + . 2>/dev/null \ + | grep -v node_modules \ + | grep -v .next \ + | sed 's/process\.env\.//' \ + | sort -u > /tmp/env-used + + # Filter out framework/platform vars that don't belong in .env.example + grep -vE '^(NODE_ENV|NODE_OPTIONS|__NEXT_|VERCEL_|CI|HOSTNAME|HOME|PATH|PWD|SHELL|USER|TERM|LANG|LC_|TZ|PORT|npm_|NEXT_RUNTIME|CF_PAGES|DYNO|FLY_|RAILWAY_|RENDER_|AWS_LAMBDA_)' \ + /tmp/env-used > /tmp/env-used-filtered + + # Vars in code but not documented + missing=$(comm -13 /tmp/env-documented /tmp/env-used-filtered) + if [ -n "$missing" ]; then + echo "Environment variables used in code but missing from .env.example:" + echo "$missing" | while read -r var; do + echo " - $var" + done + echo "" + echo "::warning::$(echo "$missing" | wc -l) env var(s) used in code but not documented in .env.example" + fi + + # Vars documented but not used + dead=$(comm -23 /tmp/env-documented /tmp/env-used) + if [ -n "$dead" ]; then + echo "" + echo "Environment variables in .env.example but not found in code:" + echo "$dead" | while read -r var; do + echo " - $var" + done + echo "" + echo "::warning::$(echo "$dead" | wc -l) env var(s) in .env.example may be dead or renamed" + fi + + compose-consistency: + name: Cross-Compose Consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Compare env vars across compose files + run: | + # Extract env var names from each compose file + grep -oP '[A-Z_][A-Z0-9_]*(?=[:=])' docker-compose.yml \ + | sort -u > /tmp/compose-dev-vars + grep -oP '[A-Z_][A-Z0-9_]*(?=[:=])' docker-compose.test.yml \ + | sort -u > /tmp/compose-test-vars + + # Vars in dev but not test + dev_only=$(comm -23 /tmp/compose-dev-vars /tmp/compose-test-vars) + + # Filter to operationally significant vars (API keys, URLs, service config) + significant=$(echo "$dev_only" | grep -E '(API_KEY|API_URL|SERVICE_|_URL|_HOST|_PORT|ENCRYPTION|SECRET|PASSWORD|TOKEN)' || true) + + if [ -n "$significant" ]; then + echo "Operationally significant env vars in docker-compose.yml but missing from docker-compose.test.yml:" + echo "$significant" | while read -r var; do + echo " - $var" + done + echo "" + echo "::warning::Test compose may be missing service configuration that dev compose has" + else + echo "No significant env var gaps between compose files." + fi + + plugin-integrity: + name: Plugin Discovery Integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate plugin structure + run: | + failed=0 + for plugin_dir in keeperhub/plugins/*/; do + plugin=$(basename "$plugin_dir") + for required in index.ts icon.tsx; do + if [ ! -f "$plugin_dir$required" ]; then + echo "::error::Plugin '$plugin' missing required file: $required" + failed=1 + fi + done + if [ ! -d "${plugin_dir}steps" ]; then + echo "::error::Plugin '$plugin' missing required directory: steps/" + failed=1 + fi + done + exit $failed + + - name: Verify discovery produces consistent registry + run: | + cp keeperhub/plugins/index.ts /tmp/plugins-before.ts + cp plugins/index.ts /tmp/core-plugins-before.ts + pnpm discover-plugins + if ! diff -q keeperhub/plugins/index.ts /tmp/plugins-before.ts > /dev/null 2>&1; then + echo "::error::keeperhub/plugins/index.ts is stale. Run 'pnpm discover-plugins' and commit the result." + diff keeperhub/plugins/index.ts /tmp/plugins-before.ts || true + exit 1 + fi + if ! diff -q plugins/index.ts /tmp/core-plugins-before.ts > /dev/null 2>&1; then + echo "::error::plugins/index.ts is stale. Run 'pnpm discover-plugins' and commit the result." + diff plugins/index.ts /tmp/core-plugins-before.ts || true + exit 1 + fi + echo "Plugin registry is up to date." From 2d2aa9584167d18517cc7a460638c36e1d7cfc3a Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 24 Feb 2026 12:20:10 +1100 Subject: [PATCH 2/4] fix: resolve maintainability workflow failures - Fix hadolint Docker invocation in workflow (pass args correctly) - Fix shellcheck warnings: separate declare/assign in deploy scripts, quote command substitutions, remove unused MIN_CPU_CORES variable - Normalize fork marker end syntax from 'end custom keeperhub code' to 'end keeperhub code' across 14 files, add missing start marker in edit-connection-overlay, remove orphan end marker in action-config --- .github/workflows/maintainability.yml | 2 +- app/api/workflows/[workflowId]/duplicate/route.ts | 6 +++--- app/api/workflows/events/route.ts | 2 +- components/overlays/edit-connection-overlay.tsx | 1 + components/ui/template-autocomplete.tsx | 12 ++++++------ .../workflow/config/action-config-renderer.tsx | 4 ++-- components/workflow/config/action-config.tsx | 2 -- components/workflow/config/trigger-config.tsx | 4 ++-- components/workflow/node-config-panel.tsx | 2 +- components/workflow/workflow-toolbar.tsx | 8 ++++---- deploy/local/deploy.sh | 10 ++++++---- deploy/local/hybrid/deploy.sh | 7 ++++--- deploy/local/setup-local.sh | 6 ++++-- .../workflow/config/abi-event-select-field.tsx | 2 +- lib/utils/template.ts | 2 +- lib/workflow-executor.workflow.ts | 2 +- lib/workflow-store.ts | 4 ++-- tests/integration/workflow-duplicate.test.ts | 2 +- 18 files changed, 41 insertions(+), 37 deletions(-) diff --git a/.github/workflows/maintainability.yml b/.github/workflows/maintainability.yml index 11248be9..6ff52171 100644 --- a/.github/workflows/maintainability.yml +++ b/.github/workflows/maintainability.yml @@ -124,7 +124,7 @@ jobs: failed=0 for dockerfile in $(find . -name "Dockerfile" -not -path "./node_modules/*" -not -path "./.git/*"); do echo "=== $dockerfile ===" - if ! docker run --rm -i hadolint/hadolint --failure-threshold warning < "$dockerfile"; then + if ! docker run --rm -i hadolint/hadolint hadolint --failure-threshold warning - < "$dockerfile"; then failed=1 fi done diff --git a/app/api/workflows/[workflowId]/duplicate/route.ts b/app/api/workflows/[workflowId]/duplicate/route.ts index a78e1a63..8a92ed84 100644 --- a/app/api/workflows/[workflowId]/duplicate/route.ts +++ b/app/api/workflows/[workflowId]/duplicate/route.ts @@ -12,7 +12,7 @@ import { generateId } from "@/lib/utils/id"; // start custom keeperhub code // import { remapTemplateRefsInString } from "@/lib/utils/template"; -// end custom keeperhub code // +// end keeperhub code // // Node type for type-safe node manipulation type WorkflowNodeLike = { @@ -84,7 +84,7 @@ function duplicateNodes( return newNode; }); } -// end custom keeperhub code // +// end keeperhub code // // Edge type for type-safe edge manipulation type WorkflowEdgeLike = { @@ -166,7 +166,7 @@ export async function POST( idMap.set(n.id, nanoid()); } const newNodes = duplicateNodes(oldNodes, idMap); - // end custom keeperhub code // + // end keeperhub code // const newEdges = updateEdgeReferences( sourceWorkflow.edges as WorkflowEdgeLike[], oldNodes, diff --git a/app/api/workflows/events/route.ts b/app/api/workflows/events/route.ts index 24b8a7a2..7b1585ea 100644 --- a/app/api/workflows/events/route.ts +++ b/app/api/workflows/events/route.ts @@ -146,4 +146,4 @@ export async function GET(request: Request) { } } -// end custom keeperhub code // +// end keeperhub code // diff --git a/components/overlays/edit-connection-overlay.tsx b/components/overlays/edit-connection-overlay.tsx index bb75c2d7..37d3cfac 100644 --- a/components/overlays/edit-connection-overlay.tsx +++ b/components/overlays/edit-connection-overlay.tsx @@ -189,6 +189,7 @@ export function EditConnectionOverlay({ } }; + // start custom keeperhub code // const runConnectionTest = (): Promise<{ status: "success" | "error"; message: string; diff --git a/components/ui/template-autocomplete.tsx b/components/ui/template-autocomplete.tsx index 2a1ab8b1..bc9797c5 100644 --- a/components/ui/template-autocomplete.tsx +++ b/components/ui/template-autocomplete.tsx @@ -35,7 +35,7 @@ import { schemaToFields, } from "@/keeperhub/lib/template-helpers"; import { getTriggerOutputFields } from "@/keeperhub/lib/trigger-output-fields"; -// end custom keeperhub code // +// end keeperhub code // type TemplateAutocompleteProps = { isOpen: boolean; @@ -156,7 +156,7 @@ const getCommonFields = (node: WorkflowNode) => { return outputFields; } } - // end custom keeperhub code // + // end keeperhub code // if (triggerType === "Webhook" && webhookSchema) { try { @@ -180,7 +180,7 @@ const getCommonFields = (node: WorkflowNode) => { return outputFields; } } - // end custom keeperhub code // + // end keeperhub code // return [ { field: "triggered", description: "Trigger status" }, @@ -211,7 +211,7 @@ export function TemplateAutocomplete({ const currentWorkflowIdRef = useRef(null); const lastFetchWorkflowIdRef = useRef(null); currentWorkflowIdRef.current = currentWorkflowId; - // end custom keeperhub code // + // end keeperhub code // const [selectedIndex, setSelectedIndex] = useState(0); const menuRef = useRef(null); const listRef = useRef(null); @@ -290,7 +290,7 @@ export function TemplateAutocomplete({ lastExecutionLogs.workflowId, setLastExecutionLogs, ]); - // end custom keeperhub code // + // end keeperhub code // // Find all nodes that come before the current node const getUpstreamNodes = () => { @@ -445,7 +445,7 @@ export function TemplateAutocomplete({ template: `{{@${node.id}:${nodeName}.${fieldPath}}}`, }); } - // end custom keeperhub code // + // end keeperhub code // } else { const fields = getCommonFields(node); diff --git a/components/workflow/config/action-config-renderer.tsx b/components/workflow/config/action-config-renderer.tsx index e0a16c2e..f8668ba4 100644 --- a/components/workflow/config/action-config-renderer.tsx +++ b/components/workflow/config/action-config-renderer.tsx @@ -563,12 +563,12 @@ function renderField( onUpdateConfig(field.key, val)} value={value} /> diff --git a/components/workflow/config/action-config.tsx b/components/workflow/config/action-config.tsx index 63cec1a8..0a0c33a5 100644 --- a/components/workflow/config/action-config.tsx +++ b/components/workflow/config/action-config.tsx @@ -479,8 +479,6 @@ function CollectFields() { ); } -// end keeperhub code // - // System action fields wrapper - extracts conditional rendering to reduce complexity function SystemActionFields({ actionType, diff --git a/components/workflow/config/trigger-config.tsx b/components/workflow/config/trigger-config.tsx index 66451934..bfbf99a8 100644 --- a/components/workflow/config/trigger-config.tsx +++ b/components/workflow/config/trigger-config.tsx @@ -102,7 +102,7 @@ export function TriggerConfig({ Block - {/* end custom keeperhub code // */} + {/* end keeperhub code // */} @@ -311,7 +311,7 @@ export function TriggerConfig({ ); })()} - {/* end custom keeperhub code // */} + {/* end keeperhub code // */} ); } diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index b4207ddc..18558a64 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -1105,7 +1105,7 @@ export const PanelInner = () => { isOwner={isOwner} // start custom keeperhub code // nodeId={selectedNode.id} - // end custom keeperhub code // + // end keeperhub code // onUpdateConfig={handleUpdateConfig} /> ) : null} diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 090590e8..f1e52806 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -1090,7 +1090,7 @@ function useWorkflowActions(state: ReturnType) { await updateWorkflowEnabled(true); } }; - // end custom keeperhub code // + // end keeperhub code // const handleDuplicate = async () => { if (!currentWorkflowId) { @@ -1393,7 +1393,7 @@ function ToolbarActions({ )} - {/* end custom keeperhub code // */} + {/* end keeperhub code // */} @@ -1554,7 +1554,7 @@ function RunButtonGroup({ state.nodes.length === 0 || state.isGenerating || isNonManualTrigger; - // end custom keeperhub code // + // end keeperhub code // const button = (