Limit homepage devlogs and add refresh trigger
Deploy production / deploy (push) Successful in 19s

This commit is contained in:
2026-08-20 16:03:22 -07:00
parent 790e3dcafa
commit ad2fa3729d
5 changed files with 217 additions and 18 deletions
+18 -15
View File
@@ -72,6 +72,7 @@ assets/static/ CSS, filtering JavaScript, favicon, and static assets
configs/project-sources.ini Approved public remote-project registry
scripts/project_sources.py Remote-content validator and importer
scripts/build_with_projects.py Isolated build/preview entry point
scripts/trigger_project_refresh.py Authenticated remote-project refresh client
ops/deploy-labyricorn Versioned copy of the production deployment command
tests/ Importer validation tests
.gitea/workflows/ Push, schedule, and manual deployment automation
@@ -199,12 +200,13 @@ The public routes are `/projects/thinkloom/`,
`/projects/thinkloom/devlog/`, and
`/projects/thinkloom/devlog/<entry-slug>/`. The project repository owns the
native Lektor records and approved images. This site owns their models,
templates, layout, repository-information card, and import policy. Imported
devlog entries also join the homepage Recent Activity feed, ordered with
projects, articles, and blog entries by publication date. The feed initially
shows six items and reveals up to six more per button activation. This is a
progressive enhancement implemented by `assets/static/activity.js`; without
JavaScript, the complete semantic feed remains visible.
templates, layout, repository-information card, and import policy. The latest
imported devlog entry from each project also joins the homepage Recent Activity
feed, ordered with projects, articles, and blog entries by publication date.
Older devlog entries remain available on each project's complete devlog. The
feed initially shows six items and reveals up to six more per button activation.
This is a progressive enhancement implemented by `assets/static/activity.js`;
without JavaScript, the complete semantic feed remains visible.
`configs/project-sources.ini` is the allowlist. Each source declares a stable
project ID, credential-free HTTPS Git URL, public web/API URL, and branch. Add
@@ -865,19 +867,20 @@ From Gitea, open **Labyricorn/labyricorn-site → Actions → Deploy production*
and choose **Run workflow** for `main`.
Automation and coding assistants may dispatch the same workflow through
Gitea's authenticated API after pushing project publishing content:
Gitea's authenticated API after pushing project publishing content. With
`LABYRICORN_REFRESH_TOKEN` supplied by an approved credential store or
ephemeral environment, run:
```bash
export LABYRICORN_REFRESH_TOKEN='set this outside Git and shell history'
curl --fail --silent --show-error \
-X POST \
-H "Authorization: token $LABYRICORN_REFRESH_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"ref":"main"}' \
https://git.labyricorn.com/api/v1/repos/Labyricorn/labyricorn-site/actions/workflows/deploy.yml/dispatches
unset LABYRICORN_REFRESH_TOKEN
python scripts/trigger_project_refresh.py
```
Use `python scripts/trigger_project_refresh.py --dry-run` to inspect the fixed
site workflow target without reading the token or making a network request.
The script sends only `{"ref":"main"}`, keeps the credential out of the URL
and request body, and reports whether Gitea accepted the dispatch. It does not
wait for or claim a successful deployment.
The token must be supplied through an approved credential store or ephemeral
environment and must have permission to dispatch Actions for the site
repository. Never put it in the URL, a project repository, `AGENTS.md`, logs,
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Request an authenticated production refresh after a remote project update."""
from __future__ import annotations
import argparse
import json
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DISPATCH_URL = (
"https://git.labyricorn.com/api/v1/repos/Labyricorn/labyricorn-site/"
"actions/workflows/deploy.yml/dispatches"
)
TOKEN_ENVIRONMENT_VARIABLE = "LABYRICORN_REFRESH_TOKEN"
class RefreshError(RuntimeError):
"""Raised when Gitea does not accept a refresh request."""
def dispatch_refresh(token: str, *, timeout: float = 15.0) -> None:
token = token.strip()
if not token:
raise RefreshError("the refresh token is empty")
if "\r" in token or "\n" in token:
raise RefreshError("the refresh token contains an invalid newline")
request = Request(
DISPATCH_URL,
data=json.dumps({"ref": "main"}).encode("utf-8"),
headers={
"Accept": "application/json",
"Authorization": f"token {token}",
"Content-Type": "application/json",
"User-Agent": "labyricorn-project-refresh/1",
},
method="POST",
)
try:
with urlopen(request, timeout=timeout) as response:
status = response.getcode()
except HTTPError as exc:
raise RefreshError(f"Gitea rejected the refresh request (HTTP {exc.code})") from exc
except URLError as exc:
reason = getattr(exc, "reason", "connection failed")
raise RefreshError(f"could not contact Gitea: {reason}") from exc
except OSError as exc:
raise RefreshError(f"could not send the refresh request: {exc}") from exc
if not 200 <= status < 300:
raise RefreshError(f"Gitea returned unexpected HTTP status {status}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="show the target workflow without sending a request or reading a token",
)
parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="request timeout in seconds (default: 15)",
)
args = parser.parse_args(argv)
if args.timeout <= 0:
parser.error("--timeout must be greater than zero")
if args.dry_run:
print(f"Would dispatch deploy.yml for main via {DISPATCH_URL}")
return 0
token = os.environ.get(TOKEN_ENVIRONMENT_VARIABLE)
if token is None:
print(
f"project-refresh: ERROR: {TOKEN_ENVIRONMENT_VARIABLE} is not set",
file=sys.stderr,
)
return 2
try:
dispatch_refresh(token, timeout=args.timeout)
except RefreshError as exc:
print(f"project-refresh: ERROR: {exc}", file=sys.stderr)
return 1
print("Production refresh requested for site main.")
print("Verify the Deploy production Action and the published project revision.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -3
View File
@@ -24,9 +24,8 @@
{% for project in site.query('/projects').filter(F._model == 'project') %}
{% set devlog = site.get(project.path ~ '/devlog') %}
{% if devlog %}
{% for entry in devlog.children %}
{% if entries.append(entry) %}{% endif %}
{% endfor %}
{% set latest_devlog_entry = devlog.children.order_by('-date', '-source_commit_time').first() %}
{% if latest_devlog_entry and entries.append(latest_devlog_entry) %}{% endif %}
{% endif %}
{% endfor %}
{% for item in entries|sort(attribute='date', reverse=true) %}
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from pathlib import Path
import unittest
class HomeTemplateTests(unittest.TestCase):
def test_recent_activity_adds_only_latest_devlog_from_each_project(self) -> None:
site_root = Path(__file__).resolve().parents[1]
template = (site_root / "templates" / "home.html").read_text(encoding="utf-8")
recent_activity = template.split(
'<section class="homepage-panel featured-projects-panel"', 1
)[0]
self.assertIn(
"devlog.children.order_by('-date', '-source_commit_time').first()",
recent_activity,
)
self.assertIn("entries.append(latest_devlog_entry)", recent_activity)
self.assertNotIn("for entry in devlog.children", recent_activity)
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import contextlib
import io
import os
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from urllib.error import HTTPError
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import trigger_project_refresh as refresh # noqa: E402
class FakeResponse:
def __init__(self, status: int = 204) -> None:
self.status = status
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
def getcode(self) -> int:
return self.status
class TriggerProjectRefreshTests(unittest.TestCase):
def test_dispatch_posts_main_without_putting_token_in_url_or_body(self) -> None:
with patch.object(refresh, "urlopen", return_value=FakeResponse()) as opener:
refresh.dispatch_refresh("secret-token", timeout=7)
request = opener.call_args.args[0]
self.assertEqual(request.full_url, refresh.DISPATCH_URL)
self.assertEqual(request.method, "POST")
self.assertEqual(request.data, b'{"ref": "main"}')
self.assertEqual(request.get_header("Authorization"), "token secret-token")
self.assertNotIn(b"secret-token", request.data)
self.assertEqual(opener.call_args.kwargs["timeout"], 7)
def test_dispatch_reports_http_status_without_response_or_token(self) -> None:
error = HTTPError(refresh.DISPATCH_URL, 403, "Forbidden", {}, None)
with patch.object(refresh, "urlopen", side_effect=error):
with self.assertRaisesRegex(refresh.RefreshError, r"HTTP 403") as raised:
refresh.dispatch_refresh("secret-token")
self.assertNotIn("secret-token", str(raised.exception))
def test_main_requires_environment_token(self) -> None:
stderr = io.StringIO()
with patch.dict(os.environ, {}, clear=True), contextlib.redirect_stderr(stderr):
result = refresh.main([])
self.assertEqual(result, 2)
self.assertIn(refresh.TOKEN_ENVIRONMENT_VARIABLE, stderr.getvalue())
def test_dry_run_does_not_read_or_send_token(self) -> None:
stdout = io.StringIO()
with (
patch.dict(os.environ, {}, clear=True),
patch.object(refresh, "urlopen") as opener,
contextlib.redirect_stdout(stdout),
):
result = refresh.main(["--dry-run"])
self.assertEqual(result, 0)
self.assertIn(refresh.DISPATCH_URL, stdout.getvalue())
opener.assert_not_called()
if __name__ == "__main__":
unittest.main()