Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/validate-content.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Validate blog content

# Runs the same checks the publishing tools apply, so a hand-written post is
# held to the same rules as a generated one.
#
# The site is built first: with a build present, link checking resolves against
# the paths the site actually serves instead of inferring them from the content
# tree.

on:
pull_request:
paths:
- 'content/**'
- 'data/taxonomy.yaml'
- 'data/community-links.yaml'
- 'hack/mcp/**'
- '.github/workflows/validate-content.yaml'

jobs:
validate:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.164.0
steps:
Comment on lines +19 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a least-privilege permissions block.

The job only reads the repository. Without an explicit permissions key, the job inherits the repository or organization default, which can include write scopes for GITHUB_TOKEN. The job runs repository code, including npm install lifecycle scripts and ./hack/download_openapi.sh, so a broad token increases the impact of a malicious dependency.

🔒️ Proposed fix
 jobs:
   validate:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     env:
       HUGO_VERSION: 0.164.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
jobs:
validate:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.164.0
steps:
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
env:
HUGO_VERSION: 0.164.0
steps:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/validate-content.yaml around lines 19 - 24, Add a
job-level permissions block to the validate job, granting only read access
required for repository checkout and workflow execution, with all unspecified
scopes disabled. Keep the existing runs-on, environment, and steps unchanged.

- name: Install Hugo CLI
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&& sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Install Dart Sass
run: sudo snap install dart-sass
- name: Checkout
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
npm install
python3 -m pip install --quiet PyYAML Pillow
- name: Run publishing tool tests
run: python3 hack/mcp/test_mcp.py
- name: Build site
run: ./hack/download_openapi.sh && hugo --gc --minify
- name: Validate content
run: python3 hack/mcp/server.py --check
Comment on lines +52 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The strict validation step fails on the existing posts.

The PR description states that 18 published posts already have validation errors. This step exits non-zero, so every pull request that touches content/** or hack/mcp/** fails until those posts are corrected.

Choose one path before merge:

  • Correct the 18 posts in this PR or in a preceding PR.
  • Keep the step non-blocking for now, for example with continue-on-error: true, and remove that once the content is clean.

I can generate the report of failing posts and the required front matter corrections, or open a tracking issue. Tell me which you prefer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/validate-content.yaml around lines 52 - 53, Make the
Validate content step in the workflow non-blocking by adding continue-on-error:
true, allowing existing post validation failures without failing pull requests.
Keep the python3 hack/mcp/server.py --check command unchanged so the validation
report still runs.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ static/docs/*/cozystack-api/

# Claude Code local settings
.claude/

# Python bytecode from hack/ tooling
__pycache__/
*.pyc
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"cozystack-website": {
"command": "python3",
"args": ["hack/mcp/server.py"]
}
}
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,24 @@ brew install hugo
```bash
hugo serve
```

## Publishing tools

`hack/mcp/` holds two tools for the blog: one that creates a post, one that
checks posts. They share a single implementation, so a generated post and a
hand-written one are held to the same rules — closed taxonomy vocabularies,
resolvable internal links, a present description, and an Open Graph card social
parsers can actually render.

Check content before opening a pull request:

```bash
python3 hack/mcp/server.py --check
```

The same command runs in CI. `.mcp.json` registers the tools as an MCP server,
so an MCP-capable client picks them up from a checkout with no separate
installation.

See [`hack/mcp/README.md`](hack/mcp/README.md) for the tool reference, what the
checks cover, and what the tools deliberately leave alone.
18 changes: 18 additions & 0 deletions data/community-links.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# The closing "Join the community" section appended to blog posts.
#
# Single source of truth: changing a link here changes it for every post
# published from now on, instead of requiring edits across published posts.
# Kept in data/ so templates can read it too if that ever becomes useful.

heading: Join the community

links:
- text: Cozystack on GitHub
url: https://github.com/cozystack/cozystack
- text: Telegram group
url: https://t.me/cozystack
- text: Slack group
url: https://kubernetes.slack.com/archives/C06L3CPRVN1
note: "(get an invite at [slack.kubernetes.io](https://slack.kubernetes.io))"
- text: Community Meeting Calendar
url: https://calendar.google.com/calendar?cid=ZTQzZDIxZTVjOWI0NWE5NWYyOGM1ZDY0OWMyY2IxZTFmNDMzZTJlNjUzYjU2ZGJiZGE3NGNhMzA2ZjBkMGY2OEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t
118 changes: 118 additions & 0 deletions hack/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Publishing tools

Two tools for publishing to the blog: one that creates a post, one that checks
posts. They share a single implementation, so a generated post and a
hand-written one are held to the same rules.

Writing a markdown file into the right directory is not the hard part. Keeping
every post consistent with rules that live scattered across documentation,
templates and habit is. Each check here exists because the mistake it prevents
has already been made in this repository.

## Running the checker

```bash
python3 hack/mcp/server.py --check # every blog post
python3 hack/mcp/server.py --check --path content/en/blog/some-post.md
```

Exits non-zero when any post has an error, so it works as a CI step.

Link checking has two modes. If `public/` holds a build, links are resolved
against it, which is exact — those are the paths the site actually serves, and
an unresolved link is an error. Without a build, URLs are inferred from the
content tree, which is only approximate: Hugo derives them through permalinks,
per-page aliases and version directories. In that mode an unresolved link is a
warning, so the checker never fails on its own guesswork. Build first for a
strict run:

```bash
hugo --gc --minify && python3 hack/mcp/server.py --check
```

## Running the tests

```bash
python3 hack/mcp/test_mcp.py
```

Each test builds a throwaway site in a temporary directory. Nothing touches the
real content tree.

## Using it as an MCP server

`.mcp.json` in the repository root registers the server, so an MCP-capable
client picks it up from a checkout with no separate installation. It speaks MCP
over stdio as line-delimited JSON-RPC.

### publish_post

Creates a post from markdown: writes a page bundle when images are supplied and
a plain file otherwise, copies the images beside the markdown, appends the
standard community section, validates the result, and commits to a branch.
Validation runs before anything is written — and if a later step fails, whatever
was created is removed, so a failed publish never leaves debris behind.

Required: `title`, `description`, `author`, `body`, `article_types`, `topics`.
Optional: `images`, `doc_links`, `slug`, `date`, `branch`, `commit`.

Metadata is expected ready-made. Turning a Google Doc or a raw draft into
markdown and choosing sensible taxonomy terms is the calling agent's job; this
server only lays the result out correctly and refuses what breaks the rules.

Images are copied unchanged. Resizing and AVIF or WebP conversion belong to
Hugo, which processes bundle resources natively and encodes AVIF as of 0.162 —
there is no reason to keep a second implementation of that here.

### validate

The same checks with nothing written. Pass `path` for a single post, omit it for
the whole blog.

## What the checks cover

**Markdown only.** `.html` content is refused. Hugo denies `text/html` content
by default, as the fix for an XSS vulnerability, and this repository carries no
such files any more; one added by hand would break the build again.

**Taxonomy.** Terms must come from `data/taxonomy.yaml`, and the two axes must
stay separate — a genre in `topics` or a subject in `article_types` is an error.
The vocabularies are closed on purpose: a term invented while writing produces a
taxonomy page with one entry, which reads as thin content. One post once carried
an image filename among its topics, which is what an unchecked list eventually
yields.

**Structure.** `slug` matches the bundle directory, the date in the directory
matches the front matter, posts with local images live in a bundle, and a bundle
without assets is flagged as pointless.

**Open Graph card.** The first entry in `images` must exist, be raster, and be
roughly 1200×630. SVG, AVIF and WebP are refused for the card specifically:
Telegram, LinkedIn and other parsers do not render them in `og:image`, and the
Telegram preview is the reason the card exists. AVIF and WebP remain fine in the
article body.

**Description.** Required, since it feeds both the meta description and the
JSON-LD `BlogPosting`. An empty one yields an empty field in structured data and
no useful search snippet.

**Links.** Internal links must resolve, and a link pinning a docs version other
than the current one is flagged as something that will age out. Links into
`/docs/next/` are refused — that trunk is excluded from production builds.

## What these tools deliberately do not do

**Generate meta tags.** The SEO and structured-data setup already lives in
`layouts/partials/hooks/head-end.html`: canonical URLs, `noindex` for superseded
docs versions, JSON-LD for the organization, the site and every blog post, plus
`robots.txt`, `llms.txt` and Open Graph tags from Docsy. Emitting any of that
here would only conflict with it. The job is to guarantee the quality of the
fields those templates read.

**Touch documentation.** `content/*/docs/**` is out of scope. Versioning, the
`next/` trunk and pages generated from upstream belong to the release pipeline
in `cozystack/cozystack`.

**Touch translations.** The localization pipeline has its own review gates.

**Parse arbitrary formats.** Input is markdown.
Loading
Loading