How We Turned GitHub Activity Into Automated Portfolio Updates for Client Trust

Every consultancy has the same dirty secret: the portfolio page is six months stale. The case studies reference last year's stack. The "projects completed" counter stopped incrementing the week it was manually typed into an HTML file. Meanwhile, your team ships code daily, publishes blog posts weekly, and runs production systems around the clock — and precisely zero of that momentum reaches the one page prospects actually visit before deciding whether to book a call.

At Ledd Consulting, we run 25 services on a single VPS, publish technical content through a Ghost instance, and operate multiple autonomous agent pipelines. Every day, real work happens. The question was: how do we make the portfolio page reflect that work automatically, so the numbers a prospect sees at 2 AM are accurate as of 6 AM that morning?

The Pain Point

Consultancies live and die on credibility signals. When a prospect lands on your site, they evaluate three things in under ten seconds: do these people actually build things, how recently did they build them, and at what scale?

A portfolio page that says "50+ projects delivered" with a copyright footer from last year answers all three questions — unfavorably. The prospect assumes you peaked, moved on, or lack the discipline to maintain your own systems. And they would be right to assume that, because most consultancies treat their portfolio as a one-time design project rather than a living data product.

The real cost is invisible. You close fewer deals. The deals you close require more convincing. And the ones that got away never tell you why — they just ghosted after visiting a page that looked like a museum exhibit.

Why Common Solutions Fall Short

Most teams try one of three approaches, and each breaks in its own way.

Manual updates on a cadence. Someone adds "update portfolio" to a Monday task list. It gets done twice, then falls off. By month three, the numbers are stale again. The fundamental problem: humans are unreliable at recurring low-urgency tasks.

CMS-driven portfolios. You wire up a headless CMS and tell the team to "just add entries." This shifts the burden from one person to everyone, which means it lands on nobody. CMS-driven portfolios also suffer from a structural flaw — they require authoring work on top of the actual work. Every shipped feature now demands a second creative effort to describe it.

GitHub profile READMEs with badges. Better, but limited. Badge services show commit streaks and language breakdowns. They tell a developer's story, yet they fail to tell a consultancy's story. Prospects care about systems running in production, content being published, and pipelines processing real data — GitHub contribution graphs communicate effort, yet they say little about outcomes.

Our Approach

We built two complementary updaters that run on systemd timers: one refreshes a GitHub profile README with live infrastructure stats, and one updates the consulting site's HTML with current production numbers. Both pull from the same real data sources — the blog database, report directories, service logs, and system timers — and both run daily before business hours.

The architecture follows a simple principle: the portfolio is a projection of production state, generated the same way we generate any other report.

┌──────────────────────────────────────────────┐
│              systemd timers                    │
│         (daily, 6:00 AM + 6:30 AM EST)        │
└──────────┬──────────────────┬─────────────────┘
           │                  │
     ┌─────▼──────┐    ┌─────▼──────────┐
     │  portfolio  │    │ github-profile  │
     │  updater    │    │ updater         │
     └──┬──┬──┬───┘    └──┬──┬──┬───────┘
        │  │  │            │  │  │
   ┌────▼┐ │ ┌▼────┐  ┌───▼┐ │ ┌▼─────────┐
   │Ghost│ │ │Logs │  │ gh │ │ │Report Dir│
   │ DB  │ │ │     │  │CLI │ │ │          │
   └─────┘ │ └─────┘  └────┘ │ └──────────┘
      ┌────▼────┐        ┌───▼──────┐
      │systemctl│        │Agent Conf│
      │ timers  │        │  Parser  │
      └─────────┘        └──────────┘

Every data source is read-only. The updaters query — they modify only the output files. If any single source fails, the updater falls back to the last known value rather than zeroing out the stat. This makes the system resilient to transient failures in any upstream service.

Implementation

Data Collection Layer

Each stat comes from a dedicated collector function. Here is how we pull the published blog post count directly from the Ghost CMS database:

function getBlogPostCount() {
  try {
    const result = execSync(
      `sqlite3 "${GHOST_DB_PATH}" "SELECT count(*) FROM posts WHERE status='published';"`,
      { encoding: 'utf8', timeout: 5000 }
    ).trim();
    const count = parseInt(result, 10);
    log(`Ghost published posts: ${count}`);
    return count;
  } catch (e) {
    log(`WARNING: Could not read Ghost DB: ${e.message}`);
    return null;
  }
}

The 5-second timeout is deliberate. SQLite reads on our VPS complete in under 100ms. If a query takes 5 seconds, something is structurally wrong, and we want to fail fast rather than block the entire update pipeline.

We use the same pattern for the latest posts, which appear as links on both the GitHub profile and the consulting site:

function getLatestBlogPosts(limit = 3) {
  try {
    const result = execSync(
      `sqlite3 "${GHOST_DB_PATH}" "SELECT title, slug, published_at FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT ${limit};"`,
      { encoding: 'utf8', timeout: 5000 }
    ).trim();
    return result.split('\n').filter(Boolean).map(line => {
      const parts = line.split('|');
      return { title: parts[0], slug: parts[1], published_at: parts[2] };
    });
  } catch (e) {
    log(`WARNING: Could not read Ghost posts: ${e.message}`);
    return [];
  }
}

Counting What Runs in Production

The automation count comes straight from systemd. We count active timers, because timers represent scheduled work that actually executes — a far more honest metric than "services deployed":

function getTimerCount() {
  try {
    const result = execSync(
      'systemctl list-timers --all --no-pager --no-legend 2>/dev/null',
      { encoding: 'utf8', timeout: 5000 }
    );
    const lines = result.trim().split('\n').filter(Boolean);
    return lines.length;
  } catch (e) {
    log(`WARNING: Could not count timers: ${e.message}`);
    return 0;
  }
}

In our production environment, this returns 60+ active timers — each one representing a real scheduled pipeline, from research report generation to social content publishing to the portfolio updater itself.

Agent Configuration as a Data Source

One of the more interesting collectors parses our agent orchestration config to count active agents. Rather than hardcoding a number, the updater reads the runner configuration file and extracts the count programmatically:

function getAgentCount() {
  try {
    const content = fs.readFileSync(AGENT_RUNNER_PATH, 'utf8');
    const match = content.match(/(\d+)\s*agents/i);
    if (match) {
      log(`Agent count from runner: ${match[1]}`);
      return parseInt(match[1], 10);
    }
    // Fallback: count role entries
    const roles = (content.match(/role:\s*'/g) || []).length;
    log(`Agent count from roles: ${roles}`);
    return roles || 33;
  } catch (e) {
    log(`WARNING: Could not read agent runner: ${e.message}`);
    return 33;
  }
}

This means when we add a new agent to the fleet, the portfolio updates itself on the next daily run. Zero manual intervention. The number of agents on the consulting site always matches the number of agents in production config.

Graceful Degradation

Every collector follows the same contract: return a value on success, return null or a safe fallback on failure. The README generator checks each stat before templating:

function run(cmd, opts = {}) {
  try {
    return execSync(cmd, { encoding: 'utf-8', timeout: 30000, ...opts }).trim();
  } catch (e) {
    if (opts.fallback !== undefined) return opts.fallback;
    console.error(`Command failed: ${cmd}`);
    return '';
  }
}

The fallback option is key. Some stats — like uptime days or repository count — are acceptable to show as zero temporarily. Others, like agent count, should retain their last known value rather than dropping to zero and making the portfolio look broken. Each call site decides its own degradation strategy.

Report Directory Scanning

For metrics like research reports generated, we scan the actual output directory:

function getReportCount() {
  try {
    const files = fs.readdirSync(REPORTS_DIR).filter(f => f.endsWith('.md'));
    log(`Total research reports: ${files.length}`);
    return files.length;
  } catch (e) {
    log(`WARNING: Could not read reports: ${e.message}`);
    return 0;
  }
}

function getReportCountThisWeek() {
  try {
    const now = Date.now();
    const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
    const files = fs.readdirSync(REPORTS_DIR).filter(f => {
      if (!f.endsWith('.md')) return false;
      try {
        const stat = fs.statSync(path.join(REPORTS_DIR, f));
        return stat.mtimeMs > weekAgo;
      } catch { return false; }
    });
    log(`Reports this week: ${files.length}`);
    return files.length;
  } catch (e) {
    return 0;
  }
}

The "this week" variant is particularly effective for social proof. A prospect seeing "14 research reports generated this week" understands immediately that the system is active right now, compared to a lifetime total that could represent past effort.

Structured Logging for Auditability

Every updater run produces a dated log file:

function saveLog() {
  if (!fs.existsSync(LOG_DIR)) {
    fs.mkdirSync(LOG_DIR, { recursive: true });
  }
  const today = new Date().toISOString().slice(0, 10);
  const logPath = path.join(LOG_DIR, `update-${today}.log`);
  fs.writeFileSync(logPath, logLines.join('\n') + '\n');
  log(`Log saved to ${logPath}`);
}

This gives us a daily audit trail. When a number on the site looks unexpected, we check the log for that day and see exactly which sources returned what values. We have caught data source issues within hours this way — a Ghost DB migration that changed the schema, a moved directory, a renamed log file.

Results

Since deploying the automated portfolio pipeline, we have measured concrete improvements:

  • Portfolio freshness: Stats update daily at 6:00 AM EST. The longest a number stays stale is 24 hours, down from "whenever someone remembers."
  • Data sources connected: 9 live sources — Ghost DB, report directories, social logs, agent configs, systemd timers, job analysis reports, property trackers, GitHub API, and system uptime.
  • 60+ scheduled timers reflected accurately on the consulting site, counted from the actual systemd state.
  • 21 active agents reported by parsing production config files — a number that self-corrects when agents are added or removed.
  • Maintenance burden: Zero ongoing effort. The updaters themselves run as systemd timers. The system that updates the portfolio is itself tracked by the portfolio.
  • Failure rate: In 90+ days of daily runs, we have seen three partial failures (Ghost DB locked during backup), all handled gracefully by the fallback mechanism. The site always displayed accurate-or-close numbers.

The most meaningful result is qualitative: prospects who mention the portfolio in sales calls now reference specific numbers. "I saw you run 60 scheduled automations" is a different conversation opener than "your site looks nice." The specificity creates a credibility signal that generic portfolio design simply cannot replicate.

Adapting This for Your System

The pattern generalizes to any consultancy or dev shop that produces artifacts as part of normal work:

  1. Inventory your data sources. Every team already generates countable outputs — commits, deploys, tickets closed, tests passing, blog posts published, client projects shipped. List every system that holds a number worth showing.
  2. Write thin collector functions. Each one queries a single source, returns a typed value, and handles its own failures. Keep them independent — a broken blog database should have zero effect on your deploy count.
  3. Template from live data. Your portfolio page becomes a template with placeholders, filled by the collectors on each run. Static HTML with injected numbers performs identically to hand-crafted pages while staying permanently current.
  4. Schedule aggressively. Daily runs are a minimum. If your data changes hourly, run hourly. The compute cost is negligible — our entire pipeline completes in under 3 seconds — and freshness directly correlates with perceived credibility.
  5. Log everything. When a number looks wrong in six months, you want a timestamped record of exactly what each collector returned on that day.

The key insight: your portfolio already exists in your production systems. It lives in your databases, your file systems, your CI pipelines, and your monitoring dashboards. The only missing piece is the projection layer that turns production state into public-facing social proof.

Conclusion

Stale portfolios cost consultancies real revenue. Every day your site displays last quarter's numbers, a prospect somewhere decides you are less active than you are. The fix is straightforward: treat your portfolio as a derived view of production state, regenerate it on a schedule, and let the work speak for itself through current, verifiable numbers.

We built this system at Ledd Consulting in under a day. It runs reliably on a single VPS, queries 9 data sources, and keeps two public-facing surfaces — our GitHub profile and consulting site — permanently current. The total infrastructure cost is one systemd timer and a few hundred lines of Node.js.

Ledd Consulting automates the trust signals that win contracts — ask us about self-updating portfolio systems.

Need help building AI agent systems or designing multi-agent architectures? Ledd Consulting specializes in autonomous workflow design and agent orchestration for enterprise teams.

Read more

Intelligence Brief — Saturday, April 11, 2026

MetalTorque Daily Brief — 2026-04-11 Cross-Swarm Connections The Audit Trail Is the Attack Surface — Everywhere. Three swarms converged on the same structural conclusion from radically different entry points. Agentic Design found that peer-preservation corrupts agent-generated logs, confidence inflation poisons self-reported metrics, and context contamination makes audit-time behavior diverge from production behavior.

By Ledd Consulting