Skip to content
Merged
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
35 changes: 35 additions & 0 deletions .github/workflows/publish-release-notes-to-discourse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Publish Release Notes to Discourse

on:
release:
types: [published]

jobs:
publish-to-discourse:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"

- name: Install dependencies
run: pip install requests

- name: Publish release to Discourse
env:
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }}
DISCOURSE_USERNAME: "pymc-bot"
DISCOURSE_URL: "https://discourse.pymc.io"
DISCOURSE_CATEGORY: "Development"
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_BODY: ${{ github.event.release.body }}
Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure if I need to escape to json or not

RELEASE_URL: ${{ github.event.release.html_url }}
REPO_NAME: ${{ github.repository }}
Comment on lines +8 to +34

Check warning

Code scanning / zizmor

overly broad permissions Warning

overly broad permissions
run: python ./scripts/publish_release_notes_to_discourse.py
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ lines-between-types = 1
"scripts/run_mypy.py" = [
"T201", # No print statements
]
"scripts/publish_release_notes_to_discourse.py" = [
"T201", # No print statements
]
"*.ipynb" = [
"T201", # No print statements
]
Expand Down
135 changes: 135 additions & 0 deletions scripts/publish_release_notes_to_discourse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3

import os

import requests


def load_config() -> dict[str, str]:
env_config = {
"DISCOURSE_URL": os.getenv("DISCOURSE_URL"),
"DISCOURSE_API_KEY": os.getenv("DISCOURSE_API_KEY"),
"DISCOURSE_USERNAME": os.getenv("DISCOURSE_USERNAME"),
"DISCOURSE_CATEGORY": os.getenv("DISCOURSE_CATEGORY"),
# Release information from GitHub
"RELEASE_TAG": os.getenv("RELEASE_TAG"),
"RELEASE_BODY": os.getenv("RELEASE_BODY"),
"RELEASE_URL": os.getenv("RELEASE_URL"),
"REPO_NAME": os.getenv("REPO_NAME"),
}

missing_env_values = {key: value for key, value in env_config.items() if value is None}
if missing_env_values:
raise RuntimeError(
f"Missing required environment variables: {', '.join(missing_env_values.keys())}"
)
return env_config


def find_category_id(config: dict[str, str]) -> int:
headers = {
"Api-Key": config["DISCOURSE_API_KEY"],
"Api-Username": config["DISCOURSE_USERNAME"],
"Content-Type": "application/json",
}

category_to_find = config["DISCOURSE_CATEGORY"].lower()
url = f"{config['DISCOURSE_URL']}/categories.json"
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
except Exception as e:
print("Error fetching categories")
raise

if data.get("category_list") and data["category_list"].get("categories"):
categories = data["category_list"]["categories"]

for category in categories:
cat_id = category.get("id")
cat_name = category.get("name")
if cat_name.lower() == category_to_find:
return int(cat_id)

raise ValueError(f"Category '{category_to_find}' not found")


def format_release_content(config: dict[str, str]) -> tuple[str, str]:
title = f"🚀 Release {config['RELEASE_TAG']}"
repo_name = config["REPO_NAME"].split("/")[1]
content = f"""A new release of **{repo_name}** is now available!

## 📦 Release Information

- **Version:** `{config["RELEASE_TAG"]}`
- **Repository:** [{config["REPO_NAME"]}](https://github.com/{config["REPO_NAME"]})
- **Release Page:** [View on GitHub]({config["RELEASE_URL"]})
- Note: It may take some time for the release to appear on PyPI and conda-forge.

## 📋 Release Notes

{config["RELEASE_BODY"]}

---

*This post was automatically generated from the GitHub release.*
"""

return title, content


def publish_release_to_discourse(config: dict[str, str]) -> bool:
print("🎯 GitHub Release to Discourse Publisher")
print(f"Release: {config['RELEASE_TAG']}")
print(f"Repository: {config['REPO_NAME']}")
print(f"Target Forum: {config['DISCOURSE_URL']}")
print(f"Target Category: {config['DISCOURSE_CATEGORY']}")
print("-" * 50)

category_id = find_category_id(config)
print(f"Publishing to category: {config['DISCOURSE_CATEGORY']} (ID: {category_id})")

# Format the release content
title, content = format_release_content(config)

# Create the topic data
topic_data = {"title": title, "raw": content, "category": category_id}

# Post to Discourse
headers = {
"Api-Key": config["DISCOURSE_API_KEY"],
"Api-Username": config["DISCOURSE_USERNAME"],
"Content-Type": "application/json",
}
url = f"{config['DISCOURSE_URL']}/posts.json"

try:
response = requests.post(url, headers=headers, data=topic_data)
Copy link

Copilot AI Jul 21, 2025

Choose a reason for hiding this comment

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

The request should use 'json=topic_data' instead of 'data=topic_data' since the headers specify 'Content-Type: application/json' but this parameter sends form-encoded data.

Suggested change
response = requests.post(url, headers=headers, data=topic_data)
response = requests.post(url, headers=headers, json=topic_data)

Copilot uses AI. Check for mistakes.

Copy link
Member Author

@ricardoV94 ricardoV94 Jul 21, 2025

Choose a reason for hiding this comment

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

data seemed to work fine, but this is something I'm still not sure about, also on how to pass from the workflow to script. Rather test and see how it goes

response.raise_for_status()

data = response.json()
topic_id = data.get("topic_id")
post_id = data.get("id")

print("✅ Release published successfully!")
print(f"Topic ID: {topic_id}")
print(f"Post ID: {post_id}")
print(f"URL: {config['DISCOURSE_URL']}/t/{topic_id}")
return True

except requests.exceptions.RequestException as e:
print(f"❌ Error publishing release: {e}")
if hasattr(e, "response") and e.response is not None:
print(f"Response status: {e.response.status_code}")
try:
error_data = e.response.json()
print(f"Error details: {error_data}")
except Exception:
print(f"Response content: {e.response.text}")
raise


if __name__ == "__main__":
config = load_config()
publish_release_to_discourse(config)