As developers managing modern web portfolios, agency sites, or open-source software, keeping visitors updated on what we are building is crucial. However, manually writing release notes for every code update is tedious—and third-party changelog SaaS widgets often come with monthly fees, unwanted branding, or invasive tracking scripts.
When building out the digital platforms for SWAPP Technologies and ¡Hola from Ola!, I wanted an automated, self-hosted activity changelog that would aggregate public commits, version releases, pull requests, and closed issues across all my GitHub repositories—without incurring API rate limits or runtime server costs.
Here is a deep dive into how I solved this challenge using GitHub Actions, the GitHub REST API, and a zero-latency static JSON architecture on GitHub Pages.
🛑 The Problem: Client-Side API Rate Limits & SaaS Bloat
If you try to build a live developer feed by making direct JavaScript fetch() calls from your visitor's browser to GitHub's public API endpoint (https://api.github.com/users/{username}/events/public), you immediately run into two major bottlenecks:
- Strict API Rate Limits: GitHub enforces a strict limit of 60 unauthenticated API requests per hour per IP address. If multiple visitors view your site or refresh the page, your changelog breaks with HTTP 403 rate-limit errors.
- Exposing Secrets: Passing a personal access token (
GITHUB_TOKEN) inside client-side JavaScript to bypass the rate limit is a security anti-pattern, as it exposes your authentication token to anyone inspecting browser network traffic.
💡 The Solution: Asynchronous Build-Time Aggregation
Instead of forcing site visitors to query GitHub’s servers, we move the data retrieval to an asynchronous build pipeline:

By decoupling API fetching from page rendering, visitors read a statically hosted changelog.json directly from GitHub Pages. The result is instant load times, zero API rate limits, and zero hosting costs.
🔍 Understanding the GitHub REST API & Endpoints
GitHub provides robust REST API endpoints for accessing user event streams:
- Official Endpoint:
GET https://api.github.com/users/{username}/events/public - Documentation: GitHub REST API — List Public Events for a User
Event Payload Structure
The GitHub API returns an array of event objects. To create a meaningful developer changelog, we target four specific event types while filtering out noise like stars (WatchEvent) or forks (ForkEvent):
PushEvent: Triggered when code is pushed to a repository branch. Contains an array of commit objects with SHA hashes and commit messages.ReleaseEvent: Triggered when a new release tag or release note is published (GitHub Release API Docs).PullRequestEvent: Triggered when pull requests are opened, merged, or closed (GitHub Pull Requests API Docs).IssuesEvent: Triggered when issues are opened or resolved (GitHub Issues API Docs).
⚙️ Setting Up the GitHub Action Workflow
To automate the pipeline, create a workflow file inside your website repository at .github/workflows/update-changelog.yml.
This workflow utilizes GitHub Actions Scheduled Events (Cron) to run automatically every hour:
name: Generate Public GitHub Changelog
on:
schedule:
- cron: '0 * * * *' # Runs every hour on the hour
workflow_dispatch: # Allows manual trigger from GitHub Actions UI
permissions:
contents: write
jobs:
build-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Fetch & Process GitHub Public Activity
run: |
python -c "
import urllib.request, json, os, datetime
USERNAME = '${{ github.repository_owner }}'
url = f'https://api.github.com/users/{USERNAME}/events/public'
req = urllib.request.Request(url, headers={'User-Agent': 'GitHub-Action-Changelog-Generator'})
try:
with urllib.request.urlopen(req) as resp:
events = json.loads(resp.read().decode('utf-8'))
changelog = []
for event in events:
repo = event.get('repo', {}).get('name', '').split('/')[-1]
created_at = event.get('created_at', '')
event_type = event.get('type')
payload = event.get('payload', {})
# 1. CODE COMMITS
if event_type == 'PushEvent':
for commit in payload.get('commits', []):
msg = commit.get('message', '').strip()
if msg and not msg.startswith('Merge'):
title = msg.split('\n')[0] # Extract first line
changelog.append({
'type': 'commit',
'repo': repo,
'message': title,
'sha': commit.get('sha', '')[:7],
'date': created_at
})
# 2. RELEASES
elif event_type == 'ReleaseEvent':
rel_name = payload.get('release', {}).get('name') or payload.get('release', {}).get('tag_name')
changelog.append({
'type': 'release',
'repo': repo,
'message': f'Published Release {rel_name}',
'sha': '',
'date': created_at
})
# 3. PULL REQUESTS
elif event_type == 'PullRequestEvent':
action = payload.get('action')
pr = payload.get('pull_request', {})
pr_title = pr.get('title', '')
pr_num = pr.get('number', '')
is_merged = pr.get('merged', False)
if action == 'closed' and is_merged:
msg = f'Merged PR #{pr_num}: {pr_title}'
elif action == 'opened':
msg = f'Opened PR #{pr_num}: {pr_title}'
else:
continue
changelog.append({
'type': 'pr',
'repo': repo,
'message': msg,
'sha': '',
'date': created_at
})
# 4. ISSUES
elif event_type == 'IssuesEvent':
action = payload.get('action')
issue = payload.get('issue', {})
issue_title = issue.get('title', '')
issue_num = issue.get('number', '')
if action in ['opened', 'closed']:
status_label = 'Resolved Issue' if action == 'closed' else 'Opened Issue'
changelog.append({
'type': 'issue',
'repo': repo,
'message': f'{status_label} #{issue_num}: {issue_title}',
'sha': '',
'date': created_at
})
# Save top 50 recent events to changelog.json
with open('changelog.json', 'w', encoding='utf-8') as f:
json.dump(changelog[:50], f, indent=2)
print('Changelog JSON updated successfully.')
except Exception as e:
print(f'Error updating changelog: {e}')
"
- name: Commit & Push Updated Changelog JSON
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action Bot"
git add changelog.json
git diff --quiet && git diff --staged --quiet || (git commit -m "chore: auto-update public changelog.json" && git push)The Magical GitHub Action that does most of the work 💪🏾
🎨 Building the Responsive Frontend (changelog.html)
On the client side, we create a clean, responsive HTML page matching our dark-mode design system. The page contains an empty container <div id="changelog-feed"></div> and a lightweight JavaScript snippet that fetches changelog.json on page load:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Developer Activity & Changelog</title>
<style>
:root {
--bg-color: #1a1a1a;
--card-bg: #232323;
--text-white: #ffffff;
--text-muted: #a7a7a7;
--border-color: #333333;
}
body {
background-color: var(--bg-color);
color: var(--text-white);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
margin: 0;
padding: 40px 20px;
}
.container { max-width: 800px; margin: 0 auto; }
.header { border-bottom: 2px solid var(--border-color); padding-bottom: 20px; margin-bottom: 30px; }
.header h1 { color: var(--text-white); margin: 0 0 10px 0; font-size: 2.2rem; }
.header p { color: var(--text-muted); margin: 0; }
.timeline { position: relative; padding-left: 20px; border-left: 2px solid var(--border-color); }
.timeline-item { position: relative; margin-bottom: 22px; padding-left: 20px; }
.timeline-item::before {
content: ''; position: absolute; left: -27px; top: 12px;
width: 12px; height: 12px; border-radius: 50%;
background-color: var(--event-color, #ff8100);
box-shadow: 0 0 8px var(--event-color, #ff8100);
}
.card {
background-color: var(--card-bg);
border-radius: 8px; padding: 14px 18px;
border: 1px solid var(--border-color);
}
.card-meta {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px; font-size: 0.85rem;
}
.badge-wrap { display: flex; align-items: center; gap: 8px; }
.type-badge {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
padding: 2px 8px; border-radius: 4px; color: #fff;
}
.repo-tag { color: var(--text-white); font-weight: 600; font-size: 0.9rem; }
.date-tag { color: var(--text-muted); font-size: 0.8rem; }
.card-msg { margin: 4px 0 0 0; font-size: 1rem; color: var(--text-white); }
.sha-tag { font-family: monospace; color: var(--text-muted); font-size: 0.8rem; }
.loading { text-align: center; color: var(--text-muted); font-style: italic; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 Developer Activity & Changelog</h1>
<p>Automated public feed tracking commits, releases, pull requests, and issues across GitHub repositories.</p>
</div>
<!-- EMPTY BODY CONTAINER FOR JAVASCRIPT CONTENT -->
<div id="changelog-feed" class="timeline">
<div class="loading">Loading activity feed...</div>
</div>
</div>
<script>
const EVENT_CONFIG = {
commit: { label: 'Commit', color: '#ff8100' },
release: { label: 'Release', color: '#25d366' },
pr: { label: 'Pull Request', color: '#a855f7' },
issue: { label: 'Issue', color: '#06b6d4' }
};
async function loadChangelog() {
const feedEl = document.getElementById('changelog-feed');
try {
const response = await fetch('changelog.json');
if (!response.ok) throw new Error('Data not found');
const data = await response.json();
if (!data || data.length === 0) {
feedEl.innerHTML = '<div class="loading">No recent public activity found.</div>';
return;
}
feedEl.innerHTML = data.map(item => {
const cfg = EVENT_CONFIG[item.type] || EVENT_CONFIG.commit;
const formattedDate = new Date(item.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
return `
<div class="timeline-item" style="--event-color: ${cfg.color}">
<div class="card">
<div class="card-meta">
<div class="badge-wrap">
<span class="type-badge" style="background: ${cfg.color}">${cfg.label}</span>
<span class="repo-tag">${item.repo}</span>
${item.sha ? `<span class="sha-tag">#${item.sha}</span>` : ''}
</div>
<span class="date-tag">${formattedDate}</span>
</div>
<p class="card-msg">${escapeHtml(item.message)}</p>
</div>
</div>
`;
}).join('');
} catch (err) {
feedEl.innerHTML = '<div class="loading">Activity feed will update on the next automated GitHub run.</div>';
}
}
function escapeHtml(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[m]);
}
document.addEventListener('DOMContentLoaded', loadChangelog);
</script>
</body>
</html>Example of a Responsive Frontend
📷 The Result: Automated Live Changelog in Action
Below is an example screenshot of the working changelog feed deployed live on the site:

Figure 1: Real-time aggregated developer activity feed featuring color-coded badges for commits, releases, merged PRs, and resolved issues.
🎯 Conclusion
By combining GitHub Actions, urllib Python processing, and GitHub Pages static JSON delivery, we built an enterprise-grade developer changelog system with:
- $0 Monthly Overhead: Leverages free GitHub Actions compute minutes and free GitHub Pages static hosting.
- Zero API Rate Limits: Page views hit local static
changelog.jsonfiles instead of external API endpoints. - Full Customization: Complete styling control over color palettes, typography, and event filtering without third-party iframe badges.
Whether you're running a personal dev portfolio or an IT consulting agency like SWAPP Technologies, this architecture gives your visitors a transparent, automated window into your ongoing software engineering journey!