This commit is contained in:
@@ -72,6 +72,7 @@ assets/static/ CSS, filtering JavaScript, favicon, and static assets
|
|||||||
configs/project-sources.ini Approved public remote-project registry
|
configs/project-sources.ini Approved public remote-project registry
|
||||||
scripts/project_sources.py Remote-content validator and importer
|
scripts/project_sources.py Remote-content validator and importer
|
||||||
scripts/build_with_projects.py Isolated build/preview entry point
|
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
|
ops/deploy-labyricorn Versioned copy of the production deployment command
|
||||||
tests/ Importer validation tests
|
tests/ Importer validation tests
|
||||||
.gitea/workflows/ Push, schedule, and manual deployment automation
|
.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/`, and
|
||||||
`/projects/thinkloom/devlog/<entry-slug>/`. The project repository owns the
|
`/projects/thinkloom/devlog/<entry-slug>/`. The project repository owns the
|
||||||
native Lektor records and approved images. This site owns their models,
|
native Lektor records and approved images. This site owns their models,
|
||||||
templates, layout, repository-information card, and import policy. Imported
|
templates, layout, repository-information card, and import policy. The latest
|
||||||
devlog entries also join the homepage Recent Activity feed, ordered with
|
imported devlog entry from each project also joins the homepage Recent Activity
|
||||||
projects, articles, and blog entries by publication date. The feed initially
|
feed, ordered with projects, articles, and blog entries by publication date.
|
||||||
shows six items and reveals up to six more per button activation. This is a
|
Older devlog entries remain available on each project's complete devlog. The
|
||||||
progressive enhancement implemented by `assets/static/activity.js`; without
|
feed initially shows six items and reveals up to six more per button activation.
|
||||||
JavaScript, the complete semantic feed remains visible.
|
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
|
`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
|
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`.
|
and choose **Run workflow** for `main`.
|
||||||
|
|
||||||
Automation and coding assistants may dispatch the same workflow through
|
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
|
```bash
|
||||||
export LABYRICORN_REFRESH_TOKEN='set this outside Git and shell history'
|
python scripts/trigger_project_refresh.py
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
The token must be supplied through an approved credential store or ephemeral
|
||||||
environment and must have permission to dispatch Actions for the site
|
environment and must have permission to dispatch Actions for the site
|
||||||
repository. Never put it in the URL, a project repository, `AGENTS.md`, logs,
|
repository. Never put it in the URL, a project repository, `AGENTS.md`, logs,
|
||||||
|
|||||||
@@ -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
@@ -24,9 +24,8 @@
|
|||||||
{% for project in site.query('/projects').filter(F._model == 'project') %}
|
{% for project in site.query('/projects').filter(F._model == 'project') %}
|
||||||
{% set devlog = site.get(project.path ~ '/devlog') %}
|
{% set devlog = site.get(project.path ~ '/devlog') %}
|
||||||
{% if devlog %}
|
{% if devlog %}
|
||||||
{% for entry in devlog.children %}
|
{% set latest_devlog_entry = devlog.children.order_by('-date', '-source_commit_time').first() %}
|
||||||
{% if entries.append(entry) %}{% endif %}
|
{% if latest_devlog_entry and entries.append(latest_devlog_entry) %}{% endif %}
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for item in entries|sort(attribute='date', reverse=true) %}
|
{% for item in entries|sort(attribute='date', reverse=true) %}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user