Swapp Tech Blog

ARTICLE / Swapp Tech Blog

ARTICLE / Swapp Tech Blog

Continued: Swapp Tech Blog

📢 Sponsored / Affiliate Disclosure Included

Building an Automated, Rate-Limit-Free GitHub Changelog for Static Sites Using GitHub Actions

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:

  1. 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.
  2. 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:

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):

  1. PushEvent: Triggered when code is pushed to a repository branch. Contains an array of commit objects with SHA hashes and commit messages.
  2. ReleaseEvent: Triggered when a new release tag or release note is published (GitHub Release API Docs).
  3. PullRequestEvent: Triggered when pull requests are opened, merged, or closed (GitHub Pull Requests API Docs).
  4. 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 => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
      })[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:

Live Working GitHub Changelog UI Placeholder

Figure 1: Real-time aggregated developer activity feed featuring color-coded badges for commits, releases, merged PRs, and resolved issues.


🎯 Conclusion

By combining GitHub Actionsurllib 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.json files 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!

Work on a Solution with Swapp

You can contact us in multiple ways!


Sign up for our Newsletter

© Copyright 2020 Swapp Technologies LLC - All Rights Reserved