diff --git a/README.md b/README.md index ca40ddb..25c736a 100644 --- a/README.md +++ b/README.md @@ -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//`. 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, diff --git a/scripts/trigger_project_refresh.py b/scripts/trigger_project_refresh.py new file mode 100644 index 0000000..2d2fcd2 --- /dev/null +++ b/scripts/trigger_project_refresh.py @@ -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()) diff --git a/templates/home.html b/templates/home.html index 09efb0e..0c944e3 100644 --- a/templates/home.html +++ b/templates/home.html @@ -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) %} diff --git a/tests/test_home_template.py b/tests/test_home_template.py new file mode 100644 index 0000000..3bb5230 --- /dev/null +++ b/tests/test_home_template.py @@ -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( + '