Andrey Antukh 4da6499197 🐛 Fix linear gradients in SVG text exports (#11272)
* 🐛 Use gradient type instead of export type in SVG renderer

data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.

Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])

Closes #5972

* 🐛 Add SVG gradient export regression test

Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.

AI-assisted-by: gpt-5.6-luna

*  Standardize exporter testing workflow

Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.

AI-assisted-by: gpt-5.6-luna

*  Add focused exporter test execution

Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.

AI-assisted-by: gpt-5.6-luna

* 🐛 Replace shell exec with execFile in exporter

Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.

This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.

Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission

All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)

AI-assisted-by: qwen3.7-plus

* 🐛 Use existing hex-color-string? and fix test path mismatch

Address code review feedback:

- Replace duplicated hex-color-rx and valid-hex-color? with existing
  hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned

AI-assisted-by: qwen3.7-plus

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-08-19 13:57:14 +02:00

479 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/ci - CI orchestration script for Penpot monorepo
set -euo pipefail
# Constants
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
LOG_DIR="$PROJECT_ROOT/.ci-logs"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Available modules
ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library")
# Module commands
declare -A LINT_CMD=(
[frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss"
[backend]="pnpm run lint"
[common]="pnpm run lint:clj"
[render-wasm]="./lint"
[exporter]="pnpm run lint:clj"
[mcp]=""
[plugins]="pnpm run lint"
[library]="pnpm run lint"
)
declare -A TEST_CMD=(
[frontend]="pnpm run test:quiet"
[backend]="clojure -M:dev:test"
[common]="clojure -M:dev:test && pnpm run test:quiet"
[render-wasm]="./test"
[exporter]="pnpm run test:quiet"
[mcp]="pnpm run test"
[plugins]="pnpm run test"
[library]="pnpm run test"
)
declare -A FMT_CHECK_CMD=(
[frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss"
[backend]="pnpm run check-fmt"
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
[render-wasm]="cargo fmt --check"
[exporter]="pnpm run check-fmt:clj"
[mcp]="pnpm run fmt:check"
[plugins]="pnpm run format:check"
[library]="pnpm run check-fmt"
)
declare -A FMT_FIX_CMD=(
[frontend]="pnpm run fmt"
[backend]="pnpm run fmt"
[common]="pnpm run fmt:clj && pnpm run fmt:js"
[render-wasm]="cargo fmt"
[exporter]="pnpm run fmt:clj"
[mcp]="pnpm run fmt"
[plugins]="pnpm run format"
[library]="pnpm run fmt"
)
# Default options
MODULES=()
TASKS=("lint" "test" "fmt")
FIX_MODE=false
FAIL_FAST=false
VERBOSE=true
DRY_RUN=false
# Results tracking
declare -A RESULTS=()
declare -a FAILED_LOGS=()
timestamp() {
date "+%H:%M:%S"
}
log_info() {
echo -e "${BLUE}[$(timestamp)]${NC} $1"
}
log_success() {
echo -e "${GREEN}[$(timestamp)] ✓${NC} $1"
}
log_error() {
echo -e "${RED}[$(timestamp)] ✗${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $1"
}
log_header() {
local module=$1
local current=$2
local total=$3
echo ""
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${CYAN} ${BOLD}[$current/$total] $module${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
run_task() {
local module=$1
local task=$2
local cmd=$3
local logfile="$LOG_DIR/${module}-${task}.log"
if [[ -z "$cmd" ]]; then
log_warning "$task: not defined for $module, skipping"
RESULTS["$module:$task"]="SKIPPED"
return 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
log_info "$task: would run '$cmd' in $module/"
RESULTS["$module:$task"]="DRY-RUN"
return 0
fi
log_info "Running ${BOLD}$task${NC} for $module..."
mkdir -p "$LOG_DIR"
local start_time
start_time=$(date +%s)
if [[ "$VERBOSE" == "true" ]]; then
echo -e "${BLUE} cmd: $cmd${NC}"
fi
local exit_code=0
(cd "$PROJECT_ROOT/$module" && eval "$cmd") > "$logfile" 2>&1 || exit_code=$?
local end_time
end_time=$(date +%s)
local duration=$((end_time - start_time))
if [[ $exit_code -eq 0 ]]; then
log_success "$task passed for $module ${BLUE}(${duration}s)${NC}"
RESULTS["$module:$task"]="PASSED"
return 0
else
log_error "$task failed for $module ${BLUE}(${duration}s)${NC}"
echo -e " ${RED}log: $logfile${NC}"
RESULTS["$module:$task"]="FAILED"
FAILED_LOGS+=("$logfile")
if [[ "$VERBOSE" == "true" ]]; then
echo -e "${YELLOW} --- last 30 lines ---${NC}"
tail -n 30 "$logfile" | sed 's/^/ /'
echo -e "${YELLOW} ---------------------${NC}"
fi
return 1
fi
}
run_module() {
local module=$1
local failed=false
for task in "${TASKS[@]}"; do
local cmd=""
case $task in
lint)
cmd="${LINT_CMD[$module]:-}"
;;
test)
cmd="${TEST_CMD[$module]:-}"
;;
fmt)
if [[ "$FIX_MODE" == "true" ]]; then
cmd="${FMT_FIX_CMD[$module]:-}"
else
cmd="${FMT_CHECK_CMD[$module]:-}"
fi
;;
esac
if ! run_task "$module" "$task" "$cmd"; then
failed=true
if [[ "$FAIL_FAST" == "true" ]]; then
return 1
fi
fi
done
if [[ "$failed" == "true" ]]; then
return 1
fi
return 0
}
print_summary() {
echo ""
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${CYAN} ${BOLD}SUMMARY${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
local total=0
local passed=0
local failed=0
local skipped=0
for module in "${MODULES[@]}"; do
for task in "${TASKS[@]}"; do
local result="${RESULTS[$module:$task]:-N/A}"
total=$((total + 1))
case $result in
PASSED)
passed=$((passed + 1))
echo -e " ${GREEN}${NC} $module:$task"
;;
FAILED)
failed=$((failed + 1))
echo -e " ${RED}${NC} $module:$task"
;;
SKIPPED)
skipped=$((skipped + 1))
echo -e " ${YELLOW}${NC} $module:$task"
;;
DRY-RUN)
echo -e " ${BLUE}~${NC} $module:$task"
;;
esac
done
done
echo ""
echo -e " ${BOLD}Total:${NC} $total ${GREEN}Passed:${NC} $passed ${RED}Failed:${NC} $failed ${YELLOW}Skipped:${NC} $skipped"
if [[ $failed -gt 0 ]]; then
echo ""
echo -e " ${RED}${BOLD}Failed logs:${NC}"
for logfile in "${FAILED_LOGS[@]}"; do
echo -e " ${RED}${NC} $logfile"
done
return 1
fi
return 0
}
validate_module() {
local mod=$1
for valid in "${ALL_MODULES[@]}"; do
if [[ "$valid" == "$mod" ]]; then
return 0
fi
done
return 1
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] [MODULES...]
CI orchestration script for Penpot monorepo.
MODULES:
frontend, backend, common, render-wasm, exporter, mcp, plugins, library
Use --all to run all modules.
OPTIONS:
--all Run all modules
--exclude MOD Exclude a module (can be repeated)
--lint Run lint only
--no-lint Skip lint
--test Run tests only
--no-test Skip tests
--fmt Run format check only
--no-fmt Skip format check
--fix Run format fix instead of check
--fail-fast Stop on first failure
--verbose Show command output on failure (default)
--quiet Suppress command output
--dry-run Show what would run without executing
--clean Remove .ci-logs directory
--help Show this help message
EXAMPLES:
$(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend
$(basename "$0") --all --no-test # Run lint and fmt on all modules
$(basename "$0") --lint common # Run only lint on common
$(basename "$0") --fix frontend # Fix formatting in frontend
$(basename "$0") --all --fail-fast --verbose
EOF
exit 0
}
main() {
local exclude_modules=()
local run_all=false
while [[ $# -gt 0 ]]; do
case $1 in
--all)
run_all=true
shift
;;
--exclude)
exclude_modules+=("$2")
shift 2
;;
--lint)
TASKS=("lint")
shift
;;
--no-lint)
TASKS=("${TASKS[@]/lint/}")
shift
;;
--test)
TASKS=("test")
shift
;;
--no-test)
TASKS=("${TASKS[@]/test/}")
shift
;;
--fmt)
TASKS=("fmt")
shift
;;
--no-fmt)
TASKS=("${TASKS[@]/fmt/}")
shift
;;
--fix)
FIX_MODE=true
shift
;;
--fail-fast)
FAIL_FAST=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--quiet)
VERBOSE=false
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--clean)
if [[ -d "$LOG_DIR" ]]; then
rm -rf "$LOG_DIR"
echo "Cleaned $LOG_DIR"
else
echo "No logs to clean"
fi
exit 0
;;
--help | -h)
usage
;;
-*)
echo -e "${RED}Error: Unknown option $1${NC}" >&2
echo "Run '$(basename "$0") --help' for usage." >&2
exit 1
;;
*)
if validate_module "$1"; then
MODULES+=("$1")
else
echo -e "${RED}Error: Unknown module '$1'${NC}" >&2
echo "Valid modules: ${ALL_MODULES[*]}" >&2
exit 1
fi
shift
;;
esac
done
# Clean empty entries from TASKS after removal
TASKS=("${TASKS[@]// /}")
TASKS=("${TASKS[@]/#/}")
local clean_tasks=()
for t in "${TASKS[@]}"; do
if [[ -n "$t" ]]; then
clean_tasks+=("$t")
fi
done
TASKS=("${clean_tasks[@]}")
# Set modules
if [[ "$run_all" == "true" ]]; then
MODULES=("${ALL_MODULES[@]}")
fi
# Apply exclusions
if [[ ${#exclude_modules[@]} -gt 0 ]]; then
local filtered=()
for mod in "${MODULES[@]}"; do
local excluded=false
for ex in "${exclude_modules[@]}"; do
if [[ "$mod" == "$ex" ]]; then
excluded=true
break
fi
done
if [[ "$excluded" == "false" ]]; then
filtered+=("$mod")
fi
done
MODULES=("${filtered[@]}")
fi
# Validate
if [[ ${#MODULES[@]} -eq 0 ]]; then
echo -e "${RED}Error: No modules specified.${NC}" >&2
echo "Use --all or specify modules: ${ALL_MODULES[*]}" >&2
exit 1
fi
if [[ ${#TASKS[@]} -eq 0 ]]; then
echo -e "${RED}Error: No tasks selected.${NC}" >&2
exit 1
fi
# Print configuration
echo ""
echo -e "${CYAN}${BOLD}Penpot CI Orchestrator${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD}Modules:${NC} ${MODULES[*]}"
echo -e " ${BOLD}Tasks:${NC} ${TASKS[*]}"
echo -e " ${BOLD}Fix mode:${NC} $FIX_MODE"
echo -e " ${BOLD}Fail-fast:${NC} $FAIL_FAST"
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${BOLD}Mode:${NC} ${YELLOW}DRY RUN${NC}"
fi
echo ""
# Run
local total_modules=${#MODULES[@]}
local current=0
local modules_failed=0
local start_time
start_time=$(date +%s)
for module in "${MODULES[@]}"; do
current=$((current + 1))
log_header "$module" "$current" "$total_modules"
if ! run_module "$module"; then
modules_failed=$((modules_failed + 1))
if [[ "$FAIL_FAST" == "true" ]]; then
log_error "Fail-fast: stopping due to failure in $module"
break
fi
fi
done
local end_time
end_time=$(date +%s)
local total_duration=$((end_time - start_time))
print_summary || true
echo ""
echo -e " ${BOLD}Duration:${NC} ${total_duration}s"
echo ""
if [[ $modules_failed -gt 0 ]]; then
exit 1
fi
exit 0
}
main "$@"