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
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.
Every decision is evaluated against these five criteria, in this order:
- Functional fit — does it do exactly the job I need?
- Migration cost — how much config / habit must be redone?
- Maturity and maintenance — releases, community, bus factor.
- Fit with the existing stack (
uv,just,pre-commit, GH Actions). - 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.
| 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/enandcontent/esare portable to any SSG with i18n support.- What would be lost: specific
zensical.tomlconfiguration (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).
| 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.
| 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.).
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 |
- 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.
- 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-hookscatalog in current use:check-yaml,check-toml,check-json,check-merge-conflict,mixed-line-ending,trailing-whitespace,check-added-large-files,check-case-conflict— 8 hooks that underlefthookmust be replaced manually with scripts or alternative binaries. Not trivial.pre-commit autoupdate: automatic rev updates;lefthookhas no native equivalent (requires Dependabot or similar).- Declarative hook versioning: in
pre-commit, each hook ships with a fixedrev:. Inlefthookyou 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).
| 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: ✅ 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 rationale — lefthook is more modern and would
be the right choice if at least one of the following held:
- the repo were not already Python (does not apply: Zensical is), or
- orchestrating binaries across multiple runtimes were needed (does not apply: only the current hooks are needed), or
- the cost of parallelism were measurable (does not apply: the suite runs in seconds), or
- 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-hookscatalog argument no longer applies).
| 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.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:
- Edit
CHANGELOG.md— move items from[Unreleased]into the new version section; add a short framing paragraph. - (Optional) Run
just release-preview(cz bump --dry-run) to audit the SemVer increment that commitizen would infer before touching any file. - Run
cz bump(commitizen) — bumpspyproject.toml, creates the git tag (tag_format = "v$version"), commits the version file. - Push tag + commit. GitHub Release notes can be copy-pasted from the
relevant
CHANGELOG.mdsection.
cz changelog (auto-generation) is not part of this workflow.
Important:
cspellandtypossolve 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.
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.
| 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
gitleaksdoes not detect it before the next minor release. - Real-credential verification becomes a requirement
(consider
trufflehogas a complementary scan, not a replacement). - Upstream maintenance cadence drops below quarterly for 12+ months.
| 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.
| 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.
| 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.
| 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.
| 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.
Adding pinact as a pre-commit hook was evaluated but discarded:
pinactdoes not publish a.pre-commit-hooks.yaml, so it would require alocalhook depending on the binary inPATH.- In CI (
lint.yml) an install step would have to be added. zizmoralready acts as a safety net: if anyone introduces a ref without a pin, the hook (local + CI) fails with thehash-pinpolicy (default).
So: zizmor flags, pinact fixes on demand
(just pin-actions), Dependabot keeps things current.
| 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).majorupdates 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): ...
- GitHub Actions →
- 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'sdependabot-cooldownaudit (≥ 7 days). Caveat: thegithub-actionsecosystem only acceptscooldown.default-days(nosemver-*-dayskeys — actions are not semver-strict in Dependabot). It usesdefault-days: 7; major action bumps are individual PRs reviewed by hand, so the 14-day buffer isn't critical there. pre-commitdoes not have a native ecosystem in Dependabot — the manual flow withjust hooks-updateis 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.
| 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.
| 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 |
✅ | ✅ |
UV_VERSIONandLYCHEE_VERSIONare build args in theDockerfile. To bump: edit the correspondingARGvalue. 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:0feature is tracked by Dependabot (package-ecosystem: github-actionsalready covers features indevcontainer.json).
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.
| 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-reviewdoes not apply (no PRs from forks here).security_events: writepermission 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).
| 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.0chosen overCC-BY-SA-4.0to maximize reach without forcing downstream to adopt copyleft. OverCC-BY-NC-4.0to 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.0or all-rights-reserved.
| 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:
pushtomain+pull_request(paths-filtered) +workflow_dispatch. Same convention aslint.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, noterror): performance ≥ 0.85, accessibility ≥ 0.9, best-practices ≥ 0.9, SEO ≥ 0.9. - Storage:
filesystem+ artifact upload. NotemporaryPublicStorage(privacy: reports stay private).
Re-evaluate (tighten) if:
- Scores stabilize above budgets for 8+ consecutive weeks → flip
warn→errorto make the gate effective. - A specific category (e.g. perf) consistently drops below budget due to upstream changes — investigate before relaxing the budget.
| 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-auditor bylint.yml— switch cadence to monthly. - Conflicts with Dependabot become frequent — move uv-upgrade to a different day or coordinate ordering via job dependencies.
| 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.
| 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.
| 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.
| 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.
| 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).
| 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, andstylelintvia 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).
| # | 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 |
Tools evaluated and discarded, with the reason, to avoid reopening the discussion later:
cocogitto(replacecommitizen) — Overlapping function, no tangible benefit for a repo of this size. High migration cost.typos(replacecspell) — Different category. Poor Spanish support. Not a substitute.dprint(replacemarkdownlint-cli) — Different category (format vs. lint). Switching drops the rules.mise(replacejust) — Over-engineering; no need to manage multiple runtimes in this repo.detect-secrets(replacegitleaks) — 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 (
LICENSEfor code,LICENSE-CONTENTforcontent/**). 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.
- 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.