Add repository-owned project publishing
Deploy production / deploy (push) Successful in 4s

This commit is contained in:
2026-08-11 23:24:55 -07:00
parent 2b4eb9f0f6
commit ec6e21bb25
17 changed files with 1742 additions and 35 deletions
+187 -31
View File
@@ -20,6 +20,7 @@ troubleshooting the site.
| Rollback listing | `sudo rollback-labyricorn` |
| Rollback command | `sudo rollback-labyricorn RELEASE_NAME` |
| Deployment log | `/srv/labyricorn/logs/deploy.log` |
| Remote-project cache | `/srv/labyricorn/project-cache` |
| nginx site config | `/etc/nginx/sites-available/labyricorn` |
| Lektor executable | `/srv/labyricorn/.local/bin/lektor` |
| Deployment account | `labyricorn-deploy` |
@@ -27,14 +28,16 @@ troubleshooting the site.
## Architecture
```text
Gitea: Labyricorn/labyricorn-site (main)
|
| authenticated Git fetch
v
/srv/labyricorn/repo
|
| Lektor static build
v
Gitea: Labyricorn/labyricorn-site (main) public project repositories
| |
| authenticated Git fetch | read-only Git/API sync
v v
/srv/labyricorn/repo /srv/labyricorn/project-cache
| |
+---------- validated build input -------+
|
| isolated Lektor build
v
/srv/labyricorn/releases/<UTC timestamp>-<commit>
|
| validated, atomic symlink switch
@@ -53,6 +56,9 @@ Gitea: Labyricorn/labyricorn-site (main)
Gitea is the source of truth. The production checkout is disposable state and
must not contain unpublished edits during deployment. Lektor's development
server is not part of production serving; nginx serves generated files only.
Remote project repositories remain authoritative for their own exhibition and
devlog records. They are never checked out into the site repository and cannot
modify trusted site models, templates, scripts, or content.
## Repository contents
@@ -63,6 +69,12 @@ content/tags/ Controlled tag vocabulary and dedicated tag routes
models/ Lektor content models
templates/ Jinja templates
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
ops/deploy-labyricorn Versioned copy of the production deployment command
tests/ Importer validation tests
.gitea/workflows/ Push, schedule, and manual deployment automation
.gitignore Excludes generated and local files
README.md This runbook
```
@@ -82,7 +94,7 @@ git pull --ff-only origin main
# Edit content, templates, models, or assets.
lektor build --output-path build
python scripts/build_with_projects.py --output-path build
git status
git add <reviewed-files>
git commit -m "Describe the site change"
@@ -140,7 +152,7 @@ keeps the selected filter in the `tag` query parameter.
After taxonomy or filtering changes:
1. Run `lektor build --output-path build`.
1. Run `python scripts/build_with_projects.py --output-path build`.
2. Verify `/tags/`, at least one dedicated tag URL, and both section listings.
3. Confirm tag counts cover blog entries and articles and that every entry slug
has a matching tag record.
@@ -150,6 +162,87 @@ After taxonomy or filtering changes:
6. If filtering JavaScript or tag styles changed, increment the corresponding
cache-busting version in `templates/base.html`.
## Repository-owned project pages and devlogs
The site can publish a project page whose source lives in the project's own
public Git repository. Thinkloom is the first configured source:
```text
https://git.labyricorn.com/Labyricorn/thinkloom-openai-hackathon
.labyricorn/
├── AGENTS.md
├── project/
│ ├── AGENTS.md
│ ├── contents.lr
│ └── icon.png
└── devlog/
├── AGENTS.md
├── contents.lr
└── <entry-slug>/contents.lr
```
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.
`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
or remove a source only through a reviewed site change. The importer also
checks the provider API and refuses a repository reported as private.
Remote Git commands run with an isolated empty home/config and interactive
credential lookup disabled, so the production account's site-repository
credential cannot silently turn a private project fetch into an authenticated
one. An anonymous API response of 401, 403, or 404 is treated as a visibility
failure rather than a metadata-cache condition.
The importer reads only these paths:
- `.labyricorn/project/contents.lr`
- `.labyricorn/project/*.{png,jpg,jpeg,webp}`
- `.labyricorn/devlog/contents.lr`
- `.labyricorn/devlog/<entry-slug>/contents.lr`
- approved image types within a devlog entry directory
Everything else is ignored, including remote `README.md` and `AGENTS.md`
instructions. Symbolic links, submodules, executable files, raw HTML,
unsupported models or schema versions, invalid slugs, unknown paths,
out-of-tree references, and invalid image signatures are rejected. Limits are
100 imported files, 5 MiB per file, and 20 MiB total per project snapshot.
Devlog `source_commit` values must exist in the repository and be ancestors of
the imported branch revision.
Every build uses a temporary copy of trusted site source, materializes validated
remote records into that copy, and invokes Lektor there. Imported files never
enter the site working tree. Git mirrors, state, and the last validated snapshot
live under the project cache. If a new snapshot or network fetch fails, the
build uses the last-known-good validated revision. Initial publication fails
when no valid snapshot exists. The generated
`.labyricorn-projects.json` records the site revision, imported project
revisions, metadata digests, synchronization status, and synchronization time.
The project card combines repository-owned content with public provider and Git
metadata: source and README links, default branch, latest commit, license,
languages, open issues, stars, forks, latest release, imported revision, and
sync time. API metadata failure uses cached metadata and is identified in the
sync status; it never causes an unvalidated new snapshot to replace a working
one.
To preview or build with remote project content, use the wrapper instead of
calling Lektor directly:
```bash
python scripts/build_with_projects.py --serve
python scripts/build_with_projects.py --output-path build
python -m unittest discover -s tests -v
```
The default development cache is `.cache/project-sources`, which is ignored by
Git. Lektor must be installed because the importer deliberately uses Lektor's
native record parser and the wrapper invokes the Lektor build/server. No custom
Lektor plugin or third-party project-import package is used.
## Local development
Install Lektor in an isolated Python environment. For example:
@@ -161,16 +254,16 @@ python -m pip install --upgrade pip
python -m pip install lektor
```
Run the local editor and preview server:
Run the local editor and preview server with validated project content:
```bash
lektor server
python scripts/build_with_projects.py --serve
```
Run a production-style build:
```bash
lektor build --output-path build
python scripts/build_with_projects.py --output-path build
test -f build/index.html
```
@@ -186,6 +279,7 @@ The project currently has no third-party Lektor packages or plugins.
├── logs/
│ ├── deploy.log
│ └── deploy.lock
├── project-cache/ Bare mirrors, metadata, and last-known-good state
├── .local/bin/lektor pipx-installed Lektor
├── .netrc Git HTTPS credential (0600; secret)
├── .ssh/ Reserved deployment SSH material
@@ -208,15 +302,19 @@ or credential. nginx cannot modify either source or releases.
4. Fetches and prunes `origin`.
5. Verifies that `origin/main` exists.
6. Switches to local `main` and resets it exactly to `origin/main`.
7. Locates exactly one `.lektorproject` file instead of assuming its name.
8. Creates a unique release named with UTC time and the 12-character commit ID.
9. Runs Lektor with an explicit output directory.
10. Requires the build to succeed and contain `index.html`.
11. Writes the full commit ID to `.labyricorn-commit` in the release.
12. Atomically replaces `/srv/labyricorn/current` with a relative symlink to
7. Creates a unique release named with UTC time and the 12-character commit ID.
8. Runs `scripts/build_with_projects.py` with the persistent project cache and
an explicit output directory.
9. Fetches, validates, and imports each approved public project source into an
isolated temporary workspace before invoking Lektor.
10. Requires `index.html` and `.labyricorn-projects.json`.
11. Writes the full site commit ID to `.labyricorn-commit`.
12. Compares the site commit plus project commit/metadata digests with the
active release and removes the candidate as an unchanged no-op when equal.
13. Atomically replaces `/srv/labyricorn/current` with a relative symlink to
the new release.
13. Keeps the active release plus recent prior releases, targeting five total.
14. Logs start, failure, success, release name, and commit.
14. Keeps the active release plus recent prior releases, targeting five total.
15. Logs start, failure, unchanged, success, release name, and commit.
Run it with:
@@ -440,8 +538,10 @@ an unauthenticated editor through nginx.
## Git authentication and secrets
The private Gitea repository is accessed over HTTPS because the Gitea SSH port
was not reachable through `git.labyricorn.com` during setup.
The site repository is accessed over authenticated HTTPS because the Gitea SSH
port was not reachable through `git.labyricorn.com` during setup. Approved
remote project repositories, including Thinkloom, are public and are fetched
without embedding credentials in their configured URLs.
Credential purpose and location:
@@ -529,9 +629,12 @@ Run a disposable diagnostic build; do not build into `current`:
test_dir=$(mktemp -d /srv/labyricorn/releases/.diagnostic.XXXXXX)
chown labyricorn-deploy:labyricorn-deploy "$test_dir"
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
/srv/labyricorn/.local/bin/lektor \
--project /srv/labyricorn/repo/Labyricorn.lektorproject \
build --output-path "$test_dir"
/srv/labyricorn/.local/pipx/venvs/lektor/bin/python \
/srv/labyricorn/repo/scripts/build_with_projects.py \
--site-root /srv/labyricorn/repo \
--cache-path /srv/labyricorn/project-cache \
--output-path "$test_dir" \
--lektor /srv/labyricorn/.local/bin/lektor
```
Inspect the error and source. Remove the diagnostic directory only after
@@ -565,14 +668,36 @@ The first origin request should redirect; the simulated Cloudflare HTTPS request
should return the site. A different result indicates a proxy/header or nginx
configuration problem.
## Automatic deployment
### Remote project synchronization fails
Pushes to `main` automatically deploy through Gitea Actions. The workflow is
versioned with the site at `.gitea/workflows/deploy.yml` and has one job:
Inspect the deployment log and active manifest without editing the cache:
```bash
tail -n 100 /srv/labyricorn/logs/deploy.log
cat /srv/labyricorn/current/.labyricorn-projects.json
```
`current` means the latest remote revision and provider metadata validated.
`metadata-cached` means Git content is current but provider metadata fell back
to its cache. `last-known-good` means the latest fetch or candidate snapshot
could not safely replace the previously validated commit. A first import with
no valid cached snapshot fails the deployment. Do not bypass validation by
copying remote files into `content/`, the build workspace, or an active release.
## Automatic deployment and project refresh
The workflow at `.gitea/workflows/deploy.yml` runs for three event types:
- every push to site `main`;
- every 30 minutes (`*/30 * * * *`, evaluated by Gitea in UTC);
- an authenticated manual `workflow_dispatch`.
All events run the same one-job deployment:
1. Run on the repository-scoped runner labelled `labyricorn-deploy`.
2. Execute the existing root-owned `deploy-labyricorn` command.
3. Confirm `/srv/labyricorn/current/.labyricorn-commit` equals the pushed commit.
3. Confirm `/srv/labyricorn/current/.labyricorn-commit` equals the workflow's
site commit.
4. Request the site through nginx on the local origin and fail if it is unhealthy.
The action intentionally does not check out the repository into its own
@@ -580,7 +705,38 @@ workspace. The deployment script fetches `origin/main` using the dedicated
`labyricorn-deploy` account, validates a clean checkout, builds a new release,
and atomically changes the `current` symlink. Concurrent action runs share the
`labyricorn-production` concurrency group, and the deployment script also uses
a filesystem lock.
a filesystem lock. A scheduled/manual run updates the site when an approved
project commit or public metadata digest changes. When neither the site nor any
project input changed, it validates and builds a candidate, removes it, logs an
`unchanged` result, and leaves the active symlink untouched.
### Trigger a refresh
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:
```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
```
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,
or committed scripts. Send at most one dispatch after the relevant project
push, then verify the Action and public project revision. The scheduled run is
the fallback when no refresh credential is available. This is deliberately not
an unauthenticated public webhook or long-running URL listener, so it cannot be
spammed by anonymous requests.
### Runner installation