14 Commits

Author SHA1 Message Date
Oleks 0be268307e style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-14 14:25:22 +03:00
Oleks 0072716733 fix(seed): rename reserved 'status' field to 'parity_status'
ci/woodpecker/push/container Pipeline was canceled
emdash reserves 'status' as a built-in entry field (publish state), so
'emdash seed' rejected the plugins collection's custom 'status' select
with 'Field slug status is reserved' — leaving the catalog empty. Rename
the domain field slug to parity_status (label stays 'Migration status')
across the seed field def + 39 entries, the collections type, and all
plugin-data reads. The public ?status= URL filter param and StatusBadge
prop name are unchanged.
2026-06-14 14:25:15 +03:00
Oleks 9b1090b614 style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-14 14:06:48 +03:00
Oleks 87eb6a0f84 fix(seed): add required id to seed entries + make seed non-fatal
ci/woodpecker/push/container Pipeline was canceled
emdash seed validates that every content entry has an id (validate.ts),
but seed/seed.json entries only had slug — so seed aborted with 'id is
required' and, under set -e, crash-looped the pod (502). Set id=slug for
all 42 entries (conflict-detection keys off slug, so id is just the
seed-local ref key). Also move the seed step out from under set -e: a bad
content seed should log loudly but not take the whole site down (init
migrations stay fatal).
2026-06-14 14:06:41 +03:00
Oleks 96c220825f fix: seed catalog on boot + emit https canonical/og:url
ci/woodpecker/push/container Pipeline failed
- entrypoint: run 'emdash seed' after 'emdash init' (init no longer loads
  JSON seeds in newer emdash, so the catalog booted empty). Idempotent
  onConflict=skip.
- Base.astro: derive canonical/og:url base from EMDASH_SITE_URL (per-env
  https URL the chart injects) instead of Astro.url.origin, which is plain
  http behind Traefik TLS termination.
2026-06-14 13:59:21 +03:00
Oleks 7b60fe452e ci: use arch-scoped buildkit service (buildkit-<arch>) instead of per-instance
ci/woodpecker/push/container Pipeline was successful
2026-06-04 19:10:28 +03:00
Oleks 597f089c53 ci: echo build arch (uname -m) as first line of every step for visibility
ci/woodpecker/push/container Pipeline was successful
2026-06-02 13:44:45 +03:00
Oleks c284c27aa8 style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-02 09:29:45 +03:00
Oleks d8de7617fb ci(#192): thin .woodpecker via #196 escape-hatch (emmett#44)
ci/woodpecker/push/container Pipeline was successful
cms-plugins is an Astro/emdash web app whose image is built by npm/astro
against an upstream package-lock.json (better-sqlite3 native build) and
cannot be expressed as Nix on emmett/amd64, so it stays on docker buildx.

Apply the cluster #196 OCI escape-hatch: move all build/tag/registry truth
into ci/local.sh, parameterized by BUILDKIT_ADDR (local docker-container
default, dry-run; CI overrides to the in-cluster native arm64 remote +
PUBLISH=1). CI now runs the same script a developer runs, so CI and local
can't drift. The three-tag Flux ImagePolicy contract (0.1.<pipeline>,
<branch>, <branch>-latest) and the arm64/kotkan targeting are preserved
verbatim; the Dockerfile is unchanged.
2026-06-02 09:29:38 +03:00
Oleks 0b1f2ebfbc docs(#3): go-live note — floating tags must exist before flux resume
ci/woodpecker/push/container Pipeline was successful
While HRs are suspended (Phase 0) the staging/production tags are referenced by
no live workload, so gitea-oci-cleanup can reap them. Document the registry-pins
mitigation and a pre-resume existence check in the first-deploy checklist.
2026-06-02 05:05:30 +03:00
Oleks 90a4b8088b fix(#4): consistent normalized plugin↔CMS join + orphan-ref check
Interim fix for the free-text title-match footguns (issue #4); durable ULID
reference + seed migration tracked separately.

- New lib/cms.ts: single normCms() match key + cmsSlugByTitle / resolveCmsSlug,
  used by all three join sites so a plugin can no longer link from its own page
  yet vanish from a CMS list over case/whitespace drift.
- cms/index.astro + cms/[slug].astro: counts and "plugins from / targeting"
  lists now use the normalized key (were exact-match).
- plugins/[slug].astro: drop the local normalize copy; link target_cms too
  (was source-only) for parity.
- warnOrphanCmsRefs(): logs any source_cms/target_cms that resolves to no CMS,
  so silent orphans surface in the server log.
2026-06-02 05:05:30 +03:00
Oleks 6e6fd76459 chore(docker): satisfy hadolint on the hardened Dockerfile
ci/woodpecker/push/container Pipeline was successful
- DL3008: explicit `hadolint ignore` on the two apt-get installs — bookworm-slim
  tracks current security-patched versions; pinning is brittle (reference image
  is also unpinned).
- DL3059: fold `npm run build` + `npm prune --omit=dev` into one RUN layer.
2026-06-02 04:59:20 +03:00
Oleks 8c119efff8 harden(deploy): apply safe fixes from review report-only items
- #3 Liveness probe targets full SSR DB-querying / route, coupling pod liveness to SQLite
- #4 Chart values-staging/production.yaml are dead config under Flux; drift trap
- #6 tsconfig includes gitignored emdash-env.d.ts that only the dev server generates
- #7 Dockerfile package-lock glob + npm install fallback can silently build an unlocked image
- #8 Dockerfile creates runtime user without pinning its GID
- #9 entrypoint.sh gates `emdash init` on data.db absence, skipping migrations on PVC reuse
- #10 pullPolicy: Always vs digest pinning
- #11 Dockerfile state symlinks contradict the STATE_DIR contract; Dockerfile does not set ENV STATE_DIR
- #12 astro is a production dependency, so npm prune --omit=dev keeps build-only tooling
- #14 Two ImageUpdateAutomations write back to the same anton-helm-workloads main branch
- #16 memoryCache provider is per-process; correctness depends implicitly on replicas:1
- #17 Root catch-all [slug].astro couples nav links to pages-collection rows + DB hit per unmatched path
- #18 Detail pages render a 200-style body under a 404 status and have no try/catch around getEmDash* calls
- #19 vite allowedHosts hardcodes ddev hostnames (dev-only; no prod impact)
2026-06-02 04:50:54 +03:00
Oleks 0c2cea8c25 fix: architecture + UI/UX review fixes
Architecture:
- Cap homepage plugin list at PLUGIN_FETCH_CAP like other pages
- Declare @types/node directly instead of relying on transitive dep
- Single-source status label text (statuses.ts vs seed.json drift)

UI/UX:
- Stop auto-submitting filter selects so keyboard navigation works
- Fix heading hierarchy (add h2) on flat list pages
- Improve homepage title beyond bare "Plugins"
- Make status taxonomy descriptions self-contained
- Render only relevant statuses in the legend, not all 7
- Fix PluginCard "WordPress -> —" for missing target
- Clarify "{n} from / {n} targeting" microcopy
- Use proper count meta markup on CMS list
- Allow header nav row to wrap
- Fix bare CMS URL horizontal overflow
- Add standard line-clamp fallback to cards
- Even out footer stacked paragraph spacing
- Center plugin detail status line (drop margin-left hack)
- Raise toolbar tap targets to 44px
- Surface status badge meaning beyond title attribute
- Include source-CMS breadcrumb step on lookup miss
- Add link into filtered catalog from CMS detail
2026-06-02 04:16:58 +03:00
31 changed files with 1215 additions and 220 deletions
+15 -37
View File
@@ -5,6 +5,14 @@
# repo → pod rolls), and <branch>-latest (same image; chart image.tag
# fallback). Only staging/production have an ImagePolicy, so only those
# move pods.
#
# Thin wrapper (cluster #196, emmett#44): this is an Astro/emdash WEB app
# whose image is built by npm/astro against an upstream lockfile and can NOT
# be expressed as Nix on emmett/amd64, so it stays on `docker buildx`. CI
# runs the SAME ci/local.sh a developer runs — the only CI-specific bits are
# env overrides (BUILDKIT_ADDR -> in-cluster native-arch remote, PUBLISH=1).
# All the tag/registry semantics live in ci/local.sh so CI and local can't
# drift.
labels:
# kotkan (the deploy target) is an arm64 host, so we build natively on
@@ -32,19 +40,15 @@ steps:
environment:
REGISTRY_TOKEN:
from_secret: registry_token
# In-cluster native arm64 buildkit (kotkan's arch). ci/local.sh treats
# this as a native remote builder and skips qemu emulation.
BUILDKIT_ADDR: "tcp://buildkit-arm64.infra.svc.cluster.local:1234"
PUBLISH: "1"
commands:
- BRANCH="$CI_COMMIT_BRANCH"
- SHA=$(echo "$CI_COMMIT_SHA" | cut -c1-12)
# Semver-shaped immutable tag, one per build. First two components
# stay 0.1 (no real semver discipline yet); patch is the pipeline
# number, monotonic across the whole repo.
- VERSION="0.1.$CI_PIPELINE_NUMBER"
- IMAGE="git.oleks.space/oleks/cms-plugins"
- 'echo "Building $IMAGE:$VERSION (branch=$BRANCH sha=$SHA linux/arm64)"'
- echo "▸ arch=$(uname -m)"
# Wait for the in-cluster buildkit to be reachable (it can be cold).
- |
BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local"
BUILDER_HOST="buildkit-arm64.infra.svc.cluster.local"
BUILDER_PORT="1234"
echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..."
for i in $(seq 1 30); do
@@ -54,33 +58,7 @@ steps:
[ "$i" -eq 30 ] && echo "Builder not available" && exit 1
sleep 10
done
- echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin
- docker buildx create --name cms-plugins-builder --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT"
# Tagging scheme — every build pushes three refs:
# $VERSION — semver-shaped, one per build, immutable (audit).
# $BRANCH — floating channel pointer. THIS is what Flux's
# ImagePolicy tracks (filterTags `^staging$` /
# `^production$`, digestReflectionPolicy: Always);
# retagging it onto the new image is what makes
# ImageUpdateAutomation rewrite the pinned digest
# in the workloads repo and roll the pod.
# $BRANCH-latest — same image, kept only so the chart's cosmetic
# `image.tag` fallback (used when image.digest is
# unset) resolves to a real ref.
# All branches publish all three; only staging/production have an
# ImagePolicy, so only those actually move pods.
- |
TAGS="-t $IMAGE:$VERSION -t $IMAGE:$BRANCH -t $IMAGE:$BRANCH-latest"
docker buildx build \
--builder cms-plugins-builder \
--platform linux/arm64 \
$TAGS \
--push \
.
- 'echo "Pushed $IMAGE:$VERSION + floated $IMAGE:$BRANCH and $IMAGE:$BRANCH-latest"'
- ci/local.sh --arch arm64
backend_options:
kubernetes:
nodeSelector:
+17 -4
View File
@@ -66,13 +66,26 @@ the matching branch of this repo. The `ignore` rule in `source.yaml`
restricts reconciliation to `/deploy/helm`, so app-source pushes don't
trigger chart re-reconcile.
Per-env values overlay `values.yaml` via `values-staging.yaml` /
`values-production.yaml` (for `helm upgrade` direct use) and via the
`values:` block in the FluxCD `HelmRelease`.
Per-env values come from **two independent sources** that must be kept in
sync by hand:
- The inline `values:` block in each FluxCD `HelmRelease` (in
`anton-helm-workloads`, templated under `deploy/fleet-overlay/`). **This is
the only source Flux applies to the cluster.**
- `values-staging.yaml` / `values-production.yaml` in `deploy/helm/`, used
*only* for direct `helm upgrade -f ...` invocations. **Flux never reads
these files**, so editing them has no effect on the live deploy and they
can silently drift from the HelmRelease.
Key shape decisions:
- **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer.
- **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer. This
also keeps the in-process Astro response cache (`memoryCache()` in
`app/astro.config.mjs`, fed by `Astro.cache.set(cacheHint)` on the
content pages) coherent: it is per-process, so a second replica would
hold a divergent, independently-expiring cache. Scaling out would
require a shared cache provider, not just relaxing the SQLite writer
constraint.
- **`nodeSelector: kotkan`.** `local-path` PV is sticky to one node; emdash
is colocated with the legacy kotkanagrilli WP install.
- **Pinned by digest, not tag.** The HelmRelease sets
+6 -1
View File
@@ -53,7 +53,12 @@ cd app && npm install && npm run bootstrap && npx emdash dev
# Build production image
docker build -t cms-plugins:dev .
# Typecheck
# Typecheck — NOTE: requires app/emdash-env.d.ts, which tsconfig.json
# includes but which is gitignored + untracked. Emdash regenerates it
# ONLY via the dev-server `astro:server:setup` hook, so on a clean
# checkout you must start the dev server once (`npx emdash dev`, then
# stop it) before `astro check` will resolve emdash types. `astro build`
# (the Docker/CI image path) does NOT type-check and is unaffected.
cd app && npm run typecheck
```
+16 -2
View File
@@ -225,10 +225,24 @@ Both should resolve to the `kotkan` ingress IP (the same one
### D. First deploy
1. Push to `staging` to trigger the first build. Watch Woodpecker.
2. Once the image lands, Flux reconciles. Watch the rollout:
> **Go-live prerequisite — floating tag must exist (issue #3).** While the
> HelmReleases are `suspend: true` (Phase 0) and no pod is running, the
> floating `staging`/`production` tags are referenced by **no live workload**.
> `gitea-oci-cleanup` is fleet-aware and only auto-pins in-use tags, so an
> unreferenced floating tag is reapable. If it gets reaped before go-live, the
> `ImagePolicy` has nothing to resolve and the first deploy can't pin a digest.
> Mitigation: the floating tags are explicitly pinned in
> `servers/armer/scripts/registry-pins.txt`
> (`container/cms-plugins==staging`, `container/cms-plugins==production`) so
> the cleaner always keeps them. Before the first `flux resume`, confirm both
> tags still exist (`oleks/-/packages/container/cms-plugins`); if not, push the
> branch to rebuild them first.
1. Re-run/confirm the `staging` build so `git.oleks.space/oleks/cms-plugins:staging` exists. Watch Woodpecker.
2. Resume the release and watch Flux reconcile + the rollout:
```bash
flux resume helmrelease cms-plugins-staging -n kotkan
kubectl -n kotkan get pods -w | grep cms-plugins
flux get all -n flux-system | grep cms-plugins
```
+28 -9
View File
@@ -2,30 +2,43 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
# bookworm-slim: track the distro's current security-patched versions, don't pin.
# hadolint ignore=DL3008
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY app/package.json app/package-lock.json* ./
# package-lock.json may not exist on the first commit — fall back to `npm install`
# so the image still builds; once a lockfile is committed, npm ci kicks in.
RUN if [ -f package-lock.json ]; then npm ci --include=dev; else npm install --include=dev; fi
COPY app/package.json app/package-lock.json ./
# Lockfile (lockfileVersion 3) is committed; npm ci is reproducible and
# fails if it drifts from package.json. No npm install fallback.
RUN npm ci --include=dev
FROM deps AS build
WORKDIR /app
COPY app/ ./
RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads
RUN npm run build
RUN npm prune --omit=dev
# `astro` is a runtime dependency (required by the @astrojs/node standalone
# SSR server), so the prune only drops the two devDependencies
# (@astrojs/check, @types/node). Astro's transitive build tooling
# (vite, esbuild, @astrojs/compiler, rollup plugins) stays in node_modules
# because Astro itself declares them as runtime deps. Slimming those out
# would require verifying the dist/server bundle never imports `astro/*` at
# boot; not attempted here. Image-size tradeoff is accepted for now.
# (build + prune in one layer; separate RUNs would trip hadolint DL3059.)
RUN npm run build \
&& npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4321
# bookworm-slim: track the distro's current security-patched versions, don't pin.
# hadolint ignore=DL3008
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --home /app emdash \
&& groupadd --system --gid 1001 emdash \
&& useradd --system --uid 1001 --gid 1001 --home /app emdash \
&& mkdir -p /app/state/uploads \
&& chown -R emdash:emdash /app
@@ -35,8 +48,14 @@ COPY --from=build --chown=emdash:emdash /app/dist ./dist
COPY --from=build --chown=emdash:emdash /app/seed ./seed
# Persistent state lives in /app/state (single PVC in k3s).
# data.db and uploads/ are symlinked from the working directory so the
# default emdash paths (./data.db, ./uploads) resolve into the volume.
# STATE_DIR is intentionally NOT set here: the Helm chart injects
# STATE_DIR=/app/state (deploy/helm values.yaml), so the running app writes
# directly to /app/state/data.db and /app/state/uploads and these symlinks
# are never traversed. Leaving it unset keeps the image deploy-agnostic so a
# bare `docker run` / DDEV smoke test falls back to the WORKDIR (./data.db).
# The symlinks below only backstop that STATE_DIR-unset fallback
# (astro.config.mjs: `process.env.STATE_DIR ?? "."`), redirecting the default
# emdash paths into the volume — they do not contradict the STATE_DIR contract.
RUN ln -s /app/state/data.db /app/data.db \
&& ln -s /app/state/uploads /app/uploads
+3 -2
View File
@@ -16,8 +16,9 @@ export default defineConfig({
server: { host: true, port: 4321 },
vite: {
server: {
// Dev runs behind DDEV's nginx (https://cms-plugins.ddev.site/).
// Vite's host check must allow the public hostname.
// Dev-only: `vite.server` applies to `astro dev` / `emdash dev`
// (DDEV's nginx fronts https://cms-plugins.ddev.site/). Production
// runs the @astrojs/node standalone server, so this has no prod effect.
allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"],
},
},
+10 -11
View File
@@ -14,7 +14,8 @@
"emdash": "^0.10.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
"@astrojs/check": "^0.9.7",
"@types/node": "^22"
}
},
"node_modules/@astrojs/check": {
@@ -3607,13 +3608,12 @@
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@@ -9505,11 +9505,10 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT",
"optional": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
+2 -1
View File
@@ -22,6 +22,7 @@
"emdash": "^0.10.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
"@astrojs/check": "^0.9.7",
"@types/node": "^22"
}
}
+551 -95
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -7,7 +7,7 @@ interface Props {
data: {
title: string;
purpose?: string | null;
status?: string | null;
parity_status?: string | null;
source_cms?: string | null;
target_cms?: string | null;
};
@@ -19,8 +19,8 @@ const d = entry.data;
<li class="plugin-card">
<h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3>
<div class="meta">
<StatusBadge status={d.status} />
{d.source_cms && <span>{d.source_cms} → {d.target_cms ?? ""}</span>}
<StatusBadge status={d.parity_status} />
{d.source_cms && <span>{d.source_cms}{d.target_cms ? `${d.target_cms}` : ""}</span>}
</div>
{d.purpose && <p>{d.purpose}</p>}
</li>
+1 -1
View File
@@ -8,4 +8,4 @@ const value = (status ?? "proposed").toLowerCase();
const label = STATUS_LABELS[value] ?? value;
const desc = STATUSES.find((s) => s.value === value)?.desc;
---
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span>
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: {label}{desc ? ` — ${desc}` : ""}. </span>{label}</span>
+5 -1
View File
@@ -1,9 +1,13 @@
---
import StatusBadge from "./StatusBadge.astro";
import { STATUSES } from "../lib/statuses";
interface Props {
items?: typeof STATUSES;
}
const { items = STATUSES } = Astro.props;
---
<div class="legend">
{STATUSES.map((i) => (
{items.map((i) => (
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
))}
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ declare module "emdash" {
source_cms: string;
target_cms?: string | null;
category?: string | null;
status: string;
parity_status: string;
source_repo_url?: string | null;
target_repo_url?: string | null;
notes?: import("emdash").PortableTextBlock[] | null;
+6 -1
View File
@@ -19,7 +19,12 @@ const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity cat
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
const pageDescription = description ?? siteTagline;
const canonicalBase = Astro.site ?? new URL(Astro.url.origin);
// Prefer the per-env site URL the chart injects (EMDASH_SITE_URL, https://<host>)
// over Astro.url.origin: TLS terminates at Traefik, so the pod sees plain http
// and would otherwise emit http:// canonical + og:url. Read at request time via
// process.env (runtime pod env, not a build-time import.meta.env constant).
const siteUrlEnv = process.env.EMDASH_SITE_URL;
const canonicalBase = Astro.site ?? (siteUrlEnv ? new URL(siteUrlEnv) : new URL(Astro.url.origin));
const canonical = new URL(Astro.url.pathname, canonicalBase).href;
const pageCtx = createPublicPageContext({
+65
View File
@@ -0,0 +1,65 @@
// Plugin↔CMS relation helpers.
//
// A plugin references a CMS by the free-text `source_cms` / `target_cms`
// string, which is matched against a CMS entry's `title`. There is no
// reference field / foreign key yet (tracked in issue #4 — the durable fix is
// an Emdash relation field keyed by the CMS ULID + a seed migration). Until
// then, EVERY page that joins plugins to CMSes must use the SAME normalized
// match so a plugin can't link from its own page yet vanish from a CMS list
// over mere casing/whitespace drift.
interface CmsEntry {
id: string;
data: { title?: string | null };
}
interface PluginRefData {
source_cms?: string | null;
target_cms?: string | null;
}
/** Canonical CMS-match key: trim + lowercase. The ONE place case/whitespace is normalized. */
export const normCms = (s: string): string => s.trim().toLowerCase();
/** Map of normalized CMS title → CMS entry slug (`entry.id`, used in URLs). */
export function cmsSlugByTitle(cmses: CmsEntry[]): Map<string, string> {
return new Map(
cmses.filter((c) => c.data.title).map((c) => [normCms(c.data.title as string), c.id]),
);
}
/** Resolve a plugin's CMS reference to a CMS slug, or undefined if it matches no CMS. */
export function resolveCmsSlug(
ref: string | null | undefined,
index: Map<string, string>,
): string | undefined {
return ref ? index.get(normCms(ref)) : undefined;
}
/**
* Integrity check: log any plugin `source_cms`/`target_cms` that resolves to no
* CMS entry. These are silent orphans (excluded from CMS counts/lists with no
* user-visible error), so surfacing them in the server log is the cheap guard
* until the relation becomes a real reference (issue #4). Call from a page that
* already has both collections in scope; it does not fetch.
*/
export function warnOrphanCmsRefs(
plugins: Array<{ id: string; data: PluginRefData }>,
cmses: CmsEntry[],
): void {
const known = new Set(
cmses.filter((c) => c.data.title).map((c) => normCms(c.data.title as string)),
);
const orphans: string[] = [];
for (const p of plugins) {
for (const field of ["source_cms", "target_cms"] as const) {
const ref = p.data[field];
if (ref && !known.has(normCms(ref))) orphans.push(`${p.id}.${field}="${ref}"`);
}
}
if (orphans.length) {
console.warn(
`[cms] ${orphans.length} plugin CMS reference(s) match no CMS entry (issue #4):`,
orphans.join(", "),
);
}
}
+9 -7
View File
@@ -1,3 +1,5 @@
export const PLUGIN_FETCH_CAP = 10000;
export interface StatusDef {
value: string;
label: string;
@@ -5,13 +7,13 @@ export interface StatusDef {
}
export const STATUSES: StatusDef[] = [
{ value: "port", label: "Port", desc: "must be reimplemented on the target CMS" },
{ value: "built-in", label: "Built-in", desc: "covered by target CMS core / framework" },
{ value: "saas", label: "SaaS", desc: "replaced by an external service" },
{ value: "drop", label: "Drop", desc: "not needed on the target CMS" },
{ value: "gated", label: "Gated", desc: "fate depends on an unresolved decision" },
{ value: "done", label: "Done", desc: "ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "submitted for cataloging" },
{ value: "port", label: "Port", desc: "Reimplemented as a new Emdash plugin" },
{ value: "built-in", label: "Built-in", desc: "Already covered by Emdash core" },
{ value: "saas", label: "SaaS", desc: "Replaced by an external hosted service" },
{ value: "drop", label: "Drop", desc: "Not needed after migration" },
{ value: "gated", label: "Gated", desc: "Blocked on an open decision" },
{ value: "done", label: "Done", desc: "Ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "Newly added, not yet classified" },
];
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
+15
View File
@@ -3,8 +3,23 @@ import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
// Root-level catch-all for the `pages` content collection (standard Emdash model:
// a CMS "page" IS a pages-collection row whose `entry.id` is its slug). The site nav
// (`/about` in Base.astro) deliberately links to pages-collection rows rather than to
// hard-coded .astro files, so adding/renaming a static page is a content edit, not a
// code change. Consequence: every unmatched top-level path (incl. bot probes) does one
// indexed SQLite point-lookup here and 404s on a miss. Acceptable by design — single
// replica, single-writer local-path SQLite on kotkan, and hits already emit the
// `cacheHint` below. Do NOT "fix" by reintroducing per-page .astro files.
export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry is intentionally NOT wrapped in try/catch: a thrown error
// here means SQLite/infra is unhealthy and should surface as a 500 (a real
// pod-health signal on this single-replica/local-path deploy), not be masked
// as a 404. Mirrors the emdash-kotkanagrilli reference, which also lets these
// calls propagate.
const slug = decodeSlug(Astro.params.slug);
const { entry, cacheHint } = slug
? await getEmDashEntry("pages", slug)
+15 -3
View File
@@ -2,9 +2,17 @@
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import Base from "../../layouts/Base.astro";
import PluginCard from "../../components/PluginCard.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
import { normCms } from "../../lib/cms";
export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry/getEmDashCollection are intentionally NOT wrapped in
// try/catch: a thrown error here means SQLite/infra is unhealthy and should
// surface as a 500 (a real pod-health signal on this single-replica/local-path
// deploy), not be masked as a 404. Mirrors the emdash-kotkanagrilli reference,
// which also lets these calls propagate.
const slug = decodeSlug(Astro.params.slug);
const { entry: cms, cacheHint } = slug
? await getEmDashEntry("cmses", slug)
@@ -14,14 +22,17 @@ if (!slug || !cms) {
}
if (cacheHint) Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: all } = cms
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
: { entries: [] };
if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
const cmsName = cms?.data.title as string;
const fromHere = all.filter((p) => p.data.source_cms === cmsName);
const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
// Normalized match (see lib/cms.ts / issue #4): a plugin whose source_cms
// differs from this CMS title only by case/whitespace must still appear here,
// exactly as it links from its own detail page.
const cmsKey = cmsName ? normCms(cmsName) : "";
const fromHere = all.filter((p) => p.data.source_cms && normCms(p.data.source_cms) === cmsKey);
const targetingHere = all.filter((p) => p.data.target_cms && normCms(p.data.target_cms) === cmsKey);
---
{!cms ? (
<Base title="Not found">
@@ -36,6 +47,7 @@ const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
{cms.data.description && <p>{cms.data.description}</p>}
<h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
<p><a href={`/?source=${encodeURIComponent(cmsName)}`}>Filter these in the catalog</a></p>
{fromHere.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p>
) : (
+9 -4
View File
@@ -1,6 +1,8 @@
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
import { normCms, warnOrphanCmsRefs } from "../../lib/cms";
export const prerender = false;
@@ -9,25 +11,28 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
});
Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: plugins } = await getEmDashCollection("plugins", { limit: PLUGIN_FETCH_CAP });
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
warnOrphanCmsRefs(plugins, cmses);
// Count by NORMALIZED CMS key so the tallies match the (normalized) joins on
// the CMS detail and plugin detail pages — see lib/cms.ts / issue #4.
const countBySource = new Map<string, number>();
const countByTarget = new Map<string, number>();
for (const p of plugins) {
const s = p.data.source_cms;
if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1);
if (s) countBySource.set(normCms(s), (countBySource.get(normCms(s)) ?? 0) + 1);
const t = p.data.target_cms;
if (t) countByTarget.set(t, (countByTarget.get(t) ?? 0) + 1);
if (t) countByTarget.set(normCms(t), (countByTarget.get(normCms(t)) ?? 0) + 1);
}
---
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
<h1>By CMS</h1>
<h2 class="sr-only">CMSes</h2>
<ul class="plugin-grid">
{cmses.map((c) => (
<li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} from · {countByTarget.get(c.data.title) ?? 0} targeting</p>
<div class="meta">{countBySource.get(normCms(c.data.title)) ?? 0} plugins from · {countByTarget.get(normCms(c.data.title)) ?? 0} targeting</div>
{c.data.description && <p>{c.data.description}</p>}
</li>
))}
+12 -9
View File
@@ -3,13 +3,15 @@ import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro";
import { STATUSES } from "../lib/statuses";
import { STATUSES, PLUGIN_FETCH_CAP } from "../lib/statuses";
export const prerender = false;
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" },
limit: PLUGIN_FETCH_CAP,
});
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[index] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- list may be truncated");
Astro.cache.set(cacheHint);
const url = new URL(Astro.request.url);
@@ -18,44 +20,45 @@ const statusFilter = url.searchParams.get("status") ?? "";
const sourceFilter = url.searchParams.get("source") ?? "";
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
const present = new Set(plugins.map((p) => p.data.status).filter(Boolean));
const present = new Set(plugins.map((p) => p.data.parity_status).filter(Boolean));
const statuses = STATUSES.filter((s) => present.has(s.value));
const filtered = plugins.filter((p) => {
const d = p.data;
if (q && !(`${d.title} ${d.purpose ?? ""}`.toLowerCase().includes(q))) return false;
if (statusFilter && d.status !== statusFilter) return false;
if (statusFilter && d.parity_status !== statusFilter) return false;
if (sourceFilter && d.source_cms !== sourceFilter) return false;
return true;
});
---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
<Base title="WordPress → Emdash plugin parity catalog" description="How each WordPress plugin maps onto its Emdash replacement when migrating a site — browse by migration status, source CMS, or name.">
<h1>Plugins</h1>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} entries cataloged. Filter by status, source CMS, or search by name.`}</p>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} WordPress plugins cataloged with how each maps onto Emdash. Filter by status, source CMS, or search by name.`}</p>
<StatusLegend />
<StatusLegend items={statuses} />
<form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" />
<select name="source" aria-label="Filter by source CMS" onchange="this.form.submit()">
<select name="source" aria-label="Filter by source CMS">
<option value="">All source CMSes</option>
{sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option>
))}
</select>
<select name="status" aria-label="Filter by status" onchange="this.form.submit()">
<select name="status" aria-label="Filter by status">
<option value="">All statuses</option>
{statuses.map((s) => (
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
))}
</select>
<button type="submit">Apply</button>
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>}
{(q || statusFilter || sourceFilter) && <a href="/">Clear all</a>}
</form>
{filtered.length === 0 ? (
<p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p>
) : (
<h2 class="sr-only">Plugin results</h2>
<ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)}
</ul>
+16 -7
View File
@@ -3,9 +3,16 @@ import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import StatusBadge from "../../components/StatusBadge.astro";
import { cmsSlugByTitle, resolveCmsSlug } from "../../lib/cms";
export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry/getEmDashCollection are intentionally NOT wrapped in
// try/catch: a thrown error here means SQLite/infra is unhealthy and should
// surface as a 500 (a real pod-health signal on this single-replica/local-path
// deploy), not be masked as a 404. Mirrors the emdash-kotkanagrilli reference,
// which also lets these calls propagate.
const slug = decodeSlug(Astro.params.slug);
const { entry, cacheHint } = slug
? await getEmDashEntry("plugins", slug)
@@ -18,8 +25,10 @@ if (cacheHint) Astro.cache.set(cacheHint);
const d = entry?.data;
const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] };
const cmsSlugByTitle = new Map(cmses.map((c) => [c.data.title as string, c.id]));
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefined;
// Shared normalized join (lib/cms.ts / issue #4) — same match the CMS pages use.
const cmsIndex = cmsSlugByTitle(cmses);
const sourceCmsSlug = resolveCmsSlug(d?.source_cms, cmsIndex);
const targetCmsSlug = resolveCmsSlug(d?.target_cms, cmsIndex);
---
{!entry || !d ? (
<Base title="Not found">
@@ -31,7 +40,7 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
title={d.title}
description={d.purpose ?? undefined}
content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(sourceCmsSlug ? [{ name: d.source_cms, url: `/cms/${sourceCmsSlug}` }] : []), { name: d.title, url: Astro.url.pathname }]}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(d.source_cms ? [{ name: d.source_cms, url: sourceCmsSlug ? `/cms/${sourceCmsSlug}` : "/cms" }] : []), { name: d.title, url: Astro.url.pathname }]}
>
<article class="detail">
<header>
@@ -40,8 +49,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
{d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)}
</p>
<h1>{d.title}</h1>
<p>
<StatusBadge status={d.status} />
<p class="meta">
<StatusBadge status={d.parity_status} />
{d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p>
{d.purpose && <p class="lead">{d.purpose}</p>}
@@ -49,8 +58,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
<dl>
{d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{d.target_cms}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{targetCmsSlug ? <a href={`/cms/${targetCmsSlug}`}>{d.target_cms}</a> : d.target_cms}</dd></>)}
{d.source_repo_url && (<><dt>Source repo</dt><dd><a href={d.source_repo_url}>{d.source_repo_url}</a></dd></>)}
{d.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
</dl>
+10 -5
View File
@@ -70,7 +70,7 @@ header.site {
background: var(--c-bg);
}
header.site .row {
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1.5rem;
}
header.site .brand { font-weight: 700; color: var(--c-heading); }
header.site .brand:hover { text-decoration: none; }
@@ -85,6 +85,8 @@ footer.site {
color: var(--c-muted);
font-size: var(--fs-sm);
}
footer.site .container { display: flex; flex-direction: column; gap: 0.35rem; }
footer.site p { margin: 0; }
/* status badge */
.badge {
@@ -115,14 +117,15 @@ footer.site {
.toolbar input[type="search"], .toolbar select {
font: inherit;
padding: 0.4rem 0.6rem;
min-height: 44px;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background: white;
}
.toolbar input[type="search"] { flex: 1 1 240px; min-width: 0; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; min-height: 44px; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button:hover { filter: brightness(1.08); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; border-radius: var(--radius); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; min-height: 44px; display: inline-flex; align-items: center; border-radius: var(--radius); }
.plugin-grid {
list-style: none; padding: 0; margin: 0;
@@ -142,7 +145,7 @@ footer.site {
.plugin-card h3 { margin: 0; font-size: var(--fs-card-title); }
.plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: var(--fs-sm); color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
@@ -151,7 +154,9 @@ footer.site {
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail .lead { font-size: 1.05rem; }
.detail .source-target { margin-left: .75rem; color: var(--c-muted); }
.detail .meta { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.detail .source-target { color: var(--c-muted); }
main p a { overflow-wrap: anywhere; }
.section-gap { margin-top: 2rem; }
.detail .notes { line-height: 1.65; }
.detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
Executable
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# oci-image escape hatch (cluster #196) for a keep-Dockerfile repo.
#
# cms-plugins is an Astro/emdash WEB app: its production image is built by
# `npm ci` + `astro build` against an upstream package-lock.json with an
# apt-installed C toolchain (better-sqlite3 native build). It does NOT
# nixify and cannot be built on emmett/amd64 as Nix, so it stays on
# `docker buildx` instead of the parity-lib nix archetypes.
#
# This script is the single source of build truth: a developer runs it
# locally (default builder, dry-run) and CI runs the EXACT same script with
# a couple of env overrides, so CI and local can't drift (emmett#44).
#
# Builder is parameterized:
# BUILDKIT_ADDR default docker-container://local (dev: local buildkitd)
# CI overrides to the in-cluster native-arch remote, e.g.
# tcp://buildkit-arm64.infra.svc.cluster.local:1234
#
# kotkan (the deploy target) is arm64, so the default target arch is arm64.
# Foreign-arch (an arch != the host) is emulated via qemu binfmt and is
# SLOW — an explicit notice is printed and, for a remote NATIVE-arch
# builder, emulation is skipped. If a foreign arch cannot be built locally
# (no binfmt handler, no remote builder) the blocker is named + exit != 0.
#
# DRY-RUN by default. Pass --publish (or PUBLISH=1) to actually --push.
#
# Tagging contract (PRESERVED verbatim from the old .woodpecker pipeline):
# every PUBLISH pushes three refs onto the one image:
# $VERSION — semver-shaped, one per build, immutable (audit).
# Default 0.1.$CI_PIPELINE_NUMBER (override via VERSION).
# $BRANCH — floating channel pointer Flux's ImagePolicy tracks.
# Retagging it is what rolls staging/production pods.
# $BRANCH-latest — same image; chart image.tag cosmetic fallback.
# Only staging/production have an ImagePolicy, so only those move pods.
#
# Token: $REGISTRY_TOKEN, else `pass <PASS_ENTRY>`, else a named hard fail.
# The token is NEVER echoed and `set -x` is never enabled.
set -euo pipefail
# ---- configuration (override via env) ---------------------------------------
REGISTRY_HOST="${REGISTRY_HOST:-git.oleks.space}"
REGISTRY_OWNER="${REGISTRY_OWNER:-oleks}"
REGISTRY_USER="${REGISTRY_USER:-oleks}"
IMAGE_REPO="${IMAGE_REPO:-cms-plugins}"
PASS_ENTRY="${PASS_ENTRY:-infra/gitea/personal_access_token_packages_rw}"
# Local buildkitd by default; CI overrides to the cluster native-arch remote.
BUILDKIT_ADDR="${BUILDKIT_ADDR:-docker-container://local}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ---- argument parsing -------------------------------------------------------
PUBLISH="${PUBLISH:-0}"
TARGET_ARCH=""
usage() {
cat <<'EOF'
Usage: ci/local.sh [--arch <arch>] [--publish]
--arch <arch> Target arch: amd64 | arm64 | s390x (default: arm64,
the deploy target kotkan's arch)
--publish Actually push to the registry (default: DRY-RUN)
Environment:
BUILDKIT_ADDR buildx builder endpoint
(default docker-container://local; CI -> cluster remote)
PUBLISH=1 same as --publish
REGISTRY_TOKEN registry token (else `pass PASS_ENTRY`)
VERSION immutable semver-shaped tag (default 0.1.$CI_PIPELINE_NUMBER)
CI_COMMIT_BRANCH branch -> floating channel tag (default current git branch)
Examples:
ci/local.sh # dry-run, arm64, both via local buildkit
ci/local.sh --arch amd64 # dry-run, foreign arch via qemu (slow)
PUBLISH=1 ci/local.sh # real push of the arm64 image (3 tags)
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--arch)
TARGET_ARCH="${2:-}"
shift 2
;;
--publish)
PUBLISH=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
# ---- arch resolution --------------------------------------------------------
host_arch() {
case "$(uname -m)" in
x86_64) echo amd64 ;;
aarch64 | arm64) echo arm64 ;;
s390x) echo s390x ;;
*) echo "unknown" ;;
esac
}
HOST_ARCH="$(host_arch)"
# kotkan is arm64; the image is only ever deployed there.
[ -n "$TARGET_ARCH" ] || TARGET_ARCH="arm64"
case "$TARGET_ARCH" in
amd64 | arm64 | s390x) ;;
*)
echo "ERROR: unsupported --arch '$TARGET_ARCH' (want amd64|arm64|s390x)" >&2
exit 2
;;
esac
# ---- version + branch derivation --------------------------------------------
# $VERSION -> $VERSION env -> 0.1.$CI_PIPELINE_NUMBER -> 0.1.0-dev.
derive_version() {
if [ -n "${VERSION:-}" ]; then
printf '%s' "$VERSION"
return
fi
if [ -n "${CI_PIPELINE_NUMBER:-}" ]; then
printf '0.1.%s' "$CI_PIPELINE_NUMBER"
return
fi
printf '0.1.0-dev'
}
# $CI_COMMIT_BRANCH -> current git branch -> "dev".
derive_branch() {
if [ -n "${CI_COMMIT_BRANCH:-}" ]; then
printf '%s' "$CI_COMMIT_BRANCH"
return
fi
local b
if b="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" &&
[ -n "$b" ] && [ "$b" != "HEAD" ]; then
printf '%s' "$b"
return
fi
printf 'dev'
}
IMAGE_VERSION="$(derive_version)"
BRANCH="$(derive_branch)"
# ---- token resolution (never printed) ---------------------------------------
resolve_token() {
if [ -n "${REGISTRY_TOKEN:-}" ]; then
printf '%s' "$REGISTRY_TOKEN"
return 0
fi
if command -v pass >/dev/null 2>&1; then
local t
if t="$(pass "$PASS_ENTRY" 2>/dev/null)" && [ -n "$t" ]; then
printf '%s' "$t"
return 0
fi
fi
echo "BLOCKER: no registry token." >&2
echo " Set \$REGISTRY_TOKEN or store it at: pass $PASS_ENTRY" >&2
return 1
}
# ---- foreign-arch (qemu binfmt) gate ----------------------------------------
binfmt_handler_present() {
local q="$1"
[ -e "/proc/sys/fs/binfmt_misc/qemu-${q}" ]
}
qemu_name_for() {
case "$1" in
amd64) echo "x86_64" ;;
arm64) echo "aarch64" ;;
s390x) echo "s390x" ;;
esac
}
# Is BUILDKIT_ADDR the matching per-arch cluster builder (native, no qemu)?
builder_is_native_remote() {
case "$BUILDKIT_ADDR" in
tcp://*"buildkit-"*"${TARGET_ARCH}"*) return 0 ;;
*) return 1 ;;
esac
}
emulation_gate() {
if [ "$TARGET_ARCH" = "$HOST_ARCH" ]; then
return 0
fi
if builder_is_native_remote; then
echo "NOTE: target $TARGET_ARCH served NATIVELY by remote builder ($BUILDKIT_ADDR)."
return 0
fi
local q
q="$(qemu_name_for "$TARGET_ARCH")"
echo "================================================================"
echo " SLOW / ACCELERATOR NOTICE"
echo " Target arch '$TARGET_ARCH' != host '$HOST_ARCH'."
echo " This build runs under qemu-${q} binfmt emulation and will be"
echo " MUCH slower than a native build. For speed, point BUILDKIT_ADDR"
echo " at a native remote builder, e.g.:"
echo " BUILDKIT_ADDR=tcp://buildkit-rootless-${TARGET_ARCH}.infra.svc.cluster.local:1234"
echo "================================================================"
if ! binfmt_handler_present "$q"; then
echo "BLOCKER: cannot build '$TARGET_ARCH' locally — no qemu-${q} binfmt" >&2
echo " handler is registered with this kernel." >&2
echo " Fix one of:" >&2
echo " * register emulation: docker run --privileged --rm tonistiigi/binfmt --install ${TARGET_ARCH}" >&2
echo " * or use a native remote builder via BUILDKIT_ADDR (see notice above)." >&2
return 1
fi
return 0
}
# ---- buildx builder bootstrap ----------------------------------------------
BUILDER_NAME="cc-local-${TARGET_ARCH}"
ensure_builder() {
if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
return 0
fi
case "$BUILDKIT_ADDR" in
docker-container://*)
docker buildx create --name "$BUILDER_NAME" \
--driver docker-container >/dev/null
;;
tcp://*)
local endpoint="${BUILDKIT_ADDR#tcp://}"
docker buildx create --name "$BUILDER_NAME" \
--driver remote "tcp://${endpoint}" >/dev/null
;;
*)
echo "ERROR: unrecognized BUILDKIT_ADDR scheme: $BUILDKIT_ADDR" >&2
echo " want docker-container://... or tcp://host:port" >&2
exit 2
;;
esac
}
# ---- build / publish --------------------------------------------------------
# Builds the single repo-root Dockerfile and (on --publish) pushes the three
# contract tags onto it in one buildx invocation.
build_image() {
local image="${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
local -a args=(
docker buildx build
--builder "$BUILDER_NAME"
--platform "linux/${TARGET_ARCH}"
-f "${REPO_ROOT}/Dockerfile"
-t "${image}:${IMAGE_VERSION}"
-t "${image}:${BRANCH}"
-t "${image}:${BRANCH}-latest"
)
if [ "$PUBLISH" = "1" ]; then
args+=(--push)
echo ">> PUBLISH $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest"
else
echo ">> DRY-RUN $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest (build-only; pass --publish to push)"
fi
args+=("$REPO_ROOT")
"${args[@]}"
}
# ---- main -------------------------------------------------------------------
main() {
echo "repo : ${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
echo "host arch : ${HOST_ARCH}"
echo "target arch : ${TARGET_ARCH}"
echo "branch : ${BRANCH}"
echo "version tag : ${IMAGE_VERSION}"
echo "builder : ${BUILDKIT_ADDR}"
echo "mode : $([ "$PUBLISH" = "1" ] && echo PUBLISH || echo DRY-RUN)"
emulation_gate
if [ "$PUBLISH" = "1" ]; then
local token
token="$(resolve_token)"
echo "$token" | docker login "$REGISTRY_HOST" \
-u "$REGISTRY_USER" --password-stdin >/dev/null
unset token
fi
ensure_builder
build_image
echo "done."
}
main
+29
View File
@@ -50,3 +50,32 @@ The HelmRelease itself lives in the workloads repo because that repo is
the source of truth for what runs on the kotkanagrilli.fi subdomain
pool. Same convention as the existing `kotkanagrilli/` (legacy WP) and
`hello-kotkan/` entries there.
## Why two image automations share one branch
Both `cms-plugins-staging` and `cms-plugins-production` define an
`ImageUpdateAutomation` that checks out, commits to, and pushes the
**same** `main` branch of `anton-helm-workloads` on the same `interval: 1m`.
This is intentional and safe:
- Each automation is scoped to a disjoint `update.path`
(`./cms-plugins-staging` vs `./cms-plugins-production`), so they only ever
rewrite the digest setter inside their *own* `helmrelease.yaml`. They
never touch the same file.
- `strategy: Setters` rewrites only the explicitly marked digest setter, not
arbitrary YAML — there is no whole-file regeneration that could clobber a
sibling's change.
- The image-automation-controller serializes its git pushes and retries on
a non-fast-forward rejection, so two automations landing commits on `main`
in the same reconcile window resolve cleanly rather than racing.
This mirrors the per-env automations under
`~/projects/servers/fleet/apps/base/` for `emdash-kotkanagrilli-*`. The
only deviation (justified in `image-automation.yaml`) is that these reuse
the read-side `anton-helm-workloads` `GitRepository` as the write-back
`sourceRef` instead of a dedicated image-automation source, because these
workloads live in that same repo.
Note for go-live: nothing here reconciles while the HelmReleases are
`suspend: true` (Phase 0). These automations only begin writing back once
the releases are deliberately resumed.
@@ -32,6 +32,8 @@ spec:
# change when CI retags the floating `production` tag.
tag: production
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-production:digest"}
# digest-pinned below, so this is effectively a no-op (a digest is
# immutable); kept as Always to match the chart default.
pullPolicy: Always
ingress:
host: cms-plugins-production.kotkanagrilli.fi
@@ -32,6 +32,8 @@ spec:
# change when CI retags the floating `staging` tag.
tag: staging
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-staging:digest"}
# digest-pinned below, so this is effectively a no-op (a digest is
# immutable); kept as Always to match the chart default.
pullPolicy: Always
ingress:
host: cms-plugins-staging.kotkanagrilli.fi
+2
View File
@@ -69,6 +69,7 @@ spec:
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
@@ -76,6 +77,7 @@ spec:
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
+7 -2
View File
@@ -1,5 +1,10 @@
# Production overrides — applied via the FluxCD HelmRelease (or directly with
# `helm upgrade -f values-production.yaml`).
# Production overrides for DIRECT `helm upgrade -f values-production.yaml` use only.
#
# IMPORTANT: FluxCD does NOT read this file. The live production deploy is driven
# solely by the inline `spec.values:` block in
# deploy/fleet-overlay/cms-plugins-production/helmrelease.yaml (copied into
# anton-helm-workloads). Editing values here has NO effect on the cluster.
# Keep this file in sync with that HR `values:` block by hand, or it will drift.
image:
tag: production-latest
+7 -2
View File
@@ -1,5 +1,10 @@
# Staging overrides — applied via the FluxCD HelmRelease (or directly with
# `helm upgrade -f values-staging.yaml`).
# Staging overrides for DIRECT `helm upgrade -f values-staging.yaml` use only.
#
# IMPORTANT: FluxCD does NOT read this file. The live staging deploy is driven
# solely by the inline `spec.values:` block in
# deploy/fleet-overlay/cms-plugins-staging/helmrelease.yaml (copied into
# anton-helm-workloads). Editing values here has NO effect on the cluster.
# Keep this file in sync with that HR `values:` block by hand, or it will drift.
image:
tag: staging-latest
+25 -7
View File
@@ -1,13 +1,21 @@
# Defaults for the cms-plugins chart.
# Per-env overrides come from values-staging.yaml / values-production.yaml
# and from the FluxCD HelmRelease's `values:` block.
# Per-env overrides: Flux applies ONLY the HelmRelease `values:` block.
# values-staging.yaml / values-production.yaml are for direct `helm upgrade -f`
# use and are NOT read by Flux — keep them in sync with the HR by hand.
image:
repository: git.oleks.space/oleks/cms-plugins
tag: develop-latest
# The tag is a mutable floating pointer (CI retags <branch>-latest onto
# each new build), so kubelet must always re-pull — IfNotPresent would
# pin the node to whatever digest it cached first and never roll.
# `Always` is here for the chart-default FLOATING-TAG path: with no
# `digest` set, the image renders as `repository:<branch>-latest`
# (a mutable pointer CI retags onto each build), so kubelet must
# re-pull or it would pin to the first cached digest and never roll.
# NOTE: the deployed overlays pin by `digest` (repository@sha256:…),
# where a tag change instead changes the image *reference string*, so
# `helm upgrade` already detects it and `Always` is a no-op (a digest
# is content-addressed — it can never resolve to different bytes).
# `IfNotPresent` would be marginally better on the digest path but is
# left as `Always` so both render paths share one safe value.
pullPolicy: Always
service:
@@ -57,17 +65,27 @@ imagePullSecrets:
probes:
liveness:
# /_emdash/api/health requires auth (401 to unauthenticated requests),
# so kubelet probes fail and the pod gets killed. The site root is
# public and a 200 from it is a reasonable proxy for "the server is up".
# so we probe the public site root instead. But `/` is server-rendered
# and queries SQLite content, so a content/render or DB fault makes it
# 500 while the Node process is perfectly alive. Liveness must NOT
# crash-loop the single SQLite replica over a transient content/DB
# error: keep failureThreshold high so only a genuinely wedged process
# (sustained failures) triggers a restart. Readiness (below) is what
# sheds traffic on a content 500.
path: /
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 6
readiness:
# Probe the public site root. A content/render 500 here removes the pod
# from Endpoints (stops serving 500s) WITHOUT the kubelet killing the
# process — readiness failures never restart the container.
path: /
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
+21 -5
View File
@@ -4,11 +4,27 @@ set -eu
# Ensure persistent state dirs exist (volume may be empty on first boot).
mkdir -p /app/state/uploads
# Bootstrap on first run: create data.db and apply migrations.
# emdash init is expected to be idempotent on subsequent boots.
if [ ! -f /app/state/data.db ]; then
echo "[entrypoint] no data.db found in /app/state, running emdash init"
node_modules/.bin/emdash init
# Run emdash init + seed on EVERY boot, before exec'ing the server. Both are
# idempotent: `init` runs only pending migrations (no-op when all applied) and
# `seed` applies seed/seed.json with onConflict=skip (no-op once rows exist).
# `init` does NOT load JSON seeds — that moved to the separate `emdash seed`
# command — so without this seed step the catalog boots empty. Under `set -e` a
# non-zero exit aborts before `exec "$@"`, so a failed/partial init or seed
# surfaces as a crash-loop with logs instead of a silently half-set-up boot.
# (Gating on the mere presence of data.db would skip pending migrations on image
# upgrades against an existing PVC and never recover a partial first-run init.)
echo "[entrypoint] running emdash init (applies pending migrations)"
node_modules/.bin/emdash init
# Seed is best-effort: unlike migrations, a content-seed failure (e.g. a bad
# seed.json entry) must NOT crash-loop the whole site, so it is NOT under the
# `set -e` abort. We still surface a non-zero rc loudly in the logs rather than
# swallowing it silently.
echo "[entrypoint] running emdash seed (applies seed/seed.json, onConflict=skip)"
if node_modules/.bin/emdash seed; then
echo "[entrypoint] emdash seed ok"
else
echo "[entrypoint] WARNING: emdash seed failed (rc=$?) — serving without full seed" >&2
fi
exec "$@"