feat(cms): implement Git publishing for content changes and add entrypoint script

- Added a Docker entrypoint script to manage content repository cloning and updates.
- Updated server configuration to include public URL and proxy settings.
- Enhanced content file utilities to return file paths for written entries.
- Introduced GitPublisher class for handling Git operations and deployments.
- Added tests for Git publishing functionality.
- Updated Dockerfile and docker-compose for production setup.
- Added new public PGP key and security.txt for contact information.
- Created new About and Archive pages in the site.
- Updated site configuration to include new navigation links.
This commit is contained in:
2026-09-01 16:50:26 +03:00
parent ce65be8852
commit d958a298f7
24 changed files with 960 additions and 59 deletions
+6 -5
View File
@@ -1,11 +1,12 @@
/public
node_modules
**/node_modules
.git
manimations/*.*
/zola
/zola-v*.tar.gz
/artifacts
dist
.strapi
.tmp
build
**/dist
**/.astro
**/.strapi
**/.tmp
**/build
+95
View File
@@ -0,0 +1,95 @@
# Dokploy deployment
The production deployment is one Dokploy Compose application with two
services:
- `site` builds the Astro app and serves its static output with
`static-web-server`.
- `cms` runs Strapi with persistent SQLite/admin data and a persistent Git
checkout.
Markdown remains the source of truth. A Strapi edit writes into the checkout,
commits and pushes the changed Markdown (plus a generated bookshelf cover when
applicable), then calls Dokploy's deploy webhook. The redeploy builds Astro
from that new commit.
## GitHub token
Create a fine-grained personal access token restricted to the
`andwati/andwati.com` repository with:
- Repository contents: read and write
- Metadata: read (automatically included)
The configured publishing branch must accept direct pushes from the token.
Store the token only in Dokploy as `GITHUB_TOKEN`.
## Dokploy application
1. Create a Compose application from this repository and select
`docker-compose.prod.yml`.
2. Configure its source branch to match `CONTENT_GIT_BRANCH` (normally
`main`).
3. Route the public site domain to the `site` service on port `80`.
4. Route the CMS domain (for example `cms.andwati.com`) to the `cms` service
on port `1337`.
5. Copy the application's deploy webhook URL into
`DOKPLOY_DEPLOY_WEBHOOK_URL`.
6. Add the environment variables below and deploy.
Required secrets:
```dotenv
CMS_PUBLIC_URL=https://cms.andwati.com
APP_KEYS=<four comma-separated random values>
API_TOKEN_SALT=<random value>
ADMIN_JWT_SECRET=<random value>
TRANSFER_TOKEN_SALT=<random value>
JWT_SECRET=<random value>
ENCRYPTION_KEY=<random value>
GITHUB_TOKEN=<fine-grained GitHub token>
DOKPLOY_DEPLOY_WEBHOOK_URL=<Dokploy Compose deploy webhook>
```
Optional publishing settings:
```dotenv
CONTENT_GIT_REPOSITORY=andwati/andwati.com
CONTENT_GIT_BRANCH=main
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
[email protected]
```
Generate each random secret independently; do not reuse the local development
values from `docker-compose.yml`.
## Persistent data
The production Compose file declares three named volumes:
- `cms_database` keeps Strapi's admin users, API tokens, and SQLite cache.
- `cms_uploads` keeps files uploaded through Strapi.
- `content_checkout` keeps the Git working tree used for publishing.
Back up `cms_database` and `cms_uploads`. The content checkout is recoverable
from Git, but retaining it protects an edit if the process stops between the
filesystem write and Git push.
On startup, the CMS fast-forwards a clean checkout to the configured branch.
If it finds uncommitted files, it preserves them and starts without pulling so
an interrupted edit is not overwritten. Resolve and push those files from the
volume before forcing a reset.
## Local validation
Provide temporary values for required interpolation variables, then validate
the production manifest:
```sh
docker compose --env-file .env.production \
-f docker-compose.prod.yml config
docker compose --env-file .env.production \
-f docker-compose.prod.yml build
```
The existing `docker-compose.yml` remains the local Strapi development setup.
+14 -15
View File
@@ -1,21 +1,20 @@
# Multi-stage build: generate llms.txt with Node, build the site with Zola,
# then serve the static output with static-web-server.
#
# This is the Dokploy/self-hosted deployment path (the only one - the prior
# Cloudflare Pages/Workers path has been retired).
# Production image for the Astro site. Build from the workspace root:
# docker build -t andwati-site .
FROM node:22-slim AS llms
WORKDIR /project
COPY . .
RUN node scripts/generate-llms.mjs
FROM node:24-bookworm-slim AS build
RUN corepack enable
WORKDIR /app
FROM ghcr.io/getzola/zola:v0.23.1 AS zola
WORKDIR /project
COPY . .
COPY --from=llms /project/static/llms.txt static/llms.txt
RUN ["/zola", "build"]
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/site/package.json apps/site/package.json
COPY apps/cms/package.json apps/cms/package.json
COPY apps/site apps/site
COPY content content
COPY scripts scripts
RUN pnpm install --frozen-lockfile --filter andwati-com --filter site
RUN pnpm --filter site build
FROM ghcr.io/static-web-server/static-web-server:2
COPY --from=zola /project/public /public
COPY --from=build /app/apps/site/dist /public
COPY sws.toml /sws.toml
EXPOSE 80
+17 -12
View File
@@ -164,10 +164,12 @@ approved plan for full rationale on each decision referenced below.
`from-c-to-machine-code`
- [x] Preserve slugs/URLs via redirects — the script generates 301s from
`/posts/<slug>/` to `/writings/<slug>/` into `sws.toml`, idempotently
- [ ] Migrate `content/archive/`, `content/about.md` equivalents — holding
off: the Astro site doesn't have archive/about pages built yet, and
"about" copy/framing is exactly the kind of thing you'd want to
review/rewrite for the new "digital legacy" identity, not port as-is
- [x] Migrate `content/archive/`, `content/about.md` equivalents —
`/archive/` groups every published writing by year; `/about/` reframes
the legacy copy around the site's "digital legacy" identity while
preserving its PGP and security contact details. Both are in the main
navigation, and the linked `.well-known` files are copied into Astro's
public output
- [ ] Verify RSS subscribers and inbound links survive the cutover — can
only really be checked once this is deployed live (**needs your
input**: this is a production-cutover verification, not a build-time
@@ -175,14 +177,17 @@ approved plan for full rationale on each decision referenced below.
## Infra / deployment
- [ ] Wire Strapi into the Dokploy deployment alongside the Astro build —
**needs your input**: requires your Dokploy access
- [ ] Decide build trigger: webhook on Strapi publish → CI rebuild, vs.
manual/scheduled rebuild — **needs your input**: your call, not an
engineering constraint either way
- [ ] Update `Dockerfile`/`sws.toml` for the new build output (currently
Zola-specific) — holding off until the migration/cutover is actually
ready to ship, so this doesn't drift out of sync in the meantime
- [x] Wire Strapi into the Dokploy deployment alongside the Astro build —
`docker-compose.prod.yml` defines both production services, persistent
CMS/Git volumes, reverse-proxy settings, and secret-only configuration
- [x] Build trigger decided and implemented: Strapi commits Markdown and
generated covers to Git, pushes the production branch, then calls the
Dokploy Compose deploy webhook so Astro rebuilds from the durable commit
- [x] Update `Dockerfile`/`sws.toml` for Astro's `apps/site/dist` output,
current security headers, legacy redirects, and the custom 404 page
- [ ] Launch the production Compose application and configure its domains,
secrets, GitHub token, and deploy webhook in Dokploy — repository-side
setup is complete in `DEPLOYMENT.md`; host access is still required
## Cleanup
+13
View File
@@ -6,3 +6,16 @@ ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified
# Production reverse-proxy and Git publishing settings. Git publishing stays
# disabled during local development unless explicitly enabled.
PUBLIC_URL=http://localhost:1337
IS_PROXIED=false
GIT_PUBLISH_ENABLED=false
CONTENT_REPOSITORY_ROOT=
CONTENT_GIT_REPOSITORY=andwati/andwati.com
CONTENT_GIT_BRANCH=main
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
CONTENT_GIT_COMMIT_EMAIL=[email protected]
GITHUB_TOKEN=
DOKPLOY_DEPLOY_WEBHOOK_URL=
+5 -1
View File
@@ -26,8 +26,12 @@ RUN pnpm --filter cms build
FROM base AS runtime
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/cms /app/apps/cms
COPY --chmod=755 apps/cms/docker-entrypoint.sh /usr/local/bin/andwati-cms-entrypoint
WORKDIR /app/apps/cms
EXPOSE 1337
CMD ["pnpm", "start"]
ENTRYPOINT ["andwati-cms-entrypoint"]
+2
View File
@@ -5,6 +5,8 @@ const config = ({
}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
url: env("PUBLIC_URL", "http://localhost:1337"),
proxy: env.bool("IS_PROXIED", false),
app: {
keys: env.array("APP_KEYS")!,
},
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
repository_root="${CONTENT_REPOSITORY_ROOT:-/content-repository}"
repository="${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}"
branch="${CONTENT_GIT_BRANCH:-main}"
case "$repository" in
*[!A-Za-z0-9._/-]* | /* | */ | *..*)
echo "Invalid CONTENT_GIT_REPOSITORY: $repository" >&2
exit 1
;;
esac
mkdir -p "$repository_root"
if [ ! -d "$repository_root/.git" ]; then
if [ -n "$(find "$repository_root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "$repository_root is not empty and is not a Git checkout" >&2
exit 1
fi
git clone \
--branch "$branch" \
--single-branch \
"https://github.com/$repository.git" \
"$repository_root"
else
git -C "$repository_root" fetch origin "$branch"
git -C "$repository_root" checkout "$branch"
if [ -z "$(git -C "$repository_root" status --porcelain)" ]; then
git -C "$repository_root" merge --ff-only "origin/$branch"
else
echo "Content checkout has unpublished changes; preserving them without pulling." >&2
fi
fi
export CONTENT_REPOSITORY_ROOT="$repository_root"
exec node node_modules/@strapi/strapi/bin/strapi.js start
+2
View File
@@ -13,6 +13,7 @@
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"test:publisher": "tsx --test tests/git-publisher.test.ts",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
@@ -32,6 +33,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"tsx": "^4.23.13",
"typescript": "^5"
},
"engines": {
+2 -1
View File
@@ -1,12 +1,13 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { coversRoot } from "./content-paths";
/**
* Covers are cached into the Astro app's public/ dir directly, so the built
* site serves them from /covers/<slug>.jpg with no runtime dependency on the
* source (Open Library, Semantic Scholar, ...).
*/
const COVERS_DIR = join(process.cwd(), "..", "site", "public", "covers");
const COVERS_DIR = coversRoot();
async function download(url: string): Promise<Buffer | undefined> {
const res = await fetch(url);
+4 -2
View File
@@ -107,11 +107,12 @@ export function writeEntry(
layout: "nested" | "flat",
frontmatter: Record<string, unknown>,
body: string,
): void {
): string {
const file = entryPath(dir, slug, layout);
mkdirSync(dirname(file), { recursive: true });
const toml = stringifyToml(compact(frontmatter)).trimEnd();
writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`);
return file;
}
/** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */
@@ -119,9 +120,10 @@ export function deleteEntry(
dir: string,
slug: string,
layout: "nested" | "flat",
): void {
): string {
const file = entryPath(dir, slug, layout);
rmSync(file, { force: true });
if (layout === "nested")
rmSync(join(dir, slug), { recursive: true, force: true });
return file;
}
+22
View File
@@ -0,0 +1,22 @@
import { isAbsolute, join, resolve } from "node:path";
/**
* Root of the Git checkout that owns content/ and apps/site/public/covers/.
* Production points this at the persistent checkout prepared by the
* container entrypoint; local development falls back to the workspace root.
*/
export function repositoryRoot(): string {
const configured = process.env.CONTENT_REPOSITORY_ROOT;
if (!configured) return resolve(process.cwd(), "..", "..");
return isAbsolute(configured)
? configured
: resolve(process.cwd(), configured);
}
export function contentRoot(): string {
return join(repositoryRoot(), "content");
}
export function coversRoot(): string {
return join(repositoryRoot(), "apps", "site", "public", "covers");
}
+51 -15
View File
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { rmSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import {
cacheRemoteImage,
fetchBookCover,
@@ -10,6 +11,8 @@ import {
toTomlDate,
writeEntry,
} from "./content-file";
import { contentRoot, coversRoot } from "./content-paths";
import { publishContentChanges } from "./git-publisher";
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
@@ -39,11 +42,6 @@ const isoDate = (value: unknown): string | undefined =>
? value
: undefined;
function contentRoot(): string {
// apps/cms -> repo root/content (also the mount point in the dev container)
return join(process.cwd(), "..", "..", "content");
}
function buildConfigs(): Record<string, SyncConfig> {
const root = contentRoot();
@@ -209,11 +207,28 @@ let importing = false;
function writeEntryForConfig(
config: SyncConfig,
entry: Record<string, unknown>,
): void {
): string | undefined {
const slug = entry.slug as string | undefined;
if (!slug) return;
if (!slug) return undefined;
const { frontmatter, body } = config.toFile(entry);
writeEntry(config.dir, slug, config.layout, frontmatter, body);
return writeEntry(config.dir, slug, config.layout, frontmatter, body);
}
function coverFileForEntry(
entry: Record<string, unknown> | null,
): string | undefined {
const coverImage = entry?.cover_image;
if (typeof coverImage !== "string" || !coverImage.startsWith("/covers/")) {
return undefined;
}
const root = resolve(coversRoot());
const file = resolve(root, coverImage.slice("/covers/".length));
const fromRoot = relative(root, file);
if (fromRoot === ".." || fromRoot.startsWith("../")) {
throw new Error(`Refusing unsafe local cover path: ${coverImage}`);
}
return file;
}
/**
@@ -325,6 +340,9 @@ async function enrichBookshelfEntry(
}
if (Object.keys(patch).length === 0) return entry;
// Keep the nested update outside the network-failure guard. Its middleware
// persists and publishes the enriched entry; publication errors must reach
// the caller instead of being mistaken for a cover-provider outage.
return documents(BOOKSHELF_UID).update({
documentId: entry.documentId as string,
data: patch,
@@ -348,14 +366,13 @@ export function registerContentSync(strapi: {
const config = SYNC_CONFIGS[ctx.uid];
if (!config || importing) return next();
let preDeleteSlug: string | undefined;
let preDeleteEntry: Record<string, unknown> | null = null;
if (ctx.action === "delete") {
const documentId = (ctx.params as { documentId?: string }).documentId;
if (documentId) {
const existing = await strapi
preDeleteEntry = await strapi
.documents(ctx.uid)
.findOne({ documentId });
preDeleteSlug = existing?.slug as string | undefined;
}
}
@@ -374,14 +391,33 @@ export function registerContentSync(strapi: {
result as Record<string, unknown>,
)
: (result as Record<string, unknown>);
writeEntryForConfig(config, entry);
} else if (ctx.action === "delete" && preDeleteSlug) {
deleteEntry(config.dir, preDeleteSlug, config.layout);
const entryFile = writeEntryForConfig(config, entry);
if (entryFile) {
const paths = [entryFile];
const coverFile = coverFileForEntry(entry);
if (coverFile) paths.push(coverFile);
await publishContentChanges(
paths,
`content: ${ctx.action} ${entry.slug as string}`,
);
}
} else if (ctx.action === "delete" && preDeleteEntry) {
const slug = preDeleteEntry.slug as string | undefined;
if (!slug) return result;
const paths = [deleteEntry(config.dir, slug, config.layout)];
const coverFile = coverFileForEntry(preDeleteEntry);
if (coverFile) {
rmSync(coverFile, { force: true });
paths.push(coverFile);
}
await publishContentChanges(paths, `content: delete ${slug}`);
}
} catch (err) {
strapi.log.error(
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
);
throw err;
}
return result;
+186
View File
@@ -0,0 +1,186 @@
import { execFile } from "node:child_process";
import { relative, resolve } from "node:path";
import { promisify } from "node:util";
import { repositoryRoot } from "./content-paths";
const execFileAsync = promisify(execFile);
interface GitPublisherConfig {
worktree: string;
remote?: string;
branch?: string;
token?: string;
commitName?: string;
commitEmail?: string;
deployWebhookUrl?: string;
fetchImpl?: typeof fetch;
}
function gitAuthenticationEnv(token?: string): NodeJS.ProcessEnv {
if (!token) return process.env;
const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
return {
...process.env,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
};
}
export class GitPublisher {
private readonly worktree: string;
private readonly remote: string;
private readonly branch: string;
private readonly token?: string;
private readonly commitName: string;
private readonly commitEmail: string;
private readonly deployWebhookUrl?: string;
private readonly fetchImpl: typeof fetch;
private queue: Promise<void> = Promise.resolve();
private deployPending = false;
constructor(config: GitPublisherConfig) {
this.worktree = resolve(config.worktree);
this.remote = config.remote ?? "origin";
this.branch = config.branch ?? "main";
this.token = config.token;
this.commitName = config.commitName ?? "andwati.com CMS";
this.commitEmail =
config.commitEmail ?? "[email protected]";
this.deployWebhookUrl = config.deployWebhookUrl;
this.fetchImpl = config.fetchImpl ?? fetch;
}
publish(paths: string[], message: string): Promise<void> {
const operation = this.queue.then(() => this.publishNow(paths, message));
this.queue = operation.catch(() => undefined);
return operation;
}
private async git(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: this.worktree,
env: gitAuthenticationEnv(this.token),
timeout: 30_000,
});
return stdout.trim();
}
private repositoryPaths(paths: string[]): string[] {
return [...new Set(paths)].map((path) => {
const absolute = resolve(path);
const repositoryPath = relative(this.worktree, absolute);
if (
repositoryPath === "" ||
repositoryPath === ".." ||
repositoryPath.startsWith("../")
) {
throw new Error(
`Refusing to publish path outside Git checkout: ${path}`,
);
}
return repositoryPath;
});
}
private async hasStagedChanges(): Promise<boolean> {
try {
await this.git(["diff", "--cached", "--quiet"]);
return false;
} catch (error) {
if ((error as { code?: number }).code === 1) return true;
throw error;
}
}
private async publishNow(paths: string[], message: string): Promise<void> {
const repositoryPaths = this.repositoryPaths(paths);
await this.git(["add", "--all", "--", ...repositoryPaths]);
if (await this.hasStagedChanges()) {
await this.git([
"-c",
`user.name=${this.commitName}`,
"-c",
`user.email=${this.commitEmail}`,
"-c",
"commit.gpgsign=false",
"commit",
"-m",
message,
]);
}
await this.git(["fetch", this.remote, this.branch]);
try {
await this.git([
"rebase",
"--autostash",
`${this.remote}/${this.branch}`,
]);
} catch (error) {
await this.git(["rebase", "--abort"]).catch(() => undefined);
throw error;
}
const ahead = Number(
await this.git([
"rev-list",
"--count",
`${this.remote}/${this.branch}..HEAD`,
]),
);
if (ahead > 0) {
await this.git(["push", this.remote, `HEAD:${this.branch}`]);
this.deployPending = true;
}
if (this.deployPending && this.deployWebhookUrl) {
const response = await this.fetchImpl(this.deployWebhookUrl, {
method: "POST",
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(
`Dokploy deploy webhook returned ${response.status} ${response.statusText}`,
);
}
this.deployPending = false;
}
}
}
let publisher: GitPublisher | undefined;
function publisherFromEnvironment(): GitPublisher | undefined {
if (process.env.GIT_PUBLISH_ENABLED !== "true") return undefined;
const token = process.env.GITHUB_TOKEN;
const deployWebhookUrl = process.env.DOKPLOY_DEPLOY_WEBHOOK_URL;
if (!token) throw new Error("GITHUB_TOKEN is required for Git publishing");
if (!deployWebhookUrl) {
throw new Error(
"DOKPLOY_DEPLOY_WEBHOOK_URL is required for Git publishing",
);
}
return new GitPublisher({
worktree: repositoryRoot(),
remote: process.env.CONTENT_GIT_REMOTE ?? "origin",
branch: process.env.CONTENT_GIT_BRANCH ?? "main",
token,
commitName: process.env.CONTENT_GIT_COMMIT_NAME,
commitEmail: process.env.CONTENT_GIT_COMMIT_EMAIL,
deployWebhookUrl,
});
}
/** Publishes changed source files when production Git publishing is enabled. */
export async function publishContentChanges(
paths: string[],
message: string,
): Promise<void> {
publisher ??= publisherFromEnvironment();
await publisher?.publish(paths, message);
}
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { GitPublisher } from "../src/utils/git-publisher.ts";
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
function repositoryFixture(): {
root: string;
worktree: string;
cleanup: () => void;
} {
const root = mkdtempSync(join(tmpdir(), "andwati-git-publisher-"));
const remote = join(root, "remote.git");
const worktree = join(root, "worktree");
git(root, "init", "--bare", remote);
git(root, "clone", remote, worktree);
writeFileSync(join(worktree, "README.md"), "seed\n");
git(worktree, "add", "README.md");
git(
worktree,
"-c",
"user.name=Test",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"seed",
);
git(worktree, "branch", "-M", "main");
git(worktree, "push", "-u", "origin", "main");
return {
root,
worktree,
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}
test("commits, pushes, and deploys a changed source file", async () => {
const fixture = repositoryFixture();
let deploys = 0;
const publisher = new GitPublisher({
worktree: fixture.worktree,
branch: "main",
deployWebhookUrl: "https://deploy.invalid/hook",
fetchImpl: async () => {
deploys += 1;
return new Response(null, { status: 200 });
},
});
try {
const contentFile = join(fixture.worktree, "content", "post.md");
mkdirSync(join(fixture.worktree, "content"));
writeFileSync(contentFile, "published\n", { flag: "w" });
const concurrentFile = join(fixture.worktree, "content", "next.md");
writeFileSync(concurrentFile, "still being edited\n");
await publisher.publish([contentFile], "content: update post");
assert.equal(
git(fixture.worktree, "show", "origin/main:content/post.md"),
"published",
);
assert.equal(deploys, 1);
assert.equal(readFileSync(concurrentFile, "utf8"), "still being edited\n");
assert.match(git(fixture.worktree, "status", "--short"), /next\.md/);
await publisher.publish([contentFile], "content: update post");
assert.equal(deploys, 1, "unchanged content must not trigger a deploy");
} finally {
fixture.cleanup();
}
});
test("retries a pending deploy without creating another commit", async () => {
const fixture = repositoryFixture();
let deploys = 0;
const publisher = new GitPublisher({
worktree: fixture.worktree,
branch: "main",
deployWebhookUrl: "https://deploy.invalid/hook",
fetchImpl: async () => {
deploys += 1;
return new Response(null, {
status: deploys === 1 ? 503 : 200,
statusText: deploys === 1 ? "Unavailable" : "OK",
});
},
});
try {
const contentFile = join(fixture.worktree, "entry.md");
writeFileSync(contentFile, "durable\n");
await assert.rejects(
publisher.publish([contentFile], "content: create entry"),
/Dokploy deploy webhook returned 503/,
);
await publisher.publish([contentFile], "content: create entry");
assert.equal(deploys, 2);
assert.equal(
git(fixture.worktree, "rev-list", "--count", "origin/main"),
"2",
"the webhook retry must not create a duplicate commit",
);
} finally {
fixture.cleanup();
}
});
+13
View File
@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEalcjtBYJKwYBBAHaRw8BAQdA55X1mRCKw9DA35kCAy+7Z30ye0rUHWBZsl7O
Wmar+RS0IklhbiBBbmR3YXRpIDxhbmR3YXRpaWFuQGdtYWlsLmNvbT6IkwQTFgoA
OxYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsDBQsJCAcCAiICBhUKCQgL
AgQWAgMBAh4HAheAAAoJEB8g/tCqBbDace8BAN1SpkEMSYll+XupKHiaIUwnhm4N
eS79G1i+345Gl6CyAQDjgn+3rzG6sexjKh+10EKiy5omsQEGzUW03s7gS0T1Drg4
BGpXI7QSCisGAQQBl1UBBQEBB0CXofBIYYXG2+dZqgllhsbkNPF4qlyfPgbRW+zb
7Kw3IAMBCAeIeAQYFgoAIBYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsM
AAoJEB8g/tCqBbDauMkA/RG4iqtPZOI3DQzFJYu7SnxaD81vdP0LyTLKDQkynySu
AQDrlWyF8LRlad4iX1yQPCuOV0aUpTNq5kPlWurtX99/BQ==
=uKsS
-----END PGP PUBLIC KEY BLOCK-----
+17
View File
@@ -0,0 +1,17 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Contact: mailto:[email protected]
Expires: 2027-07-16T00:00:00.000Z
Encryption: https://andwati.com/.well-known/pgp-key.txt
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/AD58AAB7A20D8523CBF59A791F20FED0AA05B0DA
Encryption: https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xAD58AAB7A20D8523CBF59A791F20FED0AA05B0DA
Preferred-Languages: en
Canonical: https://andwati.com/.well-known/security.txt
-----BEGIN PGP SIGNATURE-----
iHUEARYKAB0WIQStWKq3og2FI8v1mnkfIP7QqgWw2gUCaljXlQAKCRAfIP7QqgWw
2qAoAP0Q7Cc0YsP2dvV7fqDlqcnjuJepQQSTZBz7ehkPyjGV5gEA3K9ZqQmuI2ea
KKADWxuKTTY43PBmuV9rMqLm8ZOPlAY=
=NUkY
-----END PGP SIGNATURE-----
+146
View File
@@ -0,0 +1,146 @@
---
import Base from "../layouts/Base.astro";
import { siteConfig } from "../site.config";
---
<Base
title="About"
description="About Ian Andwati, the software engineer behind Thoughts and Musings."
>
<article class="about">
<header>
<p class="eyebrow">About this place</p>
<p class="lede">
I’m Ian Andwati, a software engineer. This site is my durable record of
what I learn, build, read, and find worth returning to.
</p>
</header>
<section>
<p>
I spend my free time on hobby projects and occasionally write about the
experience. I’m especially interested in offensive security, reverse
engineering, computer science, and mathematics.
</p>
<p>
The <a href="/writings/">writings</a> hold longer-form notes, the
<a href="/portfolio/">portfolio</a> records finished work, and the
<a href="/bookshelf/">bookshelf</a> keeps the sources that shaped both. The
<a href="/archive/">archive</a> is the unfiltered chronology.
</p>
</section>
<section class="contact">
<div>
<p class="eyebrow">Contact</p>
<h2>Say hello</h2>
</div>
<p>
Email is the most reliable way to reach me:
<a href={`mailto:${siteConfig.social.email}`}
>{siteConfig.social.email}</a
>. You can also find me on
<a href={siteConfig.social.github} rel="me noopener" target="_blank"
>GitHub</a
>.
</p>
</section>
<section>
<p class="eyebrow">For sensitive messages</p>
<h2>PGP</h2>
<p>
My public key is available as a <a href="/.well-known/pgp-key.txt"
>plain-text download</a
>
and on
<a
href="https://keys.openpgp.org/search?q=AD58AAB7A20D8523CBF59A791F20FED0AA05B0DA"
rel="noopener"
target="_blank">keys.openpgp.org</a
>. The signed <a href="/.well-known/security.txt">security.txt</a> lists the
same contact and encryption details.
</p>
<div class="fingerprint" aria-label="PGP fingerprint">
<span>Fingerprint</span>
<code>AD58 AAB7 A20D 8523 CBF5 9A79 1F20 FED0 AA05 B0DA</code>
</div>
</section>
</article>
</Base>
<style>
.about > header {
margin-bottom: var(--space-8);
}
.about > header .eyebrow {
margin-bottom: var(--space-2);
}
.lede {
max-width: 36rem;
color: var(--text-muted);
font-size: var(--font-size-xl);
line-height: 1.45;
}
section {
padding-block: var(--space-6);
border-top: 1px solid var(--border);
}
section p:last-child {
margin-bottom: 0;
}
section a {
text-decoration-color: var(--border);
text-underline-offset: 0.2em;
}
section a:hover {
color: var(--accent);
text-decoration-color: var(--accent);
}
.contact {
display: grid;
grid-template-columns: minmax(8rem, 1fr) 2fr;
gap: var(--space-6);
}
.contact .eyebrow {
margin-bottom: var(--space-2);
}
.fingerprint {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
overflow-x: auto;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.fingerprint span {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.fingerprint code {
white-space: nowrap;
}
@media (max-width: 34rem) {
.contact {
grid-template-columns: 1fr;
gap: 0;
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
const writings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const writingsByYear = Map.groupBy(writings, (entry) =>
entry.data.date.getFullYear(),
);
---
<Base
title="Archive"
description="A chronological archive of every published writing on Thoughts and Musings."
>
<header class="archive-header">
<h1>Archive</h1>
</header>
<div class="archive">
{
[...writingsByYear].map(([year, entries]) => (
<section class="archive-year" aria-labelledby={`year-${year}`}>
<h2 id={`year-${year}`}>{year}</h2>
<ol>
{entries.map((entry) => (
<li>
<time datetime={entry.data.date.toISOString().slice(0, 10)}>
{entry.data.date.toLocaleDateString("en", {
day: "2-digit",
month: "short",
})}
</time>
<a href={`/writings/${entry.id}/`}>{entry.data.title}</a>
</li>
))}
</ol>
</section>
))
}
</div>
</Base>
<style>
.archive-header {
margin-bottom: var(--space-7);
}
.archive-header .eyebrow {
margin-bottom: var(--space-2);
}
.archive-header p:last-child {
color: var(--text-muted);
}
.archive-year {
display: grid;
grid-template-columns: 5rem 1fr;
gap: var(--space-5);
padding-block: var(--space-5);
border-top: 1px solid var(--border);
}
.archive-year h2 {
position: sticky;
top: var(--space-5);
align-self: start;
margin: 0;
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-style: normal;
letter-spacing: 0.08em;
}
ol {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: grid;
grid-template-columns: 4.5rem 1fr;
gap: var(--space-3);
align-items: baseline;
}
time {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
text-transform: uppercase;
}
li a {
text-decoration-color: var(--border);
text-underline-offset: 0.2em;
}
li a:hover {
color: var(--accent);
text-decoration-color: var(--accent);
}
@media (max-width: 30rem) {
.archive-year {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.archive-year h2 {
position: static;
}
}
</style>
+2
View File
@@ -12,9 +12,11 @@ export const siteConfig = {
"Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.",
nav: [
{ name: "Writings", url: "/writings/" },
{ name: "Archive", url: "/archive/" },
{ name: "Portfolio", url: "/portfolio/" },
{ name: "Bookshelf", url: "/bookshelf/" },
{ name: "Blogs", url: "/blogs/" },
{ name: "About", url: "/about/" },
],
social: {
github: "https://github.com/andwati",
+50
View File
@@ -0,0 +1,50 @@
name: andwati-production
services:
site:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
expose:
- "80"
cms:
build:
context: .
dockerfile: apps/cms/Dockerfile
restart: unless-stopped
init: true
expose:
- "1337"
environment:
HOST: 0.0.0.0
PORT: "1337"
NODE_ENV: production
PUBLIC_URL: ${CMS_PUBLIC_URL:?Set CMS_PUBLIC_URL in Dokploy}
IS_PROXIED: "true"
APP_KEYS: ${APP_KEYS:?Set APP_KEYS in Dokploy}
API_TOKEN_SALT: ${API_TOKEN_SALT:?Set API_TOKEN_SALT in Dokploy}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:?Set ADMIN_JWT_SECRET in Dokploy}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT:?Set TRANSFER_TOKEN_SALT in Dokploy}
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in Dokploy}
DATABASE_CLIENT: sqlite
DATABASE_FILENAME: .tmp/data.db
CONTENT_REPOSITORY_ROOT: /content-repository
CONTENT_GIT_REPOSITORY: ${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}
CONTENT_GIT_BRANCH: ${CONTENT_GIT_BRANCH:-main}
CONTENT_GIT_COMMIT_NAME: ${CONTENT_GIT_COMMIT_NAME:-andwati.com CMS}
CONTENT_GIT_COMMIT_EMAIL: ${CONTENT_GIT_COMMIT_EMAIL:[email protected]}
GIT_PUBLISH_ENABLED: "true"
GITHUB_TOKEN: ${GITHUB_TOKEN:?Set a GitHub contents-write token in Dokploy}
DOKPLOY_DEPLOY_WEBHOOK_URL: ${DOKPLOY_DEPLOY_WEBHOOK_URL:?Set the Dokploy deploy webhook URL}
volumes:
- cms_database:/app/apps/cms/.tmp
- cms_uploads:/app/apps/cms/public/uploads
- content_checkout:/content-repository
volumes:
cms_database:
cms_uploads:
content_checkout:
+1
View File
@@ -2,6 +2,7 @@
"name": "andwati-com",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"scripts": {
"dev": "concurrently -n site,cms -c blue,magenta \"pnpm --filter site dev\" \"pnpm --filter cms develop\"",
"dev:site": "pnpm --filter site dev",
+22 -7
View File
@@ -75,6 +75,9 @@ importers:
'@types/react-dom':
specifier: ^18
version: 18.3.7(@types/[email protected])
tsx:
specifier: ^4.23.13
version: 4.23.13
typescript:
specifier: ^5
version: 5.9.3
@@ -98,7 +101,7 @@ importers:
version: 5.3.0
astro:
specifier: ^7.2.9
version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])
version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])
highlight.js:
specifier: ^11.12.0
version: 11.12.0
@@ -7231,6 +7234,11 @@ packages:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}
[email protected]:
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@@ -12109,7 +12117,7 @@ snapshots:
dependencies:
tslib: 2.8.1
[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected]):
[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]):
dependencies:
'@astrojs/compiler-rs': 0.4.0(@emnapi/[email protected])(@emnapi/[email protected])
'@astrojs/internal-helpers': 0.10.4
@@ -12159,8 +12167,8 @@ snapshots:
ultrahtml: 1.7.0
unifont: 0.7.5
unstorage: 1.17.5
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])
vitefu: 1.1.3([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vitefu: 1.1.3([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
zod: 4.4.3
@@ -16279,6 +16287,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
[email protected]:
dependencies:
safe-buffer: 5.2.1
@@ -16516,7 +16530,7 @@ snapshots:
lightningcss: 1.33.0
terser: 5.51.2
[email protected](@types/[email protected])([email protected])([email protected])([email protected]):
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -16528,11 +16542,12 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
terser: 5.51.2
tsx: 4.23.13
yaml: 2.9.0
[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
optionalDependencies:
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
[email protected](@volar/[email protected]([email protected])):
dependencies:
+4 -1
View File
@@ -1,9 +1,12 @@
[general]
page404 = "./404.html"
[advanced]
[[advanced.headers]]
source = "**"
[advanced.headers.headers]
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://utteranc.es https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.google-analytics.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://cloudflareinsights.com; frame-src https://www.youtube-nocookie.com https://utteranc.es; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"