07 Oct 2026
Django community aggregator: Community blog posts
Questions to Ask a Company Using Django
Interview questions for your next job.
07 Oct 2026 11:57am GMT
06 Oct 2026
Django community aggregator: Community blog posts
Math-grounded APIs
Some types arrive with their operations already specified - by mathematics. If a type is a number, a set, or a sequence, math has written the complete API for us; the work is to implement it, and to implement it efficiently.

06 Oct 2026 10:00am GMT
03 Oct 2026
Django community aggregator: Community blog posts
Python 3.10, Django 4.2 and Node.js 20 Are Out of Support: What to Do Now
Django 4.2, Node.js 20, Amazon Linux 2, RDS MySQL 8.0 and Python 3.10 all reached end of support in 2026, with PostgreSQL 14 next. Here are the dates, what they mean, and the upgrade order we use.
03 Oct 2026 10:23am GMT
Python 3.10, Django 4.2 and Node.js 20 Are Out of Support: What to Do Now
Django 4.2, Node.js 20, Amazon Linux 2, RDS MySQL 8.0 and Python 3.10 all reached end of support in 2026, with PostgreSQL 14 next. Here are the dates, what they mean, and the upgrade order we use.
03 Oct 2026 10:23am GMT
02 Oct 2026
Django community aggregator: Community blog posts
Issue 357: Malcolm Tredinnick Prize Nominations and Django 6.2 Features
News
Nominate Someone for the 2026 Malcolm Tredinnick Memorial Prize
The annual prize honors someone who welcomes newcomers, freely helps others, and grows the community, with a stipend meant to fund travel to a DjangoCon, PyCon, or sprint. Nominate someone by October 15 (Anywhere on Earth).
Python 3.10.22, 3.11.17, 3.12.15, 3.13.16 and 3.14.8 are now available!
Security releases across all five series, with fixes for tarfile extraction filters, zipfile decompression bombs, and SSL hostname validation. Python 3.10.22 is the final 3.10 release, and 3.13.16 is the last full maintenance release of 3.13, so plan your upgrades.
Python Language Summit 2026
Seth Larson's writeups from the first summit held in Europe since 2011, where 47 core developers in Kraków covered free-threading, Rust for CPython, garbage collection, type manipulation, and an AGENTS.md for CPython. The summit will now alternate between PyCon US and EuroPython each year.
Updates to Django
Today, "Updates to Django" is presented by Raffaella from Djangonaut Space! 🚀
Last week we had 6 pull requests merged into Django by 5 different contributors
News in Django 6.2:
- The new
django.utils.asyncio.maybe_aclosingreturns a context manager that callsaclose()on a caller-provided iterator only if it defines one. - Support for GDAL 3.3 and 3.4 and for GEOS 3.10 is removed.
- Most Django-provided middleware now set
async_capable = Falseto avoid repetitive context switching inMiddlewareMixin.__acall__()under ASGI. For atypical use cases, e.g. high in-process concurrency over non-ORM I/O, where no thread is otherwise held, the repetitive context switching may be preferable to pinning a thread per request (seeasync_performance). Such deployments can restore the prior behavior by settingasync_capable = True; seeMiddlewareMixin <upgrading-middleware>.~django.contrib.auth.middleware.RemoteUserMiddlewareis unaffected, because it does not useMiddlewareMixin. Neither is~django.contrib.auth.middleware.LoginRequiredMiddlewarenordjango.contrib.admindocs.middleware.XViewMiddlewareaffected, as they did not implementprocess_request()orprocess_response().
Django Fellow Reports
Django Fellow Report - Jacob
When Paolo wasn't otherwise showing us around Abruzzo last week at Django on the Med 🏖️, I had the pleasure of supporting a number of groups there, spanning:
- frontend topics (subresource integrity and importmaps)
- backend topics (temporal constraints and
django-subatomic) - performance topics (a benchmarking group kicked off, and Carlton Gibson got me to commit to auditing our free-threading readiness)
- documentation topics, and …, and …, and …
Special thanks to Anna and Simon for taking up my suggestion to pair on some delicate issues around NULL handling in the ORM. They each have PRs I'm excited to review, and the three of us now have more context for tackling whatever comes next in this area.
Django Fellow Report - Natalia
I was mostly OoO (out-of-office) this week due to 🌸 Spring break 🌼 in Uruguay. We travelled 🚗 to visit family and had a wonderful time, including multiple rounds of ice cream eating 🍦. I still prioritized attending the Security Team meeting and doing a release notes fix.
Sponsored
Reach 4,300+ Engaged Django Developers
Sponsor this newsletter to reach an active community of Python and Django developers.

Articles
Black Python Devs has a "New" website
Black Python Devs moved its website from Render Engine to Django and opened the repo, trading a project manager that cost about $1,000 a year for automated workflows (elections and award nominations already run there). Members can now create accounts and set notification preferences, and issues and PRs are welcome.
Django, arrosticini, and the Adriatic ... Django on The Med Pescara 2026
Valentino Gagliardi's sprint recap from Pescara, where a couple of coffees on day two turned into a proposal to replace QUnit with vitest for Django's admin and GIS JavaScript tests, with browser mode, coverage reporting, and accessibility-based selectors as a complement to the Playwright end-to-end work.
Start thinking about running for the Django Steering Council
Tim Schilling, speaking for himself rather than the Council, urges people to run in the next Steering Council election, which starts when Django 6.2 ships in April. DEP 19 now values teaching and community organizing alongside code, and his advice is to start writing publicly about your ideas now, since voters pick candidates they already know and trust.
Django: serve a security.txt file
A security.txt at /.well-known/security.txt (RFC 9116) tells researchers where to report vulnerabilities instead of guessing at addresses. Adam Johnson's view serves it as UTF-8 plain text, with tests that validate the required Contact and Expires fields and a system check that warns before Expires lapses without blocking your commands.
Show and hide Wagtail admin fields without writing any JavaScript
Tim Kamanin was about to write a web component to toggle between a page chooser and a URL field, then found Wagtail's built-in w-rules Stimulus controller already does it. Pass the data-w-rules attributes through a panel's attrs argument; hiding fields needs Wagtail 7.2 or newer.
Setting Up DNS for SaaS Emails
Five years of running a SaaS taught Aidas Bendoraitis to send marketing mail from a different subdomain than transactional and direct mail, so newsletter spam complaints don't drag down password resets. The guide walks through MX, SPF, DKIM, and DMARC records for each, with working examples for FastMail, Mailjet, and Brevo.
djust 1.2: More Django-Compatible, Much Faster
djust now runs Django's own template test suite against its Rust engine and passes 98.6% of it. Render time depends only on what a template reads, so the heaviest benchmark template dropped from 215 ms to 4.7 ms (Django takes about 9 ms), and the release adds class-level components and djust init for existing projects.
Sendgrid is bad at spending their marketing money
django-anymail has dropped official SendGrid support after Twilio SendGrid disabled the project's testing account in June 2025, leaving no way to run integration tests or triage SendGrid bugs. Frank Wiles puts the savings at roughly the cost of three LinkedIn ad clicks.
Rebuilding my development setup in 2026
Six weeks of rebuilding a setup so Claude Code keeps running with the laptop lid closed and can be checked from a phone: Ghostty with the herdr multiplexer, chezmoi-managed dotfiles, a Mac mini as the agent machine (with tailnet ACLs keeping it off production), and a fresh Docker Compose stack per git worktree.
Undocumented Django: Generating a SECRET_KEY
Not everything in Django is documented. This article showcases a "hidden" way to generate a new SECRET_KEY and provides general advice around keeping them actually, well, secret.
I don't write codebase documentation anymore
Instead of keeping docs current by hand, a GitHub Actions workflow runs a headless Claude Code session on every push to main and rewrites only the wiki pages that describe the changed files. One project now has a 95-page wiki with 67 Mermaid diagrams, read mostly by coding agents, and the post includes both files (a skill and a workflow) to copy.
Events
Django Day Copenhagen 2026
It is today, October 2nd! A full day of talks for the 6th edition of this event. You can participate online and in person so it's not too late.
My Django on the Med 2026 experience 🏖️
Former Djangonaut mentee Annabelle Wiegart has a lovely write-up of the recently concluded event, highlighting the magic that comes from actually having the right people together in the room to tackle new advances for Django.
Looking back at Django on the Med 🏖️ 2026
Matthias Kestenholz took the train from Zurich to Pescara and restarted his DEP for import maps in Django core, which let ES modules import stable names even as hashed static filenames change on every deploy. Firefox still can't handle multiple import maps, which is why he argues Django should merge them itself.
Podcasts
Real Python Podcast #312: Navigating AI in Open Source: Insights From Wagtail
Wagtail's Thibaud Colas and Meagen Voss explain how the project handles the flood of AI-assisted contributions, how they compare open-weight models and inference providers, and why Wagtail 8.0 aims to be a "CMS with AI, not AI CMS."
Django Chat #206: Django on the Med
Carlton and Will are back for the fall season. This episode discusses the recent Django on the Med event as well as Django news from the summer.
Videos
Django on the Med
The video version of Django Chat's 36-minute recap of the Pescara sprints, with links to everything discussed: DEP 19, DjangoCon Europe 2027 in Innsbruck, django-bgt, django-benchmark, and the open DEP pull requests that came out of the event.
Django Job Board
Proxify AB joins the board with two senior roles, Python backend and React/Node fullstack, alongside Django work at The Developer Society and The Cruise Brothers and machine learning at Provision.
Django Developer at The Developer Society
Senior Backend Developer (Python) at Proxify AB
Senior Fullstack Developer (React.js / Node.js) at Proxify AB
Machine Learning Engineer (Hybrid) at Provision
Django Developer at The Cruise Brothers
Projects
RegioHelden/django-scrubber
django_scrubber is a django app meant to help you anonymize your project's database data. It destructively alters data directly on the DB and therefore should not be used on production.
carltongibson/django-bgt
Django-orchestrated background threads for Python, built on bgt (a great way to reliably run background threads).
Sponsor Django News
Reach 4,300+ Django developers every Friday. See sponsorship details and rates.
02 Oct 2026 3:00pm GMT
Django: serve apple-app-site-association and assetlinks.json
If your site has companion Apple or Android apps, you probably want links to your site to open in those apps, when installed. Both platforms support this, with Apple calling the feature Universal Links and Android calling it App Links.
But an app can't just claim to handle your links, or any malicious app could hijack them. Instead, both platforms require two-way association: the app declares which domains it handles, and each domain confirms which apps may handle its links. The domain's side of that handshake is a JSON file served under the reserved /.well-known/ path, one per platform:
/.well-known/apple-app-site-associationfor iOS, iPadOS, and macOS./.well-known/assetlinks.jsonfor Android.
These files can do more than link handling. Both can also associate apps with your site for password autofill and passkeys, so users can sign in to your apps with credentials saved from your site.
In this post, we'll look at serving these two files from Django, with tests. It follows the same pattern as my recent post on serving a security.txt file, but with JSON and a few gotchas covered below.
Write the Apple file
Here's an example apple-app-site-association file, following Apple's documentation:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.app"],
"components": [
{
"/": "/admin/*",
"exclude": true,
"comment": "Keep the admin in the browser"
},
{
"/": "/*"
}
]
}
]
},
"webcredentials": {
"apps": ["ABCDE12345.com.example.app"]
}
}
This declares two services:
applinksfor Universal Links.appIDslists your app IDs, each your Apple developer team ID, a dot, and the app's bundle ID.componentslists URL patterns, checked in order until the first match, soexcludepatterns come first. In the example, the first pattern keeps URLs under/admin/opening in the browser, while the second sends all other URLs to the app.webcredentialsfor password autofill and passkeys.
Replace the example app ID with your own, which your iOS developers can provide.
Apple specifies the filename with no .json extension, but save your copy as apple-app-site-association.json. That extension lets Django's FileResponse detect the right content type, as we'll see below, and makes your editor recognize the file as JSON. The URL will still use Apple's name, without the extension. Put it in one of your Django apps, next to its views.py. Like the security.txt post, I'll use a "core" app within a project package called example, so example/core/apple-app-site-association.json.
Write the Android file
Here's an example assetlinks.json file, following Android's documentation:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]
This grants the app identified by target two permissions: handle_all_urls for App Links, and get_login_creds for password autofill and passkeys. The sha256_cert_fingerprints identify your app's signing certificates. If you use Play App Signing, copy the fingerprint from the Play Console's app signing page. Replace the example package name and fingerprint with your own, which your Android developers can provide.
Save this file as assetlinks.json in the same Django app, Like example/core/assetlinks.json.
Serve the files
Both platforms have similar serving requirements:
- The file must be served over HTTPS.
- The response must be
200 OK, with no redirects. - The
content-typeheader must beapplication/json. - Each domain must serve its own file. Associating
example.comdoesn't coverwww.example.com, and vice versa.
Here are views that meet these requirements:
from pathlib import Path
from django.contrib.auth.decorators import login_not_required
from django.http import FileResponse, HttpRequest, HttpResponse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_safe
APPLE_APP_SITE_ASSOCIATION_PATH = (
Path(__file__).parent / "apple-app-site-association.json"
)
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def apple_app_site_association(request: HttpRequest) -> HttpResponse:
"""
Serve the apple-app-site-association file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
return FileResponse(APPLE_APP_SITE_ASSOCIATION_PATH.open("rb"))
ASSETLINKS_JSON_PATH = Path(__file__).parent / "assetlinks.json"
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def assetlinks_json(request: HttpRequest) -> HttpResponse:
"""
Serve the assetlinks.json file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
return FileResponse(ASSETLINKS_JSON_PATH.open("rb"))
…with these corresponding entries in your root URLconf:
from django.urls import path
from example.core import views as core_views
urlpatterns = [
# ...
path(
".well-known/apple-app-site-association",
core_views.apple_app_site_association,
),
path(
".well-known/assetlinks.json",
core_views.assetlinks_json,
),
# ...
]
Notes:
@login_not_requiredmarks the views as public, for projects using Django'sLoginRequiredMiddleware. I recommend you use this feature from Django 5.1 to secure your site by default!@require_saferestricts the views to GET and HEAD requests.@cache_controlsets acache-controlheader allowing clients and intermediate caches, such as a content delivery network (CDN), to cache the files for five minutes. That saves repeat requests from the various verifiers, while still letting changes roll out quickly.FileResponsestreams each file as the response body, byte-for-byte. It also sets thecontent-typeheader based on the file extension, so both files getapplication/json.
Sprinkle on some friendly tests
Tests are your friends, and these ones are extra friendly, since mistakes in these files fail silently. A broken file means links open in the browser, with no error message anywhere. Here are tests covering both views, which you could put in your Django app's tests.py:
import json
from http import HTTPStatus
from django.test import SimpleTestCase
class AppleAppSiteAssociationTests(SimpleTestCase):
"""
Test the apple-app-site-association file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
url = "/.well-known/apple-app-site-association"
def test_success(self):
response = self.client.get(self.url)
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "application/json"
assert response["cache-control"] == "max-age=300, public"
data = json.loads(response.getvalue())
app_id = "ABCDE12345.com.example.app"
assert data["applinks"]["details"][0]["appIDs"] == [app_id]
assert data["webcredentials"]["apps"] == [app_id]
def test_head(self):
response = self.client.head(self.url)
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post(self.url)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
class AssetlinksJsonTests(SimpleTestCase):
"""
Test the assetlinks.json file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
url = "/.well-known/assetlinks.json"
def test_success(self):
response = self.client.get(self.url)
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "application/json"
assert response["cache-control"] == "max-age=300, public"
data = json.loads(response.getvalue())
assert data[0]["target"]["package_name"] == "com.example.app"
def test_head(self):
response = self.client.head(self.url)
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post(self.url)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
Notes:
- The
test_success()methods check for a200 OKstatus code, which also means there's no redirect, plus thecontent-typeandcache-controlheaders. - They then parse the body with
json.loads(), which fails on invalid JSON, such as from a trailing comma.FileResponseis a streaming response, with nocontentattribute, so they read the body withgetvalue(). - Finally, they check for the app identifiers, so swap in your own. You could extend these checks to cover more of the structure, such as URL patterns that should or shouldn't open your app.
test_head()andtest_post_disallowed()check the effect of@require_safe.
Check in production
Tests confirm your views work, but you can go a step further to check the platforms are properly integrated. After deploying, verify each file through the platforms' own infrastructure.
For Apple, since iOS 14 and macOS 11, devices don't fetch the file from your site directly. Instead, they fetch it from Apple's CDN, which periodically downloads it from your site. You can see the CDN's current copy with curl:
$ curl https://app-site-association.cdn-apple.com/a/v1/example.com
Replace example.com with your domain. If this returns an outdated version, wait, as the CDN takes a while to pick up changes. During development, your iOS developers can bypass the CDN with an alternate mode in the app's associated domains entitlement.
For Android, Google provides the Digital Asset Links API to check the statements it sees for your site:
$ curl -G https://digitalassetlinks.googleapis.com/v1/statements:list \
--data-urlencode source.web.site=https://example.com \
--data-urlencode relation=delegate_permission/common.handle_all_urls
Again, replace example.com with your domain. Alternatively, Google's Statement List Generator and Tester lets you check your file against your app's package name and fingerprint, through a web form. On a device with your app installed, your Android developers can also check and re-run verification with commands like:
$ adb shell pm get-app-links com.example.app
$ adb shell pm verify-app-links --re-verify com.example.app
Fin
Two small JSON files to let your links open where your users want them to. Serve them, test them, and check what Apple and Google see.
May your apps be interlinked and may your heart be interlinked,
-Adam
02 Oct 2026 4:00am GMT
01 Oct 2026
Django community aggregator: Community blog posts
Undocumented Django: Generating a SECRET_KEY
Django has some of the best documentation out there, a point of pride from the beginning that continues today thanks to the heroic efforts of many volunteers. But for all …
01 Oct 2026 2:09pm GMT
Django: serve a security.txt file
When a security researcher finds a vulnerability in your site, they need a way to tell you about it. Without a clear contact, they may resort to guessing at addresses like security@<yourdomain>, messaging random folks on social media, or give up. And of course, in the worst case, they might just publish the details, leaving you to find out when attackers do.
security.txt is a web standard to fix this problem. It's a small text file, served at the reserved path /.well-known/security.txt, that says how to report security issues to your organization. It was standardized in April 2022 as RFC 9116.
In this post, we'll look at serving a security.txt file and adding unit tests and a system check to keep it current.
Write the file
A security.txt file contains a series of Field: value lines, plus optional comments starting with #. Here's an example:
# Security contact information for example.com
Contact: mailto:security@example.com
Expires: 2027-09-01T00:00:00Z
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/security/
The two required fields are:
Contact: Gives a way to reach you, as a URI. That's normally amailto:email address, but it can also be anhttps:URL for a web page or form, or atel:phone number. You can list severalContactlines, in order of preference.Expires: Gives a date and time after which the file should be considered stale, in RFC 3339 format. It must appear exactly once. The RFC recommends setting it less than a year in the future.
Expires exists because contact details rot-folks leave, mailboxes get deleted, and bug bounty programmes close. An expiry date forces you to periodically confirm that the file is still accurate, and it tells researchers not to trust it if you forget.
To write your own security.txt file, use the handy dandy form at securitytxt.org. That beats copy-pasta'ing the above example, and lets you pick from all the possible fields.
Note that a security.txt file only applies to the domain that serves it. If you have several domains or subdomains, such as api.example.com, each needs to serve a file. You can serve the same file on each, with one Canonical line per domain.
Serve the file
The RFC requires that you serve security.txt over HTTPS, with the content type text/plain and a charset of utf-8. HTTPS should come from your production setup, such as your load balancer or the SECURE_SSL_REDIRECT setting, so the view only needs to handle the content type bit.
Below is a view that serves it appropriately, assuming your security.txt file is in the same directory as the views module file.
from pathlib import Path
from django.contrib.auth.decorators import login_not_required
from django.http import FileResponse, HttpRequest, HttpResponse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_safe
SECURITY_TXT_PATH = Path(__file__).parent / "security.txt"
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def security_txt(request: HttpRequest) -> HttpResponse:
"""
Serve the security.txt file, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
return FileResponse(
SECURITY_TXT_PATH.open("rb"),
content_type="text/plain; charset=utf-8",
)
…with this corresponding entry in your root URLconf:
from django.urls import path
from example.core import views as core_views
urlpatterns = [
# ...
path(".well-known/security.txt", core_views.security_txt),
# ...
]
Deconstructing the view code:
@login_not_requiredmarks the view as public, for projects using Django'sLoginRequiredMiddleware, added in Django 5.1. I highly recommend using this middleware, as it makes your site more secure by default! If you aren't using it, you can skip this decorator, but it is harmless to leave it in place.@require_saferestricts the view to the "safe" HTTP methods: GET and HEAD.@cache_controlsets theCache-Controlheader so browsers and CDNs can cache the file for five minutes. That's a small bit of load protection which might help if a researcher hammers your site with a vulnerability scanner.SECURITY_TXT_PATHpoints to the file, relative to the views module, using pathlib. If you put the file elsewhere, adjust the path.- Django's
FileResponsestreams the file as the response body. - The explicit
content_typeadds the RFC-compliant content type, rather thanFileResponse's default guess (justtext/plain, no charset).
After adding the URL, you can check it in your browser, for example at http://localhost:8000/.well-known/security.txt.
Add tests
It's test time! Test time is the best time! Here's a test case covering the view, which you could put in your app's tests.py:
import datetime as dt
from http import HTTPStatus
from django.test import SimpleTestCase
class SecurityTxtTests(SimpleTestCase):
"""
Test the security.txt file, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
def test_success(self):
response = self.client.get("/.well-known/security.txt")
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "text/plain; charset=utf-8"
assert response["cache-control"] == "max-age=300, public"
# Parse the security.txt format
content = response.getvalue().decode()
fields: dict[str, list[str]] = {}
for line in content.splitlines():
name, sep, value = line.partition(": ")
if sep and not line.startswith("#"):
fields.setdefault(name, []).append(value)
assert fields["Contact"] == ["mailto:security@example.com"]
assert len(fields["Expires"]) == 1
expires = dt.datetime.fromisoformat(fields["Expires"][0])
assert expires.tzinfo is not None
def test_head(self):
response = self.client.head("/.well-known/security.txt")
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post("/.well-known/security.txt")
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
Notes:
FileResponseis a streaming response, sotest_success()needs to read the whole body withgetvalue(), instead of thetextattribute.test_success()checks the headers set by the view, including thecache-controlheader from@cache_control.test_success()parses the file into a dictionary mapping field names to lists of values, skipping comments. It then checks theContactvalue, so swap in your own.- The
Expireschecks ensure there's exactly one value, which parses withdatetime.fromisoformat()and includes a timezone, as the RFC requires. test_head()andtest_post_disallowed()check the effect of@require_safe.
Add a system check for expiry
The tests check that Expires is valid, but not that it's current. You need to keep confirming the data and bumping that field every year or so, to ensure your file still gets used.
To ensure that your file goes near, you need some kind of system. You could add a test that fails when the date is near, but that would start failing on some arbitrary day, blocking unrelated work. Instead, you can add a custom system check that warns when the file needs attention. (Or you could set a calendar alert, if that works for you!)
Django runs system checks at the start of most management commands, including runserver, migrate, and test. Warnings are displayed without stopping the command, so they nag without blocking.
Here's such a check, ready to be pasted in a checks.py module next to the views module:
import datetime as dt
from django.core import checks
from example.core.views import SECURITY_TXT_PATH
@checks.register
def check_security_txt_expires(app_configs, **kwargs):
"""
Check the security.txt file is current, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
for line in SECURITY_TXT_PATH.read_text().splitlines():
if line.startswith("Expires: "):
value = line.removeprefix("Expires: ")
break
else:
return [
checks.Error(
"security.txt has no Expires field.",
id="core.E001",
)
]
expires = dt.datetime.fromisoformat(value)
remaining = expires - dt.datetime.now(dt.timezone.utc)
if remaining < dt.timedelta(days=30):
return [
checks.Warning(
f"security.txt expires on {expires:%Y-%m-%d}.",
hint="Review security.txt and update its Expires field.",
id="core.W001",
)
]
if remaining > dt.timedelta(days=365):
return [
checks.Warning(
"security.txt expires more than a year from now.",
hint="The RFC recommends an Expires under a year out.",
id="core.W002",
)
]
return []
Import the module in your app config's ready() method, so the @checks.register decorator runs:
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "example.core"
def ready(self):
from example.core import checks # noqa: F401
Notes:
- The check function starts with a mini parser to find the
Expiresentry, or fail if it's missing. - The function emits a check warning when your file is within 30 days of expiring, or past.
- It emits a second warning to enforces the RFC's recommendation to keep
Expiresunder a year out.
With the check in place, when expiry nears, all Django commands will show a warning like:
$ ./manage.py check
System check identified some issues:
WARNINGS:
?: (core.W001) security.txt expires on 2027-09-01.
HINT: Review security.txt and update its Expires field.
System check identified 1 issue (0 silenced).
Then, hopefully, someone will see the warning and remember to update the file.
Fin
So there we go: put up one small text file and watch the vulnerability reports pour in. Well, hopefully trickle.
May your security reports be few, slop-free, and low impact,
-Adam
01 Oct 2026 4:00am GMT
30 Sep 2026
Django community aggregator: Community blog posts
Weeknotes (2026 week 40)
Weeknotes (2026 week 40)
I have been at Django on the Med 🏖️ and already wrote a lengthy post about that.
I did a lot of work on a DEP for adding import map support to Django which is currently also being discussed on the forum. Apart from that I'm not going to repeat anything from the post linked above, so check it out if you want to know more.
Motivated by a discussion I had at the sprint I also improved my release process. I now have a make-release script in my dotfiles which updates the CHANGELOG with the version, bumps the version itself in the repo and commits and tags the release. The rest is handled by trusted publishing. I'm now finally also properly handling patch releases so that you don't have to check the history to know what's in a patch release. I already did that for minor and major version bumps, but was a bit too lazy. Now I can be even lazier and still more correct, and it feels great.
Next, I refactored the static site generator script for this blog to be much faster. I now do not have to wait when saving before refreshing the browser. Much nicer.
Releases from the last three weeks
django-js-asset
django-js-asset 5.0a1 implements the API proposed in the DEP. This is an alpha release because the DEP is still being discussed and I don't want to break people's code again and again if, during discussion, it appears that the API should be different.
django-json-schema-editor, django-prose-editor and django-content-editor
The three alpha releases django-json-schema-editor 0.15a1, django-prose-editor 0.28a2 and django-content-editor 9.1a1 depend on django-js-asset 5.0a1 mentioned above and implement the necessary changes for the new import map definition style.
django-authlib
django-authlib 0.20 adds support for specifying the tenant when using Microsoft Entra ID.
feincms3-downloads
feincms3-downloads 0.6 includes translation fixes, uses different error codes when pdftocairo or convert are missing, and changed the PATH environment variable handling to be less annoying for local development.
30 Sep 2026 5:00pm GMT
Django on the Med
🔗 Links
- Django on the Med
- New technical governance approved (DEP 19)
- Executive director search extended
- DjangoCon Europe 2027 (Innsbruck, Austria)
- django-bgt
- django-benchmark
- Open DEPs pull requests from Django on the Med
📚 Books
- The Narrow Road to the Deep North and Other Travel Sketches by Matsuo Basho
- The Wall by Marlen Hausofer
🎥 YouTube
30 Sep 2026 3:00pm GMT
Rebuilding my development setup in 2026
It's been another busy month and I have been promising myself that I would write about what has been this yet unfinished rabbit hole of my spare time for the last 6 weeks. It's unfinished as I am yet to test the biggest hypothesis of this article, however let's start at the beginning.
It started with a desire to more easily review code Claude was generating from my phone, before it got committed to git, before being pushed to Github. At a similar time Jeff publish his article of how he works from anywhere and I also listened to a podcast which peaked my interest in Ghostty as a potential replacement to iTerm2. Finally I was getting slightly frustrated with that Claude would stop running anytime I closed my laptop lid.
This generated my current hypothesis, could I create a setup that would work for me personally where I could run Claude anytime and access it from anywhere. This is the rabbit hole I jumped down and started a very long conversation with Claude Fable where I considered other terminal emulators, I gave it Jeff's article and we were off to the races with some trials using my laptop from my phone via Moshi as well as trying a few other apps.
So far in this update, which has been the most comprehensive in my career to date, I along with Claude have done the following:
- Adopted a multiplexer, specifically herdr.dev and switched to Ghostty
- Cleaned up and templated my dotfiles repo using chezmoi for multiple machines
- Configured a local network so all development container setups can run over https using the .test domain at the same time
- Cleaned up my Zed windows so they consistently open the same projects in the same windows
- Created a tool to hook Claude Code into Zed's terminal panes and into herdr tabs
- Tested the use of local models, with the assumption that Claude will explode in price at some point
- Created tooling around worktrees to spin up a new compose container setup per worktree
- Created tooling to batch up agent runs overnight for my review in the morning
- Bought a mac mini to be the primary agent machine
- Configured ACLs on my tailnet for the mini to prevent access to production boxes.
- Reviewed and decided on a few different mobile apps to access Claude and other machines while on the go.
- Built a tool to shift projects from my laptop to the mini and vice versa
- A weekly maintainence script for both systems so they stay up to date with what matters and don't drift from the definition in my dotfiles repo
- Adopted a few herdr.dev plugins and built a couple of my own.
And there are still a few more tools and updates to make specifically around onboarding new and existing projects into this new setup. I'm also pondering the idea of consolidating the custom configuration into an sqlite database (with Django on top), but that's a nice to have at this point.
Historically I have generally kept to the defaults provided by my tools, often due to the lack of perceived time to play with each tool, the time to configure it correctly and general frustration that causes. However this is where I have found AI to be very helpful, I have given it the broader context with some pointers on my preferences and it's nailed picking the tooling against my criteria and when something hasn't quite worked, building the tooling required has become a real possibility that didn't exist before.
It's been a delight and rewarding to update and upgrade my setup. The above is only an overview of what I have worked on with Claude, if you're particularly interested in any details, let me know and that can be the next post!
30 Sep 2026 5:00am GMT
I don't write codebase documentation anymore
Hello everyone 👋
Confession time: in 10+ years of writing software, I have never kept documentation up to date. Not once.
And I've tried! Confluence spaces, GitHub wikis, a docs/ folder, READMEs that start strong and stop being true three sprints later. It always goes the same way. Someone writes a nice page, the code moves, nobody touches the page, and six months later a new person reads it, believes it, and loses an afternoon. Updating docs always felt like a chore I owed someone, and I pay chores about as reliably as you'd expect.
A while back I started reading some really cool wikis that a paid AI service had generated for a few projects. Architecture overviews, flow diagrams, a page for every subsystem, all built from the code. I loved them. What I didn't love was that they lived on someone else's platform, and I couldn't shape what they said or how they said it. So I thought: I want my own. In my repo, in plain markdown, maintained by an agent, updated on every push.
So I built it. One of my projects now has a 95-page wiki: about 134,000 words and 67 Mermaid diagrams. I didn't write a single one of those pages, and it updates itself every time something lands on main.
This post is the full shebang: what the wiki looks like, how it works, every part that broke along the way, what it's still bad at, and the two files you need to copy to get it in your own repo (both are at the end of the post, complete).
Quick caveat before we start, because the title is doing some heavy lifting: what I stopped writing is codebase documentation, the pages that describe what the code does. There's still a small set of docs the wiki doesn't touch, and I'll get to those near the end.
What it looks like
The wiki lives in docs/wiki/ inside the repo, and it's two levels deep, never more:
docs/wiki/
README.md the index: every reader starts here
architecture-overview.md root pages: stuff that spans more than one section
getting-started.md
api/
README.md a section "hub": directory map, a diagram, a table of pages
app-and-routes.md a "leaf": one seam of the code
auth-and-sessions.md
...
web/
README.md
...
workers/
operations/
.outline.json which source files each page owns
.wiki-state.json the commit the wiki was last generated from
(The real one has eight sections, but they're very specific to the project, so I'm keeping this one generic.)
The project is a client one, so I can't show you the real pages. Here's the top of a leaf page, lightly trimmed:
> Auto-generated by the wiki skill from commit `20c2b08` on 2026-09-28. Do not
> edit by hand; changes will be overwritten.
# App Bootstrap, Routes and the Error Envelope
The FastAPI application is assembled in `backend/main.py`: `create_app` builds
the app with the `ClerkAuthMiddleware` and the aggregated router, and the
`lifespan` function verifies Clerk settings, constructs every provider-backed
service that was not injected for tests, and fills an empty Clerk mirror before
serving. [...]
## Key files
| Path | Role |
|--------------------------+---------------------------------------------------------|
| backend/main.py | `create_app` and `lifespan`: middleware, router, ... |
| backend/routes/errors.py | Registers the three exception handlers that render ... |
| backend/exceptions.py | `ErrorCode` vocabulary and the `ApiError` family ... |
After that you get sections on how the app gets built, the route families, the error format, and a Mermaid diagram of the startup sequence. At the bottom there's a ## Related section linking back to the hub and to the sibling pages it depends on. Every page has the same shape, and that turned out to be a big deal for the readers.
Who actually reads this?
Humans and agents.
For humans, it's been great for onboarding. When someone new joins the project I send them to the index and the "Reading order" list at the bottom of it, and they get a tour of the codebase that matches what's on main right now. Much better than whatever Confluence page someone last updated when they felt guilty.
But honestly, the agents are the heavy users. The coding agents working on this repo read it all the time. Instead of grepping around for ten minutes to figure out how auth works, an agent reads the index, jumps to the right hub, and loads the one leaf page it needs. It's a shortcut into the codebase, and a cheap one, since a leaf page is small enough to load whole.
The index even has a section written just for them:
## For agents
The lookup path is this index, then a section hub, then a leaf. Each hub's
directory map names the page owning each part of its tree, and
`docs/wiki/.outline.json` maps every source path to the page that describes it.
How it works: two files
The whole thing is two files:
| File | What it is |
|---|---|
.claude/skills/wiki/SKILL.md |
The instructions: layout, page templates, size rules, three modes, a self-check. About 230 lines of prose, zero code. |
.github/workflows/wiki.yml |
The plumbing: triggers, model choice, the commit step, and a check for runs that got cut off. |
I split them on purpose. The skill never touches git: it reads code and writes markdown, and that's it. The workflow never decides what a page says: it runs the skill and commits whatever changed. Because of that, you can drop the skill into any repo, or run it by hand with /wiki full in Claude Code, and it doesn't care who's calling it.
On every push to main, GitHub Actions starts a headless Claude Code session with exactly one prompt: /wiki incremental. The session reads the diff since the last wiki run, figures out which pages describe the files that changed, rewrites those pages from the current code, and exits. Then a plain shell step commits docs/wiki/ back to main.
Pages follow seams
The first big decision was how to cut a codebase into pages. The skill calls the unit a seam: a boundary the code already has, like a package, a service, a pipeline stage, or a bunch of modules that always change together. Pages follow seams, and never file types or a fixed template like "one page for models, one for views".
The skill also has to find the seams on its own, from git ls-files and the code. There's no hardcoded list of directory names anywhere. On my repo it came up with eight sections, including some splits I never asked for: it broke the backend into the core service, identity, the answer pipeline, and the test suites.
The two state files
These two files are what make incremental updates possible.
.outline.json is the page map. Each page lists covers (the globs that make the page suspect when they change) and seeds (one to five files to start reading from):
{
"file": "api/app-and-routes.md",
"title": "App Bootstrap, Routes and the Error Envelope",
"covers": ["backend/main.py", "backend/routes/**", "backend/exceptions.py", "..."],
"seeds": ["backend/main.py", "backend/routes/__init__.py"]
}
The rule is that every tracked file belongs to exactly one page, and the most specific glob wins. So when a file changes, there's always exactly one page to re-check. If a file doesn't match any page, that's a hole in the outline, and the run has to fix it by extending a page or adding a new one.
.wiki-state.json is one line:
{"last_generated_sha": "a60e3827...", "generated_at": "2026-09-29T14:11:31Z", "mode": "incremental"}
The skill always writes this file last, and that one rule is the whole crash-safety story. If a run dies halfway (timeout, API error, whatever), the old SHA is still there, so the next run computes the same diff and does the work again. The work gets done a bit later, but it gets done.
Three modes
incremental runs on every push. It diffs last_generated_sha..HEAD, maps each changed file to its page, and rewrites each affected page from scratch using the current code. The diff only tells it where to look, and the old page is just a checklist of topics to re-verify, so a page always reads like it was written today (no "this was changed to…" edit logs). Then it rewrites the hubs and index tables that changed.
audit runs every Monday at 03:23 UTC. Ten incremental runs can each correctly decide "nothing to change here" and still, together, leave a page wrong. So once a week the audit re-derives the whole outline, fixes the structure (splits, merges, new pages, deleted pages), and checks every page's main claims against the code, oldest page first. A page that passes keeps its banner untouched, so a clean audit produces no diff at all.
full runs when I ask for it, or automatically when the state files are missing. It writes the outline first, then the leaves, the hubs, the root pages, and the index last, so every level describes pages that actually exist.
Incremental also bumps itself up to an audit when the diff touches more than ~40% of the tracked files, or when the recorded SHA doesn't exist anymore (someone rewrote history).
Oh, and the 03:23 is on purpose. GitHub's scheduler gets hammered at the top of the hour, so an odd minute dodges the delay.
Keeping it honest
The skill's top priority is literally written as "accuracy beats coverage". A wiki that confidently describes code that doesn't exist anymore is worse than no wiki, because humans and agents both trust it. So a big chunk of the skill is rules about that:
- Every claim has to be checked by reading the code in the checkout.
- Every page starts with a banner with the commit and date it was generated from, so you always know how fresh it is.
- Code is referenced by path and symbol, and never pasted in. Anything over ~10 lines is out; the reader has the repo.
- Present tense only. No roadmaps, no history, no ticket numbers. If the code looks buggy, the page describes what it does, and doesn't guess what the author meant.
- A fact lives on exactly one page, and every other page links to it.
- Secret values never show up, even ones someone committed by accident. The page names the config key and that's it.
- Pages have size limits: leaves are 300 to 1,500 words and must split past 2,500, hubs stay under ~600, and the index under ~800.
Before it writes the state file, every run does a self-check: links and anchors resolve, navigation works both ways (leaf to hub, hub to index), every file has exactly one owner, the outline matches what's on disk, and a few greps make sure there are no em dashes, no "now / recently / no longer", no ticket references, and no bold-label bullets.
Yes, I banned em dashes in my generated docs. I have strong feelings about em dashes.
The workflow
Here's the flow:
push to main Monday 03:23 UTC manual dispatch
│ │ (pick a mode)
▼ │ │
┌────────────┐ │ │
│ dispatch │ gh workflow run wiki.yml │
│ job │──────────────┐ │
└────────────┘ ▼ ▼
┌──────────────────────────────────┐
│ wiki job │
│ checkout (full history) │
│ claude-code-action: /wiki <mode>│
└────────────────┬─────────────────┘
▼
did THIS run write the state file?
│ │
yes no
▼ ▼
commit docs/wiki, rebase, commit partial pages,
push to main fail the job (red run)
Some of these details took way more effort than they look like they should.
Re-dispatching the push
claude-code-action doesn't accept push events. It throws Unsupported event type: push and that's the end of that. So a tiny dispatch job (it runs for a few seconds) re-fires every push as a workflow_dispatch, which is one of only two event types the default GITHUB_TOKEN is allowed to trigger.
Then there's a second wall: the action refuses to run for bots by default, and a run dispatched by GITHUB_TOKEN shows up as github-actions[bot]. So the workflow sets allowed_bots: github-actions to let exactly that one bot through.
Avoiding the infinite loop
The wiki commits to main. That's a push to main. Which would trigger the wiki… 🤔
It doesn't, for two separate reasons. paths-ignore: docs/wiki/** means a push that only touches the wiki doesn't trigger the workflow, and the commit is pushed with GITHUB_TOKEN, which GitHub never lets trigger push-based runs. Either one would be enough. I like having both.
The newest run wins
A concurrency group cancels a run in progress when a newer push comes in, so the wiki is always generated from the latest main. This is safe thanks to the state-file-last rule: whatever the cancelled run didn't finish, the new run redoes.
Different models for different modes
Incremental runs happen on every push, so they should be cheap. Audits and full runs make the structural decisions, so they get the stronger setup. The models and the audit effort level are all repo variables, which means switching models is a settings change and not a commit.
Everything goes through Lazer Proxy (that's the ANTHROPIC_BASE_URL line in the workflow). Right now both slots run GLM 5.3. Audits and full runs get max effort, and incremental runs use the model's default effort (which, now that I'm writing it down, I should probably bump to high lol).
The commit step
The commit step is plain shell, and it always runs, even when the Claude step fails or times out, so generated pages never get thrown away. It commits as claude[bot] with [skip ci], then rebases onto the latest main and retries up to three times, because main has probably moved during a run that can take over an hour.
It also checks whether the run actually finished. It compares the state file's SHA and timestamp with the time the run started, and if this run didn't write the state file (and the diff wasn't empty), it still commits the partial pages, since the next run redoes that diff anyway, but then it exits 1 so I get a red run.
That check exists because of the worst bug in this whole project.
What broke along the way
Three trigger designs in one day
The very first day was all about getting it to run on push at all. I started with claude-code-action, hit the push rejection, and switched to calling the CLI directly (claude -p "/wiki incremental") in one job with a normal push trigger. That worked, and honestly it's simpler! But it meant owning the CLI install and version pin myself, and losing the action's run reports and GitHub integration. So the same day, I went back to the action and built the re-dispatch job.
There's a research doc in the repo comparing every option I looked at: workflow_run chaining, repository_dispatch, schedule-only, the raw CLI, and dispatch plus allowed_bots. The workflow_run option was sneaky. My CI workflows only run when their own project's files change, so a push that only touched the README wouldn't trigger any of them, and the wiki would silently skip that push. The workflow header links to that doc, so the next person who opens wiki.yml and thinks "why is this so weird?" gets an answer.
The first layout was flat
The first version of the skill wrote one flat level of pages. Later I restructured it into index, hubs, and leaves, and added "outline_version": 2 to the outline. An old outline without that field forces a full regeneration automatically, so the upgrade happened on the next push without me doing anything.
That regeneration was also the last time I touched the wiki by hand. Of the 111 commits to docs/wiki/, 105 are from the bot. My 6 are merge commits, one feature commit that happened to touch the directory, one hand edit on day one, and that regeneration.
Eleven green runs that did nothing
My favorite bug, in the "I want to throw my laptop into the sea" sense.
A big batch of changes landed at once, and the diff grew past ~100 files. At that size, the model decided (on its own, nobody asked it to) that the smart move was to hand the page rewrites to subagents. Which is very reasonable! In an interactive session that's exactly what you'd want.
But in a headless run, subagents launch asynchronously, and the process exits when the main turn ends. So every run went like this: spawn a bunch of agents, then end the turn with some version of "the agents are still running, I'll resume when they complete". The process exited, the job reported success, the commit step committed whatever was written so far (between 1 and 12 files per run), and the state SHA never moved.
This happened eleven times in a row, and all eleven runs were green 🙃 The diff snowballed to about 245 files before one run happened to do everything inline and finish.
The fix was three changes:
--disallowedTools Agentin the workflow, so the model can't spawn subagents at all.- A line in the skill: do every step in your own turn and never hand page writing to subagents or background tasks, because a headless run ends when your turn ends.
- The "did this run write the state file?" check from above, so a run like that shows up red.
The third one is the one I care about most. The subagent thing was a bug, sure, but what really bothered me was eleven green checkmarks telling me everything was fine.
Nineteen refused commands
The next problem showed up on an audit that split a page in two and then couldn't delete the old one.
Without an explicit allow rule, a headless Claude Code session refuses any shell command it can't prove is safe. That includes git -C, anything with a pipe, small Python helpers, and the rm that deletes a page dropped from the outline. That audit lost 19 commands this way. The skill has an allowed-tools list in its frontmatter, but those rules match on command prefixes, so rm docs/wiki/foo.md was allowed and the exact same delete with an absolute path wasn't.
Two fixes here. The skill now tells the model to always run commands from the repo root with relative paths, never with cd or git -C in front. And the workflow allows Bash outright. That second one is a judgment call: it's fine for me because the prompt is a constant string, the repo is private, and the job token can only write repo contents, so there's nothing untrusted for the sandbox to protect against. If your repo is public, think twice before you copy that line.
The numbers
| Measure | Value |
|---|---|
| Pages | 95: 1 index, 4 root pages, 8 section hubs, 82 leaves |
| Words | ~134,000 |
| Mermaid diagrams | 67 |
Commits to docs/wiki/ |
111 (105 by the bot) |
| Workflow runs | 136: 101 succeeded, 28 cancelled, 7 failed |
Most of the cancellations are by design: a newer push cancelled an older run.
Here's what a normal incremental run looks like. The diff touched 24 of the repo's 1,029 tracked files. The run took 23 minutes and 162 turns, cost $5.29, and changed 25 files in the wiki: 14 pages rewritten, 4 new pages (existing pages had grown past 2,500 words and had to split), plus the hubs and the index. At the end it left a summary in the CI log, including a list of pages that need the next audit to clean them up.
On models: an audit on Claude Opus got killed at the 50-minute timeout I had back then without finishing. I switched to GLM 5.3 at max effort, and the first audit on that setup finished in 13 minutes for about $4.30.
What I don't have is a monthly total, sadly. The wiki shares its API key with the Claude workflows that review our PRs in CI, so the spend on that key is both of them mixed together, and I can't tell how much of it is the wiki. The cost also depends on how big each diff is and how often you push, so your mileage will vary a lot. If you want a rough number for your repo, multiply a few dollars per run by your pushes to main, then add one audit per week. I'm giving the wiki workflow its own API key so I can track exactly what it spends, and I'll update the post when I have real numbers.
What it's still bad at
It's not perfect, so here's where it falls short.
Walls of text
This is the big one, and it's what I'm tuning next. The skill says paragraphs are at most ~120 words and hubs stay under ~600. My architecture overview has a paragraph that's 524 words long 😅 Several hubs are between 870 and 1,050 words, and a dozen leaves are past the 1,500-word "you should split this" line.
My theory: the model follows the hard rules that the self-check measures (it always splits a page past 2,500 words) and treats the soft ones as suggestions. Agents don't mind a wall of text. Humans very much do. I think the fix is turning more of the soft rules into self-check failures, because everything the self-check measures gets fixed, and everything it doesn't measure slowly drifts.
It's sometimes wrong
I've caught pages getting things wrong. I don't lose sleep over it, because it fixes itself: the next time anyone touches those files, the page gets rewritten from the code, and even if nobody does, the Monday audit re-checks every page. When a human-written page is wrong, it stays wrong until a human notices. This one is on a timer.
I also want to be upfront about what I haven't done: nobody has fact-checked the wiki sentence by sentence. The one independent check I ran confirmed the structure was right: the outline matched what's on disk, the links worked, and no page was behind the files it covers. That tells me the wiki is structurally sound, but it doesn't tell me every sentence is true. For the content, I'm trusting the verify-against-code rule, the self-check, and the weekly audit. So far that's been good enough for me, but it's a bet, and you should know it's a bet.
And one that made me laugh: the run summary at the end of each CI log (the one thing the self-check doesn't grep) is full of em dashes and bold-label bullets. Apparently the rules only count when someone is checking.
What the wiki doesn't write
Back to the caveat from the beginning.
The wiki describes what the code does right now. It can't know intent: why we picked this design, what we decided in a meeting, how an operator should run a data refresh. So the repo still has a small set of docs that live outside the wiki:
docs/adr/, the architecture decision records, with the "why" behind the big calls.docs/handbook/, with an operator guide, a runbook, and an architecture reference for maintainers.CONTEXT.md, the domain glossary (including the words we don't use).AGENTS.md, the condensed project context for coding agents.
I don't write these by hand either, to be clear. I write them with AI. The difference is that I'm driving: the decision or the procedure comes from me (or the team), and the AI helps me turn it into a doc. The wiki doesn't need me at all.
The handbook also has a rule, backed by an ADR: if you change an admin page or the data refresh workflow, you update the handbook page that describes it in the same pull request. That rule lives in the agent rules too, so the coding agents follow it.
My favorite detail: the wiki has a page about the handbook, explaining what it is, why it's maintained outside the wiki, and which wiki pages describe the code behind each handbook doc. The wiki documents the docs it isn't allowed to touch, which I find hilarious.
Stop writing the docs a machine can write
OK, opinion time.
Docs that describe code are basically a build artifact. We don't hand-write compiled binaries or minified JS, we generate them from the source every time the source changes. For "how does this work" docs, the source is the code. And now we have something that can read the code and write decent pages about it for a few dollars a run. Asking a person to keep those pages in sync by hand is how you end up with a Confluence graveyard again.
Docs about decisions, intent, and procedure are different. Those come from people, so a person should be in the loop, with AI helping. And that set turned out to be way smaller than I expected: a handful of files and two directories, versus 95 pages I never have to think about.
Show me the code
Both files are below, complete. To use them in your repo:
- Copy
SKILL.mdto.claude/skills/wiki/SKILL.mdandwiki.ymlto.github/workflows/wiki.yml. Make suredocs/wiki/isn't git-ignored. - Set up model access. My workflow uses a
LAZER_PROXY_API_KEYsecret and aLAZER_PROXY_BASE_URLvariable because everything goes through our proxy. If you're calling Anthropic directly, put your API key in a secret, pointanthropic_api_keyat it, and delete theANTHROPIC_BASE_URLline. If you use another gateway, point that line at it instead. The model and effort variables are optional. Without them, the workflow uses Sonnet for incremental runs and Opus for audits. - If
mainis protected, let the workflow push (a GitHub App or a ruleset bypass), or change the commit step to open a PR. - If your repo is public, rethink two choices I made for a private repo:
show_full_output: true(it dumps the whole transcript into the public run log) and allowing Bash outright.
Then push something! The first run finds no state file and switches to full by itself. On a big repo that first run takes a while, so go get a coffee ☕
You can also skip CI entirely: open Claude Code in your repo and type /wiki full.
SKILL.md
---
name: wiki
description: Generate and maintain an agentic codebase wiki in docs/wiki/ (browsable markdown pages with Mermaid diagrams). Use this skill whenever the user asks to build, update, sync, audit, or regenerate the project wiki, codebase documentation, or architecture docs, or whenever it is invoked as /wiki. Also use it when asked "document this codebase" or "keep the wiki up to date".
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git ls-files:*), Bash(git rev-parse:*), Bash(date:*), Bash(wc:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Bash(jq:*), Bash(python3:*), Bash(rm docs/wiki/:*)
---
# Codebase Wiki Generator
Maintain a browsable markdown wiki describing this repository in `docs/wiki/`. The wiki is machine-owned: every run may rewrite any page, so treat existing pages as prior output, not as human work to preserve. Write files only. Never run `git add`, `git commit`, or `git push`; committing is the caller's job (CI or the human).
Accuracy beats coverage. Every claim in a page must be something you verified by reading the code in this checkout. A wiki that confidently describes code that no longer exists is worse than no wiki, because readers (human and agent) trust it as context.
Readers are humans browsing on GitHub and agents loading pages as context. Both navigate the same path: index, then section hub, then leaf. Every rule below exists to keep that path short and every page on it accurate.
## Invocation and mode selection
The invocation is `/wiki [mode]` where mode is `incremental`, `audit`, or `full`. Rules:
1. Run `full` regardless of the requested mode when any of these hold: `docs/wiki/.wiki-state.json` does not exist; `docs/wiki/.outline.json` is missing, unparseable, or lacks `"outline_version": 2`.
2. If no mode is given, run `incremental`.
3. In `incremental` mode, if `last_generated_sha` is missing or is not a commit in this repo (`git rev-parse --verify <sha>^{commit}` fails, for example after a history rewrite), there is no diff base: escalate to `audit` and say so in your summary. Audit verifies every page against the current code, which covers whatever the lost diff would have shown.
4. In `incremental` mode, if the diff since the last run touches more than ~40% of tracked source files, escalate to `audit` and say so in your summary.
Runs are time-bounded and the caller may cancel one. Write `.wiki-state.json` last, so a cut-off run leaves the previous SHA in place and the next run re-processes the same diff.
Do every step in your own turn. Never hand page writing to subagents or background tasks: a headless run ends when your turn ends, so work still running elsewhere is lost while the run reports success. Run shell commands from the repository root with repo-relative paths, never prefixed with `cd` or `git -C`: tool allowlists match command prefixes, so `rm docs/wiki/<path>` is permitted where the same removal by absolute path is refused.
## Layout
The wiki is two levels deep: root and sections.
| Path | Role |
|---|---|
| `docs/wiki/README.md` | Index. The only page every reader starts from. |
| `docs/wiki/<page>.md` | Root page. Spans more than one section. Always present: `architecture-overview.md`, `getting-started.md`. |
| `docs/wiki/<section>/README.md` | Hub. The landing page for one seam. GitHub renders it when a reader browses into the directory. |
| `docs/wiki/<section>/<page>.md` | Leaf. One seam within the section. |
| `docs/wiki/.outline.json` | The page map: the contract that makes incremental updates possible. |
| `docs/wiki/.wiki-state.json` | Run state. |
A **seam** is a boundary the code already has: a package, a service, a subsystem, a pipeline stage, a bounded set of modules that change together. Pages follow seams, never file types or a generic template.
Sections are earned. A section exists when one seam yields two or more leaves. A repo whose whole outline is three to six leaves has no section directories: the index is the only hub and the leaves sit beside it at the root. A seam that fits on one page gets `<section>/README.md` alone, hub and leaf in one file. There is never a third level: if a section wants one, raise the abstraction of its leaves instead. Decide all of this from `git ls-files` and the code, never from a fixed list of directory names.
## `.outline.json`
```json
{
"outline_version": 2,
"root_pages": [
{
"file": "architecture-overview.md",
"title": "Architecture Overview",
"covers": ["README.md", "AGENTS.md"],
"seeds": ["README.md"]
}
],
"sections": [
{
"dir": "api",
"title": "API Service",
"covers": ["api/**"],
"pages": [
{
"file": "api/auth-and-sessions.md",
"title": "Auth and Sessions",
"covers": ["api/src/auth/**", "api/src/middleware/session.*"],
"seeds": ["api/src/auth/service.*"]
}
]
}
]
}
```
- `covers` on a leaf or root page is the set of paths/globs whose changes make that page suspect.
- `covers` on a section is the fallback for the whole seam: any file in the section's tree that no leaf claims (configs, lockfiles, READMEs, one-file directories) belongs to the hub.
- `seeds` are the 1 to 5 files to start reading from when writing the page.
- A section with a single page has `pages: []`; its `README.md` is written from the section's own `covers` and `seeds` (add `seeds` on the section in that case).
**Ownership rule:** every non-excluded tracked path resolves to exactly one page. Resolution is most-specific-glob-wins (the longest matching pattern). Leaf `covers` inside a section must not overlap each other; a path matching two leaves is a defect to fix in the outline, and the self-check reports it.
**Order of writes:** a page is written to disk before its entry is added to `.outline.json`, and a page dropped from the outline is deleted from disk in the same step. A cut-off run must never leave an outline entry without its page, or a page without its entry.
Two shapes the outline takes. A small single-service repo:
```
docs/wiki/
README.md
architecture-overview.md
getting-started.md
request-pipeline.md
persistence.md
background-jobs.md
```
A monorepo with two packages, one of which fits on a page:
```
docs/wiki/
README.md
architecture-overview.md
getting-started.md
web/
README.md hub: directory map, diagram, page table
routing-and-shell.md
auth.md
rendering.md
tooling-and-tests.md
worker/README.md hub and leaf in one file
operations/
README.md
ci-workflows.md
agent-configuration.md
```
`.wiki-state.json`:
```json
{"last_generated_sha": "<full sha>", "generated_at": "<ISO 8601 UTC>", "mode": "incremental"}
```
Get the SHA with `git rev-parse HEAD`. Rewrite this file at the end of every successful run; audit and full runs rewrite it even when no page changed. The caller reads its SHA and `generated_at` to tell a finished run from a cut-off one.
## Page sizing and splitting
A page is one seam, sized so a reader finishes it in one sitting and an agent can load it whole:
- A leaf covers typically 3 to 15 project-authored files and runs 300 to 1,500 words. Over 1,500 words is a split candidate; over 2,500 words splits in the same run, whatever the mode, with the new leaf added to the outline and the hub.
- A leaf has at most 7 H2 sections, and its H2s share one concern. Concerns are stack-neutral: bootstrap and configuration; auth and access control; request handling and routing; domain logic; data model and persistence; UI and rendering; external integrations; safety and compliance; tooling and tests. A page whose H2s straddle two concerns splits along that line. Read the concerns off the code; the list above is a vocabulary, not a template.
- A paragraph is at most ~120 words. Inventories (test files, routes, config keys, commands, environment variables) are tables.
- A section with more than ~8 leaves splits into two sections. A one-file directory is a row in the hub's directory map, never a page.
- Hubs run under ~600 words. The index runs under ~800.
- Vendored and generated directories get one row in the hub's directory map and one sentence on how they are produced, plus the local maintenance policy when the repo documents one (in its agent rules or README). Read that policy; never assume one.
## Page templates
Every page starts with this banner, values filled in:
```markdown
> Auto-generated by the wiki skill from commit `<short sha>` on <YYYY-MM-DD>. Do not edit by hand; changes will be overwritten.
```
**Index** (`docs/wiki/README.md`), in order:
1. Banner, H1, overview: what the project is and how it runs, two paragraphs at most.
2. One table per section (and one for root pages) with columns `Page | Summary | Key paths`. Summary is one sentence; key paths are the two or three directories the page is about.
3. `## Reading order`: a numbered list of 4 to 6 pages for a newcomer.
4. `## For agents`: two sentences stating that the lookup path is index, hub, leaf, and that `.outline.json` maps source paths to pages.
**Hub** (`docs/wiki/<section>/README.md`), in order:
1. Banner, H1, orientation: what the seam is and where it lives, one paragraph.
2. `## Directory map`: a table `Path | What lives there | Page` with one row per top-level subdirectory and config file of the seam. Vendored and generated directories are rows too, marked as such. The Page column links the leaf that owns the row, or says "this page".
3. One Mermaid diagram of the seam: module dependencies or the main flow through it.
4. `## Pages`: a table `Page | Summary`.
5. `## Cross-cutting`: links to the root pages and other sections this seam touches.
6. A final line linking back to the index: `Back to [the index](../README.md).`
**Leaf** (`docs/wiki/<section>/<page>.md`, and root pages), in order:
1. Banner, H1, orientation: one paragraph on what this seam does and where its code lives.
2. `## Key files`: a table `Path | Role` of the 3 to 10 files that matter most, each path a relative link into the repo (one `../` per directory level between the page and the repo root, so from a section directory it is `../../path/to/file`).
3. H2 sections describing purpose, structure, key flows, and interactions. A Mermaid diagram wherever the page describes a flow, a state machine, or a handoff between components; a sentence wherever a sentence is enough.
4. `## Related`: links to the hub, the sibling pages this page references, and the pages in other sections it depends on. Root pages link the index here instead of a hub.
## Page conventions
- Reference code by path and symbol (`src/auth/service.py`, `SessionStore.refresh()`), never with large pasted code blocks. Snippets over ~10 lines defeat the purpose; the reader has the repo.
- Link pages with relative links (`[Auth](auth.md)`, `[Worker](../worker/README.md)`), anchors allowed (`auth.md#session-refresh`).
- Describe what the code does today, in the present tense, as if the page were written fresh this run. Roadmaps, intent, and history belong in commits and tickets. If behavior looks like a bug, describe the behavior, not your guess about what was meant.
- **Single source.** A fact lives on exactly one page; other pages link to it. When two pages both need a fact, the owner is the page whose `covers` includes the file that defines it.
- Never copy values of secrets, tokens, API keys, connection strings, or `.env` contents into a page, even values found committed in the repo. Name the config key, never the value.
Writing style: plain, specific, low ceremony. Concretely:
- Punctuate with commas, colons, semicolons, periods, and parentheses. Em dashes are banned; the self-check greps for them.
- Say what the thing does: "X does Y" over "X serves as / is responsible for Y". Show that something is simple rather than asserting it.
- Plain vocabulary: delve, leverage, robust, seamless, streamline, comprehensive, and "plays a crucial role" are banned, as are bold-label bullets (`**Performance**: ...`) and negative parallelism ("it's not X, it's Y").
## What to exclude
Skip vendored dependencies, generated code, lockfiles, build output, fixtures/snapshots, minified bundles, and `docs/wiki/` itself. Use `git ls-files` as the source of truth for what is tracked, then apply judgment: if a directory is clearly machine-written (codegen output, migrations dumps, registry-pulled components), document that it exists and how it is produced, not its contents.
## Full mode
1. Inventory: `git ls-files`, apply exclusions, read the README and obvious entrypoints (main modules, app factories, CLI definitions, CI config) to understand what the project is.
2. Write `.outline.json`: find the seams, decide which earn sections, assign `covers` so the ownership rule holds, pick `seeds`.
3. Write each leaf and single-page section: start from its seeds, then explore with Read/Grep/Glob (follow imports, find callers, check config defaults) until you can describe the seam's purpose, structure, key flows, and interactions. Verify claims against code you actually read. Apply the sizing rules as you go; split before writing rather than after.
4. Write each hub from its finished leaves, then the root pages, then the index last, so each reflects the pages that exist.
5. Remove any `docs/wiki/**/*.md` not in the new outline (`rm docs/wiki/<path>`); an emptied directory may stay. Run the self-check. Write `.wiki-state.json`.
## Incremental mode
1. Read `.wiki-state.json` and `.outline.json`. Compute changes: `git diff --name-status <last_generated_sha>..HEAD -- . ':(exclude)docs/wiki'`. If the diff is empty, update nothing (you may still rewrite `.wiki-state.json`) and report a no-op. Commits after `last_generated_sha` that touch only `docs/wiki/` are not evidence that their diff was processed: a cut-off run commits partial pages without advancing the state file. The state file is the only record of what was processed; never narrow the diff by reasoning about those commits.
2. Resolve each changed path to its owning page via the ownership rule. A path that resolves to no page means the outline has a gap: assign it to the best-fitting existing page (extend its `covers`) or, if it belongs to a genuinely new seam, add a page or section.
3. For each affected page: read the diff for its files (`git diff <last_sha>..HEAD -- <paths>`) to learn where to look, then rewrite the whole page from the current code, using the old page only as a checklist of topics to re-verify. A page is a fresh description, never an edit log. Renames and deletions are reflected; a page whose entire subject was deleted is removed from disk and from the outline. Apply the sizing rules: a page that grew past its bounds splits now.
4. Rewrite the hub of every section whose leaves changed (directory map and page table included). Rewrite the index tables whenever any page was written, added, removed, or retitled. Both are short; keeping them current on every run is what stops the index going stale.
5. Run the self-check. Write `.wiki-state.json`.
## Audit mode
The weekly safety net. Incremental runs can each correctly conclude "no page change needed" while ten of them together leave a page wrong. Audit exists to catch that drift plus structural rot.
1. Re-derive an outline from the current tree as if running full mode, but don't write it yet. Compare with `.outline.json`: seams that grew enough to deserve their own page or section, pages whose subject shrank or vanished, tracked paths no page owns, leaves whose `covers` overlap, pages outside the sizing bounds. Apply the structural fixes (add/split/merge/remove pages; new or split pages are written as in full mode). Structural decisions are sticky: a split or merge made by an earlier run stands unless the code moved or a hard bound is exceeded, so audits do not oscillate on word counts alone.
2. For every surviving page, oldest banner SHA first: confirm each path in `covers` still exists, then verify the page's main claims against the current code (entry points it names, flows it describes, config keys it references). Rewrite what drifted. A page that passes keeps its banner untouched, so a clean audit produces no churn; say which pages passed.
3. Rewrite hubs and the index if the page set changed. Run the self-check. Write `.outline.json` and `.wiki-state.json`.
## Self-check
Run before writing `.wiki-state.json` in every mode. Fix what fails; anything you cannot fix goes in the summary.
| Check | How |
|---|---|
| Links resolve | For every `](<path>.md` target, Glob the file relative to the linking page; for anchors, confirm a heading in the target produces that slug. |
| Navigation is two-way | Every leaf is in its hub's page table and links the hub in `## Related`; every hub and root page is in the index; every hub links the index. |
| Present tense | Grep pages for `-`, for `\b(now|recently|no longer|previously|used to)\b`, for ticket and PR references (`\b[A-Z]{2,}-\d+\b`, `#\d+\b`), and for bold-label bullets (`^- \*\*[^*]+\*\*:`). Rewrite each hit. |
| Sizes | `wc -w` on every page against the bounds in Page sizing. |
| Ownership | Every tracked, non-excluded path resolves to exactly one page. Enumerate with `git ls-files <glob>` per pattern; list gaps and overlaps. |
| Outline matches disk | Every page in `.outline.json` exists on disk, and every `docs/wiki/**/*.md` (index and hubs included) is in the outline. Delete strays with `rm docs/wiki/<path>`; never leave a redirect stub in place of a deletion. |
| Banners | Every page written this run carries `git rev-parse --short HEAD` and today's date. |
## Reporting
End every run with a short summary: mode actually run (and why, if escalated); the page tree with word counts; pages created, updated, deleted, and verified unchanged; self-check results (gaps, overlaps, unresolvable links); whether `.wiki-state.json` was written; and anything that needs a human (huge uncovered directory, suspected bug, unparseable state). Keep it to a screen; it lands in a CI log.
wiki.yml
# Generated codebase wiki (docs/wiki/), maintained by the /wiki skill.
#
# Runs anthropics/claude-code-action. The action doesn't accept push events,
# so a push to main re-enters this workflow as a workflow_dispatch (one of
# the two event types the default GITHUB_TOKEN may still trigger), and
# allowed_bots lets that GITHUB_TOKEN-dispatched run pass the action's
# human-actor check. Full rationale and the failure history:
# docs/agents/research/claude-automation-on-push.md
#
# Triggers:
# - push to main, re-dispatched (incremental: diff since last wiki commit)
# - Mondays 03:23 UTC (audit: re-derive outline, catch drift)
# - manual dispatch with a mode override
#
# Loop safety (both hold, either alone is enough):
# - paths-ignore: pushes that only touch docs/wiki/ don't trigger this
# - the commit step pushes with the default GITHUB_TOKEN, and GitHub never
# creates push-triggered runs for events caused by that token
#
# Setup per repo:
# 1. Copy .claude/skills/wiki/ and this file into the repo, and make sure
# docs/wiki/ is not git-ignored (this repo ignores docs/* with exceptions).
# The skill writes a two-level tree (index, section hubs, leaf pages) and
# escalates to a full run on its own when docs/wiki/.outline.json is
# missing or predates the current outline format.
# 2. Set the LAZER_PROXY_API_KEY secret and the LAZER_PROXY_BASE_URL Actions
# variable (org-level recommended so repos don't each need a copy).
# Optional model overrides: LAZER_PROXY_WIKI_MODEL for incremental runs
# (cheap, every push) and LAZER_PROXY_WIKI_FULL_MODEL for full and audit
# runs (structure and verification decisions, worth a stronger model).
# LAZER_PROXY_WIKI_FULL_EFFORT (low, medium, high, xhigh, max) sets the
# effort level for full and audit runs; unset leaves the model default.
# Installing the Claude GitHub App is optional: without it the action
# falls back to the job's default GITHUB_TOKEN.
# 3. If main is a protected branch, allow this workflow to push (GitHub App
# or Actions bypass in the ruleset) or switch the commit step to a PR.
name: Wiki
on:
push:
branches: [main]
paths-ignore:
- "docs/wiki/**"
schedule:
- cron: "23 3 * * 1" # Mondays 03:23 UTC; odd minute to dodge the top-of-hour delay
workflow_dispatch:
inputs:
mode:
description: Generation mode
type: choice
options: [incremental, audit, full]
default: incremental
jobs:
# claude-code-action validates the event type and fails on push
# ("Unsupported event type: push"), so a push re-enters this workflow as a
# workflow_dispatch. Dispatching with the default GITHUB_TOKEN works:
# workflow_dispatch and repository_dispatch are the two events exempt from
# GitHub's no-retrigger rule for that token.
dispatch:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- run: gh workflow run wiki.yml --ref main -f mode=incremental
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
wiki:
if: github.event_name != 'push'
runs-on: ubuntu-latest
# A newer run cancels an in-progress one, so the wiki is always generated
# from the latest main. Cancelling mid-run is safe: the skill writes
# .wiki-state.json last, so the replacement run re-processes the same
# diff. Job-level (not workflow-level) so the seconds-long dispatch job
# doesn't churn the group.
concurrency:
group: wiki-generate
cancel-in-progress: true
timeout-minutes: 85
permissions:
contents: write
id-token: write
env:
WIKI_MODE: ${{ inputs.mode || (github.event_name == 'schedule' && 'audit') || 'incremental' }}
steps:
- uses: actions/checkout@v7.0.1
with:
# Full history: incremental mode diffs against the SHA recorded
# in docs/wiki/.wiki-state.json, which can be arbitrarily old.
fetch-depth: 0
# The commit step compares this against the state file's generated_at
# to tell a state file this run wrote from one left over from before.
- id: start
run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: anthropics/claude-code-action@v1.0.235
# Below the job's 85-minute limit so the commit step (if: !cancelled())
# still has time to push whatever was generated when a run hits this
# ceiling. An audit of the current tree needs more than 50 minutes.
# Committing a truncated run is safe: the skill writes
# .wiki-state.json last, so a killed run leaves the previous SHA in
# place and the next run re-processes the same diff.
timeout-minutes: 75
with:
anthropic_api_key: ${{ secrets.LAZER_PROXY_API_KEY }}
# Dispatched runs are initiated by GITHUB_TOKEN, which the action's
# human-actor check sees as the github-actions bot.
allowed_bots: github-actions
# Stream Claude's full transcript into the run log. By default the
# action prints only the init and final-result messages, so a
# multi-minute generation looks stalled. The prompt is a constant
# string against our own repo, and the repo is private, so there is
# no untrusted output to hide.
show_full_output: true
prompt: "/wiki ${{ env.WIKI_MODE }}"
# Incremental runs happen on every push and only touch the pages a
# diff points at; full and audit runs decide the page structure and
# verify every page, so they get the stronger model.
#
# Agent is disallowed because subagents launch asynchronously and a
# headless run ends with the main turn: the model would hand page
# rewrites to subagents, end its turn to wait for them, and the run
# would exit "success" having committed almost nothing. Eleven runs
# did exactly that on 2026-09-15.
#
# Bash is allowed outright. Without an allow rule the action runs in
# default permission mode, where a headless session refuses any
# command it cannot statically clear: git diffs prefixed with cd or
# -C, python and node helpers, pipelines, and the rm that removes a
# page dropped from the outline (an audit on 2026-09-16 lost 19
# commands this way and could not delete a split page). The prompt
# is a constant, the repo is private, and the job token can only
# write repo contents, so there is nothing for the sandbox to guard.
claude_args: |
--model ${{ env.WIKI_MODE == 'incremental' && (vars.LAZER_PROXY_WIKI_MODEL || 'claude-sonnet-5') || (vars.LAZER_PROXY_WIKI_FULL_MODEL || 'claude-opus-5') }}
--allowedTools "Read,Write,Edit,Glob,Grep,Bash"
--disallowedTools Agent
${{ env.WIKI_MODE != 'incremental' && vars.LAZER_PROXY_WIKI_FULL_EFFORT && format('--effort {0}', vars.LAZER_PROXY_WIKI_FULL_EFFORT) || '' }}
env:
# Routes all inference through Lazer Proxy (org-level Actions variable).
ANTHROPIC_BASE_URL: ${{ vars.LAZER_PROXY_BASE_URL }}
- name: Commit wiki updates
# Run even when the Claude step fails or times out, so generated
# changes aren't dropped; the job still reports the step failure.
if: ${{ !cancelled() }}
env:
RUN_STARTED_AT: ${{ steps.start.outputs.at }}
run: |
# The skill writes .wiki-state.json last, so a state file this run
# did not write means the run was cut off before it finished
# (timeout, API error, or the model ending its turn early). The
# only legitimate skip is an incremental run whose diff was empty.
# The partial pages are still committed below, because the next
# run re-processes the same diff, but the job fails so the gap is
# visible instead of buried in a green run.
head="$(git rev-parse HEAD)"
previous="$(git show HEAD:docs/wiki/.wiki-state.json 2>/dev/null | jq -r '.last_generated_sha // empty')"
recorded="$(jq -r '.last_generated_sha // empty' docs/wiki/.wiki-state.json 2>/dev/null || true)"
generated="$(jq -r '.generated_at // empty' docs/wiki/.wiki-state.json 2>/dev/null || true)"
state_written=true
if [ "$recorded" != "$head" ] || [ -z "$generated" ] || [[ "$generated" < "$RUN_STARTED_AT" ]]; then
state_written=false
fi
run_incomplete=false
if [ "$state_written" = false ]; then
if [ "$WIKI_MODE" != incremental ] || [ -z "$previous" ] \
|| ! git rev-parse --verify --quiet "${previous}^{commit}" >/dev/null \
|| ! git diff --quiet "$previous" HEAD -- . ':(exclude)docs/wiki'; then
run_incomplete=true
echo "::error::docs/wiki/.wiki-state.json records '${recorded:-nothing}' at '${generated:-no time}' but this ${WIKI_MODE} run started at ${RUN_STARTED_AT} from ${head}; the wiki run did not finish." >&2
fi
fi
if [ -n "$(git status --porcelain docs/wiki)" ]; then
# Author only; the push still uses GITHUB_TOKEN, which is what
# the loop-safety rule above depends on.
git config user.name "claude[bot]"
git config user.email "209825114+claude[bot]@users.noreply.github.com"
git add docs/wiki
git commit -m "docs(wiki): update generated wiki [skip ci]"
# Main may have moved during the (up to 75-minute) Claude run.
# Rebase onto the latest main and retry; our commit only touches
# docs/wiki, so conflicts are only possible against another wiki
# commit, which the concurrency group already serializes.
pushed=false
for attempt in 1 2 3; do
if git pull --rebase origin main && git push origin main; then
pushed=true
break
fi
git rebase --abort 2>/dev/null || true
sleep 10
done
if [ "$pushed" != true ]; then
echo "Failed to push wiki updates after 3 attempts." >&2
exit 1
fi
else
echo "No wiki changes."
fi
if [ "$run_incomplete" = true ]; then
exit 1
fi
Was it worth it?
Yes. This started as an itch: I liked someone else's generated wikis and I wanted one that was mine. Now it's the first thing I send to new people on the project, and the thing every agent in the repo reads before touching anything. It cost me one weird day of GitHub Actions trigger archaeology, eleven green runs that lied to me, and a few dollars per push.
I still need to fix the walls of text. When I do, I'll update the skill in this post.
If you set this up in your own repo, let me know how it goes! I'm really curious to see what seams it finds in codebases that aren't mine.
See you in the next one!
30 Sep 2026 5:00am GMT
29 Sep 2026
Django community aggregator: Community blog posts
The cost of dependencies
Adding a dependency takes one line in a manifest, and someone else's hard problem is solved. With that line we also take on their bugs, their security holes, their release schedule, and their own dependencies, for as long as our code lives. The cost is small while we write the code and grows once the system is in production and users ask for changes. I have watched teams take whatever was available to keep moving, some on principle, and pay for it in maintenance. So a dependency should be chosen on purpose, after an evaluation. Let's look at what it costs, and then at how to evaluate one.

29 Sep 2026 10:00am GMT
28 Sep 2026
Django community aggregator: Community blog posts
Looking back at Django on the Med 🏖️ 2026
Looking back at Django on the Med 🏖️ 2026
I haven't been to a programming conference in a really long time. That was mostly due to laziness, wanting to stay at home and decision fatigue because I didn't know how to travel sustainably and didn't know where to stay during the conference.
I had been talking online to Carlton for some time and when Django on the Med 🏖️ 2026 was announced I knew I had to go. What's not to like about a conference in Italy with all the good food and the Mediterranean Sea? I managed to overcome my inner Schweinehund (the German term for the lazy voice in your head that tells you to stay on the couch) and reserved both the (free) ticket for the conference itself and also the train ticket to go from Zurich to Pescara. The train takes 8 or 9 hours depending on the connection with a single change in Milano.
The conference itself consists only of development sprints and socialising - no talks and nothing to prepare in advance for participants, except taking the computer with you and optionally having some ideas about what you want to work on.
The import map Django Enhancement Proposal (DEP) I worked on
The forum discussion about rejuvenating Django's forms.Media sparked my interest in reviving Thibaud's DEP draft in early 2025. Some features which were mentioned in the early draft, such as a Stylesheet object for including additional stylesheet attributes in class Media and CSP support, have been added to Django 6.1 in the meantime. My main motivation was and still is to bring import map support to Django. I changed django-prose-editor to use import maps back then and wrote a first draft but then got stuck while trying to write a good DEP.
For those who don't know import maps: ES modules import each other by URL. When static file storages add hashes to file names for cache busting, those URLs change on every deployment. Import maps solve this using a web standard: Modules import stable identifiers such as my-library, and the import map tells the browser which file to actually load.
If all browsers were to support multiple import maps the DEP would maybe not be necessary. People could just ship an ImportMap media object and include it in forms.Media(js=[...]) before the ES modules actually using it and things would just work. At the time of writing this post Chromium and Safari support multiple import maps but Firefox still doesn't. So, if we want to use this feature without having import map merging we will have to wait several years for browser support to be widespread enough. And since third-party Django apps and Django websites which want to use import maps have to agree on a common implementation it makes most sense to me to propose adding this to Django core. The proposal lives in the DEP pull request and the accompanying new feature ticket.
As an aside: Django has been famous for not having a frontend story for the longest time. I think this is mostly a strength, because Django has therefore allowed everyone to use the frontend technologies they want and hasn't decided on a particular technology, library or framework which, in the meantime, would have become obsolete or not really state of the art. ES modules and import maps aren't opinionated in the same way that, for example, jQuery, htmx, React or Svelte are; they really are a basic implementation of modules, namespaces and a specification of how those modules should be loaded in the browser. So, I don't think it would be fair to reject adding better support for these things on the grounds that Django wants to be agnostic to the frontend. I'm not saying here that the DEP has to be accepted or that there cannot be good reasons to reject or modify it further - I'm sure there are. I'm just proposing that we cannot just use the old arguments to argue against it.
The diary
Enough about the DEP. Now I am going to recap the last few days. I'm not adding any pictures to this post, but you can head over to Mastodon and check out the #DjangoOnTheMed tag, for example here on hachyderm.io.
Tuesday
On Tuesday morning I left home to take the train towards Pescara. I was really glad I packed my headphones with noise cancelation. After about 9 hours I arrived in the late afternoon, checked into the hotel, took my swimming trunks and immediately went to the beach.
In the evening we had dinner at the seaside at Lido Aurora Pescara. It was a pleasure to finally meet some of the people I have been either working with or just following for a long time. A special surprise was meeting Simon again. We met at Django Under the Hood in Amsterdam in 2016 and he helped get my first pull request1 to Django off the ground back then.
During the course of the conference we went back there several times. Good food, and luckily not just options with fish and seafood, but also good vegetarian options. (I'm not strictly vegetarian, but I sometimes prefer vegetarian or even vegan food.)
After a long day I slept surprisingly well. That's not saying much, but it certainly was a welcome surprise given my issues with my back and hips.
Wednesday
The sprint officially started on Wednesday with a warm-up session led by Carlton. The three questions asked (paraphrased because I don't remember the exact wording) were "What is great about Django?", "What are the risks, or what could be better?" and "What are you planning to work on?"
I discussed ways of adding import maps with Joe, the author of django-esm and esimport. I thought we had completely different and conflicting ways of thinking about and using import maps. After talking it over it became clear quite quickly that, while we don't have to use them the same way, our ways of using them do not have to be in conflict. I think this is one of the big advantages of events like this: The same discussion would have taken weeks or months in an issue tracker, if it ever happened, and in person it was a question of sitting together for an hour, hashing it out, and then you potentially have a basic agreement and an idea for which direction to go.
As already alluded to above I restarted my work on the DEP. It basically needed a complete rewrite since (excitingly!) so much has already landed in Django in the last ~18 months.
We went back to Lido Aurora for lunch. In the afternoon, almost everyone went for a bike ride along an old railway track which has been converted into a bike lane along the sea. I already knew a similar thing from Liguria near Levanto/Bonassola but it was nice encountering the same idea near Pescara. (The new railway track has been built further inland.) We also had some nice Spritz abruzzese. Generally, Spritz isn't really my thing but those were a bit more bitter and earthy and less sweet and therefore we soon ordered a second round.
In the evening Žan, Annabelle, Simon, Carlton and I went to Fruity Burger (or something like that) for some vegan food. As expected it was really tasty. While vegan options in restaurants can unfortunately sometimes be quite bland, vegan restaurants in my experience are often some of the best: You really have to know your ingredients and can't just add bacon to everything. (Nothing against bacon, but still.)
Thursday
Again I started the day with a great coffee and an overly sweet breakfast (for my taste). Italian food is generally great but the breakfast isn't my thing. I like my müesli and whole grain bread. Anyway!
I continued working on the DEP and started tweaking django-js-asset to serve as a proving ground for the ideas. Towards the end of Thursday's sprint I had a first rough draft ready and Joe provided some great feedback on it. One of the most important points for me was to care about the API and not the implementation at this stage in the process. The DEP draft contained too much detail related to implementation and not enough examples showing off more complex use cases.
I only ate a small lunch since we were promised a bus tour with a lot of great food in the afternoon and evening. It still proved to be too much as we would learn later.
In the afternoon we took a bus to a local vineyard. We were shown around and had a look at the machines and learned a few things about the winemaking process. That was followed by a wine tasting and some bread and antipasti.
Next, we went to Penne and were shown the city and were told a little bit of the local history. Everything's built from bricks. Brick buildings and earthquakes are not a great fit it seems to me - the last larger earthquake hit the region less than 10 years ago. It's interesting that people are always rebuilding the houses anyway. From Penne we had a really nice view over the region, from the hill we were standing on all the way to Pescara and to the Adriatic.
We took the bus again to the restaurant and ate until 11 in the evening. At a certain point I couldn't go on anymore. No matter how great the food, there comes a moment when eating more seems impossible, and in my case that moment came before i secondi. The secondi were arrosticini. I could only eat one so now I'm wondering if I am a persona non grata in Pescara 🤣. Also, I was in pain from too much sitting - sitting isn't good since I'm still recovering from a disc hernia and the associated follow-up issues in the hip. Still, I had a great day and wouldn't have wanted to miss any of it.
Back in the hotel I couldn't sleep immediately so I addressed some of the feedback I got earlier in the day and then went to sleep.
Friday
The night wasn't very restorative but I was awake anyway in the morning so I got up and started the day again on time. I finished applying the feedback to the DEP and to django-js-asset and continued refining them and filling in holes. I also modified some of the packages I'm developing to use the new way of defining media using import maps just to get a feeling for whether the API is nice or not.
During the coffee break I asked around if the changes to the way import maps are defined would break existing code. Luckily it seems that django-prose-editor is "just used" and people weren't relying on being able to define import maps themselves yet. Or, if so, I don't know about it. Maybe django-probe could help with that, but we obviously aren't there yet.
We finished the sprint with a group picture and closed off the official part. Most of us went out for lunch together. Some people had to leave after that; I drank an espresso in a nice coffee bar, said goodbye to them and went to the beach afterwards.
In the evening we went to an excellent vegan restaurant. I was happy that I wasn't the only one who was surprised to learn that the restaurant wasn't that close after all.
Saturday
Almost everybody had either left or had to leave on Saturday morning so I didn't expect to meet with others anymore. I finally went for some long walks, explored other parts of the town and went to see the long bridge/walkway/passeggiata/whatever and some parks. I also took some time to note down both what we did and what I thought of finally going back to a conference.
In the evening I ran into Mark and Becky twice and we decided to have a beer together before saying goodbye again and for the final time for this sprint.
Having an additional day there was a win. I had less time during the sprints to visit the city itself and really did appreciate the extra day I allowed myself.
Sunday
I ate breakfast, went for a short walk around the city and to the beach and then took the train back home. Up to Milano it was uneventful. The train from Milano to Zurich was cancelled, but I didn't lose more than half an hour in the end.
Finally
I'm wondering about the DEP process now and what happens with the pull request I submitted. I'm linking to the new feature ticket, the DEP pull request and what I consider to be the proving ground again. Because django-js-asset has to stay backwards compatible, an implementation in Django itself could be quite a bit simpler.
I will be in Liguria, Italy again in just one week. Feels a bit stupid to cross the Alps only to cross them again a few days later, but I'm really looking forward to sleeping in my own bed for a few days.
I can very well imagine going to more conferences and sprints again. The value of meeting in person is unquestionable. It might be hard though to improve on the experience we had here with the location, the nice weather and the social events. The selection of events is large and I certainly won't be going to all of them, but I am looking at going to the next Django on the Med in Malta, and maybe also to PyCon Italia and/or DjangoCon EU. We will see!
-
I had been contributing bug reports, tests and help much earlier than that but I was somewhat intimidated by Django's processes. ↩
28 Sep 2026 5:00pm GMT
Show and hide Wagtail admin fields without writing any JavaScript
Oh boy, I love Wagtail. Haven't I said this too many times on this blog? I recently built a small promo banner for a client's Wagtail site. The editor form was simple: some text, a button label, and a "Button links to" radio with two options, "Page" or "External URL". …
28 Sep 2026 9:45am GMT
Python: join my meetup in Lisbon, 8th October
I previously announced my Python optimization workshop in Lisbon, on 10th October (which still has a few places). I am now pleased to add a Python/Django meetup on the Thursday before.
Here are the details:
- What: Python/Django meetup
- When: Thursday 8th October 2026, 18:00-20:00
- Where: Terrace Restaurant, Praça Príncipe Perfeito, Lisbon
- Cost: Free
Join us for an informal evening discussing Python, Django, and related topics (AI will surely come up). This meetup is hosted by Adam Johnson, a Django contributor, blogger, and author of four technical books (on Django, Git, and GitHub).
There won't be any talks scheduled at this event, just relaxed conversation around the core topics of software engineering and improving developer experience. Come with questions, topics of interest, or just an interest in meeting other like-minded engineers.
Drinks are available to purchase from the venue.
Places are limited to keep the evening small and conversational.
28 Sep 2026 4:00am GMT

