Skip to content

docs: implement publishing pipeline (#617) #18

docs: implement publishing pipeline (#617)

docs: implement publishing pipeline (#617) #18

Workflow file for this run

name: Docs
# Builds, validates, and deploys documentation to orphan deployment branches.
# Mintlify reads from these branches — main stays clean of generated artifacts.
#
# See docs/PUBLISHING.md for the full architecture and strategy.
on:
push:
branches: [main]
paths:
- "docs/**"
- "mellea/**"
- "cli/**"
- "tooling/docs-autogen/**"
- ".github/workflows/docs-publish.yml"
release:
types: [published]
pull_request:
types: [opened, synchronize, reopened, labeled]
paths:
- "docs/**"
- "mellea/**"
- "cli/**"
- "tooling/docs-autogen/**"
- ".github/workflows/docs-publish.yml"
workflow_dispatch:
inputs:
force_publish:
description: "Deploy even from a non-main context (for testing)"
type: boolean
default: false
target_branch:
description: "Override deploy target branch (default: docs/preview)"
type: string
default: "docs/preview"
strict_validation:
description: "Fail the build if validation checks fail"
type: boolean
default: false
permissions:
contents: write
concurrency:
group: docs-publish-${{ github.ref }}
cancel-in-progress: true
env:
UV_FROZEN: "1"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# ---------------------------------------------------------------------------
# Build & Validate
# ---------------------------------------------------------------------------
build-and-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --frozen --all-extras --group dev
# -- Generate API documentation ------------------------------------------
- name: Generate API documentation
run: uv run python tooling/docs-autogen/build.py
# -- Validate static docs ------------------------------------------------
- name: Lint static docs (markdownlint)
id: markdownlint
run: |
set -o pipefail
npx --yes markdownlint-cli "docs/docs/**/*.md" --config docs/docs/.markdownlint.json 2>&1 \
| tee /tmp/markdownlint.log
continue-on-error: ${{ inputs.strict_validation != true }}
# -- Validate generated API docs -----------------------------------------
- name: Validate MDX syntax and links
id: validate_mdx
run: |
set -o pipefail
uv run python tooling/docs-autogen/validate.py docs/docs/api --skip-coverage 2>&1 \
| tee /tmp/validate_mdx.log
continue-on-error: ${{ inputs.strict_validation != true }}
- name: Audit API coverage
id: audit_coverage
run: |
set -o pipefail
uv run python tooling/docs-autogen/audit_coverage.py --docs-dir docs/docs/api --threshold 80 --quality 2>&1 \
| tee /tmp/audit_coverage.log
continue-on-error: ${{ inputs.strict_validation != true }}
# -- Upload artifact for deploy job --------------------------------------
- name: Upload docs artifact
if: success() || (inputs.strict_validation != true)
uses: actions/upload-artifact@v7
with:
name: docs-site
path: docs/docs/
retention-days: 7
# -- Write job summary ---------------------------------------------------
- name: Write job summary
if: always()
run: |
python3 - <<'PYEOF'
import os, re
def icon(outcome):
return "✅" if outcome == "success" else ("❌" if outcome == "failure" else "⏭️")
def read_log(path):
try:
return open(path).read().strip()
except FileNotFoundError:
return ""
markdownlint_outcome = "${{ steps.markdownlint.outcome }}"
validate_outcome = "${{ steps.validate_mdx.outcome }}"
coverage_outcome = "${{ steps.audit_coverage.outcome }}"
strict = "${{ inputs.strict_validation }}" == "true"
mode = "" if strict else " *(soft-fail)*"
lint_log = read_log("/tmp/markdownlint.log")
validate_log = read_log("/tmp/validate_mdx.log")
coverage_log = read_log("/tmp/audit_coverage.log")
# Count markdownlint issues (lines matching file:line:col format)
lint_issues = len([l for l in lint_log.splitlines() if re.match(r'.+:\d+:\d+ ', l)])
lint_detail = f"{lint_issues} issue(s)" if lint_issues else "no issues"
# Extract coverage stats from audit_coverage output
cov_pct = re.search(r"Coverage:\s+(\S+%)", coverage_log)
cov_sym = re.search(r"Documented:\s+(\d+)", coverage_log)
cov_tot = re.search(r"Total classes \+ functions:\s+(\d+)", coverage_log)
cov_detail = (
f"{cov_pct.group(1)} ({cov_sym.group(1)}/{cov_tot.group(1)} symbols)"
if cov_pct and cov_sym and cov_tot else ""
)
# MDX overall result line
mdx_overall = re.search(r"Overall: .+?(PASS|FAIL)", validate_log)
mdx_detail = mdx_overall.group(1) if mdx_overall else ""
# Docstring quality annotation emitted by audit_coverage.py into the log
# Format: ::notice title=Docstring quality::message
# or ::warning title=Docstring quality::message
quality_match = re.search(r"::(notice|warning|error) title=Docstring quality::(.+)", coverage_log)
if quality_match:
quality_level, quality_msg = quality_match.group(1), quality_match.group(2)
quality_icon = "✅" if quality_level == "notice" else "⚠️"
quality_row = f"| Docstring Quality | {quality_icon} {quality_msg} |"
else:
quality_row = None
lines = [
"## Docs Build — Validation Summary\n",
"| Check | Result | Details |",
"|-------|--------|---------|",
f"| Markdownlint | {icon(markdownlint_outcome)} {markdownlint_outcome}{mode} | {lint_detail} |",
f"| MDX Validation | {icon(validate_outcome)} {validate_outcome}{mode} | {mdx_detail} |",
f"| API Coverage | {icon(coverage_outcome)} {coverage_outcome}{mode} | {cov_detail} |",
]
if quality_row:
lines.append(quality_row)
lines.append("")
for title, log in [
("Markdownlint output", lint_log),
("MDX validation output", validate_log),
("API coverage output", coverage_log),
]:
if log:
lines += [
f"<details><summary>{title}</summary>\n",
"```text",
log[:3000] + (" [truncated]" if len(log) > 3000 else ""),
"```",
"</details>\n",
]
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
f.write("\n".join(lines))
PYEOF
# ---------------------------------------------------------------------------
# Deploy to orphan branch
# ---------------------------------------------------------------------------
deploy:
needs: build-and-validate
runs-on: ubuntu-latest
timeout-minutes: 10
# Deploy on: push to main, release, force_publish via dispatch,
# or PRs labelled "docs-preview" (→ docs/preview branch).
if: >-
github.event_name == 'push' ||
github.event_name == 'release' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'docs-preview')) ||
(github.event_name == 'workflow_dispatch' && inputs.force_publish)
steps:
- name: Download docs artifact
uses: actions/download-artifact@v8
with:
name: docs-site
path: docs-site/
- name: Determine target branch
id: target
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "branch=docs/production" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
echo "branch=docs/preview" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.target_branch }}" ]; then
echo "branch=${{ inputs.target_branch }}" >> "$GITHUB_OUTPUT"
else
echo "branch=docs/staging" >> "$GITHUB_OUTPUT"
fi
- name: Add DO NOT EDIT warning
run: |
cat > docs-site/_DO_NOT_EDIT.md << 'EOF'
# DO NOT EDIT THIS BRANCH
This branch is **fully automated**. Every file here is generated by
the `docs-publish` GitHub Actions workflow and force-pushed on each run.
**Any manual edits will be overwritten without warning.**
To change documentation:
- Static guides: edit files under `docs/docs/` on `main`
- API reference: improve docstrings in Python source (`mellea/`, `cli/`)
- Pipeline config: see `tooling/docs-autogen/` on `main`
For details, see `docs/PUBLISHING.md` on `main`.
EOF
- name: Deploy to ${{ steps.target.outputs.branch }}
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: ${{ steps.target.outputs.branch }}
publish_dir: docs-site/
force_orphan: true
user_name: "github-actions[bot]"
user_email: "github-actions[bot]@users.noreply.github.com"
commit_message: |
docs: publish from ${{ github.sha }}
Branch: ${{ github.ref_name }}
Trigger: ${{ github.event_name }}${{ github.event.pull_request.number && format(' (PR #{0})', github.event.pull_request.number) || '' }}
Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}