build(make): run e2e without act, lint yaml, js and shell

test-e2e drove Cypress through act, which fails to start where it cannot
resolve a host address for its artifact server. It now starts Flask and
Cypress in one shell via scripts/run-e2e.sh, so both share a network
namespace; the act path stays available as test-e2e-act.

The script guards the cases that made the old target lie: it aborts when
something already serves the port instead of testing that server, checks
that its own Flask is alive before trusting a response, pins Cypress to
the same origin Flask binds, and drops ELECTRON_RUN_AS_NODE, which VS Code
exports and which makes Cypress' bundled Electron reject its own flags.

30 YAML and 18 JavaScript files had no linter. yamllint runs correctness
rules only, because the repository predates it and its cosmetic findings
would be noise; key-duplicates is the one that earns its keep, since
PyYAML keeps the last of two identical keys without complaining. eslint
runs the recommended set and already found a dead getBoundingClientRect()
call in navigation.js. Both get a CI job so make lint and the workflows
stop diverging.

flask>=3.1 because app.config["TRUSTED_HOSTS"] arrived in 3.1 and an older
Flask accepts the key and ignores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 23:51:40 +02:00
parent 03b7cef90e
commit a42930a699
8 changed files with 280 additions and 6 deletions

View File

@@ -19,6 +19,61 @@ jobs:
- name: Run actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
lint-yaml:
name: Lint YAML
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Run yamllint
run: yamllint --strict .
lint-js:
name: Lint JavaScript
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package.json
- name: Install Node dependencies
working-directory: app
run: npm install
- name: Run eslint
working-directory: app
run: npx eslint .
lint-shell:
name: Lint shell scripts
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run shellcheck
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
lint-python:
name: Lint Python
runs-on: ubuntu-latest

23
.yamllint Normal file
View File

@@ -0,0 +1,23 @@
---
# Correctness only. No `extends: default`, so nothing but the rules below runs:
# the repository predates this linter and its cosmetic findings (indentation,
# line length, trailing spaces) would be noise nobody acts on.
#
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
# identical keys without complaining, so a duplicated entry in a translation
# catalogue silently drops a translation.
ignore: |
.git/
.venv/
venv/
node_modules/
app/node_modules/
app/static/vendor/
build/
.ruff_cache/
rules:
key-duplicates: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true

View File

@@ -78,6 +78,8 @@ run-dev: env config config
-p "$$PORT:$$PORT" \
--name portfolio \
-v "$(PWD)/app/:/app" \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
"$$IMAGE_NAME"
@@ -89,6 +91,8 @@ run-prod: env config config
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
"$$IMAGE_NAME"
.PHONY: logs
@@ -132,6 +136,14 @@ install-dev:
# Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]"
.PHONY: i18n
i18n: env config
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: lint-actions
lint-actions:
# Lint GitHub Actions workflows.
@@ -148,6 +160,21 @@ lint-docker:
# Lint the Dockerfile.
docker run --rm -i hadolint/hadolint < Dockerfile
.PHONY: lint-yaml
lint-yaml: install-dev
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
$(PYTHON) -m yamllint --strict .
.PHONY: lint-js
lint-js: node-deps
# Lint the browser and Cypress JavaScript.
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
.PHONY: lint-shell
lint-shell:
# Lint the shell scripts.
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
.PHONY: test-lint
test-lint:
# Run lint guardrail tests.
@@ -169,7 +196,7 @@ test-security: install
$(PYTHON) -m unittest discover -s tests/security -t .
.PHONY: lint
lint: lint-actions lint-python lint-docker test-lint
lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
# Run the full lint suite.
.PHONY: security
@@ -179,9 +206,20 @@ security: install-dev test-security
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: node-deps
node-deps:
# Install the Cypress binary and the browser vendor assets into app/.
cd app && npm install
.PHONY: test-e2e
test-e2e:
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
test-e2e: env config node-deps
# Run Cypress against a locally started Flask app — no act, no runner image.
@$(call _require_env,PORT); \
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
.PHONY: test-e2e-act
test-e2e-act:
# Run the CI end-to-end job through act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
-docker start portfolio 2>/dev/null || true

78
app/eslint.config.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* Correctness only — eslint's recommended set, no stylistic rules.
*
* The browser scripts are plain <script> tags sharing one global scope: each
* file declares some functions and calls others declared elsewhere. That is why
* they are listed as globals and why no-redeclare is off — the declaring file
* would otherwise be reported for defining its own function.
*/
const js = require('@eslint/js');
const globals = require('globals');
// Vendored libraries plus the functions static/js files call across each other.
// Keep this to names that really cross a file boundary. Every superfluous
// entry is a permanent no-undef blind spot for a typo of that name.
const SHARED = {
bootstrap: 'readonly',
marked: 'readonly',
$: 'readonly',
jQuery: 'readonly',
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',
setFullWidth: 'readonly',
initFullWidthFromUrl: 'readonly',
adjustScrollContainerHeight: 'readonly',
updateCustomScrollbar: 'readonly',
};
module.exports = [
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
{
files: ['static/js/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: { ...globals.browser, ...SHARED },
},
rules: {
...js.configs.recommended.rules,
'no-redeclare': 'off',
// vars: 'local' — a top-level function here is the API other files and
// the templates call, so only unused locals are a defect.
'no-unused-vars': [
'error',
{ vars: 'local', args: 'none', caughtErrors: 'none' },
],
},
},
{
files: ['cypress/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
...globals.mocha,
cy: 'readonly',
Cypress: 'readonly',
expect: 'readonly',
assert: 'readonly',
},
},
rules: js.configs.recommended.rules,
},
{
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: globals.node,
},
rules: js.configs.recommended.rules,
},
];

View File

@@ -7,7 +7,10 @@
"marked": "^4.3.0"
},
"devDependencies": {
"cypress": "^14.5.1"
"@eslint/js": "^10.0.1",
"cypress": "^14.5.1",
"eslint": "^10.9.0",
"globals": "^17.11.0"
},
"scripts": {
"build": "node scripts/copy-vendor.js",

View File

@@ -169,7 +169,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
function adjustMenuPosition(submenu, parent, isTopLevel) {
const rect = submenu.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const spaceAbove = parentRect.top;

View File

@@ -9,7 +9,9 @@ description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask",
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
# ignores it, which would silently disable the host-forgery check.
"flask>=3.1",
"pyyaml",
"requests",
]
@@ -19,6 +21,7 @@ dev = [
"bandit",
"pip-audit",
"ruff",
"yamllint",
]
[tool.setuptools]
@@ -30,6 +33,8 @@ include = ["app", "app.*"]
[tool.setuptools.package-data]
app = [
"config.sample.yaml",
"i18n/ui/*.yaml",
"i18n/content/*.yaml",
"templates/**/*.j2",
"static/css/*.css",
"static/js/*.js",

73
scripts/run-e2e.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/sh
# Run the Cypress suite against a Flask app started by this script.
#
# Server and browser share one shell, so this works where act cannot reach the
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
# VS Code exports it to child processes, which makes Cypress' bundled Electron
# behave as Node and reject its own launch flags.
#
# Env:
# PORT required, the port Flask binds and Cypress targets
# PYTHON interpreter to run app.py with (default: python3)
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
set -u
PORT="${PORT:?PORT must be set (see env.example)}"
PYTHON="${PYTHON:-python3}"
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
FLASK_HOST=127.0.0.1
export PORT FLASK_HOST
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
BASE_URL="http://127.0.0.1:$PORT/"
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
# this script probes. Pin all three to the same origin.
export CYPRESS_baseUrl="$BASE_URL"
# A foreign listener would be tested instead of the working tree. The predicate
# is "something answers", deliberately without curl's -f: a foreign server that
# is still returning 5xx right now would otherwise pass the guard and then be
# picked up by the readiness probe once it recovers. --noproxy is what keeps
# that same looser predicate from matching an HTTP proxy instead of the port.
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: something already serves port $PORT — Cypress would test that"
echo " instead of your working tree. Stop it first ('make down' for"
echo " the container, otherwise a stray 'python app.py')."
exit 1
fi
cd "$APP_DIR" || exit 1
$PYTHON app.py > "$E2E_LOG" 2>&1 &
flask_pid=$!
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
attempt=0
while [ "$attempt" -lt 120 ]; do
# Liveness first: a server answering while ours is dead means we are about
# to hand Cypress somebody else's app.
if ! kill -0 "$flask_pid" 2>/dev/null; then
echo "ERROR: Flask exited during startup"
cat "$E2E_LOG"
exit 1
fi
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: app never became ready"
cat "$E2E_LOG"
exit 1
fi
env -u ELECTRON_RUN_AS_NODE npx cypress run
status=$?
kill "$flask_pid" 2>/dev/null || true
exit "$status"