scripts/new-post.mjs writes schema-valid posts from flags or a JSON event (the IaC publish seam). Gitea Actions workflow: ci check, audit-ci gate, build, dist scan, CycloneDX SBOM, buildah build+push, and a least-privilege digest-bump PR to home-ops (never auto-merged). Renovate + audit allowlist.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json",
|
||||||
|
"high": true,
|
||||||
|
"critical": true,
|
||||||
|
"report-type": "summary",
|
||||||
|
"allowlist": [
|
||||||
|
"GHSA-gv7w-rqvm-qjhr"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# CI: lint → typecheck → audit gate → build → scan → SBOM → image → digest-bump PR.
|
||||||
|
# Runs on a self-hosted act_runner (dedicated unprivileged user on the bastion;
|
||||||
|
# host mode, has node 22 + buildah + git). Registered against git.bztmon.com.
|
||||||
|
#
|
||||||
|
# Required repo secrets (set at go-live):
|
||||||
|
# REGISTRY_USER / REGISTRY_TOKEN - push to git.bztmon.com/jwright/bztmon-site (package:write)
|
||||||
|
# HOME_OPS_SSH_KEY - deploy key, write to home-ops ONLY
|
||||||
|
# GITEA_TOKEN - open a PR on the private instance
|
||||||
|
name: build-and-deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "public/**"
|
||||||
|
- "astro.config.mjs"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "nginx/**"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install (from lockfile)
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Dependency audit gate (high/critical)
|
||||||
|
run: npx audit-ci --config .audit-ci.json
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Security scan (dist)
|
||||||
|
run: npm run scan
|
||||||
|
|
||||||
|
- name: SBOM (CycloneDX)
|
||||||
|
run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json
|
||||||
|
|
||||||
|
- name: Upload SBOM
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: sbom
|
||||||
|
path: sbom.json
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | buildah login -u "${{ secrets.REGISTRY_USER }}" --password-stdin git.bztmon.com
|
||||||
|
|
||||||
|
- name: Build & push image
|
||||||
|
run: scripts/build-image.sh push
|
||||||
|
|
||||||
|
- name: Open digest-bump PR to home-ops
|
||||||
|
env:
|
||||||
|
HOME_OPS_SSH_KEY: ${{ secrets.HOME_OPS_SSH_KEY }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: scripts/bump-digest.sh
|
||||||
@@ -22,3 +22,6 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# CI artifact
|
||||||
|
sbom.json
|
||||||
|
|||||||
@@ -46,8 +46,46 @@ Dockerfile Debian build stage → nginx-unprivileged runtime
|
|||||||
- Fill the `TODO(Jonathon)` markers in `src/data/experience.ts`, `projects.ts`, `socials.ts`
|
- Fill the `TODO(Jonathon)` markers in `src/data/experience.ts`, `projects.ts`, `socials.ts`
|
||||||
(employer names, dates, GitHub/LinkedIn handles).
|
(employer names, dates, GitHub/LinkedIn handles).
|
||||||
|
|
||||||
|
## Publishing a post
|
||||||
|
|
||||||
|
A post is just a Markdown file in `src/content/blog/`. Write one by hand, or generate
|
||||||
|
a schema-valid one with the publish helper (this is the seam an IaC/CI step calls):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from flags
|
||||||
|
node scripts/new-post.mjs --title "My post" --summary "One line" \
|
||||||
|
--tags "kubernetes,gpu" [--draft] [--bodyFile notes.md]
|
||||||
|
|
||||||
|
# from a JSON event (e.g. an Ansible/AWX deploy summary)
|
||||||
|
echo '{"title":"...","summary":"...","tags":["x"],"body":"## Hi\n..."}' \
|
||||||
|
| node scripts/new-post.mjs --stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the file to `main` → CI rebuilds and ships. A malformed post **fails the build**
|
||||||
|
(frontmatter is zod-validated), so a bad pipeline event never reaches production.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` runs on a self-hosted runner (a dedicated unprivileged
|
||||||
|
user on the bastion):
|
||||||
|
|
||||||
|
```
|
||||||
|
npm ci → astro check → audit-ci (high/critical gate) → build → scan dist →
|
||||||
|
SBOM (CycloneDX) → buildah build+push → open a digest-bump PR to home-ops
|
||||||
|
```
|
||||||
|
|
||||||
|
The PR is **never auto-merged** — `home-ops` `main` is branch-protected; merging it is
|
||||||
|
what triggers the ArgoCD rollout. The runner holds only least-privilege creds (a
|
||||||
|
`home-ops`-scoped deploy key + a PR token + a registry push token).
|
||||||
|
|
||||||
|
- `npm run scan` — build-time gate: no secrets, no inline scripts, no third-party origins.
|
||||||
|
- `.audit-ci.json` — fails on high/critical advisories. One allowlisted: `GHSA-gv7w-rqvm-qjhr`
|
||||||
|
(esbuild install-integrity; build-time only, mitigated by the committed lockfile + trusted registry).
|
||||||
|
- `renovate.json` — keeps npm deps and the digest-pinned base images current.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Built into a container image, served by nginx-unprivileged on a homelab Kubernetes
|
Built into a container image, served by nginx-unprivileged on a homelab Kubernetes
|
||||||
cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private
|
cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private
|
||||||
`home-ops` repo and rolled out by ArgoCD. See `scripts/build-image.sh`.
|
`home-ops` repo and rolled out by ArgoCD. Manual/bootstrap build: `scripts/build-image.sh push`.
|
||||||
|
See `SECURITY.md` for the full security posture.
|
||||||
|
|||||||
Generated
+2191
-2
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.9",
|
||||||
|
"@cyclonedx/cyclonedx-npm": "^5.0.0",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
|
"audit-ci": "^7.1.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":semanticCommits",
|
||||||
|
"docker:pinDigests"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"lockFileMaintenance": { "enabled": true },
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Batch non-major npm updates",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "npm (non-major)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Keep base images (node, nginx) current and digest-pinned",
|
||||||
|
"matchManagers": ["dockerfile"],
|
||||||
|
"groupName": "container base images"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REGISTRY="${REGISTRY:-git.bztmon.com}"
|
REGISTRY="${REGISTRY:-git.bztmon.com}"
|
||||||
IMAGE="${IMAGE:-jwrong96/bztmon-site}"
|
IMAGE="${IMAGE:-jwright/bztmon-site}"
|
||||||
REF="${REGISTRY}/${IMAGE}"
|
REF="${REGISTRY}/${IMAGE}"
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Open a digest-bump PR against the PRIVATE home-ops repo after an image is pushed.
|
||||||
|
# Called by CI (.gitea/workflows/deploy.yml) after scripts/build-image.sh push.
|
||||||
|
#
|
||||||
|
# Least-privilege: uses a deploy key scoped to ONLY home-ops, and a token that can
|
||||||
|
# open a PR. It NEVER pushes to main — main is branch-protected, human-merged.
|
||||||
|
#
|
||||||
|
# Required env (CI secrets):
|
||||||
|
# HOME_OPS_SSH_KEY - private deploy key with write to home-ops only
|
||||||
|
# GITEA_API - e.g. https://gitea.bztmon.org/api/v1 (PRIVATE instance)
|
||||||
|
# GITEA_TOKEN - token that can create a PR on home-ops
|
||||||
|
# Optional:
|
||||||
|
# HOME_OPS_REPO_SSH - default ssh://git@10.0.11.241:22/jwrong96/home-ops.git
|
||||||
|
# MANIFEST - path within home-ops to patch
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-git.bztmon.com}"
|
||||||
|
IMAGE="${IMAGE:-jwright/bztmon-site}"
|
||||||
|
HOME_OPS_REPO_SSH="${HOME_OPS_REPO_SSH:-ssh://git@10.0.11.241:22/jwrong96/home-ops.git}"
|
||||||
|
MANIFEST="${MANIFEST:-kubernetes/apps/bztmon-site/bztmon-site.yaml}"
|
||||||
|
GITEA_API="${GITEA_API:-https://gitea.bztmon.org/api/v1}"
|
||||||
|
|
||||||
|
TAG="$(git rev-parse --short HEAD)"
|
||||||
|
DIGEST="$(cat /tmp/bztmon-site.digest)"
|
||||||
|
REF="${REGISTRY}/${IMAGE}:${TAG}@${DIGEST}"
|
||||||
|
echo ">> new image ref: ${REF}"
|
||||||
|
|
||||||
|
# --- SSH for the home-ops deploy key --------------------------------------
|
||||||
|
export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new"
|
||||||
|
KEYFILE="$(mktemp)"; trap 'rm -f "$KEYFILE"' EXIT
|
||||||
|
printf '%s\n' "${HOME_OPS_SSH_KEY}" > "$KEYFILE"; chmod 600 "$KEYFILE"
|
||||||
|
export GIT_SSH_COMMAND="ssh -i ${KEYFILE} -o StrictHostKeyChecking=accept-new"
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
git clone --depth 1 "${HOME_OPS_REPO_SSH}" "${WORK}/home-ops"
|
||||||
|
cd "${WORK}/home-ops"
|
||||||
|
|
||||||
|
BRANCH="deploy/bztmon-site-${TAG}"
|
||||||
|
git switch -c "${BRANCH}"
|
||||||
|
|
||||||
|
# Replace the image: line for our image (matches any current tag@digest).
|
||||||
|
sed -i -E "s#(^\s*image:\s*)${REGISTRY}/${IMAGE}.*#\1${REF}#" "${MANIFEST}"
|
||||||
|
git --no-pager diff -- "${MANIFEST}"
|
||||||
|
|
||||||
|
git config user.name "bztmon-site CI"
|
||||||
|
git config user.email "ci@bztmon.com"
|
||||||
|
git commit -am "bztmon-site: deploy ${TAG}"
|
||||||
|
git push origin "${BRANCH}"
|
||||||
|
|
||||||
|
# --- Open the PR (never auto-merge) ----------------------------------------
|
||||||
|
curl -fsSL -X POST "${GITEA_API}/repos/jwrong96/home-ops/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"head\":\"${BRANCH}\",\"base\":\"main\",\"title\":\"bztmon-site: deploy ${TAG}\",\"body\":\"Automated digest bump.\\n\\nImage: \`${REF}\`\\n\\nMerge to roll out via ArgoCD.\"}"
|
||||||
|
echo ">> PR opened for ${BRANCH}"
|
||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Publish seam: write a schema-valid blog post into src/content/blog/.
|
||||||
|
// This is what an IaC/CI step calls to turn an event into a post + a commit.
|
||||||
|
//
|
||||||
|
// Usage (flags):
|
||||||
|
// node scripts/new-post.mjs --title "My post" --summary "..." \
|
||||||
|
// --tags "kubernetes,gpu" [--date 2026-06-18] [--draft] \
|
||||||
|
// [--slug custom-slug] [--body "markdown..."] [--bodyFile notes.md] [--force]
|
||||||
|
//
|
||||||
|
// Usage (event JSON — for pipelines):
|
||||||
|
// node scripts/new-post.mjs --json '{"title":"...","summary":"...","tags":["x"],"body":"..."}'
|
||||||
|
// cat event.json | node scripts/new-post.mjs --stdin
|
||||||
|
//
|
||||||
|
// Exits non-zero on invalid input so a malformed pipeline event fails the build.
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const BLOG_DIR = fileURLToPath(new URL("../src/content/blog/", import.meta.url));
|
||||||
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: {
|
||||||
|
title: { type: "string" },
|
||||||
|
summary: { type: "string" },
|
||||||
|
tags: { type: "string" },
|
||||||
|
date: { type: "string" },
|
||||||
|
slug: { type: "string" },
|
||||||
|
body: { type: "string" },
|
||||||
|
bodyFile: { type: "string" },
|
||||||
|
draft: { type: "boolean", default: false },
|
||||||
|
force: { type: "boolean", default: false },
|
||||||
|
json: { type: "string" },
|
||||||
|
stdin: { type: "boolean", default: false },
|
||||||
|
},
|
||||||
|
allowPositionals: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function die(msg) {
|
||||||
|
console.error(`new-post: ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resolve the post fields from flags and/or a JSON event ----------------
|
||||||
|
let event = {};
|
||||||
|
if (values.stdin) {
|
||||||
|
try {
|
||||||
|
event = JSON.parse(readFileSync(0, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
die(`could not parse JSON from stdin: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else if (values.json) {
|
||||||
|
try {
|
||||||
|
event = JSON.parse(values.json);
|
||||||
|
} catch (e) {
|
||||||
|
die(`could not parse --json: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = values.title ?? event.title;
|
||||||
|
const summary = values.summary ?? event.summary;
|
||||||
|
const rawTags = values.tags ?? event.tags;
|
||||||
|
const date = values.date ?? event.date ?? new Date().toISOString().slice(0, 10);
|
||||||
|
const draft = values.draft || Boolean(event.draft);
|
||||||
|
|
||||||
|
let body = values.body ?? event.body;
|
||||||
|
if (values.bodyFile) body = readFileSync(values.bodyFile, "utf8");
|
||||||
|
|
||||||
|
// --- Validate (mirror the zod schema; fail fast) ---------------------------
|
||||||
|
if (!title || typeof title !== "string") die("a --title is required");
|
||||||
|
if (!summary || typeof summary !== "string") die("a --summary is required");
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) die(`date must be YYYY-MM-DD (got "${date}")`);
|
||||||
|
|
||||||
|
const tags = Array.isArray(rawTags)
|
||||||
|
? rawTags
|
||||||
|
: typeof rawTags === "string"
|
||||||
|
? rawTags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const slug =
|
||||||
|
values.slug ??
|
||||||
|
event.slug ??
|
||||||
|
title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
if (!slug) die("could not derive a slug from the title; pass --slug");
|
||||||
|
|
||||||
|
// --- Compose the file ------------------------------------------------------
|
||||||
|
const yaml = (s) => JSON.stringify(String(s)); // safe double-quoted YAML scalar
|
||||||
|
const frontmatter = [
|
||||||
|
"---",
|
||||||
|
`title: ${yaml(title)}`,
|
||||||
|
`date: ${date}`,
|
||||||
|
`summary: ${yaml(summary)}`,
|
||||||
|
`tags: [${tags.map(yaml).join(", ")}]`,
|
||||||
|
`draft: ${draft}`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const content =
|
||||||
|
frontmatter +
|
||||||
|
(body?.trim()
|
||||||
|
? `${body.trim()}\n`
|
||||||
|
: `Write the post here.\n`);
|
||||||
|
|
||||||
|
mkdirSync(BLOG_DIR, { recursive: true });
|
||||||
|
const file = `${BLOG_DIR}${slug}.md`;
|
||||||
|
if (existsSync(file) && !values.force) {
|
||||||
|
die(`${slug}.md already exists (use --force to overwrite)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(file, content, "utf8");
|
||||||
|
console.log(`wrote src/content/blog/${slug}.md${draft ? " (draft)" : ""}`);
|
||||||
Reference in New Issue
Block a user