M5: publish seam + CI/CD pipeline
build-and-deploy / build (push) Failing after 15m10s

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:
2026-06-17 17:20:54 +10:00
parent c1db5cec86
commit 83edaf5975
10 changed files with 2503 additions and 4 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
set -euo pipefail
REGISTRY="${REGISTRY:-git.bztmon.com}"
IMAGE="${IMAGE:-jwrong96/bztmon-site}"
IMAGE="${IMAGE:-jwright/bztmon-site}"
REF="${REGISTRY}/${IMAGE}"
cd "$(dirname "$0")/.."
+55
View File
@@ -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}"
+115
View File
@@ -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)" : ""}`);