Automating PlantUML Diagrams in GitHub README with Actions and Confluence Sync
Maintaining up-to-date documentation is crucial for any software project, but manually updating diagrams and keeping documentation synchronized across platforms can be time-consuming and error-prone. This guide demonstrates how to automate the entire workflow using GitHub Actions to generate PlantUML diagrams directly in your repository’s README.md file and automatically synchronize the content to Confluence pages.
The Complete Automation Pipeline
The solution we’ll build consists of three main components: automatic PlantUML diagram generation from source files, embedding those diagrams into the README.md file, and synchronizing the updated content to Confluence. This approach ensures that your documentation stays current with every code change while maintaining consistency across platforms.
Setting Up PlantUML Diagram Generation
First, we need to create a GitHub Actions workflow that can process PlantUML source files and generate diagrams. Create a .github/workflows/update-diagrams.yml
file in your repository:
name: Update PlantUML Diagrams and Sync to Confluence
on:
push:
branches: [ main ]
paths:
- '**.puml'
- 'README.md'
- '.github/workflows/update-diagrams.yml'
pull_request:
branches: [ main ]
paths:
- '**.puml'
- 'README.md'
jobs:
update-diagrams:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Java for PlantUML
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install PlantUML
run: |
wget -O plantuml.jar https://github.com/plantuml/plantuml/releases/latest/download/plantuml-1.2024.6.jar
echo "PLANTUML_JAR=$PWD/plantuml.jar" >> $GITHUB_ENV
- name: Generate PlantUML diagrams
run: |
find . -name "*.puml" -type f | while read -r file; do
echo "Processing $file"
java -jar $PLANTUML_JAR -tsvg "$file"
# Also generate PNG for fallback
java -jar $PLANTUML_JAR -tpng "$file"
done
- name: Update README with diagram references
run: |
python3 scripts/update-readme.py
- name: Commit and push changes
if: github.event_name == 'push'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "Auto-update PlantUML diagrams [skip ci]"
git push
fi
sync-to-confluence:
needs: update-diagrams
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: |
npm install markdown-it markdown-it-plantuml @atlassian/confluence-api
- name: Sync to Confluence
env:
CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }}
CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }}
CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
CONFLUENCE_SPACE_KEY: ${{ secrets.CONFLUENCE_SPACE_KEY }}
CONFLUENCE_PAGE_ID: ${{ secrets.CONFLUENCE_PAGE_ID }}
run: |
node scripts/sync-to-confluence.js
Creating the README Update Script
The Python script that updates the README.md file with generated diagrams needs to be intelligent about finding and replacing diagram references. Create scripts/update-readme.py
:
#!/usr/bin/env python3
import os
import re
import sys
from pathlib import Path
def find_puml_files():
"""Find all .puml files in the repository."""
puml_files = []
for root, dirs, files in os.walk('.'):
# Skip hidden directories and common build directories
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', 'build', 'dist']]
for file in files:
if file.endswith('.puml'):
puml_files.append(os.path.join(root, file))
return puml_files
def update_readme_with_diagrams(readme_path='README.md'):
"""Update README.md with generated diagram references."""
if not os.path.exists(readme_path):
print(f"README.md not found at {readme_path}")
return False
with open(readme_path, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
puml_files = find_puml_files()
for puml_file in puml_files:
# Convert file path to relative path from repository root
relative_path = os.path.relpath(puml_file, '.')
base_name = os.path.splitext(relative_path)[0]
# Generate both SVG and PNG references
svg_path = f"{base_name}.svg"
png_path = f"{base_name}.png"
# Look for existing diagram markers in the README
diagram_name = os.path.splitext(os.path.basename(puml_file))[0]
# Pattern to match existing diagram sections
pattern = rf'<!-- DIAGRAM:{diagram_name}:START -->.*?<!-- DIAGRAM:{diagram_name}:END -->'
# Create the replacement content
code_fence = "```"
replacement = f'''<!-- DIAGRAM:{diagram_name}:START -->

*Diagram: {diagram_name.replace('-', ' ').replace('_', ' ').title()}*
<details>
<summary>View PlantUML Source asdf</summary>
{code_fence}plantuml
{read_puml_content(puml_file)}
{code_fence}
</details>
<!-- DIAGRAM:{diagram_name}:END -->'''
if re.search(pattern, content, re.DOTALL):
# Replace existing diagram section
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
print(f"Updated existing diagram section for {diagram_name}")
else:
# Look for a placeholder to insert the diagram
placeholder_pattern = rf'<!-- DIAGRAM:{diagram_name} -->'
if re.search(placeholder_pattern, content):
content = re.sub(placeholder_pattern, replacement, content)
print(f"Inserted new diagram section for {diagram_name}")
else:
print(f"No placeholder found for {diagram_name}. Add '<!-- DIAGRAM:{diagram_name} -->' to README.md")
# Only write if content changed
if content != original_content:
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(content)
print("README.md updated successfully")
return True
else:
print("No changes needed in README.md")
return False
def read_puml_content(puml_file):
"""Read and return the content of a PlantUML file."""
try:
with open(puml_file, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"Error reading {puml_file}: {e}")
return f"Error reading file: {e}"
if __name__ == "__main__":
success = update_readme_with_diagrams()
sys.exit(0 if success else 1)
Implementing Confluence Synchronization
The Confluence synchronization script converts the README.md content to Confluence-compatible markup and updates the specified page. Create scripts/sync-to-confluence.js
:
const fs = require('fs').promises;
const MarkdownIt = require('markdown-it');
const ConfluenceApi = require('@atlassian/confluence-api');
class ConfluenceSync {
constructor() {
this.confluence = new ConfluenceApi({
host: process.env.CONFLUENCE_URL,
username: process.env.CONFLUENCE_USERNAME,
password: process.env.CONFLUENCE_API_TOKEN
});
this.spaceKey = process.env.CONFLUENCE_SPACE_KEY;
this.pageId = process.env.CONFLUENCE_PAGE_ID;
this.md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
});
}
async convertMarkdownToConfluence(markdown) {
// Convert markdown to HTML first
let html = this.md.render(markdown);
// Convert HTML to Confluence storage format
html = this.convertHtmlToConfluence(html);
return html;
}
convertHtmlToConfluence(html) {
// Convert GitHub-flavored markdown elements to Confluence equivalents
let confluence = html;
// Convert code blocks
confluence = confluence.replace(
/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
'<ac:structured-macro ac:name="code" ac:schema-version="1">' +
'<ac:parameter ac:name="language">$1</ac:parameter>' +
'<ac:plain-text-body><![CDATA[$2]]></ac:plain-text-body>' +
'</ac:structured-macro>'
);
// Convert inline code
confluence = confluence.replace(
/<code>(.*?)<\/code>/g,
'<code>$1</code>'
);
// Convert images to Confluence format
confluence = confluence.replace(
/<img src="([^"]+)" alt="([^"]*)"[^>]*>/g,
(match, src, alt) => {
if (src.startsWith('http')) {
return `<ac:image><ri:url ri:value="${src}" /></ac:image>`;
} else {
// For local images, we'll need to upload them as attachments
return `<ac:image><ri:attachment ri:filename="${src.split('/').pop()}" /></ac:image>`;
}
}
);
// Convert headings
confluence = confluence.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
// Convert lists
confluence = confluence.replace(/<ul>/g, '<ul>');
confluence = confluence.replace(/<\/ul>/g, '</ul>');
confluence = confluence.replace(/<ol>/g, '<ol>');
confluence = confluence.replace(/<\/ol>/g, '</ol>');
confluence = confluence.replace(/<li>/g, '<li>');
confluence = confluence.replace(/<\/li>/g, '</li>');
return confluence;
}
async uploadAttachment(filePath, pageId) {
try {
const fileContent = await fs.readFile(filePath);
const fileName = filePath.split('/').pop();
const result = await this.confluence.putAttachment(
pageId,
fileName,
fileContent,
'image/svg+xml'
);
console.log(`Uploaded attachment: ${fileName}`);
return result;
} catch (error) {
console.error(`Error uploading attachment ${filePath}:`, error.message);
throw error;
}
}
async syncReadmeToConfluence() {
try {
// Read the README.md file
const readmeContent = await fs.readFile('README.md', 'utf8');
// Convert to Confluence format
const confluenceContent = await this.convertMarkdownToConfluence(readmeContent);
// Get current page to preserve version
const currentPage = await this.confluence.getPage(this.pageId);
// Upload any local images as attachments
await this.uploadLocalImages(readmeContent);
// Update the page
const updateData = {
id: this.pageId,
type: 'page',
title: currentPage.title,
space: {
key: this.spaceKey
},
body: {
storage: {
value: confluenceContent,
representation: 'storage'
}
},
version: {
number: currentPage.version.number + 1
}
};
const result = await this.confluence.putPage(updateData);
console.log(`Successfully updated Confluence page: ${result.title}`);
console.log(`Page URL: ${process.env.CONFLUENCE_URL}/pages/viewpage.action?pageId=${this.pageId}`);
return result;
} catch (error) {
console.error('Error syncing to Confluence:', error.message);
throw error;
}
}
async uploadLocalImages(markdownContent) {
// Find all local image references
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = imageRegex.exec(markdownContent)) !== null) {
const imagePath = match[2];
// Skip external URLs
if (!imagePath.startsWith('http') && !imagePath.startsWith('//')) {
try {
await this.uploadAttachment(imagePath, this.pageId);
} catch (error) {
console.warn(`Could not upload image ${imagePath}:`, error.message);
}
}
}
}
}
async function main() {
try {
const sync = new ConfluenceSync();
await sync.syncReadmeToConfluence();
console.log('Synchronization completed successfully');
} catch (error) {
console.error('Synchronization failed:', error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
Configuring Repository Secrets
For the automation to work properly, you need to configure several secrets in your GitHub repository settings. Navigate to your repository’s Settings > Secrets and variables > Actions, and add the following secrets:
CONFLUENCE_URL: The base URL of your Confluence instance (e.g., https://yourcompany.atlassian.net
).
CONFLUENCE_USERNAME: Your Confluence username or email address.
CONFLUENCE_API_TOKEN: Generate this from your Atlassian account settings under API tokens.
CONFLUENCE_SPACE_KEY: The key of the Confluence space where your page resides.
CONFLUENCE_PAGE_ID: The ID of the specific page you want to update. You can find this in the page URL or by using the Confluence API.
Using the Automation System
To use this system in your repository, create PlantUML files with the .puml
extension anywhere in your repository. For example, create docs/architecture.puml
:
@startuml architecture
!theme plain
skinparam backgroundColor white
skinparam defaultTextAlignment center
package "Frontend" {
[React App]
[Redux Store]
}
package "Backend Services" {
[API Gateway]
[Lambda Functions]
[DynamoDB]
}
package "External Services" {
[GitHub API]
[Confluence API]
}
[React App] --> [Redux Store]
[React App] --> [API Gateway]
[API Gateway] --> [Lambda Functions]
[Lambda Functions] --> [DynamoDB]
[Lambda Functions] --> [GitHub API]
[Lambda Functions] --> [Confluence API]
@enduml
Then, in your README.md file, add a placeholder where you want the diagram to appear:
# Project Architecture
Our system follows a modern serverless architecture pattern:
<!-- DIAGRAM:architecture -->
The architecture demonstrates the flow of data from the frontend React application through our serverless backend to external services.
When you commit and push these changes, the GitHub Action will automatically generate the diagram, update your README.md with the embedded SVG, and synchronize the content to your Confluence page.
Advanced Customization Options
The system can be extended in several ways to meet specific requirements. You can modify the PlantUML generation to use different output formats, add custom styling themes, or include additional metadata in the generated diagrams.
For more complex documentation workflows, consider adding conditional logic to the Confluence sync script that handles different types of content differently. You might want to preserve certain sections of the Confluence page while only updating the automated portions.
The workflow can also be enhanced to support multiple Confluence pages, different diagram types, or integration with other documentation platforms like Notion or GitBook.
Troubleshooting Common Issues
The most common issues occur with authentication and permissions. Ensure that your GitHub repository has the necessary permissions to write to the repository, and verify that your Confluence API token has the required permissions to update pages in the target space.
If diagrams are not generating correctly, check the PlantUML syntax and ensure that the Java environment is properly configured in the GitHub Action. The workflow includes error handling, but you may need to examine the Action logs for specific PlantUML compilation errors.
For Confluence synchronization issues, verify that the page ID and space key are correct, and that the API token has not expired. The Confluence API has rate limits, so consider adding retry logic for high-frequency updates.
This automated workflow transforms documentation maintenance from a manual chore into an seamless part of your development process, ensuring that your diagrams and documentation remain current and consistent across platforms.
Comments