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
339 changes: 339 additions & 0 deletions .github/workflows/maintainability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
name: Maintainability

on:
pull_request:
branches: [staging]

jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
compose: ${{ steps.filter.outputs.compose }}
makefile: ${{ steps.filter.outputs.makefile }}
shell: ${{ steps.filter.outputs.shell }}
workflows: ${{ steps.filter.outputs.workflows }}
dockerfiles: ${{ steps.filter.outputs.dockerfiles }}
plugins: ${{ steps.filter.outputs.plugins }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
compose:
- 'docker-compose*.yml'
makefile:
- 'Makefile'
shell:
- '**/*.sh'
workflows:
- '.github/workflows/**'
dockerfiles:
- '**/Dockerfile*'
- '.hadolint.yaml'
plugins:
- 'keeperhub/plugins/**'
- 'plugins/**'

compose-validation:
name: Docker Compose Validation
needs: changes
if: needs.changes.outputs.compose == 'true'
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
needs: changes
if: needs.changes.outputs.makefile == 'true'
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
needs: changes
if: needs.changes.outputs.shell == 'true'
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
needs: changes
if: needs.changes.outputs.workflows == 'true'
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
needs: changes
if: needs.changes.outputs.dockerfiles == 'true'
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 -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml" hadolint/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
needs: changes
if: needs.changes.outputs.compose == 'true'
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
needs: changes
if: needs.changes.outputs.plugins == 'true'
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."
4 changes: 4 additions & 0 deletions .hadolint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ignored:
# Alpine package versions are tied to the base image tag (node:25-alpine).
# Pinning apk packages independently breaks when the base image updates.
- DL3018
Loading
Loading