Skip to main content

Deploy Markdown as native ADF pages to Confluence Cloud

Project description

CCFM — Confluence Cloud Flavoured Markdown

A CLI tool that converts Markdown to Atlassian Document Format (ADF) and deploys pages to Confluence Cloud. Write documentation as Markdown, deploy it as native Confluence pages — no legacy conversions, no storage format hacks, full editor compatibility.

PyPI Docker Python 3.12 Code style: black Linting: ruff codecov

  • Native ADF output — Pages open in the Confluence editor without any legacy conversion
  • Automatic page hierarchy — Directory structure maps directly to Confluence page hierarchy
  • CCFM extensions — Status badges, panels, expands, dates, smart page links, emoji, image width control
  • Idempotent — Safe to run multiple times; creates or updates pages automatically
  • CI/CD ready — Deploy documentation on every commit to your main branch

Full syntax reference: CCFM.md


Quick Start

1. Get an API token

Go to Atlassian API Tokens, create a token, and note your Atlassian email address.

2. Install

pip install ccfm-convert

3. Write a page

---
page_meta:
  title: My First Page
  labels:
    - docs

deploy_config:
  ci_banner: false
---

# My First Page

This is **bold** text, this is *italic*.

> [!info]
> This is an info panel.

::In Progress::blue::   ::Stable::green::

4. Deploy

# Deploy a single file
ccfm \
  --domain your-domain.atlassian.net \
  --email your.email@example.com \
  --token YOUR_API_TOKEN \
  --space YOUR_SPACE_KEY \
  --file path/to/my-page.md

# Deploy a directory recursively
ccfm \
  --domain your-domain.atlassian.net \
  --email your.email@example.com \
  --token YOUR_API_TOKEN \
  --space YOUR_SPACE_KEY \
  --directory path/to/docs

5. Inspect before deploying

Use --dump to write ADF JSON files locally without making any API calls:

ccfm \
  --domain your-domain.atlassian.net \
  --email your.email@example.com \
  --token YOUR_API_TOKEN \
  --space YOUR_SPACE_KEY \
  --file path/to/my-page.md \
  --dump
# Writes path/to/my-page.adf.json for inspection

Frontmatter

Every CCFM file should begin with a YAML frontmatter block. Two top-level keys:

---
page_meta:
  title: My Page Title
  parent: Architecture Overview   # Optional — overrides directory-based hierarchy
  author: Jane Smith              # Optional — added as an author-* label
  labels:
    - backend
    - api
  attachments:
    - path: diagram.png
      alt: "Architecture diagram"
      width: max                  # Optional width override

deploy_config:
  ci_banner: true                 # Show managed-by-CI banner (default: true)
  ci_banner_text: "Custom text"   # Optional — overrides default banner text
  include_page_metadata: false    # Show metadata expand block (default: false)
  page_status: "current"          # "current" or "draft" (default: current)
  deploy_page: true               # Set to false to skip deployment (default: true)
---

See CCFM.md — Front matter for the complete field reference.


CLI Reference

ccfm [OPTIONS]

Required (not needed for --plan or --dump):
  --domain DOMAIN          Confluence domain (e.g., company.atlassian.net)
  --email EMAIL            User email address
  --token TOKEN            Atlassian API token (or set CONFLUENCE_TOKEN env var)
  --space SPACE            Space key (e.g., DOCS — not the space display name)

Deployment targets (one required):
  --file PATH              Deploy a single markdown file
  --directory PATH         Deploy a directory recursively

Options:
  --config PATH            Path to ccfm.yaml config file (default: ccfm.yaml if present)
  --state PATH             Path to state file (default: .ccfm-state.json)
  --docs-root PATH         Documentation root directory (default: docs)
  --git-repo-url URL       Git repo URL for CI banner source links
  --dump                   Write ADF to .adf.json files, skip deployment
  --plan                   Show what would be deployed without making any changes
  --changed-only           Only deploy files whose content has changed since last deploy
  --archive-orphans        Archive Confluence pages for markdown files removed from disk

Examples

# Deploy a single file
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --file path/to/api/authentication.md

# Deploy entire docs folder
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --directory path/to/docs

# With CI banner links back to source files
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --directory path/to/docs \
  --git-repo-url "https://github.com/org/repo/blob/main"

State Management

CCFM tracks deployed pages in a local .ccfm-state.json file. This enables:

  • Plan mode — see what would change before deploying
  • Changed-only deploys — skip files with no content changes (faster CI)
  • Orphan archiving — archive pages whose source files have been deleted

Commit the state file alongside your documentation. Team members and CI pipelines share the same deployment history through version control.

# Preview what would be deployed (no API calls made)
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --directory docs \
  --plan

# Only deploy changed files (faster CI runs)
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --directory docs \
  --changed-only

# Archive pages whose source markdown files were deleted
ccfm \
  --domain company.atlassian.net \
  --email user@example.com \
  --token abc123 \
  --space DOCS \
  --directory docs \
  --archive-orphans

--plan exits with code 2 when there are pending changes and 0 when everything is up to date — useful for CI gates.


Config File (ccfm.yaml)

Place a ccfm.yaml in your project root to avoid repeating credentials on every run. CLI arguments always take precedence over config file values.

version: 1

domain: company.atlassian.net
email: ${CONFLUENCE_EMAIL}       # env var interpolation supported
token: ${CONFLUENCE_TOKEN}
space: DOCS
docs_root: docs
git_repo_url: https://github.com/org/repo
state_file: .ccfm-state.json

With a config file in place:

ccfm --directory docs --plan
ccfm --directory docs

Security note: ccfm.yaml is a trusted-author file. Any environment variable visible to the process can be interpolated into config values. Review ccfm.yaml changes in pull requests the same way you review CI pipeline changes.


Docker

docker pull ghcr.io/stevesimpson418/ccfm-convert:latest

docker run --rm \
  -e CONFLUENCE_DOMAIN=company.atlassian.net \
  -e CONFLUENCE_EMAIL=user@example.com \
  -e CONFLUENCE_TOKEN=your-token \
  -v $(pwd)/docs:/docs \
  ghcr.io/stevesimpson418/ccfm-convert:latest \
  --space DOCS --directory /docs

GitHub Action

- uses: stevesimpson418/ccfm-action@v0.1.0
  with:
    domain: ${{ secrets.CONFLUENCE_DOMAIN }}
    email:  ${{ secrets.CONFLUENCE_EMAIL }}
    token:  ${{ secrets.CONFLUENCE_TOKEN }}
    space:  DOCS
    directory: docs
    args: --changed-only

Page Hierarchy

Directories map directly to Confluence pages. A file at docs/Team/Engineering/api.md creates:

Team
└── Engineering
    └── api

By default, container pages (Team, Engineering) are created as placeholders. To control a container page's title and content, add a .page_content.md file inside the directory:

docs/
└── Team/
    ├── .page_content.md    ← controls the "Team" Confluence page
    └── Engineering/
        ├── .page_content.md
        └── api.md

.page_content.md files support full CCFM syntax and frontmatter, including labels and custom titles.


CI/CD

Store credentials as secrets: CONFLUENCE_DOMAIN, CONFLUENCE_EMAIL, CONFLUENCE_TOKEN.

Pipeline overview

Every PR targeting main runs three gates:

Check When Blocks merge?
Lint + unit tests (100% coverage) Every push / PR commit Yes
Smoke tests against CCFMDEV space PRs + pushes touching src/ or tests/smoke/ Yes
Markdown lint Every push / PR commit Yes

Smoke tests auto-cleanup Confluence pages after each run. For manual inspection runs (leave pages in Confluence), use Actions → Smoke Tests → Run workflow and uncheck Delete Confluence pages after tests.

Set these required status checks in GitHub → Settings → Branches → main:

  • Lint, Test, Markdown Lint, Smoke Tests (CCFMDEV)

Deploying your docs via GitHub Actions

name: Deploy Docs

on:
  push:
    branches: [main]
    paths:
      - 'docs/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install ccfm-convert
      - env:
          CONFLUENCE_DOMAIN: ${{ secrets.CONFLUENCE_DOMAIN }}
          CONFLUENCE_EMAIL: ${{ secrets.CONFLUENCE_EMAIL }}
          CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }}
        run: |
          ccfm \
            --domain "$CONFLUENCE_DOMAIN" \
            --email "$CONFLUENCE_EMAIL" \
            --token "$CONFLUENCE_TOKEN" \
            --space DOCS \
            --directory docs \
            --git-repo-url "https://github.com/${{ github.repository }}/blob/main" \
            --changed-only

Project Structure

.
├── src/
│   └── ccfm_convert/
│       ├── adf/                  # Markdown → ADF converter (pure, no I/O)
│       │   ├── nodes.py          # ADF node constructor functions
│       │   ├── inline.py         # Inline markdown parsing
│       │   ├── blocks.py         # Block markdown parsing
│       │   └── converter.py      # Orchestration; convert() entry point
│       ├── deploy/               # Confluence API and deployment logic
│       │   ├── api.py            # ConfluenceAPI class (REST v2 + v1 for attachments)
│       │   ├── frontmatter.py    # YAML frontmatter parsing
│       │   ├── orchestration.py  # deploy_page(), deploy_tree(), archive_page()
│       │   └── transforms.py     # CI banner, page link resolution, attachment media nodes
│       ├── state/                # Deployment state persistence
│       │   └── manager.py        # StateManager — filepath → page_id mapping, content hashing
│       ├── config/               # Project config file loader
│       │   └── loader.py         # ccfm.yaml loader with ${ENV_VAR} interpolation
│       ├── plan/                 # Plan/diff mode
│       │   └── planner.py        # compute_plan(), DeployPlan — terraform-style diff output
│       └── main.py               # CLI entry point (argparse)
├── tests/
│   ├── smoke/                # End-to-end smoke tests (real Confluence space)
│   │   ├── conftest.py       # Credentials, cleanup hook, ccfm_run fixture
│   │   ├── docs/             # Fixture markdown files deployed during smoke tests
│   │   └── test_*.py         # Smoke test modules
│   └── test_*.py             # Unit tests (100% coverage, all mocked)
├── .ccfm-state.json          # Deployment state (commit this alongside your docs)
├── ccfm.yaml                 # Optional project config (credentials, space, docs_root)
├── CCFM.md                   # Complete CCFM syntax and ADF mapping reference
├── requirements.txt          # Runtime dependencies
├── requirements-test.txt     # Development and test dependencies
└── pyproject.toml            # Toolchain configuration (black, ruff, pytest, coverage)

Development

Setup

python -m venv .env
source .env/bin/activate    # Windows: .env\Scripts\activate

# Install the package and dev/test dependencies
pip install -e .
pip install -r requirements-test.txt

# Install pre-commit hooks
pre-commit install

Running tests

pytest                              # All unit tests with coverage report
pytest tests/test_converter.py      # Single file
pytest -k "test_heading"            # Single test by name

Coverage runs automatically via pyproject.toml. The target is 100% line coverage on src/.

Smoke tests

End-to-end tests that deploy real pages to a Confluence space. Requires credentials for a dedicated test space (the project uses CCFMDEV at ccfm.atlassian.net).

# Copy and fill in credentials
cp .env.smoke.example .env.smoke
# Edit .env.smoke with your values, then:
source .env.smoke

# Run all smoke tests and auto-cleanup Confluence pages when done
pytest tests/smoke/ --no-cov -v

# Run tests and leave pages in Confluence for manual inspection
pytest tests/smoke/ --no-cov -v --no-cleanup

# Delete pages from a previous --no-cleanup run (skips re-running tests)
pytest tests/smoke/ --no-cov -v --cleanup-only

GitHub Actions: Go to Actions → Smoke Tests → Run workflow (manual trigger). Requires CONFLUENCE_DOMAIN, CONFLUENCE_EMAIL, and CONFLUENCE_TOKEN secrets. Uncheck "Delete Confluence pages after tests" to leave pages for manual inspection.

Code style

  • Formatter: Black (line length 100)
  • Linter: Ruff
  • Python: 3.12
black src/                  # Format
ruff check src/             # Lint
pre-commit run --all-files  # All hooks

Architecture

src/ccfm_convert/adf/ — Pure conversion

No I/O, no network calls. Entry point: convert(markdown: str) -> dict.

  • nodes.py — ADF node constructor functions (doc(), heading(), paragraph(), etc.)
  • inline.py — Inline parsing: bold, italic, code, links, emoji, status badges, dates
  • blocks.py — Block parsing: tables, lists (bullet/ordered/task), panels, expands, blockquotes
  • converter.py — Orchestrates the conversion; calls into blocks and inline parsers

src/ccfm_convert/deploy/ — Confluence API interaction

  • api.pyConfluenceAPI class wrapping REST API v2 (v1 for attachment upload — Confluence v2 lacks a POST attachment endpoint, tracked at CONFCLOUD-77196)
  • frontmatter.pyparse_frontmatter(content) -> (metadata, markdown) strips and parses YAML
  • orchestration.pydeploy_page(), deploy_tree(), ensure_page_hierarchy() coordinate the full deploy flow
  • transforms.py — Post-conversion ADF mutations: CI banner injection, internal page link resolution, attachment media node rewriting

Attachment upload flow

Confluence's v2 API lacks an attachment POST endpoint, so the deploy tool uses a multi-step workaround:

  1. Create or update the page (attachment media nodes are placeholders at this point)
  2. Upload attachments via v1 API (/rest/api/content/{id}/child/attachment)
  3. Fetch the Media Services fileId (UUID) via v2 API GET — the v1 upload response does not include it
  4. Re-update the page with correct ADF media nodes containing the real fileId and collection

Troubleshooting

Authentication failed Verify the token is correct and the email matches your Atlassian account. Ensure you have create/edit permissions in the target space.

Space not found Use the space key (e.g., DOCS), not the display name. The key appears in the URL: /wiki/spaces/DOCS/.

Image not rendering after redeploy The Confluence v1 attachment update endpoint returns a different response shape than the create endpoint. CCFM normalises this automatically — ensure you are running the latest version.

Page hierarchy issues Ensure markdown files are under the directory passed to --directory. Directories without .page_content.md get an auto-generated placeholder page. Add one to control the container page's title and content.

Debugging ADF output Use --dump to write .adf.json files alongside each markdown file. Inspect these to verify the ADF structure before deploying to Confluence.


Contributing

  1. Fork the repository and create a feature branch
  2. Run pre-commit install to set up hooks
  3. Make your changes
  4. Run pytest — all tests must pass, coverage must be maintained
  5. Run pre-commit run --all-files
  6. Submit a pull request

Credits

Built on:

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ccfm_convert-0.1.0.tar.gz (78.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ccfm_convert-0.1.0-py3-none-any.whl (40.5 kB view details)

Uploaded Python 3

File details

Details for the file ccfm_convert-0.1.0.tar.gz.

File metadata

  • Download URL: ccfm_convert-0.1.0.tar.gz
  • Upload date:
  • Size: 78.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for ccfm_convert-0.1.0.tar.gz
Algorithm Hash digest
SHA256 da20b5c6805fa060e38068066b3842d745b0d709a6504fc19a78f889424fce78
MD5 4a53753de9d6cfb49510ffc9cc4bc525
BLAKE2b-256 ceaec9f068ccf2ba52c9fe16b7d4ce4f74369ae00bdbd265032a407036d15d13

See more details on using hashes here.

Provenance

The following attestation bundles were made for ccfm_convert-0.1.0.tar.gz:

Publisher: release.yml on stevesimpson418/ccfm-convert

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ccfm_convert-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ccfm_convert-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 40.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for ccfm_convert-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a990618417c04beb67c3e73324598e2e0617d7a3bdd8a75bf558134464dfdebb
MD5 e46bd0c7868923c27444bd2b9e9524fa
BLAKE2b-256 26655e535264e997b08bf6d5704aedac56d08f0f63053d34f736e5324e7eea6f

See more details on using hashes here.

Provenance

The following attestation bundles were made for ccfm_convert-0.1.0-py3-none-any.whl:

Publisher: release.yml on stevesimpson418/ccfm-convert

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page