The Problem
Java Evolved is a static site with 113 code patterns showing the old way vs. the modern way to write Java. Each pattern has a title, summary, old/modern approach labels, JDK version, and a link to its detail page.
I wanted to promote each pattern on Twitter — one per week, in random order, cycling forever. The requirements:
-
Fully automated — no manual tweeting
-
Pre-drafted tweets — reviewable and editable before they go live
-
Resumable — survives failures, picks up where it left off
-
Auditable — git history shows what was posted and when
-
Zero infrastructure — no servers, no databases, no paid services
The Architecture
The system has three components:
content/*.yaml → [Queue Generator] → social/queue.txt
→ social/tweets.yaml
→ social/state.yaml
social/* → [Post Script] → Twitter API v2 → updated state
GitHub Actions cron → runs Post Script every Monday
Everything lives in the repository. State is tracked via committed files, not external databases.
Component 1: The Queue & Tweet Generator
File: html-generators/generatesocialqueue.java
A JBang script that scans all content YAML files, shuffles them randomly, and produces three files:
-
social/queue.txt — the posting order, one category/slug per line
-
social/tweets.yaml — pre-drafted tweet text for each pattern
-
social/state.yaml — a pointer tracking where we are in the queue
The tweet template looks like this:
☕ {title}
{summary}
{oldApproach} → {modernApproach} (JDK {jdkVersion}+)
🔗 https://javaevolved.github.io/{category}/{slug}.html
#Java #JavaEvolved
The generator also validates that every tweet fits within Twitter's 280-character limit. If a summary is too long, it's automatically truncated with an ellipsis. Of the 113 patterns, 12 needed truncation.
Handling New Patterns
When you re-run the generator after adding new content files, it detects new patterns and appends them to the end of the existing queue — preserving the current order and any manual tweet edits. Deleted or renamed patterns are automatically pruned.
Use --reshuffle to force a full reshuffle when the cycle is exhausted.
Component 2: The Post Script
File: html-generators/socialpost.java
Another JBang script that:
- Reads the current index from
social/state.yaml
- Looks up the next pattern key in
social/queue.txt
- Retrieves the pre-drafted tweet text from
social/tweets.yaml
- Posts to the Twitter API v2 using OAuth 1.0a
- Updates the state file only after confirmed API success
OAuth 1.0a in Java
I initially planned to use a shell script with curl and openssl for OAuth signing. That turned out to be a bad idea — percent-encoding, signature base strings, and nonce generation are error-prone in Bash.
Instead, the post script uses Java's built-in java.net.http.HttpClient and javax.crypto.Mac for HMAC-SHA1 signing. Here's the core of the OAuth signature:
// Build signature base string
var paramString = oauthParams.entrySet().stream()
.map(e -> percentEncode(e.getKey()) + "=" + percentEncode(e.getValue()))
.collect(Collectors.joining("&"));
var baseString = method + "&" + percentEncode(url) + "&" + percentEncode(paramString);
var signingKey = percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret);
// HMAC-SHA1
var mac = javax.crypto.Mac.getInstance("HmacSHA1");
mac.init(new javax.crypto.spec.SecretKeySpec(
signingKey.getBytes(UTF_8), "HmacSHA1"));
var signature = Base64.getEncoder().encodeToString(
mac.doFinal(baseString.getBytes(UTF_8)));
The script also supports --dry-run to preview the next tweet without posting:
$ jbang html-generators/socialpost.java --dry-run
Queue has 113 entries, current index: 1
Pattern: language/guarded-patterns
Tweet (200 chars):
---
☕ Guarded patterns with when
Add conditions to pattern cases using when guards.
Nested if → when Clause (JDK 21+)
🔗 https://javaevolved.github.io/language/guarded-patterns.html
#Java #JavaEvolved
---
DRY RUN — not posting.
Component 3: The GitHub Actions Workflow
File: .github/workflows/social-post.yml
name: Weekly Social Post
on:
schedule:
- cron: '014**1' # Every Monday at 14:00 UTC (10 AM ET)
workflow_dispatch: # Manual trigger
concurrency:
group: social-post
cancel-in-progress: false
jobs:
post:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- uses: jbangdev/setup-jbang@main
- name: Post to Twitter
env:
TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_APP_CONSUMER_KEY }}
# ... other secrets
run: jbang html-generators/socialpost.java
- name: Commit updated state
run: |
git add social/state.yaml
git commit -m "chore: update social post state [skip ci]"
git pull --rebase
git push
A few details worth noting:
-
concurrency group prevents double-posts if a manual dispatch overlaps with the cron
-
[skip ci] in the commit message prevents the state update from triggering other workflows
-
Social files live in
social/, not content/ — the deploy workflow watches content/**, so keeping state separate avoids unnecessary site rebuilds
-
git pull --rebase before push handles the rare case where another commit lands between checkout and push
The Economics
Twitter's API pricing means each tweet costs about 0ドル.01. With 113 patterns posted weekly:
- ~0ドル.52/year
-
~2.2 years of unique content per cycle before reshuffling
That's essentially free for a perpetual social media presence.
Built in a Single Copilot CLI Session
This entire feature — the queue generator, the post script, the GitHub Actions workflow, the tweet drafts, the documentation updates — was built in a single interactive session with GitHub Copilot CLI. From planning to the first live tweet, everything happened in the terminal.
The session included planning the architecture, getting a rubber-duck critique (which caught several issues — like using shell for OAuth signing and putting state files where they'd trigger deploys), implementing all three components, testing locally with --dry-run, committing, pushing, and triggering the first real tweet.
You can read the full session transcript here: gist.github.com/brunoborges/40ef1b5e9b05de279dab64e443b96a11
What I'd Do Differently
-
Add Bluesky support — the AT Protocol API is simpler than Twitter's OAuth 1.0a. The architecture already supports it; just add a second API call in the post script.
-
Content hash tracking — if a pattern's title or summary changes, the pre-drafted tweet goes stale. A hash per entry would flag which drafts need regeneration.
Try It Yourself
The entire implementation is open source at github.com/javaevolved/javaevolved.github.io. You'll need:
- A Twitter Developer account with OAuth 1.0a credentials (Read + Write)
- Java 25+ and JBang
- Content in YAML with
title, summary, oldApproach, modernApproach, jdkVersion, category, and slug fields
Generate the queue, review the drafts, push, and let GitHub Actions handle the rest.
Follow @javaevolved for a new modern Java pattern every Monday.