Scheduling Blog Posts with GitHub Actions and OIDC

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:
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:
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_dateto today's date - Publish on a specific date: set
publish_dateto that date - Queue it: omit
publish_dateentirely
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:
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.

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:
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:
- 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:
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.
If you find this blog useful, please consider a donation.
