Files
2026-09-04 22:30:00 -04:00

397 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Decap CMS Gitea pilot and promotion
Status: completed and operational for internal SloanOps use as of 2026-09-04.
The reviewed CMS application is deployed from `main`, Decap writes only to the
protected `cms` staging branch, and the final Journal date-control repair is
present on both branches. Production deployments from the approved merge
completed successfully.
This remains an internal capability for `sloanops.admin`, not a public feature
or a client-editing offer. No client account has been invited.
Updated: 2026-09-04
## Purpose
Record and operate the Decap CMS editing interface for both sites in the
`3031023.xyz` demonstration repository without allowing an editor to publish
directly to production or modify deployment code.
The pilot covers both the primary Happy Tails site and its journal subdomain.
That matches the SloanOps repository boundary: one private repository per root
domain, with independently built sites beneath `sites/`. A future client who
owns the root-domain site also owns the hosted subdomains represented in that
repository.
The completed internal rollout does not authorize public discovery, a client
repository invitation, contact-form changes, a broader OAuth grant, or removal
of a rollback resource.
## Current evidence
- `3031023.xyz` and `blog.3031023.xyz` build independently from this
repository and deploy through separate Workers Static Assets workflows.
- A push outside `main` already uploads no-index primary and/or blog preview
versions according to the paths changed.
- Blog posts, FAQs, services, and team members are Markdown collections with
Astro/Zod validation and are suitable for a bounded Decap pilot.
- The live Gitea instance reported version `1.27.3` during the Phase 9 review.
Decap's Gitea backend requires Gitea 1.20 or newer.
- Credential-free preflight verification grants the exact stable CMS origin
and does not grant an unrelated origin.
- Decap authenticates to Gitea with a non-confidential PKCE OAuth application.
Its Gitea backend currently has no editorial pull-request workflow, so it
writes directly to the configured branch.
- The Gitea backend does not support Git LFS. Pilot media must remain small,
ordinary repository assets.
- The approved `sloanops.admin` account can authenticate at the production
admin route, load all collections, and write through the `cms` branch.
- Gitea rejects protected-file pushes to `cms` and direct pushes to `main`;
operator code/configuration changes therefore use pull requests as well.
- Arbitrary branch-preview hostnames are deliberately absent from the OAuth
redirect and CORS allowlists. Login from one returns `Unregistered Redirect
URI`; rendered branch previews remain usable without expanding OAuth.
References:
- <https://decapcms.org/docs/gitea-backend/>
- <https://decapcms.org/docs/configure-decap-cms/>
- <https://decapcms.org/docs/widgets/image/>
- <https://docs.gitea.com/development/oauth2-provider/>
- <https://docs.gitea.com/usage/access-control/protected-branches/>
- <https://github.com/decaporg/decap-cms/issues/7115>
The exact Gitea CORS, OAuth, and two-rule branch-protection settings are
recorded in [Decap CMS pilot: Gitea settings](decap-cms-gitea-setup.md).
## Repository implementation
The reviewed production implementation and permanent `cms` staging branch
provide the following controls:
- `public/admin/` provides a permanently no-index admin shell and one shared
configuration for both site builds.
- The shell pins Decap CMS `3.15.1` by exact URL and verified SHA-384 integrity
value rather than a floating release range.
- The backend is fixed to `SloanOps/3031023-xyz` and `cms` and contains
only the public client ID for the non-confidential Gitea PKCE application.
- All four Markdown content collections and every persisted frontmatter field
are modeled with the current Astro/Zod bounds. Markdown controls remain
text-first; embedded images and general file widgets are absent.
- Journal publish and optional updated dates use the supported Decap
`datetime` widget in date-only UTC mode. New posts default to the current UTC
date, and the verifier rejects the unsupported `date` widget that caused the
original create/update failures.
- A fixed image-only Markdown record controls the primary home, About,
Services, and Contact page imagery without exposing either `site.json`.
- Team and page-image fields accept optional bounded uploads. Editors can add,
replace, or remove an image reference while retaining accessible image
descriptions and a layout fallback. URL insertion and SVG paths are
rejected. Builds additionally verify the extension, raster signature, flat
upload path, and a 25 MiB source safety ceiling. Every build re-encodes the
public copy to at most 1920 pixels and 500 KiB and removes embedded metadata.
- `.gitea/workflows/cms-content-validation.yml` runs on every pull request into
`main`, uses no secrets, and reports
`CMS content validation / cms-content-validation (pull_request)` after
checking the Decap contract and building and verifying both sites.
- The existing branch preview workflows remain in place. Local output checks
now prove preview robots rules and page metadata stay no-index and that the
admin response has a no-index header rule.
- Validation, preview, and production workflows cache npm's download store by
`package-lock.json` while retaining clean `npm ci` installs with
`--prefer-offline --no-audit --no-fund`. The runner advertises its cache on a
fixed host and port reachable from job containers, avoiding the timeout caused
by its former auto-detected private address. Warm jobs reuse dependency
tarballs without caching `node_modules`.
- All external workflow actions are pinned to reviewed commit SHAs. The runner
may therefore set `cache.offline_mode: true` and reuse its local action
repository cache without trusting a mutable tag or refreshing GitHub on every
job.
The pilot dependencies were verified on `cms-pilot`, promoted to the permanent
`cms` branch, and deployed to `main`. Exact-origin CORS, PKCE OAuth, branch
protection, previews, validation, and production deployment are all applied.
## Operating architecture
```text
Decap at /admin/
|
| Gitea PKCE OAuth and content/media commits only
v
cms branch
|
+--> primary no-index Worker preview
|
+--> blog no-index Worker preview
|
v
manual Gitea pull request to protected main
|
v
required validation + SloanOps approval
|
v
production merge and deploy only with separate approval
```
The shared `public/` directory may serve `/admin/` in both preview builds. That
is acceptable because both sites belong to the same root-domain repository and
the same owner. One Decap configuration exposes clearly labeled Primary Site
and Journal collection groups.
Confirmed stable preview URLs for branch `cms`:
- `https://cms-3031023-xyz-www.ksloan90.workers.dev/admin/`
- `https://cms-3031023-xyz-www.ksloan90.workers.dev/`
- `https://cms-3031023-xyz-blog.ksloan90.workers.dev/`
The production admin entry is `https://3031023.xyz/admin/`. Use the stable
`cms` admin origin or the production admin entry for interactive CMS testing;
do not use arbitrary branch aliases for OAuth login.
## Internal Decap scope
Allow editing and creation only within these collections:
| CMS group | Repository path | Initial capability |
| --- | --- | --- |
| Primary site FAQs | `sites/www/content/faq/**` | Create, edit, draft, reorder, delete after confirmation |
| Primary services | `sites/www/content/services/**` | Create, edit, draft, reorder, feature |
| Primary team | `sites/www/content/team/**` | Create, edit, draft, reorder, add, replace, or remove a portrait |
| Primary page images | `sites/www/content/page-images/primary.md` | Add, replace, or remove home, About, Services, and Contact images and edit their descriptions |
| Journal posts | `sites/blog/content/posts/**` | Create, edit, draft, date, tag, update |
| Shared pilot media | `public/images/uploads/**` | Select or upload bounded raster images |
Do not expose `sites.json`, either `site.json`, schemas, workflows, Wrangler
configuration, package files, source components, scripts, contact routing, or
analytics through the CMS during this pilot.
Configure image fields with a 25 MiB source safety limit so ordinary camera
photos can be uploaded. Do not accept executables, archives, HTML, scripts, or
SVG uploads. The repository verifier applies the same safety ceiling to every
upload, including files added through Decap's standalone media gallery.
The build re-encodes every uploaded raster without changing its public URL. It
auto-orients and scales the deployed copy to at most 1920 pixels, targets a
500 KiB output ceiling, and strips EXIF and other embedded metadata. The source
file stays unchanged in the private repository. Asset rights, alt text, and
crop remain operator review items.
Decap's image widget applies its field `pattern` to the selected file's base
name, not to the repository-relative media path. Keep that UI pattern limited
to the approved raster filename extensions; enforce the full
`public/images/uploads` path separately in the schema, verifier, and Gitea
branch rules below.
The media gallery lists only `public/images/uploads`, so the pilot seeds that
directory with the images referenced by CMS-controlled fields. Removing an
image from content clears the field and uses the page's text-only or initials
fallback; it does not delete a potentially shared asset. Delete a gallery asset
only after confirming that no content entry references it.
## Repository protection model
### `main`
- Disable direct pushes for CMS/client users.
- Require a pull request.
- Require at least one SloanOps approval.
- Require the dedicated CMS content validation status.
- Block merging an outdated or rejected pull request.
- Permit merges only by the SloanOps operator allowlist.
- Keep force-push disabled.
### Fallback rule for non-production branches
Create a branch-protection fallback covering every branch, with the specific
`main` rule at higher priority. CMS/client users may push only the following
unprotected patterns:
```text
sites/www/content/**
sites/blog/content/**
public/images/uploads/*.jpg
public/images/uploads/*.jpeg
public/images/uploads/*.png
public/images/uploads/*.webp
public/images/uploads/*.avif
```
Add the equivalent nested-path globs if the CMS is allowed to create media
subdirectories. Listing raster extensions explicitly prevents an editor from
using the media directory as a general unprotected file path.
All other paths remain protected, including:
```text
.gitea/**
astro.config.mjs
package.json
package-lock.json
schemas/**
scripts/**
src/**
wrangler/**
sites.json
sites/*/site.json
public/admin/**
```
SloanOps operator accounts may use the branch-rule bypass allowlist for
reviewed development. Verify the restrictions through the same Gitea account
used by Decap; a UI-only field restriction is not a repository security
boundary.
The protected-path fallback matters even though `main` requires a pull
request. Preview workflows inherit Cloudflare credentials. An editor must not
be able to create another branch, alter a workflow or executable build file,
and run unreviewed code with those credentials.
## Pull-request validation
Add a secret-free validation workflow for pull requests into `main`. It should
always:
1. install locked dependencies;
2. build and verify the primary site;
3. build and verify the blog;
4. reject content that fails the Astro/Zod schema; and
5. report one stable required status context.
Preview deployment remains branch-specific and may use the existing Worker
workflows. Cloudflare credentials stay scoped to the deployment step; content
validation does not need them.
Because Decap writes to a fixed `cms` branch, SloanOps creates the pull request
from `cms` to `main` in Gitea. Internal CMS application/configuration changes
start from the current `cms` state, enter protected `cms` through an
operator-reviewed pull request, and are then promoted with the staged content.
After an approved `main` merge, synchronize only file changes that are missing
from `cms` through a protected-branch pull request. When the `main` merge commit
has the same tree as `cms`, no no-op sync is required. Do not automate
production merge.
The accepted pilot is promoted by replacing the disposable branch with the
permanent, slash-free `cms` branch. It remains a preview/staging branch and
retains the same pull-request, validation, and approval path into `main`.
## Routine operating sequence
1. Edit through an approved admin origin and confirm the commit lands only on
`cms` in an allowed content or media path.
2. Confirm the affected no-index Worker version preview succeeds.
3. Run the local CMS verifier and both site build/verifier pairs before any
operator-authored CMS application or configuration PR into `cms`.
4. Review the complete staged diff and open `cms` to `main`.
5. Require the stable secret-free validation status and SloanOps approval.
6. Merge deliberately and confirm the affected production Worker jobs pass.
7. Synchronize `main` back into protected `cms` only when it contains file
changes absent from `cms`; skip a no-op history-only merge.
## Test matrix
Record the Gitea execution, commit, and preview identifiers for each test. Do
not record OAuth tokens or Cloudflare credentials.
| ID | Test | Expected result |
| --- | --- | --- |
| DC-01 | Build both sites before CMS changes | Current build and verifier suites pass. |
| DC-02 | Open each preview `/admin/` route | Admin shell loads and remains no-index. |
| DC-03 | Authenticate with the approved operator account | PKCE login succeeds and the configured collections load. |
| DC-04 | Authenticate with an account without repository write access | Repository editing is unavailable. |
| DC-05 | Edit an existing draft journal post | One content-only commit lands on `cms`; the blog preview rebuilds. |
| DC-06 | Create, edit, and delete a disposable journal post | Filename, dates, tags, body, draft state, and cleanup remain schema-valid. |
| DC-07 | Edit a primary FAQ | Only the primary preview rebuilds and displays the change. |
| DC-08 | Edit service ordering and featured state | YAML types remain correct and the primary verifier passes. |
| DC-09 | Add, replace, and remove a team portrait | The selected upload renders in the team card; removal uses the initials fallback. |
| DC-10 | Upload a camera-sized raster image and select it in the fixed page-image record | File lands only in the approved media path; its deployed copy is at most 1920 pixels and 500 KiB, contains no embedded metadata, and renders on the relevant primary preview page. |
| DC-11 | Attempt an upload over 25 MiB and a disallowed file type | The source safety limit rejects the oversized file; Gitea's unprotected-extension patterns reject the disallowed path. |
| DC-12 | Attempt to modify `.gitea/workflows/**`, source, package, script, schema, Wrangler, and site-manifest paths with the CMS account | Gitea rejects every push. |
| DC-13 | Attempt to create a differently named branch and change a protected file | The fallback rule rejects the push; branch naming cannot bypass protection. |
| DC-14 | Attempt a direct push to `main` | Gitea rejects it. |
| DC-15 | Open a pull request from `cms` to `main` | Both sites validate; required review and merge controls are present. |
| DC-16 | Attempt to merge without status or approval | Gitea blocks the merge. |
| DC-17 | Inspect both preview responses | Expected routes work; preview metadata/headers prevent indexing; production remains unchanged. |
| DC-18 | Revert disposable CMS changes on `cms` | Preview returns to baseline without changing either public site. |
| DC-19 | Refresh, sign out, reauthenticate, and let a session expire | Session behavior is understandable and no unsaved-content loss is observed. |
| DC-20 | Review commit attribution and audit history | Gitea identifies the editor and the exact content/media changes. |
The initial no-merge restriction applied only while the pilot was being
evaluated. Promotion is now allowed through the protected workflow above; each
release still requires explicit review and repeats both public site checks.
## Completion record — 2026-09-04
- PR #1 launched the no-index Decap shell and Gitea-backed collections.
- PRs #2 and #3 tightened image filename validation and added bounded,
metadata-stripping build optimization.
- PRs #4 and #5 synchronized the reviewed implementation into `cms` and then
promoted it to `main`.
- Disposable Journal, page-image, and FAQ edits exposed workflow and schema
behavior; the test content was reverted and the temporary FAQ was removed.
- PR #9 consolidated the rollback history and resynchronized the branches.
- Failed Journal content runs were traced to Decap's unsupported `date` widget.
PR #10 replaced it with date-only `datetime` controls, defaulted new publish
dates, and added a verifier regression guard.
- PR #11 synchronized the final repair into protected `cms` after the direct
protected-file push was correctly rejected.
- CMS validation and both site builds passed locally. The PR #10 required
validation passed, and production blog run 342 plus primary run 343 both
completed successfully.
- The deployed Journal editor was opened at `3031023.xyz/admin/`; Publish date
displayed a populated UTC date and Updated date displayed an optional date
control. No post was saved during this final smoke test.
## Operating acceptance boundary
The internal pilot was accepted against these controls. Re-evaluate them before
any client rollout:
- both site families are understandable in one CMS;
- content edits preserve every modeled field and pass Astro validation;
- previews update reliably without moving production traffic;
- direct `main` pushes and protected-path changes fail for the CMS account;
- PR validation and operator approval are mandatory;
- media limits and repository growth remain acceptable;
- login/session behavior is usable; and
- rollback requires no production-domain or DNS change.
Stop and redesign if the Gitea backend cannot honor the branch/file
protections, OAuth grants materially broader access than the accepted account
role, content serialization loses fields, or previews require granting the CMS
account control over executable deployment files.
## Rollback and cleanup
If the internal CMS is paused:
1. Revert or delete disposable changes on `cms`.
2. Delete the two disposable preview aliases/versions if desired; do not delete
production Workers or retained legacy rollback resources.
3. Revoke/delete the Decap OAuth application.
4. Remove the pilot CORS origins and redeploy Gitea after verifying normal Git,
UI, API, and Actions behavior.
5. Remove `/admin/` only through a reviewed commit.
6. Remove or retain branch protections deliberately; never weaken `main` as
cleanup.
## Historical implementation estimate
Expected hands-on implementation time: **6–9 hours**, normally one focused
working day. Budget up to two elapsed days if OAuth/CORS behavior or Gitea
protection semantics require troubleshooting.
| Work | Estimate |
| --- | ---: |
| Decap admin shell, pinned dependency, and Gitea configuration | 1–1.5 hours |
| Model blog, FAQ, services, and team collections | 1.5–2 hours |
| Add secret-free PR validation and preview links/instructions | 1–1.5 hours |
| Configure CORS, OAuth, CMS branch, and protection rules | 1–1.5 hours |
| Run the authentication, editing, protection, preview, and rollback matrix | 1.5–2.5 hours |
| Record results and resolve small integration defects | 0.5 hour |
This estimate covers the internal demo pilot, not a polished client-editable
service. Client rollout still needs observed usability, account invitation and
offboarding procedures, support scope, media policy, and pricing.