Skip to content

Latest commit

 

History

History
927 lines (741 loc) · 48.6 KB

File metadata and controls

927 lines (741 loc) · 48.6 KB

Stack Decisions — landerox.github.io

Living document. Per category: comparison of considered alternatives, chosen tool, and rationale.

For a quick reference of the current stack without the deliberations, see tooling.md.

Last reviewed: 2026-05-14

Repository context

Bilingual (EN/ES) personal static site built with Zensical (Python).

  • Volume: a few dozen Markdown files.
  • Maintainer: one (solo).
  • Commit cadence: low (≈ 1–3 / week).
  • Deployment: GitHub Pages via GitHub Actions.

This profile matters: many "modern" optimizations are designed for large monorepos with sizable teams. Here the migration cost almost always exceeds the speed benefit.

Evaluation criteria

Every decision is evaluated against these five criteria, in this order:

  1. Functional fit — does it do exactly the job I need?
  2. Migration cost — how much config / habit must be redone?
  3. Maturity and maintenance — releases, community, bus factor.
  4. Fit with the existing stack (uv, just, pre-commit, GH Actions).
  5. Speed — only matters when there is measured friction (>5 s).

Anti-vanity policy: a working tool is not migrated just because a "more modern Rust one" exists. Migration happens when there is a tangible benefit (security, new capability, real friction removed).

Exception: personal affinity. When no alternative clearly wins on the five criteria above, "wanting to try the tool" or "affinity with its ecosystem" is a legitimate criterion, provided the switching cost is low and there is an exit plan. It must be declared explicitly in the affected category's decision.


0. Static site generator (SSG)

Tool Language Maturity Type Notes
Zensical Python Young (0.0.x) Docs-style SSG "Modern" successor of Material for MkDocs (Squidfunk).
MkDocs Material Python Very mature Docs-style SSG De facto standard for technical docs.
Hugo Go Very mature Generalist SSG The fastest on the market.
Astro TypeScript Mature Content-first SSG MDX, islands, JS ecosystem.
Zola Rust Mature Generalist SSG Single binary, fast.
Eleventy (11ty) JavaScript Very mature Flexible SSG Highly configurable.

Decision:zensical. Status: In use. Rationale (frankly): If this were a purely objective choice, Hugo, Astro, or MkDocs Material would win on maturity and ecosystem. Zensical is at 0.0.x: new, young, with fewer plugins and less community. The choice falls under the personal affinity exception:

  • Interest in trying the successor of Material for MkDocs (Squidfunk lineage, whose philosophy resonates with the rest of the stack).
  • Cultural fit with an already-Python stack (uv, pre-commit, commitizen).
  • Low switching cost: content is standard Markdown; migrating to Hugo / Astro / MkDocs Material is doable in a few days if Zensical stalls.

Exit plan (if migration becomes necessary):

  • Markdown is already in standard format.
  • content/en and content/es are portable to any SSG with i18n support.
  • What would be lost: specific zensical.toml configuration (menu, theme), but these usually have trivial equivalents.

Re-evaluate if:

  • Zensical does not advance to 0.1.x+ within 12 months (dead project risk).
  • A blocking capability appears that the ecosystem does not cover.
  • Maintenance starts generating real friction (unfixed bugs, releases that break things).

1. Python package manager

Tool Language Lockfile Speed Maturity
uv Rust Yes Ultra fast 2025 standard
pip Python Not nat. Slow Universal
poetry Python Yes Medium Mature
pdm Python Yes Medium Mature
hatch Python Partial Medium Mature

Decision:uv Status: In use. Rationale: State of the art. Reproducible lockfile, native integration with pyproject.toml, GitHub Actions cache support via astral-sh/setup-uv.


2. Task runner

Tool Language Syntax Cross-platform Notes
just Rust Justfile Yes Simple, no fragile tabs
make C Makefile POSIX Tabs, arcane syntax
task Go YAML Yes More verbose
mise Rust TOML Yes Mixes tasks + version mgr

Decision:just Status: In use. Rationale: Readable syntax, without make's pitfalls. mise would be over-engineering for this repo (no need for a version manager for Node, Ruby, etc.).


3. Hook runner (pre-commit family)

This is the category with most nuance. lefthook and prek are legitimate modern alternatives and deserve detailed evaluation before deciding.

Tool Language Config Drop-in .pre-commit-config.yaml Parallelism Hook catalog Distribution
pre-commit Python .pre-commit-config.yaml Limited Huge (standard) PyPI
prek Rust .pre-commit-config.yaml Yes Yes Reuses pre-commit's Cargo, binstall
lefthook Go lefthook.yml No (rewrite) Yes (native) Manual / binaries Binary, npm, brew
husky Shell .husky/ No Manual Empty npm

Arguments in favor of lefthook

  • Native parallel execution, declaratively defined per stage — more explicit than pre-commit's serial model.
  • No venv management or hook repo clones: invokes system binaries. Fits very well if tools are replaced with their precompiled Rust/Go equivalents (typos, dprint, actionlint, zizmor, pinact).
  • More expressive config: glob:, exclude:, tags:, skip: by branch or command — useful for advanced flows or monorepos.
  • Growing industry adoption: created by Evil Martians, used at GitLab and other tech companies. Legitimately "modern standard" for 2025+.
  • Simple deployment: a single Go binary, no Python.

Arguments in favor of keeping pre-commit

  • Zensical is Python: the venv already exists; "removing Python" is not a net win — it is a dependency you are already paying for via the dev server.
  • pre-commit-hooks catalog in current use: check-yaml, check-toml, check-json, check-merge-conflict, mixed-line-ending, trailing-whitespace, check-added-large-files, check-case-conflict8 hooks that under lefthook must be replaced manually with scripts or alternative binaries. Not trivial.
  • pre-commit autoupdate: automatic rev updates; lefthook has no native equivalent (requires Dependabot or similar).
  • Declarative hook versioning: in pre-commit, each hook ships with a fixed rev:. In lefthook you invoke the installed binary, which shifts versioning to another tool (system package, Dependabot, etc.).
  • Repo volume: ~7 hooks across a few dozen files. Parallelism is not noticeable (suite runs in seconds).

When to choose each (general rule, not specific to this repo)

Scenario Choose
Python repo with a pre-commit-hooks catalog already configured pre-commit
You want speed without migrating existing config prek
100 % Rust/Go binary stack, monorepo, or several runtimes lefthook
Node-only repo with no hooks beyond formatting husky

Decision for this repo

Decision:pre-commit (keep), with prek as drop-in substitute if local latency becomes annoying. Status: In use. prek is a no-cost optional upgrade.

Concrete rationalelefthook is more modern and would be the right choice if at least one of the following held:

  1. the repo were not already Python (does not apply: Zensical is), or
  2. orchestrating binaries across multiple runtimes were needed (does not apply: only the current hooks are needed), or
  3. the cost of parallelism were measurable (does not apply: the suite runs in seconds), or
  4. the project would grow heavily in hook surface (does not apply: stable personal site).

Migrating to lefthook here means moving from the local optimum to the theoretical optimum while paying real cost (rewrite of the 7 current hooks, substitutes for the 8 pre-commit-hooks catalog entries, rewriting hooks-install / hooks-update in Justfile, mental retraining) without tangible gain. The "Anti-vanity policy" section applies directly.

Re-evaluate lefthook in the future if:

  • a second runtime (Node, Rust, Go) is added to the repo, or
  • the hook suite grows beyond ~15 entries, or
  • local latency >5 s is regularly measured, or
  • ≥3 current hooks get replaced by direct Rust/Go binaries (the pre-commit-hooks catalog argument no longer applies).

4. Conventional Commits and release

Tool Language CC validation Version bump Changelog Interactive prompt
commitizen Python Yes Yes Yes cz commit
cocogitto (cog) Rust Yes Yes Yes cog commit
convco Rust Yes Partial Yes No
commitlint Node Yes No No Via Commitizen

Decision:commitizen (keep) — used for validation + SemVer bump + tag creation only. The changelog is written by hand in CHANGELOG.md following Keep a Changelog 1.1.0. Status: In use. Rationale: commitizen covers two of the three jobs without extra dependencies (Python is already in the stack). Migrating to cog means rewriting tool.commitizen, redefining version_files, losing the interactive cz commit. For 1–3 commits/week, gaining milliseconds does not compensate.

Changelog format (manual, Keep a Changelog 1.1.0)

CHANGELOG.md at repo root follows Keep a Changelog 1.1.0, with the canonical sections per release: Added, Changed, Deprecated, Removed, Fixed, Security. Each release section begins with a short narrative paragraph that frames the cut.

Auto-generation from commits is deliberately not used. Reasons:

  • A single user-visible change often spans multiple commits (refactor
    • tests + docs); an auto-generated changelog would expose the internal commit boundary instead of the narrative the reader needs.
  • At 1–3 commits/week the cost of writing entries by hand is trivial and yields a much better reader experience.
  • A public personal site is read by visitors who scan the changelog for "what shipped", not by tools that consume conventional-commit metadata downstream.

Workflow for a release:

  1. Edit CHANGELOG.md — move items from [Unreleased] into the new version section; add a short framing paragraph.
  2. (Optional) Run just release-preview (cz bump --dry-run) to audit the SemVer increment that commitizen would infer before touching any file.
  3. Run cz bump (commitizen) — bumps pyproject.toml, creates the git tag (tag_format = "v$version"), commits the version file.
  4. Push tag + commit. GitHub Release notes can be copy-pasted from the relevant CHANGELOG.md section.

cz changelog (auto-generation) is not part of this workflow.


5. Spell checker / prose linter

Important: cspell and typos solve different problems. They are not interchangeable.

Tool Language Type ES support Fit
cspell Node Spell checker (dictionary) Yes Good for bilingual prose
typos Rust Known-typos detector Poor Designed for identifiers/code
vale Go Prose linter (style) Yes State of the art for content sites
hunspell C++ Traditional spell checker Yes No comfortable pre-commit integration

Decision:cspell (keep) — evaluate adding vale as a complementary style linter. Status: In use. vale pending evaluation. Rationale: The site is EN/ES content; typos is designed for code and its Spanish coverage is weak. vale brings new capabilities (style, passive voice, ableism) that cspell does not cover — it is additive, not a substitute.


6. Markdown lint and format

Lint and formatting are different jobs. The original proposal suggested replacing them with a single formatting tool, which drops the lint rules.

Tool Language Type Rules (MD013…) --fix
markdownlint-cli Node Linter Yes Yes
dprint Rust Formatter No Format
prettier Node Formatter No Format
mado Rust Linter Partial Limited

Decision:markdownlint-cli (keep). Status: In use. Rationale: The rules (headings, line length, fences, etc.) are exactly what brings value to a content site. dprint formats but does not enforce rules. Switching is a functional loss disguised as modernization.


7. Secrets detection

Tool Language Release cadence (as of 2026-05) Out-of-the-box rules False-positive handling git history scan
gitleaks Go Monthly, active (latest v8.30.0, 2026-03) 160+ providers (AWS, GCP, Stripe, OpenAI, Anthropic, GH fine-grained PATs…) .gitleaksignore (fingerprint list) Yes, native, fast
detect-secrets Python v1.5.0 (2024-05) — ~24 months without release ~20 plugins, classic token formats Rich JSON baseline Yes (slower)
trufflehog Go Active Regex + real-credential verification (HTTP probes against suspected tokens) Partial Yes

Decision:gitleaks. Status: In use, pinned to v8.30.0 in .pre-commit-config.yaml. Rationale: gitleaks covers the modern provider surface (160+ rules including Anthropic, OpenAI, Stripe new prefixes, GitHub fine-grained PATs) out of the box, ships as a single Go binary, and is actively maintained (monthly releases). False positives are tracked in .gitleaksignore at repo root, created on demand — see docs/runbook.md.

detect-secrets was considered and discarded: the rule surface lags modern cloud / SaaS providers and upstream has not shipped a release since 2024-05 (~24-month gap). For real-credential verification (HTTP probes against suspected tokens) the right tool is trufflehog, not gitleaks. Not adopted here: this repo has no real credentials to verify.

Re-evaluate if:

  • A new credential format relevant to the site appears and gitleaks does not detect it before the next minor release.
  • Real-credential verification becomes a requirement (consider trufflehog as a complementary scan, not a replacement).
  • Upstream maintenance cadence drops below quarterly for 12+ months.

8. Link validation

Tool Language Speed Official GH Action Notes
lychee Rust Very fast Yes State of the art
markdown-link-check Node Slow Community Requires Node
linkchecker Python Medium Not official Older

Decision:lychee (keep) — add scheduled workflow (schedule: weekly). Status: In use via just links. Scheduled CI in place. Rationale: lychee is already best-in-class. The improvement is operational: detect broken outbound links without waiting to open a PR.


9. Python dependency audit

Tool Language Data source pre-commit fit
pip-audit Python OSV + PyPI advisories Native
safety Python Safety DB (partial) Plugin
osv-scanner Go OSV External

Decision:pip-audit (keep) — promote from just audit to hook + CI. Status: In use. Wired as a pre-commit hook in .pre-commit-config.yaml (scoped to pyproject.toml / uv.lock changes, delegates to just audit so local and CI share one command) and as a step in lint.yml. The CI runner installs just via extractions/setup-just so the hook works there too. Rationale: Already a dev dep; running it only manually was waste.


10. GitHub Actions syntax lint

Tool Language Detects Maturity
actionlint Go Syntax, expressions, embedded shellcheck De facto standard
(none currently)

Decision:Add actionlint as a hook. Status: Installed. Rationale: Detects YAML/expression errors and embedded shell issues inside run: before they break CI. Adoption cost: one entry in .pre-commit-config.yaml.


11. GitHub Actions security

Tool Language Detects Maturity
zizmor Rust Script injection, mutable refs, broad permissions, risky pull_request_target Active, high interest
poutine Go Similar (more focused on supply chain) Young

Decision:Add zizmor as a hook and/or CI step. Status: Installed. Recommended after the tj-actions/changed-files 2025 incident. Rationale: Complements actionlint (which checks syntax) by reviewing the security of workflows. In 2026 this is standard practice.


12. Pin Actions to SHA

Tool Language Mode Auto-update
pinact Go Rewrites tags → SHA Yes (with flag)
ratchet Go Rewrites tags → SHA Yes
zizmor Rust Only detects, no pin

Decision:pinact installed in the devcontainer + just pin-actions as the manual entrypoint. Dependabot (package-ecosystem: github-actions) detects the pattern <sha> # <version> and updates both in its weekly PRs. Status: In use. All refs in .github/workflows/ are pinned by SHA. Rationale: The use of mutable tags (e.g. @v4) was the vector of the tj-actions/changed-files incident. Pinning by SHA + Dependabot opening update PRs is the standard mitigation.

Decision: pinact manual, not as a hook

Adding pinact as a pre-commit hook was evaluated but discarded:

  • pinact does not publish a .pre-commit-hooks.yaml, so it would require a local hook depending on the binary in PATH.
  • In CI (lint.yml) an install step would have to be added.
  • zizmor already acts as a safety net: if anyone introduces a ref without a pin, the hook (local + CI) fails with the hash-pin policy (default).

So: zizmor flags, pinact fixes on demand (just pin-actions), Dependabot keeps things current.


13. Automatic dependency updates

Tool Hosting PR grouping Auto-merge Configuration
Dependabot grouped GitHub native Yes (groups:) Via Action dependabot.yml
Renovate External app Advanced Native renovate.json

Decision:Add .github/dependabot.yml with the following policy (agreed after reviewing problems from previous adoptions):

  • One weekly PR per ecosystem (Mondays 06:00 UTC, grouping all minor + patch). major updates remain as individual PRs for mandatory review.
  • Conventional Commits messages compatible with commitizen:
    • GitHub Actions → ci(deps): ...
    • Python prod → build(deps): ...
    • Python dev → chore(deps): ...
  • Limit of 3 open PRs per ecosystem.
  • Cooldown: 7 days for minor/patch, 14 days for majors — protects against yanked releases and rushed publishes. Aligned with zizmor's dependabot-cooldown audit (≥ 7 days). Caveat: the github-actions ecosystem only accepts cooldown.default-days (no semver-*-days keys — actions are not semver-strict in Dependabot). It uses default-days: 7; major action bumps are individual PRs reviewed by hand, so the 14-day buffer isn't critical there.
  • pre-commit does not have a native ecosystem in Dependabot — the manual flow with just hooks-update is kept.

Status: In use. Rationale: In previous adoptions Dependabot was noisy (many loose PRs) and broke Conventional Commits (Bump foo from X to Y messages without prefix). The grouped config with commit-message.prefix + include: scope solves both. The cooldown block (added 2026-04-25) avoids opening PRs for releases that may get yanked in the days following publish. Renovate would be more powerful but also more complex, with no clear return here.


14. Development environment (devcontainer)

Tool Type Standard Cloud (Codespaces) IDE
devcontainer.json Open spec Yes (containers.dev) Yes, no extra work VS Code, JetBrains, neovim...
Docker Compose (manual) Ad-hoc YAML No Partial Manual
Nix flake (devShell) Nix De facto in its niche Not direct Manual
Vagrant Ruby Legacy No Manual

Decision:devcontainer.json (in use). Status: In use. Modernized. Rationale: Open standard, supported by VS Code, JetBrains, and GitHub Codespaces with no extra work. Nix would be more reproducible but introduces an unjustified learning curve for this repo.

Modernization applied (2026-04-24)

Aspect Before Now
Base image python:1-3.13-bookworm (v1, Debian 12) python:3-3.13-trixie (v3, Debian 13)
uv ghcr.io/astral-sh/uv:latest (mutable) Pinned UV_VERSION (build arg) + SHA digest
lychee LYCHEE_VERSION="latest" (mutable) Pinned LYCHEE_VERSION (build arg)
just Imperative install with curl ⏐ bash in Dockerfile Declarative feature ghcr.io/guiyomh/features/just:0
pip install --upgrade pip Present (unnecessary, uv manages pip) Removed
forwardPorts Absent [8000, 8001] with labels Zensical (EN) / Zensical (ES)
init: true Absent Added (PID 1 with tini-like, zombie reaping)
Non-root vscode user
ENV variables (Python/uv)
postCreateCommand

Maintenance notes

  • UV_VERSION and LYCHEE_VERSION are build args in the Dockerfile. To bump: edit the corresponding ARG value. Dependabot does not track these pins (not a supported ecosystem); review manually every 6 months or when a needed new version comes out.
  • The ghcr.io/guiyomh/features/just:0 feature is tracked by Dependabot (package-ecosystem: github-actions already covers features in devcontainer.json).

Version source-of-truth (SoT) policy

Every tool has one authoritative pin. Other places that consume the tool must reference the same version, never a divergent one.

Tool SoT Consumers (must match) How to bump
Python deps (runtime) pyproject.toml + uv.lock Devcontainer (via uv sync), CI (uv sync --frozen) uv lock --upgrade then uv sync
Zensical (display) pyproject.toml + uv.lock README.md Zensical badge (display-only, manual) When bumping zensical in pyproject.toml, edit the version in the badge URL in README.md
Pre-commit hooks .pre-commit-config.yaml (rev:) Devcontainer + CI (both via pre-commit) just hooks-update (or manual rev bump)
uv itself Dockerfile UV_VERSION build arg setup-uv action version: in every workflow (deploy.yml, lint.yml) Edit both in the same PR
lychee binary Dockerfile LYCHEE_VERSION Local only (devcontainer). CI uses lychee-action (separate surface — bundled binary). Edit LYCHEE_VERSION; review action version in links.yml quarterly
pinact Dockerfile PINACT_VERSION Local only (no CI consumer) Edit PINACT_VERSION
just (devcontainer) devcontainer.json feature :0 Devcontainer only Dependabot (package-ecosystem: github-actions)
Action SHAs .github/workflows/*.yml Workflow files Dependabot weekly (pinact run for new refs)
zizmor .pre-commit-config.yaml (rev:) Pre-commit only (no separate CI step) Manual rev bump (pre-commit autoupdate or release-driven)

Rule: a tool that appears in two places with different version strings is a bug. Adding a tool to a new surface (e.g. promoting pinact to CI) means picking the SoT first and pointing every other consumer at it — never duplicating the literal version string.

Why this matters: the historical drift that motivated this policy was uv pinned to a specific version in the Dockerfile but installed as "latest" in CI workflows (fixed 2026-04-25). Lockfile-based tools (uv.lock, pre-commit rev:) prevent drift mechanically; system tools without a lockfile must be aligned by convention.


15. Supply-chain posture (OpenSSF Scorecard)

Tool Maintainer Mechanism Output
OpenSSF Scorecard OpenSSF GitHub Action that audits the repo on push + weekly Score 0–10 + per-check JSON
Snyk Open Source Commercial SaaS scan Vulnerabilities (commercial tiers)
Sigstore policy-controller Sigstore Runtime verifier N/A for static sites

Decision:Add ossf/scorecard-action as a scheduled workflow plus README badge. Status: ✅ In use as of 2026-05-14. Workflow: .github/workflows/scorecard.yml (weekly Mondays 09:30 UTC + push + dispatch). Badge live in README. SARIF uploaded to GitHub code-scanning. Rationale: This repo has already invested in supply-chain hygiene (SHA-pinned Actions via pinact, weekly Dependabot with cooldown, zizmor, pip-audit in CI). Scorecard is the public, comparable summary of that posture — it audits Branch-Protection, Pinned-Dependencies, Token-Permissions, Dangerous-Workflow, Maintained, etc. The badge gives visitors a single number instead of asking them to read three workflows.

Concretely it requires:

  • .github/workflows/scorecard.yml (official template).
  • actions/dependency-review does not apply (no PRs from forks here).
  • security_events: write permission on the workflow job for SARIF upload.
  • README badge: https://api.securityscorecards.dev/projects/github.com/landerox/landerox.github.io/badge.

Re-evaluate scope if:

  • Score drops below 7 — investigate which check failed before publishing the badge.
  • A check requires a substantial change (e.g. Branch-Protection rules on main — currently a solo-maintainer repo, so this needs a deliberate decision before flipping).

16. Content licensing (dual-license model)

Approach Code license Content license Convention
Single license (MIT for everything) MIT MIT Default GitHub, but MIT was written for code
Dual: MIT + CC-BY-4.0 MIT CC-BY-4.0 Standard for technical blogs (Datadog, MDN)
Dual: MIT + CC-BY-SA-4.0 MIT CC-BY-SA-4.0 Copyleft on derivatives (Wikipedia model)
Dual: MIT + CC-BY-NC-4.0 MIT CC-BY-NC-4.0 Blocks commercial reuse
All-rights-reserved content MIT None (©) Maximum control, minimum reach

Decision:Dual: MIT (code) + CC-BY-4.0 (content). Status: In use as of 2026-05-14. LICENSE-CONTENT added at repo root. Rationale: MIT was drafted for software and does not cleanly cover prose / images / Markdown content. Separating the two surfaces:

  • Lets readers reuse posts with attribution (translations, citations, excerpts) without grey-area legal ambiguity.
  • Aligns with how technical content sites (Datadog blog, MDN, the Astronomer Guides, etc.) license their material in 2026.
  • CC-BY-4.0 chosen over CC-BY-SA-4.0 to maximize reach without forcing downstream to adopt copyleft. Over CC-BY-NC-4.0 to keep the content reusable in commercial-adjacent contexts (newsletters, paid courses citing the work) — atypical here but cheap to allow.

Boundary rule:

  • Anything under content/ (Markdown, images, prose) → CC-BY-4.0.
  • Everything else (pyproject.toml, Justfile, Dockerfile, configs, workflows, scripts, generated theme files) → MIT.

Re-evaluate if:

  • Drafting needs to incorporate third-party content under an incompatible license — review per-file before publishing.
  • Demand emerges to monetize content directly (paid posts) — switch to CC-BY-NC-4.0 or all-rights-reserved.

17. Quality gates (Lighthouse CI)

Tool Type Output CI Integration
Lighthouse CI Auditor Scores 0–1 per category + budgets treosh/lighthouse-ci-action
WebPageTest API SaaS Detailed waterfalls Less GitHub-native
Pa11y Auditor A11y only Specialized
Sitespeed.io Auditor Multi-metric Heavier

Decision:Add treosh/lighthouse-ci-action in a dedicated quality.yml workflow with warn-only assertions (no deploy gate). Status: ✅ In use as of 2026-05-14. Config in .config/lighthouserc.json. Rationale: A bilingual content site benefits from continuous auditing of accessibility, SEO, performance, best-practices — but Lighthouse scores are known to be noisy across CI runs, so blocking the deploy on absolute thresholds adds friction without a clear win.

Concrete setup:

  • Trigger: push to main + pull_request (paths-filtered) + workflow_dispatch. Same convention as lint.yml / deploy.yml.
  • Coverage: home EN (/) + home ES (/es/). Adding more pages inflates runtime without proportional signal.
  • Mode: staticDistDir: ./site — Lighthouse CI levanta su propio static server contra el output del build, sin requerir GitHub Pages.
  • Runs per URL: 3 (LHCI default; reduces noise via median).
  • Budgets (warn, not error): performance ≥ 0.85, accessibility ≥ 0.9, best-practices ≥ 0.9, SEO ≥ 0.9.
  • Storage: filesystem + artifact upload. No temporaryPublicStorage (privacy: reports stay private).

Re-evaluate (tighten) if:

  • Scores stabilize above budgets for 8+ consecutive weeks → flip warnerror to make the gate effective.
  • A specific category (e.g. perf) consistently drops below budget due to upstream changes — investigate before relaxing the budget.

18. Lockfile maintenance (transitive deps refresh)

Approach Touches transitives Cadence Mechanism
uv lock --upgrade weekly via Actions ✅ Yes Weekly cron Custom workflow + PR
Dependabot package-ecosystem: pip ❌ No (declared only) Weekly Native
Renovate lockFileMaintenance ✅ Yes Configurable Renovate app
Manual uv lock --upgrade ✅ Yes Ad-hoc Operator

Decision:Add .github/workflows/uv-upgrade.yml running weekly with auto-opened PR via peter-evans/create-pull-request. Status: ✅ In use as of 2026-05-14. Rationale: Dependabot (§ 13) only refreshes deps declared in pyproject.toml. Transitive deps (e.g. urllib3 pulled in by requests) live only in uv.lock and never get bumped until either (a) a declared dep bumps and pulls a newer transitive, or (b) a CVE forces a manual refresh.

The urllib3 CVE-2026-44431/2 incident (2026-05-13) exposed this gap: the vuln only surfaced when CI happened to run, days after the advisory was published.

Concrete setup:

  • Cron: Tuesdays 06:00 UTC. Deliberately separated from Monday's congestion (Dependabot 06:00, Lychee 07:00, Scorecard 09:30) — gives one full day to merge Dependabot's batch first, reducing conflicts.
  • Branch: chore/weekly-uv-upgrade (fixed, delete-branch: true). Each week's run overwrites the same PR if not merged.
  • Commit message: chore(deps): weekly uv lock upgrade — Conventional Commits compatible, commitizen-friendly.
  • Labels: dependencies, python (matches Dependabot's scheme).
  • Permissions: job-scoped contents: write + pull-requests: write. Default ${{ secrets.GITHUB_TOKEN }}.

Why not Renovate: same reason as § 13 — adds external app + config, overkill for one ecosystem.

Re-evaluate if:

  • Weekly PRs consistently introduce regressions caught by pip-audit or by lint.yml — switch cadence to monthly.
  • Conflicts with Dependabot become frequent — move uv-upgrade to a different day or coordinate ordering via job dependencies.

19. JavaScript linter (ESLint)

Tool Language Type Configuration Ecosystem fit
eslint JS / TS Linter eslint.config.js (Flat config v9) Native Node mirrors via pre-commit
jshint JS Linter .jshintrc Legacy, unmaintained
biome Rust Formatter/Linter biome.json Young, doesn't reuse ecosystem rules

Decision:eslint (v9+) flat configuration. Status: In use. Rationale: ESLint is the industry-standard linter for JavaScript. Flat configuration (v9+) provides high-performance and declarative control. Integrated into pre-commit using Node mirrors to keep the repository's root environment clean of node_modules and runtime package files.


20. CSS linter (Stylelint)

Tool Language Type Configuration Ecosystem fit
stylelint CSS Linter .config/.stylelintrc.json Native Node mirrors via pre-commit
csslint CSS Linter .csslintrc Legacy, unmaintained

Decision:stylelint (v16+) with standard community rules. Status: In use. Rationale: Ensures CSS files adhere to standard design system guidelines and prevent rule regressions. Runs inside isolated pre-commit virtual environments via mirrors, maintaining zero local package footprints.


21. Bilingual asset deduplication (DRY Assets)

Approach Maintenance Disk / Git Footprint Build compatibility
Relative Symlinks Zero manual overhead Zero-copy, absolute DRY ❌ Broken (Zensical Rust compiler ignores symlinks)
Physical Duplication High (manual replication) Multiplied copies Native
Pre-build Sync Task Zero manual overhead Zero committed duplicates Native (physical files generated on compile)

Decision:Pre-build Sync Task (via Justfile + .gitignore). Status: In use. Rationale: Standard static site generators require extra asset files to reside under each distinct locale's docs_dir (e.g., content/en/assets/ and content/es/assets/). While symbolic links initially seemed like the ideal solution, Zensical's compiler is compiled in Rust (zensical.abi3.so) and ignores symbolic links in its asset copier, resulting in missing styles, JS, and images in the Spanish built output.

To resolve this, we implemented an automated Pre-build Sync Task in the Justfile (just sync-assets linked to all serve and build recipes) that physically copies shared stylesheets, javascripts, fonts, and core images from the English assets directory into the Spanish assets directory before compilation. By adding the copied folders and files to .gitignore, they are never tracked or committed, keeping the repository 100% DRY in Git. At the same time, because the synchronization copies a few small text and image files, execution takes less than 5ms.


22. Bilingual content symmetry checker (check-i18n)

Tool Mechanisms Ecosystem fit Maintenance
Custom Python hook Frontmatter + file path comparison Native uv / Python pre-commit Low
Manual validation Visual checks only Error-prone High

Decision:Custom Python script (check-i18n) run as a pre-commit hook. Status: In use. Rationale: In a personal bilingual site, the risk of translating a page but forgetting to create its counterpart in the other language, or mismatching key frontmatter metadata (e.g., hide, icon, search), is extremely high. Developing a lightweight, customized Python script under scripts/ is the ideal SOTA solution because it integrates natively with our existing Python environment (uv) and executes in milliseconds inside our pre-commit suite.


23. Project governance (OpenSSF Silver)

Model Maintenance Suitability Contributor path
Single Developer Flow (SDF) None (natural state) ✅ High (personal site) Documented via DCO + contributor guides
Steering Committee High overhead ❌ Extremely low Unnecessary complexity

Decision:Single Developer Flow (SDF). Status: In use. Rationale: As a personal portfolio and static site, the project is owned and maintained by a single individual (@landerox). Complex governance bodies or committees would add unnecessary overhead. Governance is kept transparent by:

  • Formally documenting the maintainer's sole merge authority and role in .github/CONTRIBUTING.md.
  • Requiring contributor sign-off (DCO) to ensure clean intellectual property.
  • Documenting an exit strategy/continuity model (open-source MIT/CC-BY-4.0 dual-licensing and self-contained Markdown files allow anyone to clone, build, and host a replacement site immediately if needed).

24. Secure design principles

Principle Implementation Threat model reduction Status
Static-first design Pure static files, zero runtime servers Eliminates SQLi, XSS (reflected/stored), RCE ✅ Implemented
Secure hosting & TLS GitHub Pages, TLS 1.3 only Mitigates eavesdropping, MITM attacks ✅ Implemented
Strict security headers Content Security Policy (CSP) Prevents script injection, clickjacking ✅ Implemented

Decision:Static-first Secure Design & Threat Modeling. Status: In use. Rationale: The site is designed with absolute minimization of the attack surface in mind. By relying entirely on static Markdown compiled via Zensical and avoiding server-side logic:

  • There is no database or input handling in the frontend, eliminating injection and remote code execution vulnerabilities.
  • Script execution is tightly restricted, and dependencies are audited using pip-audit, eslint, and stylelint via automated pre-commit and CI loops.
  • SSL/TLS security is outsourced to GitHub Pages (which enforces TLS 1.3 and HSTS, graded A+ by SSL Labs).

Summary table

# Category Chosen Status
0 Site generator (SSG) zensical ✅ In use (personal affinity)
1 Python package manager uv ✅ In use
2 Task runner just ✅ In use
3 Hook runner pre-commit ✅ In use
4 Conventional commits commitizen ✅ In use
5 Spell checker cspell (+ vale?) ✅ In use (evaluate vale)
6 Markdown linter markdownlint-cli ✅ In use
7 Secrets detection gitleaks ✅ In use
8 Link validator lychee ✅ In use (local + weekly CI)
9 Python deps audit pip-audit ✅ In use (pre-commit hook + CI)
10 Workflow lint actionlint ✅ In use
11 Workflow security zizmor ✅ In use (strict policy)
12 Pin Actions to SHA pinact ✅ In use (manual + Dependabot)
13 Automatic updates Dependabot grouped ✅ In use (with cooldown)
14 Devcontainer devcontainer.json ✅ In use (modernized)
15 Supply-chain posture OpenSSF Scorecard ✅ In use (weekly + push)
16 Content licensing MIT + CC-BY-4.0 ✅ In use (2026-05-14)
17 Quality gates Lighthouse CI ✅ In use (warn-only, PR + push)
18 Lockfile maintenance uv lock --upgrade weekly ✅ In use (Tue 06:00 UTC)
19 JavaScript linter eslint ✅ In use (Flat config v9+)
20 CSS linter stylelint ✅ In use (Config-standard rules)
21 DRY Asset deduplication Pre-build Sync Task ✅ In use
22 Bilingual symmetry checker Custom Python hook ✅ In use
23 Project governance Single Developer Flow ✅ In use
24 Secure design principles Static threat model ✅ In use

Appendix: notable rejections

Tools evaluated and discarded, with the reason, to avoid reopening the discussion later:

  • cocogitto (replace commitizen) — Overlapping function, no tangible benefit for a repo of this size. High migration cost.
  • typos (replace cspell) — Different category. Poor Spanish support. Not a substitute.
  • dprint (replace markdownlint-cli) — Different category (format vs. lint). Switching drops the rules.
  • mise (replace just) — Over-engineering; no need to manage multiple runtimes in this repo.
  • detect-secrets (replace gitleaks) — Considered as the Python-native option. Discarded: upstream has not shipped a release since 2024-05 (~24-month gap) and the rule surface lags modern cloud / SaaS providers. See §7 for the full comparison.
  • Renovate (replace Dependabot) — More powerful but also more complex; the use case here is trivial.
  • Playwright / Cypress (end-to-end testing) — Static site without significant JS. Lychee + Lighthouse already cover the realistic failure modes. Re-evaluate when JS interactivity is added.
  • Branch protection rules on main — Single-maintainer repo. Rules without a second reviewer add ceremony, not safety. Re-evaluate when a second active contributor exists.
  • CodeQL / SAST — No application code to scan. Static content
    • configs only. Re-evaluate when JS bundle or Python service is added.
  • REUSE / SPDX per-file headers — Dual-license already declared cleanly at repo level (LICENSE for code, LICENSE-CONTENT for content/**). Per-file overhead unjustified for one maintainer. Re-evaluate if content moves to a multi-maintainer open-content repo.
  • WebPageTest API for quality gates — Less GitHub-native than Lighthouse CI, requires API keys. No clear benefit here.

How to update this document

  • When a tool is adopted, move its row to "✅ In use".
  • When a new alternative is discarded, add it to the appendix with the reason in one line.
  • Review at least once a year the pending "➕ Add" entries.