---
title: "Scheduling Blog Posts with GitHub Actions and OIDC"
date: "2026-03-29"
approved: true
publish_date: "2026-04-13"
excerpt: "Writing a post is the interesting bit. Remembering to hit publish on the right day is not. Here's how to make that someone else's problem."
tags: ["github-actions", "aws", "oidc", "cdk", "automation"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-03-29-scheduling-blog-posts-with-github-actions"
---

# Scheduling Blog Posts with GitHub Actions and OIDC

![A railway departures board of glowing calendar pages, a clockwork stationmaster pulling a lever at dawn](./images/scheduling-blog-posts-with-github-actions-header.png)

Writing a post is the interesting bit. Remembering to hit publish on the right day is not — especially when you're trying to maintain any kind of consistent cadence while also, you know, doing actual work. So I built a scheduling system. The goal: write, commit, push, and forget. The post appears when it's supposed to.

This post was published by that system. When I pushed it, there was no `publish_date` in the frontmatter — the workflow assigned one automatically within a few minutes.

## The mechanism

The blog build already converts markdown files to HTML and deploys to S3. Adding scheduling meant solving two problems: holding posts back until their date, and triggering a rebuild when that date arrives.

**Holding posts back** is a one-liner in the build script. After parsing frontmatter, skip any post whose `publish_date` is in the future:

```typescript
const publishDate = post.frontmatter.publish_date ?? post.frontmatter.date;
if (!publishDate || publishDate > today) {
  continue;
}
```

`publish_date` takes priority; if it's absent, fall back to `date` so existing posts keep working without modification.

**Triggering the rebuild** is a GitHub Actions workflow on a daily cron:

```yaml
on:
  schedule:
    - cron: '0 8 * * *'
  workflow_dispatch:
```

Every morning at 08:00 UTC it pulls the latest blog content, builds, and deploys. If nothing new is due that day, the deploy is a no-op. If a post's date just ticked past, it appears.

## The queue

I didn't want to think about dates at all. The three modes are:

- **Publish now**: set `publish_date` to today's date
- **Publish on a specific date**: set `publish_date` to that date
- **Queue it**: omit `publish_date` entirely

For the queue case, a second workflow runs on every push to the content repo. It finds posts without a `publish_date`, works out the next available slot (seven days after the last scheduled post), and commits the date back:

```python
latest = today
for f in files:
    pd = get_frontmatter_date(content, 'publish_date') or get_frontmatter_date(content, 'date')
    if pd and date.fromisoformat(pd) > latest:
        latest = date.fromisoformat(pd)

for f in unscheduled_posts:
    latest += timedelta(days=7)
    # insert publish_date into frontmatter
```

The commit message includes `[skip ci]` to stop the push triggering another run of the same workflow.

![A clockwork calendar machine stamping dates onto envelopes that lift off into the dawn](./images/scheduling-blog-posts-with-github-actions-1.png)

## Credentials without storing credentials

The deploy workflow needs to write to S3 and invalidate CloudFront. The wrong way to do this is to create an IAM user, generate an access key, and paste it into GitHub Secrets. Long-lived credentials are a liability — they don't expire, they're hard to rotate, and if they leak you won't necessarily know.

The right way is GitHub's OIDC integration with AWS. GitHub issues a short-lived signed token for each workflow run. AWS has a trust relationship with GitHub's OIDC provider, so it can verify the token and exchange it for temporary credentials scoped to a specific IAM role. No secrets stored anywhere.

The IAM role is defined in CDK:

```typescript
const deployRole = new iam.Role(this, 'BlogDeployRole', {
  roleName: 'blog-app-github-deploy',
  assumedBy: new iam.WebIdentityPrincipal(
    githubOidc.openIdConnectProviderArn,
    {
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
      },
      StringLike: {
        'token.actions.githubusercontent.com:sub': 'repo:nakomis/blog-app:*',
      },
    }
  ),
});

bucket.grantReadWrite(deployRole);
deployRole.addToPolicy(new iam.PolicyStatement({
  actions: ['cloudfront:CreateInvalidation'],
  resources: [`arn:aws:cloudfront::${this.account}:distribution/${distributionId}`],
}));
```

The `sub` condition scopes the trust to the `nakomis/blog-app` repository specifically. A token from any other repo — even in the same account — won't be accepted. The workflow side is two lines:

```yaml
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::637423226886:role/blog-app-github-deploy
    aws-region: eu-west-2
```

After that, `aws s3 sync` and `aws cloudfront create-invalidation` work exactly as they do locally, just without the `--profile` flag.

## One deploy script, two contexts

The deploy script was originally written for local use with a named AWS profile. Making it work in both contexts without two separate scripts:

```bash
AWS_PROFILE_ARGS=()
if [ -z "${CI:-}" ]; then
  AWS_PROFILE_ARGS=(--profile nakom.is-admin)
fi

aws s3 sync dist/ "s3://${BUCKET}/" --delete "${AWS_PROFILE_ARGS[@]}"
```

`CI` is set automatically in GitHub Actions. Locally it's unset, so the profile argument is included. In Actions it's empty, so the array is empty and no `--profile` flag is passed. The AWS CLI picks up the OIDC-derived credentials from environment variables instead.

---

*Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.*


{{donate}}