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
  • Remote state — Deployment state stored in Confluence itself, no local files to commit
  • Concurrent deploy protection — Terraform-style locking prevents conflicting deploys
  • 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. Initialise your space

Before deploying for the first time, initialise CCFM in your Confluence space. This creates a _ccfm management page that stores deployment state and lock information.

ccfm \
  --domain your-domain.atlassian.net \
  --email your.email@example.com \
  --token YOUR_API_TOKEN \
  --space YOUR_SPACE_KEY \
  init

This is idempotent — safe to run multiple times. It creates:

  • A _ccfm container page at the space root
  • A CCFM State Management child page (tagged with ccfm-internal label)

4. 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::

5. Deploy

# Deploy a single file
ccfm \
  --domain your-domain.atlassian.net \
  --email your.email@example.com \
  --token YOUR_API_TOKEN \
  --space YOUR_SPACE_KEY \
  deploy --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 \
  deploy --directory path/to/docs

6. Inspect before deploying

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

ccfm deploy \
  --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 uses subcommands. Global credential options must come before the subcommand:

ccfm [GLOBAL OPTIONS] <command> [COMMAND OPTIONS]

Commands:
  init                   Initialise remote state in a Confluence space
  deploy                 Deploy markdown files to Confluence
  state list             List all pages tracked in remote state
  state pull             Print remote state JSON to stdout
  state push <file>      Overwrite remote state from a local file
  state rm <path>        Remove a page entry from remote state
  state show <path>      Show state entry for a specific path
  lock acquire           Manually acquire the remote lock
  lock status            Show current lock status
  lock release           Force-release a stale lock

Global options (apply to all commands):
  --config PATH          Path to ccfm.yaml config file (default: ccfm.yaml if present)
  --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)

Deploy options

ccfm deploy [OPTIONS]

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

Options:
  --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 changes
  --plan-exit-code       Exit 2 when plan detects pending changes (for CI gates)
  --changed-only         Only deploy files whose content has changed
  --archive-orphans      Archive pages for markdown files removed from disk
  --lock-id ID           Lock identifier for CI traceability (e.g., pipeline ID)

Examples

# Initialise CCFM in your space (one-time setup)
ccfm --domain company.atlassian.net --email user@example.com --token abc123 --space DOCS init

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

# Deploy entire docs folder
ccfm --domain company.atlassian.net --email user@example.com --token abc123 --space DOCS \
  deploy --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 \
  deploy --directory path/to/docs --git-repo-url "https://github.com/org/repo/blob/main"

# Preview changes without deploying (credentials from ccfm.yaml)
ccfm deploy --directory docs --plan

# Check lock status
ccfm --domain company.atlassian.net --email user@example.com --token abc123 --space DOCS \
  lock status

# View tracked pages
ccfm --domain company.atlassian.net --email user@example.com --token abc123 --space DOCS \
  state list

State Management

CCFM stores deployment state remotely in Confluence itself — no local state files to commit or sync. State is stored as a ccfm-state.json attachment on the CCFM State Management page, which lives under a _ccfm container page in your space.

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
  • No checkin loops — state changes don't trigger CI rebuilds
  • No merge conflicts — concurrent deploys don't fight over a state file

Initialising

Run ccfm init before your first deploy. This creates the management infrastructure in your Confluence space:

ccfm --domain company.atlassian.net --email user@example.com --token abc123 --space DOCS init

The command is idempotent — running it again is a no-op. It is a one-time per-space operation; you do not need to run it before every deploy.

Inspecting and managing state

# List all tracked pages
ccfm --domain ... --email ... --token ... --space DOCS state list

# Show full details for a specific entry
ccfm --domain ... --email ... --token ... --space DOCS state show docs/my-page.md

# Download the raw state JSON (useful for piping to jq or saving a backup)
ccfm --domain ... --email ... --token ... --space DOCS state pull

# Remove a specific entry (e.g., after manually deleting a page in Confluence)
ccfm --domain ... --email ... --token ... --space DOCS state rm docs/old-page.md

Recovery: push a repaired state file

If state becomes corrupted, download it, edit it locally, then push it back:

ccfm --domain ... --email ... --token ... --space DOCS state pull > state-backup.json
# edit state-backup.json
ccfm --domain ... --email ... --token ... --space DOCS state push state-backup.json

Warning: state push overwrites the remote state completely. Use with caution.

Plan mode

Preview what would change without making any modifications:

ccfm deploy --directory docs --plan

Use --plan-exit-code to exit with code 2 when there are pending changes — useful for CI gates that block merges until docs are deployed.


Locking

CCFM uses Terraform-style locking to prevent concurrent deploys from corrupting state or creating duplicate pages. Locks are stored as a content property on the management page, using Confluence's optimistic concurrency (version numbers) to prevent race conditions.

How it works

  • ccfm deploy automatically acquires a lock before deploying and releases it when done
  • If another deploy is in progress, the command fails immediately with a lock error
  • --plan and --dump modes do not acquire locks (they're read-only)
  • Lock owner is auto-detected as user@hostname

CI traceability

Pass --lock-id to associate the lock with a CI pipeline for easier debugging:

ccfm --space DOCS deploy --directory docs --lock-id "$CI_PIPELINE_ID"

Checking lock status

ccfm --domain ... --email ... --token ... --space DOCS lock status

Output shows whether the lock is held, by whom, when it was acquired, and the lock ID.

Manually acquiring the lock

For maintenance or scripted workflows, acquire the lock without deploying:

ccfm --domain ... --email ... --token ... --space DOCS lock acquire

# With a custom operation label and lock ID
ccfm --domain ... --email ... --token ... --space DOCS \
  lock acquire --operation maintenance --lock-id "manual-$(date +%s)"

Recovering from stale locks

If a CI job crashes mid-deploy, the lock may be left in place. Force-release it:

ccfm --domain ... --email ... --token ... --space DOCS lock release

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

With a config file in place:

ccfm deploy --directory docs --plan
ccfm deploy --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 \
  deploy --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

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: |
          # Ensure management page exists (idempotent)
          ccfm \
            --domain "$CONFLUENCE_DOMAIN" \
            --email "$CONFLUENCE_EMAIL" \
            --token "$CONFLUENCE_TOKEN" \
            --space DOCS \
            init

          # Deploy with lock ID for CI traceability
          ccfm \
            --domain "$CONFLUENCE_DOMAIN" \
            --email "$CONFLUENCE_EMAIL" \
            --token "$CONFLUENCE_TOKEN" \
            --space DOCS \
            deploy \
            --directory docs \
            --git-repo-url "https://github.com/${{ github.repository }}/blob/main" \
            --changed-only \
            --lock-id "${{ github.run_id }}"

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.


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/properties)
│       │   ├── frontmatter.py    # YAML frontmatter parsing
│       │   ├── orchestration.py  # deploy_page(), deploy_tree(), archive_page()
│       │   └── transforms.py     # CI banner, page link resolution, attachment media nodes
│       ├── state/                # Remote state and locking
│       │   ├── backend.py        # StateBackend protocol + ConfluenceBackend
│       │   ├── manager.py        # StateManager — filepath → page_id mapping, content hashing
│       │   ├── lock.py           # LockManager — Terraform-style deploy locking
│       │   └── init.py           # init_remote_state() — one-time space setup
│       ├── 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 subcommands)
├── 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.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

src/ccfm_convert/state/ — Remote state and locking

  • backend.pyStateBackend protocol with load()/save() methods; ConfluenceBackend stores state as a JSON attachment on the management page
  • manager.pyStateManager tracks deployed pages, computes content hashes, detects orphaned pages. Backend-agnostic via StateBackend protocol
  • lock.pyLockManager implements Terraform-style locking using Confluence content properties with optimistic concurrency (409 Conflict = race condition)
  • init.pyinit_remote_state() creates the _ccfm management infrastructure

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/.

"Run ccfm init first" The management page was not found in your space. Run ccfm init to create the _ccfm container and state management page.

Deploy blocked by lock Another deploy is in progress (or a previous deploy crashed without releasing the lock). Check the lock status and force-release if the lock is stale:

ccfm --domain ... --email ... --token ... --space DOCS lock status
ccfm --domain ... --email ... --token ... --space DOCS lock release

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.3.0.tar.gz (96.5 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.3.0-py3-none-any.whl (50.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: ccfm_convert-0.3.0.tar.gz
  • Upload date:
  • Size: 96.5 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.3.0.tar.gz
Algorithm Hash digest
SHA256 f90d69c253065017c60499e4d82c6a2f2cf0c23073c795bef6beba7543ce9028
MD5 64f8e52a9b090478ed41b4d87109cc03
BLAKE2b-256 283ec46f729301186583511ebcf600391c18af9fe965c2c74cecf3c28b046bc9

See more details on using hashes here.

Provenance

The following attestation bundles were made for ccfm_convert-0.3.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.3.0-py3-none-any.whl.

File metadata

  • Download URL: ccfm_convert-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 50.0 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.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8f113dbb08903bb252c3cb007486720321ae8e83a09f877d7385f5621ce9f9bb
MD5 d3a53dc2d69adc76d6e17a3e6d963afc
BLAKE2b-256 92e66cadd54c8defb50fb0d0c5b2d26d88fab4dfbc2c60ff414d0f8dd47e4c68

See more details on using hashes here.

Provenance

The following attestation bundles were made for ccfm_convert-0.3.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