Changelog¶
Format: Keep a Changelog + Semantic Versioning — see the Changelog section of the docs site for details.
[1.0.0] "WorldWake" - 2026-07-24¶
Added¶
.github/workflows/CI.yml: path-filtered CI pipeline (dorny/paths-filter) that runs checks only for the parts of the monorepo a change actually touches —back(lint, security, types, tests viauv run scripts/workflow_ci.pyforbarrins_api/barrins_identity),front(npm run lint/build/testfortamiyo_scroll/tolaria_news),ops(ansible-lintforops/my-server), anddocs(markdownlint, cspell,mkdocs build --strict) — on every push/PR tostagingandmain. Aci-requiredjob aggregates the per-job results into a single fail-closed status check, so branch protection has one check to depend on even though the individual jobs are conditionally skipped..github/dependabot.yml: weekly dependency update PRs forapps/barrins_api(uv),apps/tamiyo_scroll(npm),docs(npm), and.github/workflows(github-actions), all targetingstagingso updates go through the same CI gate as any other change before reachingmain. Dependabot only ever opens pull requests — it never pushes commits directly to a branch..github/workflows/deploy-docs.yml: manual (workflow_dispatch) MkDocs build-and-deploy workflow to GitHub Pages, intentionally kept out of the required CI checks. The hosting target (GitHub Pages + custom domaindocs.barrins-codex.org) is a placeholder pending confirmation. Superseded: this workflow was never actually committed to the repo, and the hosting target is now decided — self-hosted viadocs.yml/thedocs_siterole (see Added, below), not GitHub Pages (the account already hosts other, unrelated projects' Pages sites — see B2,docs/project/first_production_release/b2-docs-deploy/index.md).- Ansible VPS deployment (
ops/my-server/), moved in-repo from the previous separateSpigushe/myserverrepository (now deprecated) so infrastructure changes land alongside the application changes that require them (Constitution §26.1). Playbooks:initial.yml,setup.yml,barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml; roles:create_ssh_key,setup_base_user,setup_packages,register_ssl,backend_website,react_frontend,fastapi_backend.scripts/check_no_secrets_committed.shguards against ever staging a real secrets file. Documented under the new Constitution §38-mandateddocs/content/ops/tree (architecture/independence.md,architecture/decisions.mdfor the ADRs below,deployment/{backend,frontend,rollback}.md,security/secrets.md,operations/index.md— the last honestly documenting current gaps: no health endpoint, no monitoring, no tested backups). ops/my-server/postgresql_pgadmin.ymland thepgadminrole: a Docker-based pgAdmin4 deployment/administration playbook (isolated Docker network,pg_hba.conf/listen_addresseswiring, weekly auto-update timer,unattended-upgradesfor the host), porting thepgadminrole from myserver's unmergedpostgresql-pgadmin-playbookbranch. PostgreSQL itself is already installed bysetup_packagesat host bootstrap; this playbook only exposes it via pgAdmin. Documented atdocs/content/ops/deployment/database.md.ops/my-server/secrets/tamiyo_scroll/{staging,production}.env.example: documentation templates mirroringapps/tamiyo_scroll/.env.example, for parity withbarrins_api's per-appsecrets/layout. Unlikefastapi_backend_env_file,react_frontendhas noenv_filemechanism —tamiyo_scroll.ymldoes not read these;VITE_API_BASE_URLis already computed automatically by the playbook and isn't secret.ops/my-server/roles/postgres_backup, wired intopostgresql_pgadmin.yml: a systemd-timer-driven daily backup (pg_dump --format=customper non-template database, pluspg_dumpall --globals-onlyfor roles/grants) into/var/backups/postgresql/, 14-day retention, running as thepostgresOS user (umask 077makes every dump0600by construction). Documented atdocs/content/ops/deployment/backup.md. UAT-verified on staging: timer active, a manually-triggered run produced dumps with the correct permissions (0600, owned bypostgres) for every database on the instance, and a full restore drill (restore into a scratch database,SELECT count(*)compared against the live database) matched exactly.ops/my-server/secrets/postgresql_pgadmin/admin_password.txt.example,ops/my-server/secrets/github/token.txt.example: plaintext templates for two secretssecrets/README.mdalready documented but never actually shipped — a bare pgAdmin admin password file, and a shared GitHub PAT (see Changed, below).ops/my-server/roles/github_token: the GitHub PATpre_tasksblock (see Changed, below) extracted into its own role oncedocs.ymlbecame its fourth identical copy acrossbarrins_api.yml,tamiyo_scroll.yml, andtolaria_news.yml. Every playbook that clones a private repo now listsgithub_tokenfirst in itsroles:;fastapi_backend/react_frontend/docs_sitefall back to itsgithub_tokenfact via<role>_github_token | default(github_token)unless a playbook overrides it per-invocation.ops/my-server/docs.ymland thedocs_siterole: self-hosted deployment of the mkdocs documentation site (B2), samedeploy_env/branch/release-tag options asbarrins_api.yml/tamiyo_scroll.yml. Structurally mirrorsreact_frontend(clone at a ref, build, symlink static output, hand ownership towww-data, nginx vhost +register_ssl), but builds withuv/uvx(uvx --with mkdocs-material mkdocs build --strict, matchingfastapi_backend's toolchain) instead ofnvm/npm, and serves a plain static multi-page site (try_files $uri $uri/ =404;) rather than a client-side-routed SPA. Decided over GitHub Pages because the account already hosts other, unrelated projects' Pages sites. Documented atdocs/content/ops/deployment/docs_site.md.
Changed¶
.github/workflows/CI.yml: translated remaining French inline comments and step names to English.- Constitution §34 (Secrets Management), applied while moving
ops/my-server/in-repo and decided with the user rather than guessed (§16.2, recorded as an ADR indocs/content/ops/architecture/decisions.md): backend.envfiles are local-only and git-ignored (ops/my-server/secrets/**/*.env), never committed even encrypted.fastapi_backend'sfastapi_backend_env_filestep uses one if present on the operator's machine, skips gracefully otherwise. - Constitution §25/§27/§31 (Release Policy), same ADR process:
production deploys resolve the latest GitHub release tag by default
(
fastapi_backend_use_release_tag/react_frontend_use_release_tag, wired todeploy_env == 'production'in every playbook), or a pinned tag for rollback. Staging keeps deploying a branch, since it exists to preview code before release. .gitignoreandscripts/check_no_secrets_committed.shgeneralized: allow-listsecrets/**/*.exampleandREADME.mdinstead of listing each secret filename individually, so a new secret file (e.g. pgAdmin's admin password) is caught by default without a new gitignore line.- Constitution §26.1 (Infrastructure objective): added an explicit
"one application, one playbook" rule — a frontend playbook must
never embed a backend role invocation (or vice versa), and running
one app's playbook must never touch another's systemd service,
nginx vhost, or database. Decided with the user (§16.2) after
tolaria_news.yml's embedded-backend exception (see Fixed below) was judged to need fixing rather than being grandfathered. ops/my-server/ansible.cfg: normalizedkey=valuespacing under[defaults]—inventory,vault_password_file,timeout, andansible_ssh_userhad stray spaces around=that every other key in the file didn't.- Production email sending (ADR-3): decided to go through a
transactional email provider's SMTP relay for
identity@barrins-codex.org, rather than self-hosting a mail server or keeping the temporary personal Gmail relay long-term. No code change required —SMTPEmailSenderalready speaks generic SMTP. ops/my-server/barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml,docs.yml: the shared GitHub PAT is no longer an inlineansible-vault-encrypted blob duplicated across the playbooks — externalized to a single local, git-ignoredsecrets/github/token.txt(same pattern aspostgresql_pgadmin's admin password), read via thegithub_tokenrole (see Added, above) — originally a fail-fastpre_tasksblock copy-pasted into each playbook, extracted into a role oncedocs.ymlmade it a fourth copy. Renewing the token (every 30 days) is now overwriting one local file, not re-encrypting and editing three tracked files.
Fixed¶
.github/workflows/CI.yml: thebackjob never provisioned a Postgres service or a realSECRET_KEY, so any PR touchingapps/barrins_apiwas doomed to fail —pytesterrors out whiletests/conftest.pyimportsapp.config.settings(the placeholderSECRET_KEYis rejected), and even past that the session-scopedtest_enginefixture needs a reachable database. This went unnoticed because no PR had touchedapps/barrins_apisince the CI pipeline was wired up. Added apostgres:17service container (localhost:5432, health-checked viapg_isready), job-levelDATABASE_URL/TEST_DATABASE_URLenv vars pointing at it, and a step generating an ephemeralSECRET_KEYviaopenssl rand -hex 32beforeworkflow_ci.pyruns.ops/my-serverplaybooks/roles/READMEs:become: 'no'/'yes'(andgather_facts/update/force/recurse/daemon_reload/enabled/update_cacheusing the same quoted-string pattern) are YAML strings, not booleans — schema/lint tools correctly flagged them as "Incorrect type. Expected boolean." Replaced every instance with realtrue/false..github/workflows/CI.yml: theopsjob ranansible-lint ops/myserver— the original scaffold's placeholder path (marked# TO UPDATE) that nobody updated once the real Ansible deployment landed atops/my-server/— so the job failed outright (ops/myserver: File or directory not found). Corrected the path. The newdocs/content/ops/**tree also never passed thedocsjob's actual checks: cspell didn't knowfastapi,pgadmin,certbot,journalctl,frontends,spigushe,uvicorn,HSTS,vhosts,dpage,certonly,creatordate,nohostname,inlines,ciphertext,FQCN, orkeypair(added todocs/cspell.json), anddeployment/backend.mdhad a code-block line over the 80-charMD013limit.ops/my-server: fixing theansible-lintpath above revealed the playbooks/roles themselves failed the same job hard — 114 failures + 33 warnings across 38 files, none of it caught before since this was the first timeansible-lintactually reachedops/my-server(see the path bug above). Brought it to a clean pass at ansible-lint'sproductionprofile (opsCI only requiresmin):fqcn(73×): every builtin module action FQCN-prefixed (ansible.builtin.copy, notcopy).syntax-check[unknown-module](2×):openssh_keypairandauthorized_keymoved out ofansible.builtin— switched tocommunity.crypto.openssh_keypair/ansible.posix.authorized_keyand addedops/my-server/requirements.ymldeclaring both collections (ansible-galaxy collection install -r requirements.yml, also wired into theopsCI job before linting).role-name(7×): renamed every hyphenated role directory to snake_case (fastapi-backend→fastapi_backend,react-frontend→react_frontend,backend-website→backend_website,create-ssh-key→create_ssh_key,register-ssl→register_ssl,setup-base-user→setup_base_user,setup-packages→setup_packages) and updated every reference across playbooks and docs.var-naming[no-role-prefix](56× once the roles above had valid names — the rule doesn't check unnamed roles): every role-input var and internal computed-config dict renamed to carry the full role name as prefix — abbreviations likefb_repobecamefastapi_backend_repo; the bare single-letter config dicts (r, andpgadmin's ownr) became<role>_config(fastapi_backend_config,pgadmin_config, etc.).risky-file-permissions(8×): explicitmode:added to everytemplate/copytask that lacked one.yaml[octal-values](4×):mode: 0755/0600quoted ("0755"/"0600").no-changed-when(4×): explicitchanged_whenadded tocommand/shelltasks that always reported "changed".package-latest(1×): thepipfallback install now pinsstate: presentinstead oflatest, consistent with installing from arequirements.txtin the first place.name(9×): missing play/task names added; the one task name embedding a Jinja expression mid-string moved it to the end.jinja[spacing](33× warnings):{{var}}→{{ var }}throughout, including.j2templates (not part of the lint gate, fixed for consistency while touching the surrounding code). Verified withansible-playbook --syntax-checkon all six top-level playbooks (ansible-lintitself needs the POSIXgrpmodule and doesn't run natively on Windows — validated from a WSL/Linux venv). New Constitution subsection (docs/content/CLAUDE.md§26.4, "Ansible coding standards") distills these rules for future playbook/role work.ops/my-server/roles/fastapi_backend,react_frontend:fastapi_backend_repo/react_frontend_repoinbarrins_api.yml,tamiyo_scroll.yml,tolaria_news.ymlpointed atbarrins-project/barrins_api,barrins-project/tamiyo_scroll,barrins-project/tolaria_news— repos that don't exist; the apps actually live underapps/<name>/in this monorepo (Spigushe/barrins-project). The first deploy would have failed at thegit clonestep. Both roles gained a*_repo_subdirvar: the full repo is still cloned toapp_root/site_root, but dependency detection/install, the deployed.env, the build command, and the systemdWorkingDirectorynow resolve against<root>/<repo_subdir>; the three playbooks were repointed atSpigushe/barrins-projectwith the matchingapps/<name>subdir. Also surfaced and fixed a related latent bug:react_frontend'sdistsymlink task only fired whenbuild_dir != 'dist', which would have silently served nothing once a subdir is introduced (build output lands at<site_root>/<subdir>/dist, not<site_root>/dist) — the condition now compares full resolved paths instead.ops/my-server/roles/register_ssl/tasks/main.yml: the role's own README documents templating/etc/nginx/snippets/ssl-params.confas step 1 — shared by every HTTPS-serving role viainclude snippets/ssl-params.conf;(backend_website,react_frontend,pgadmin) — but the task that actually templates it was dropped when the Ansible deployment moved in-repo;tasks/main.ymlwent straight from the HTTP vhost to reload to certbot. Any HTTPS vhost reload on a host missing that snippet failed nginx's config test outright (open() "/etc/nginx/snippets/ssl-params.conf" failed (2: No such file or directory)), surfaced when deployingbarrins_api.yml -e deploy_env=stagingto a fresh domain. Restored the missing task, ordered first as the README already described.- Two WSL-specific gotchas surfaced while diagnosing the above from a
/mnt/c(DrvFs) checkout, not repo bugs but worth recording for any operator deploying from WSL: DrvFs mounts report their directories as world-writable by default, so Ansible silently ignores a same-directoryansible.cfg(inventory/ansible_ssh_usernever applied, inventory host pattern left unmatched); and DrvFs's default file permissions can leave the owner-execute bit set even after narrowingfmask, which makes Ansible mistake.vault-password-file.txtfor a vault password script rather than a plain password file. Both resolved by settingmetadata,umask=22,fmask=111under[automount]in/etc/wsl.conf(fmask=111clears execute for owner/group/other alike, vs. a narrowerfmask=11which left owner's execute bit set) followed bywsl --shutdown. ops/my-server/tolaria_news.yml: dropped the embedded copy of thebarrins_apibackend role block (previously documented as a known exception to "one playbook per app" — see Constitution §26.1 above). It's frontend-only now, pointingVITE_API_BASE_URLat whateverbarrins_api.ymlalready has running, the same patterntamiyo_scroll.ymlalready used. Updated the SSH/Alembic path reminders (~/projects/<domain>/apps/barrins_api, not the app root) and every doc referencing the straytolaria.ymlfilename (the file has always beentolaria_news.yml):README.md,architecture/independence.md,deployment/frontend.md,deployment/rollback.md.ops/my-server/roles/fastapi_backend/tasks/main.yml: no task ever ranalembic upgrade head— the role installed dependencies, deployed.env, and restarted the service, leaving any pending schema migration unapplied against the newly deployed code (Constitution §31.1/§37.1 both list migrations as a required deployment step). Added an "Apply database migrations" task (uv run alembic upgrade head,chdirat the resolved work dir), gated on the samefastapi_backend_pyproject.stat.existscheck as theuv synctask.ops/my-server/roles/fastapi_backend/tasks/main.yml: the migrations task above initially ran right after dependency installation, before the.envdeploy step — soalembic upgrade headconnected using whatever.env(if any) was already sitting on the server from a previous deploy, not the one this run just copied. Surfaced while deployingbarrins_api.yml -e deploy_env=staging: migrations failed withpassword authentication failed for user "REPLACE_USER"even after the localsecrets/barrins_api/staging.envwas fixed, because the corrected file hadn't been copied to the server yet at the point migrations ran. Reordered so "Deploy the local .env file" (and its "no local .env found" fallback) runs before "Apply database migrations".ops/my-server/barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml:env_branchdefaulted staging deploys to adevelopbranch that doesn't exist in this repo (the actual branch isstaging) — any-e deploy_env=stagingrun would have failed at thegit clone/checkout step. Also populated the three playbooks'github_tokenvault block, still aREPLACE_WITH_...placeholder ciphertext since the ops migration in-repo, with the realansible-vault encrypt_stringoutput.ops/my-server/README.md: the venv setup instructions created a barevenv/directory, which nothing but a stale, since-superseded.gitignoreline covers — every role actually usesuv's.venvconvention (caught by the repo-wide**/.venv/pattern). Renamed to.venv, and added--forceto theansible-galaxy collection installstep so it actually reinstalls collections pinned inrequirements.ymlinstead of silently skipping already-installed ones.ops/my-server/roles/fastapi_backend/README.md: documented theuv python install 3.14step the role already performs beforeuv sync(idempotent, a no-op when already installed) — the README previously only mentioneduv syncitself.ops/my-server/roles/react_frontend/tasks/main.yml: the "Ensure nvm is installed" task setNVM_DIR=/opt/nvm(shared across apps) but never created that directory first; nvm'sinstall.shonly auto-creates$NVM_DIRwhen it matches its own default ($HOME/.nvm), so it exited with "You have $NVM_DIR set... but that directory does not exist" on a fresh host. Surfaced while deployingtamiyo_scroll.yml -e deploy_env=stagingto a fresh server. Added a task creating/opt/nvmbefore the install step.ops/my-server/roles/react_frontend/tasks/main.yml: the "Clone/update application repository" task runs as root (the play'sbecome: truedefault), but the role's last task handssite_roottowww-datarecursively — including the.git/directory — so on the next run root no longer owns the repo and git refuses it outright (detected dubious ownership in repository at ..., Git's CVE-2022-24765 protection). Surfaced on a redeploy oftamiyo_scroll.yml -e deploy_env=stagingafter the initial deploy had already flipped ownership towww-data. Added a task settingsafe.directoryin root's global gitconfig (as root) before the clone/update step, so every future run is immune to the ownership check regardless of who last owned the checkout. First written as anansible.builtin.command: git config --global --add safe.directory ...task, which passed locally but failed PR #17'sopsCI job underansible-lint'scommand-instead-of-modulerule; switched tocommunity.general.git_config(add_mode: add, which maps togit config --addand is idempotent — it skips when the value is already present), addingcommunity.generaltoops/my-server/requirements.yml.ops/my-server/roles/react_frontend/tasks/main.yml: with the ownership check above resolved, the same "Clone/update application repository" task then failed withLocal modifications exist in the destination ... (force=no)on a second deploy — the build step (npm install/ build command) can leave the working tree dirty (e.g. a regenerated lockfile), and Ansible'sgitmodule refuses to update over local modifications unless told to. Since this checkout exists solely to be rebuilt from source control on every deploy, addedforce: trueso it always resets cleanly to the target ref. Verified with a fulltamiyo_scroll.yml -e deploy_env=stagingredeploy, which completed successfully end to end..gitignore: thesecrets/**allow-list only worked forsecrets/barrins_api/because that directory predates the rule — for any brand-new subdirectory undersecrets/(surfaced while addingsecrets/postgresql_pgadmin/andsecrets/github/), git excluded the whole directory before evaluating the*.examplenegation, a parent-directory-excluded gitignore gotcha. Added!ops/my-server/secrets/*/so new secret subdirectories are traversed correctly regardless of what they're named.ops/my-server/roles/register_ssl/vars/main.yml:register_ssl_contact_namedefaulted to<username>@<domain>— a mailbox that never existed on any of the 4 domains this role certifies. Let's Encrypt doesn't verify deliverability at registration time, socertbotnever complained; every certificate issued so far was registered with a bogus contact. Made the var required (no default, matchingregister_ssl_server_name) and passed a real, monitored address from every playbook invoking the role.ops/my-server/postgresql_pgadmin.yml: bothpgadmin_admin_email: "{{ pgadmin_admin_email }}"andpgadmin_admin_password: "{{ pgadmin_admin_password }}"passed a role parameter whose value referenced a play-level var of the exact same name — Ansible resolved the right-hand side against the role parameter being defined, an infinite self-reference ("Recursive loop detected in template"). Renamed the play-level vars (admin_contact_email,pgadmin_admin_password_value) so they no longer collide with thepgadminrole's own input var names.ops/my-server/roles/pgadmin: the role unconditionally overwrotepostgresql.conf'slisten_addressesto"localhost,<gateway>"— on a host where it was already'*'(needed bybarrins_apiitself to reach Postgres over the public interface, per itsDATABASE_URL), this produced a duplicate directive and broke that connectivity, since Postgres uses whichever line appears last in the file. Surfaced live while re-testingbarrins_api.ymlright afterpostgresql_pgadmin.yml's first run. The task now reads the active value first and only narrows it when nothing already covers the gateway IP. Also dropped--add-host=host.docker.internal:host-gatewayfrom the pgAdmin container — unnecessary (it already reaches Postgres directly via the isolated network's gateway) and actively misleading, since on Linuxhost-gatewayresolves to the default bridge's gateway, not this custom network's.ops/my-server/roles/docs_site/tasks/main.yml: the "Build the docs site" task carried abecome: falsecopy-pasted fromfastapi_backend'suv-install pattern — but unlikefastapi_backend(which clones and builds entirely as the unprivileged user),docs_siteclones and builds as root likereact_frontend, only handing ownership towww-dataat the very end. Building as the unprivileged user against a still root-owned checkout failed outright:PermissionErrorwritingdocs/content/back/barrins_api/index.md, regenerated bydocs/hooks/sync_readmes.py's mkdocs pre-build hook. Surfaced deployingdocs.yml -e deploy_env=staging. Dropped the straybecome: falseso the build step runs as root, consistent withreact_frontend's equivalent build task.ops/my-server/roles/github_token/tasks/main.yml: theopsCI job failed onvar-naming[no-role-prefix]—ansible-lintwants every fact a role sets prefixed with the role's own name, but this role's entire purpose is to set a shared, unprefixedgithub_tokenfact thatfastapi_backend/react_frontend/docs_site(and every playbook invoking them) already fall back to by that exact bare name. Prefixing it would break that contract. Suppressed with a targeted# noqa: var-naming[no-role-prefix]and a comment explaining why, rather than renaming..github/workflows/CI.yml: thechangesjob'soutputs:map exposedback/front/opsbut notdocs, even though the same job'spaths-filterstep already computed it —needs.changes.outputs.docswas therefore always empty, so thedocsjob (if: needs.changes.outputs.docs == 'true') was skipped on every PR regardless of what actually changed, not just this one. Surfaced by a PR touching onlydocs/project/**still showingci / docsskipped. Added the missingdocs: ${{ steps.filter.outputs.docs }}line.