Back to blog

Guides · May 5, 2026

Build in public Twitter automation: a daily devlog tweet from yesterday's GitHub commits

It is 19:30 and you sit down to write today's build-in-public tweet, scroll your commits, type it badly, give up. Here is the daily cron that drafts the tweet for you and parks it in Typefully for tomorrow morning, so the boring part is done before bed.
crontap.com / blog
An indie hacker pattern for automating the daily build-in-public tweet: GitHub commits, GPT-5-mini, and a Typefully draft scheduled for tomorrow at 09:30 in your timezone, on a Crontap clock, for about two cents a month.

It is 19:30. You sit down to write today's build-in-public tweet. You open GitHub, scroll your commits, try to summarize what the week of you actually shipped, type something self-conscious, delete it, type something more self-conscious, give up. Three nights of skipping later, the audience that was following along starts to wonder whether you stopped shipping.

The fix is small, and most of the building blocks already exist. There are at least five open-source repos that turn yesterday's GitHub commits into a tweet draft using an LLM (IndieLog, buildinpublic-x, commit-to-tweet, and a couple more). A productized SaaS, BuildLog, charges $11.99 a month for the same idea. Every one of them defaults to GitHub Actions cron for the schedule, and every one of them inherits the two well-known problems with that choice: GitHub Actions cron drifts 30 to 60 minutes during high load periods, and scheduled workflows auto-disable after 60 days of repo inactivity. I covered the drift in detail in Why GitHub Actions cron misses and what to use instead. The auto-disable is the bigger problem for build-in-public people specifically: the most useful time for an automated devlog is exactly when you are not committing, which is exactly when GitHub turns the cron off.

Here is the version that does not break: a Crontap schedule firing at 19:30 in your local timezone, a small backend route pulling yesterday's commits from the GitHub REST API, GPT writing a draft tweet, and Typefully holding it as a scheduled draft for tomorrow at 09:30 your time. You wake up, glance at it on your phone, edit two words, tap publish. Total cost: about two cents a month.

If you want the short version: one Crontap schedule, one HTTP route, one GitHub API call, one OpenAI call, one Typefully draft. Five steps you can ship before bed tonight.

Why the existing repos all stop short

The pattern is solved at the prompt level. The shipping problems are mostly schedule problems and edit-loop problems.

GitHub Actions cron drifts. The official docs say "the schedule event can be delayed during periods of high load." In practice that means your 19:30 cron fires somewhere between 19:30 and 20:30, with the drift being worst on Sundays and at the top of the hour. For a tweet that is supposed to fire at the same moment your audience checks Twitter on the bus home, that drift is the difference between "yes she ships" and "wait, was that yesterday?".

Scheduled workflows auto-disable. GitHub disables scheduled workflows in repos that have not had any activity for 60 days. The product reasoning is to save compute on dead repos. The side effect is that if you take a two-month break (sabbatical, vacation, you got busy with the day job), your devlog tweet stops without telling you. Coming back, you push a new commit, you assume the cron is firing, you wonder why your tweet engagement dropped.

Most repos auto-publish. They use the X (Twitter) API directly with POST /2/tweets, sending the GPT-generated text live. That removes the human edit window. GPT writes a perfectly serviceable tweet 80% of the time and a tone-deaf one 20% of the time. The 20% goes out unsupervised because the cron does not wait for you. The build-in-public crowd is small enough that one bad tweet in your timeline is the one people remember.

UTC drift compounds the timezone problem. GitHub Actions cron expressions are UTC. If you live in California and your audience is in Europe, you are juggling two timezones in your head when you write the cron expression, and you are wrong twice a year on DST switchovers. Crontap holds the schedule in your IANA timezone (America/Los_Angeles, Europe/Berlin, whatever) and DST takes care of itself.

The shape that fixes all four: drift-free cron in your timezone, drafts (not auto-publish) so you keep the edit window, and a draft platform that survives even if GPT had a tone-deaf night.

The shape

Four boxes, each doing one thing.

Crontap  →  HTTPS POST  →  /devlog/draft-today  →  GitHub API  →  OpenAI  →  Typefully draft

The cron fires at 19:30 in your IANA timezone. The route hits GET /repos/:owner/:repo/commits?since=<24h ago> for one or many repos. It collects commit messages and (optionally) the changed-file paths. It hands the array to GPT with a tweet-shaped system prompt that explicitly forbids marketing language and em-dashes (every LLM tries em-dashes; you have to ask). It validates the response is under 240 characters. It posts to Typefully via POST /v1/drafts/ with schedule_date_iso8601 set to tomorrow morning 09:30 your timezone.

When you wake up, the draft is sitting in Typefully. You scan it on the train, edit two words, tap publish, move on with your day. The cron does the boring part; you keep the voice.

You can swap any box without touching the others. Move from Typefully to Hypefury, the cron does not care. Use Claude Haiku instead of gpt-5-mini, the schedule does not change. Add a second repo, just edit the route to iterate.

Worked example: a 47-commit week

The route is one handler. The exact code below assumes Next.js or any Node-shaped HTTP route; the shape is identical on Express, Hono, Cloudflare Workers, anywhere that speaks HTTP.

Step 1: pull yesterday's commits

GitHub's REST API returns commits in reverse chronological order with a since query param.

const SINCE_HOURS = 24;
 
async function listCommits(owner: string, repo: string) {
  const since = new Date(Date.now() - SINCE_HOURS * 3600 * 1000).toISOString();
  const url = `https://api.github.com/repos/${owner}/${repo}/commits?since=${since}&per_page=100`;
  const res = await fetch(url, {
    headers: {
      "User-Agent": "devlog-bot",
      Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
      Accept: "application/vnd.github+json",
    },
  });
  if (!res.ok) throw new Error(`GitHub ${res.status}`);
  const commits = await res.json();
  return commits.map((c: any) => ({
    sha: c.sha.slice(0, 7),
    message: c.commit.message.split("\n")[0],
    files: c.files?.map((f: any) => f.filename) ?? [],
  }));
}

For a private repo you need a personal access token with repo:read (classic) or a fine-grained token scoped to the repo with "Contents: read" permission. For a public repo you can skip the token but you will hit the unauthenticated rate limit (60/hour) faster.

The since window is 24 hours by default. You can stretch it to 48 hours on weekends so Monday's tweet covers Saturday and Sunday too. That is the part where the cron expression earns its keep: pick the cadence and timezone once, the route reads the right window every time.

Step 2: ask GPT for a draft tweet

The prompt has three constraints that matter. First, character limit (240 to leave room for any URL Twitter shortens). Second, "no marketing language" because GPT will reach for "exciting", "amazing", and "we're thrilled" by default. Third, an explicit "no em-dashes" line because every model on every provider still tries em-dashes in 2026.

import OpenAI from "openai";
const openai = new OpenAI();
 
async function draftTweet(commits: any[]) {
  const completion = await openai.chat.completions.create({
    model: "gpt-5-mini",
    messages: [
      {
        role: "system",
        content: [
          "You are an indie hacker writing today's #buildinpublic tweet.",
          "Constraints:",
          "- Maximum 240 characters total, including the #buildinpublic hashtag.",
          "- Reference one specific theme from the commits below. Do not generalize.",
          "- No marketing language (no 'excited', 'amazing', 'thrilled', 'launched').",
          "- No em-dashes. Use commas, colons, parentheses, or sentence breaks.",
          "- First-person, conversational, slightly wry. Like you are texting a friend.",
        ].join("\n"),
      },
      {
        role: "user",
        content: JSON.stringify(commits, null, 2),
      },
    ],
  });
  const text = completion.choices[0].message.content?.trim() ?? "";
  if (text.length > 240) {
    throw new Error(`Tweet too long: ${text.length} chars`);
  }
  if (text.includes("\u2014") || text.includes("\u2013")) {
    throw new Error("Em-dash slipped through; rerun");
  }
  return text;
}

The two server-side validators (length and em-dash check) are the cheap insurance. If GPT returns something that breaks either rule, the route throws, Crontap retries the next fire, and you find out about it five minutes later instead of finding it on Twitter tomorrow morning.

You can add a third validator that requires the tweet to mention at least one commit theme word (parse the commit messages, build a small whitelist of substantive nouns, check text.toLowerCase() includes at least one). For a 47-commit week with messages like "fix lockfile race", "ship onboarding step 3", "rewrite the prompt", that catches the case where GPT writes something generic like "shipped a lot today" and skips the actual work.

Step 3: drop it in Typefully as a scheduled draft

Typefully's API is one POST. The schedule_date_iso8601 field tells Typefully when to publish the draft if you do nothing; you can also use share: true to get a preview URL you can open from your phone notification.

import { addHours, setHours, setMinutes, setSeconds } from "date-fns";
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";
 
const TZ = "America/Los_Angeles";
 
function tomorrowMorning() {
  const now = new Date();
  const tomorrow = addHours(now, 24);
  const localMorning = setSeconds(setMinutes(setHours(tomorrow, 9), 30), 0);
  return fromZonedTime(localMorning, TZ).toISOString();
}
 
async function postDraft(text: string) {
  const res = await fetch("https://api.typefully.com/v1/drafts/", {
    method: "POST",
    headers: {
      "X-API-KEY": process.env.TYPEFULLY_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      content: text,
      schedule_date_iso8601: tomorrowMorning(),
      threadify: false,
      share: true,
    }),
  });
  if (!res.ok) throw new Error(`Typefully ${res.status}`);
  return await res.json();
}

The share: true flag returns a share_url you can include in a Slack DM to yourself or a Telegram bot ping, so you get a one-tap preview link instead of having to open Typefully in a browser.

Step 4: wire it together and schedule it

The handler ties the three steps together and returns 200 on success.

export async function POST(request: Request) {
  const auth = request.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
 
  const commits = await listCommits("yourname", "yourrepo");
  if (commits.length === 0) {
    return Response.json({ skipped: true, reason: "no commits in window" });
  }
 
  const text = await draftTweet(commits);
  const draft = await postDraft(text);
 
  return Response.json({ commits: commits.length, text, draft_url: draft.share_url });
}

In Crontap, one schedule:

  • URL: https://yourapp.com/devlog/draft-today
  • Method: POST
  • Headers: Authorization: Bearer <CRON_SECRET>
  • Cadence: 30 19 * * 1-6 (19:30, Monday through Saturday)
  • Timezone: America/Los_Angeles (or wherever you live)
  • Failure alert: email to yourself, fire on 4xx/5xx

The 1-6 skips Sunday because the Sunday cron would summarize Saturday alone, and Saturday is usually one good commit at midnight. If you prefer one tweet covering the whole weekend, use 30 19 * * * and stretch SINCE_HOURS to 48 on Mondays.

Fix this in 60 seconds with Crontap. Free tier available. No credit card. Schedule your first job →

Why this beats GitHub Actions cron specifically

The same handler runs identically on a GitHub Actions cron. The differences live entirely in the schedule layer.

Drift. GitHub Actions cron is best-effort and the docs are upfront about it. Crontap fires on the minute, every minute. For a tweet that is supposed to land at the same moment your audience checks Twitter, the difference between 19:32 and 20:14 is real.

Auto-disable. GitHub disables scheduled workflows in inactive repos after 60 days. Crontap does not care whether your repo has been touched; the schedule is owned by your Crontap account, not the repo. You can take a sabbatical and the schedule keeps firing (it just finds zero commits in the 24-hour window and returns 200 idle, which is correct).

Retries. GitHub Actions cron does not retry transient OpenAI 429s or Typefully 5xx. The run silently fails. Crontap retries on 5xx and emails you if the retries also fail. You find out about a problem in five minutes, not in two weeks when someone asks why you stopped tweeting.

Timezones. GitHub Actions cron is UTC only. Crontap holds the schedule in your IANA timezone, which means DST is automatic and "19:30 my time" is what you actually wrote.

Cost math

The pipeline is genuinely cheap.

  • GitHub REST API: free for public repos, generous on private with a personal token (5,000 requests per hour on classic tokens).
  • OpenAI: ~50 input tokens of context (the commit JSON for an average day) and ~80 output tokens (the tweet) is roughly $0.0005 per fire on gpt-5-mini. That is $0.015 per month for one tweet a day.
  • Typefully: free tier supports drafts and scheduling. The paid plan ($12/mo) adds analytics and threads, neither of which you need for this loop.
  • Crontap: the free tier covers a single schedule at hourly cadence; daily is comfortably under that.

Total: about two cents a month for the whole pipeline.

BuildLog productizes the same idea for $11.99/mo. It is a real product run by real people; if "I do not want to maintain even one HTTP route" is your decision criterion, BuildLog is a good choice. If you already have a backend (you do, you ship side projects), the DIY pattern is the difference between $11.99/mo and two cents.

FAQ

Why drafts, not auto-publish?

GPT writes a perfectly fine tweet most of the time and a tone-deaf one occasionally. The 240-char limit, the "no marketing" rule, and the em-dash check catch the worst cases, but they do not catch a tweet that is technically valid and emotionally wrong (the day you spent fixing a hard customer bug, GPT might frame it as "shipped a fix"). One human glance solves that. Drafts cost you 30 seconds the next morning; they save you the apology tweet.

What about multiple repos?

Two patterns. The first is "summarize across repos": iterate the array of repos, concat the commits, send one combined array to GPT, get one tweet. The second is "one tweet per repo": run the loop once per repo and post N drafts. The first is the better default; you ship in cohesive themes, the tweet should reflect that.

What happens on weekends?

The example uses 30 19 * * 1-6 which skips Sunday. If you commit Saturday night and want it tweeted Monday morning, change to 30 19 * * * and bump SINCE_HOURS to 48 on Mondays inside the route. Or skip the Sunday tweet entirely and let Saturday's commits land in Monday's draft via the 48-hour window.

Can I do this without committing my GitHub token to env?

Yes. Use a fine-grained personal access token with the minimum scopes ("Contents: read" on the specific repos you watch), rotate it every 90 days, store it in your hosting platform's env (Vercel/Netlify/Cloudflare/Render all have this). The token never leaves your hosting environment. Crontap only knows the URL and the bearer-secret you set up.

What about Bluesky / Mastodon / LinkedIn instead of Twitter?

Same shape, different POST. Bluesky has an HTTP API. Mastodon has an HTTP API. LinkedIn requires their developer program approval but has an API once you are in. Hypefury does X plus LinkedIn from one draft. Swap the Step 3 destination, leave the rest alone.

Will the cron keep firing if I take a long break?

Yes. The schedule is owned by your Crontap account, not the repo. If you commit nothing for two months, the route returns { skipped: true, reason: "no commits in window" } and Crontap counts that as success. The day you come back and commit, the next fire picks up the commits and drafts a tweet.

When to skip this pattern

A few cases where the cron is the wrong shape:

  • You only ship in bursts. If you commit once a week, the daily cron is mostly skipped runs. Switch to weekly (30 19 * * 5) or trigger on push (a GitHub webhook firing into the same route).
  • You write your own tweets and like it. Then this is overhead. The pattern earns its keep when "no tweet today" is a real fail mode for you.
  • Your repo is private and your tweet is public. Be careful what GPT can paraphrase from commit messages. If you commit "fix exploit in payment flow", do not let that become a public tweet. Add a denylist of keywords the route filters out.

For everything in between, the cron is the boring shape that makes shipping the tweet a non-event.

References

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.