396 lines
20 KiB
Markdown
396 lines
20 KiB
Markdown
# 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 its merge commit back into `cms`
|
||
through a protected-branch pull request before the next editing cycle. 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 the resulting `main` merge commit back into protected `cms`
|
||
before another editing cycle.
|
||
|
||
## 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.
|