Setting created_at with GitHub Actions when a PR is merged

21 Aug 2023 in Tech

The blog that you’re reading is a static site that’s generated on every push to main. That means that whatever’s in the repo is the source of truth, including the created_at date when a PR is merged.

I do a lot of my writing in batches, but don’t want to publish everything at the same time. Rather than keeping finished posts in Ulysses (my writing app of choice) and staging a new post every time I want to publish, I raise a pull request immediately and use GitHub Actions to set the created_at date when publishing a post.

Here’s the rough workflow:

  • Check if the submit-post label has been added
  • Fetch the list of changed files in the PR
  • For each added file, fetch the file contents
  • Replace the created_at date (it’s called date in the frontmatter) in each file (using .replace() but I should probably use gray-matter)
  • Use github.repos.createOrUpdateFileContents to update the contents of each file
  • Use github.pulls.merge with merge_method: "squash" to merge the PR as a single commit (createOrUpdateFileContents adds multiple commits)
  • Delete the branch

I could write a new GitHub Action to do this, but as it’s not reusable and it’s only a small number of lines I’ve chosen to use actions/github-script which lets me write JavaScript and use the GitHub API easily.

js
on:
pull_request:
types:
- labeled
jobs:
merge-label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v4
id: set-result
with:
github-token: $
script: |
const labelName = context.payload.label.name;
if (labelName != "submit-post"){
console.log(`${labelName} label was added. No action needed`);
return;
}
const {data: files} = await github.pulls.listFiles({
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
// Load all new files + their contents
const newPosts = await Promise.all(files.filter(f => {
return f.status == 'added' && f.filename.endsWith(".md")
}).map(async f => {
const blob = await github.request(`GET ${f.contents_url}`);
const content = Buffer.from(blob.data.content, 'base64').toString();
return {sha: f.sha, filename: f.filename, content};
}));
// Replace the date + updated_at with the current date
const now = (new Date).toISOString();
await Promise.all(newPosts.map(async f => {
f.content = f.content.replace(/date: "[^"]+"/mi, `date: "${now}"`);
f.content = f.content.replace(/updated_at: "[^"]+"/mi, `updated_at: "${now}"`);
return await github.repos.createOrUpdateFileContents({
owner: context.repo.owner,
repo: context.repo.repo,
path: f.filename,
message: "Update date",
branch: context.payload.pull_request.head.ref,
sha: f.sha,
content: Buffer.from(f.content).toString('base64'),
committer: {
name: "Michael Heap",
}
});
}));
// Wait 10 seconds for changes to propogate
await new Promise((resolve) => {
setTimeout(resolve, 10000);
});
// Squash and merge the PR
await github.pulls.merge({
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
merge_method: "squash"
});
// Delete the branch
github.git.deleteRef({
repo: context.repo.repo,
owner: context.repo.owner,
ref: `heads/${context.payload.pull_request.head.ref}`
});
result-encoding: string

This action allows me to stage posts when I finish writing them rather than when I plan to publish. Once my intended publishing date arrives, I add the submit-post label to the PR (usually via my phone) and the post is published as intended.