Introducing Hashnode GitHub Sync - Part 1 (GitHub to Hashnode Sync)

Just a normal guy who loves tech, food, coffee and video-games.
Hey there!
Today, I want to introduce you to Hashnode GitHub Sync. I have been working on this for the past few days, and after many ups and downs, the initial version is finally out for everyone to use.
What is Hashnode Github Sync?
Hashnode GitHub Sync is a GitHub action that allows your GitHub repo to sync seamlessly with your Hashnode blog. With this, you can publish, update, and even delete blogs directly from your GitHub repo, effectively backing up your blogs. Additionally, any changes made to your Hashnode blog will also reflect on your GitHub repo.
Pretty convenient, isn't it?
Since there are two synchronization routes, we will discuss part 1 (GitHub to Hashnode Sync) in this article and part 2 (Hashnode to GitHub Sync) in the next one.
Creating action
We will break down everything happening in our GitHub action step by step to understand the entire workflow.
Getting Required Inputs
To track all added, modified, and deleted files in a repo, we use another great GitHub action called Changed Files. This action provides many outputs, but we only use the list of added, modified, and deleted files as input for our action.
Additionally, we need to provide a Personal Access Token from Hashnode's developer settings as hashnode_token and the hostname of our blog as hashnode_host.
# action.yml
name: Hashnode Github Sync
description: Establish a two way sync between your Github repo and Hashnode Blog
author: Ammar Mirza
branding:
icon: 'link'
color: 'blue'
runs:
using: 'node20'
main: 'dist/index.js'
inputs:
hashnode_host:
description: Publication host where blogs will be published
required: true
hashnode_token:
description: Hashnode Secret Token
required: true
added_files:
description: The files which have been added
modified_files:
description: The files which have been modified
deleted_files:
description: The files which have been deleted
As you would have noticed, there can be 3 cases of synchronization while blogging from GitHub repo. You can either add a file, update a file or delete a file.
Boilerplate
// src/index.ts
import * as core from "@actions/core";
import githubToHashnodeSync from "./github-to-hashnode";
export async function run() {
try {
await githubToHashnodeSync();
} catch (error: any) {
core.setFailed(error.message);
}
}
run();
This is our index file which will run on each push. In this we are calling githubToHashnodeSync function.
// src/github-to-hashnode/index.ts
import { getPublicationId } from "./utils";
import { getInput } from "src/shared";
import { publishArticle } from "./publishArticle";
import { modifyArticle } from "./modifyArticle";
import { deleteArticle } from "./deleteArticle";
export const githubToHashnodeSync = async () => {
// getting all the files from input
const { added_files, modified_files, deleted_files } = getInput()
const publicationId = await getPublicationId();
// filtering for markdown files
const added_files_arr = added_files
.split(" ")
.filter((fileName: string) => fileName.endsWith(".md"));
// calling publishArticle function on files from repo
const publishPromises = added_files_arr.map((file: string) =>
publishArticle({file, publicationId})
);
await Promise.all(publishPromises);
const modified_files_arr = modified_files
.split(" ")
.filter((fileName: string) => fileName.endsWith(".md"));
// calling modifyArticle function on updated files from repo
const modifyPromises = modified_files_arr.map((file: string) =>
modifyArticle({file, publicationId})
);
await Promise.all(modifyPromises);
const deleted_files_arr = deleted_files
.split(" ")
.filter((fileName: string) => fileName.endsWith(".md"));
// calling deleteArticle function on deleted files from repo
const deletePromises = deleted_files_arr.map((file: string) =>
deleteArticle({file, publicationId})
);
await Promise.all(deletePromises);
}
export default githubToHashnodeSync
Changed Files action provides file names as a string separated by spaces or as undefined. We create arrays for added_files and modified_files and deleted_files to filter out only .md files, then run a specific function for each task.
We also fetch the publication ID to pass to these functions.
// src/github-to-hashnode/utils/getPublicationId.ts
import {
assertPublicationIsNotNull,
callGraphqlAPI,
QUERY,
getInput,
} from "src/shared";
export const getPublicationId = async (): Promise<string> => {
const { host } = getInput();
const result = await callGraphqlAPI({
query: QUERY.getPublicationId,
variables: {
host,
},
});
assertPublicationIsNotNull(result);
return result.data.publication.id;
};
callGraphqlAPIis a common function that sends an API call to the Hashnode API. You will see this function used multiple times throughout the article.
// src/shared/callGraphqlAPI.ts
import { assertErrorIsNotNull } from "./assertions"
import { HASHNODE_ENDPOINT } from "./constants"
import { getInput } from "./getInput"
export const callGraphqlAPI = async ({query, variables}: {
query: string,
variables: any
}): Promise<any> => {
const {hashnode_token} = getInput()
const response = await fetch(HASHNODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-type': 'application/json',
Authorization: hashnode_token
},
body: JSON.stringify({
query,
variables,
})
})
const result = await response.json()
assertErrorIsNotNull(result)
return result
}
getInputis a simple function that retrieves the input of the action and returns it.
// src/shared/getInput.ts
import * as core from '@actions/core';
import { GetInput } from './types';
export const getInput = () : GetInput => {
const hashnode_event = core.getInput("hashnode_event");
const hashnode_token = core.getInput("hashnode_token");
const host = core.getInput("hashnode_host");
const added_files = core.getInput("added_files");
const modified_files = core.getInput("modified_files");
const deleted_files = core.getInput("deleted_files");
return { hashnode_event, hashnode_token, host, added_files, modified_files, deleted_files }
}
PUBLICATION_ID_QUERYis the query used to get the publication ID.assertPublicationIsNotNullchecks the API response. If the publication is null, it throws an error.
Case 1 : Publish Flow (Adding a file)
In the githubToHashnodeSync we filtered out files with .md extension from added_files_arr and then called publishArticle function.
Let's break down the publishArticle function now.
// src/github-to-hashnode/publishArticle.ts
import { callGraphqlAPI, QUERY, parseFile } from "src/shared";
import { GithubToHashnodeSync, PublishedArticle } from "src/shared/types";
import { createSlug, mapMarkdownToGqlPublishInput } from "./utils";
export const publishArticle = async ({
file,
publicationId,
}: GithubToHashnodeSync): Promise<PublishArticle> => {
const slug = createSlug(file);
const parsedArticle = await parseFile(file);
const input = mapMarkdownToGqlPublishInput({
parsedArticle,
publicationId,
slug,
});
const response = await callGraphqlAPI({
query: QUERY.publish,
variables: {
input,
}
});
console.log(`Post published successfully on Hashnode with slug ${response.data.publishPost.post.slug}`);
return response
};
There are multiple steps in the publishArticle function. Let's go through them one by one.
- First, we create a slug from the file name.
// src/shared/createSlug.ts
export const createSlug = (fileName: string) : string => {
const slug = fileName.toLowerCase().replace('.md', '')
return slug
}
- Then we parse the file and proceed with publishing the article.
// src/shared/parseFile.ts
import fs from 'fs-extra'
import { default as matter } from 'gray-matter'
import { ParsedContent } from './types'
export const parseFile = async (fileName: string) : Promise<ParsedContent> => {
const content = await fs.readFile(fileName, "utf-8")
const parsedArticle = matter(content, { language: "yaml" })
return parsedArticle as unknown as ParsedContent
}
We use fs-extra to read files and gray-matter to parse them, returning the parsedArticle.
- Next, we map the data from our parsedArticle to create an input for the mutation.
// src/github-to-hashnode/utils/mapMdtoGqlPublishInput.ts
import { ParsedContent, PostPublishInput } from "src/shared/types";
export const mapMarkdownToGqlPublishInput = ({
parsedArticle,
publicationId,
slug,
}: {
parsedArticle: ParsedContent;
publicationId: string;
slug: string;
}): PostPublishInput => {
const input = {
title: parsedArticle.data.title,
subtitle: parsedArticle.data.subtitle,
publicationId: publicationId,
contentMarkdown: parsedArticle.content,
publishedAt: parsedArticle.data.publishedAt,
coverImageOptions: {
coverImageURL: parsedArticle.data.coverImageUrl,
isCoverAttributionHidden: parsedArticle.data.isCoverAttributionHidden,
coverImageAttribution: parsedArticle.data.coverImageAttribution,
coverImagePhotographer: parsedArticle.data.coverImagePhotographer,
stickCoverToBottom: parsedArticle.data.stickCoverToBottom
},
slug: slug,
originalArticleURL: parsedArticle.data.originalArticleURL,
tags: parsedArticle.data.tags,
disableComments: parsedArticle.data.disableComments,
metaTags: {
title: parsedArticle.data.ogTitle,
description: parsedArticle.data.ogDescription,
image: parsedArticle.data.ogImage
},
publishAs: parsedArticle.data.publishAs,
seriesId: parsedArticle.data.seriesId,
settings: {
scheduled: parsedArticle.data.scheduled,
enableTableOfContent: parsedArticle.data.enableTableOfContent,
slugOverridden: parsedArticle.data.slugOverridden,
isNewsletterActivated: parsedArticle.data.isNewsletterActivated,
delisted: parsedArticle.data.delisted
},
coAuthors: parsedArticle.data.coAuthors,
};
return input;
};
- Lastly, we make an API request to publish the article using our common
callGraphqlAPIfunction.
We won't discuss TypeScript types here you can check them out in the GitHub repo if you're interested.
Case 2: Update Flow (Modifying a file)
Similar to case one, we run a map on modified_files_arr and then call the modifyArticle function within it.
Let's break down modifyArticle in a similar way as publishArticle.
// src/github-to-hashnode/modifyArticle.ts
import { parseFile, callGraphqlAPI, QUERY } from "src/shared";
import { GithubToHashnodeSync, ModifiedArticle } from "src/shared/types";
import { extractInfoFromFilename, mapMdToGqlModifyInput } from "./utils";
export const modifyArticle = async ({
file,
publicationId,
}: GithubToHashnodeSync): Promise<ModifiedArticle> => {
const { slug, postId } = extractInfoFromFilename(file);
const parsedArticle = await parseFile(file);
const input = mapMdToGqlModifyInput({
parsedArticle,
slug,
postId,
publicationId,
});
const response = await callGraphqlAPI({
query: QUERY.modify,
variables: {
input,
},
});
console.log(
`Post successfully modified on Hashnode with slug ${response.data.updatePost.post.slug}`
);
return response;
};
After publishing article from GitHub to Hashnode, we delete the article and then create a new file for the same article with a different naming syntax.
We name our articles into this format:${postId}-${postSlug}.We extract both
postIdandslugfrom the file name itself usingextractInfoFromFileNamefunction.
// src/github-to-hashnode/utils/extractInfoFromFilename.ts
type ExtractedInfo = {
postId: string
slug: string | undefined
}
export const extractInfoFromFilename = (fileName: string) : ExtractedInfo => {
const postId = fileName.split('-')[0]
const slug = fileName.split('-').slice(1).join('-').replace('.md', '')
return {postId, slug}
}
Then we parse the article just like we do in the publish function.
After this, we map the data from
parsedArticleto create an input for theupdatePostmutation. The mapper function here is almost the same as in the publish case, but you might notice an extra function calledisPublishedAtValid. According to the Hashnode API Docs, if we are updatingpublishedAton our post, it should always be backdated relative to the current date.
// src/github-to-hashnode/utils/mapMdToGqlModifyInput.ts
import { isPublishedAtValid } from "src/shared";
import { ParsedContent, PostUpdateInput } from "src/shared/types";
export const mapMdToGqlModifyInput = ({
parsedArticle,
slug,
postId,
publicationId,
}: {
parsedArticle: ParsedContent;
slug: string | undefined;
postId: string;
publicationId: string;
}): PostUpdateInput => {
const input = {
id: postId,
title: parsedArticle.data.title,
subtitle: parsedArticle.data.subtitle,
publicationId: publicationId,
slug: slug,
contentMarkdown: parsedArticle.content,
publishedAt: isPublishedAtValid(parsedArticle.data.publishedAt),
coverImageOptions: {
coverImageURL: parsedArticle.data.coverImageUrl,
isCoverAttributionHidden: parsedArticle.data.isCoverAttributionHidden,
coverImageAttribution: parsedArticle.data.coverImageAttribution,
coverImagePhotographer: parsedArticle.data.coverImagePhotographer,
stickCoverToBottom: parsedArticle.data.stickCoverToBottom,
},
originalArticleUrl: parsedArticle.data.originalArticleURL,
tags: parsedArticle.data.tags,
metaTags: {
title: parsedArticle.data.ogTitle,
description: parsedArticle.data.ogDescription,
image: parsedArticle.data.ogImage,
},
publishAs: parsedArticle.data.publishAs,
coAuthors: parsedArticle.data.coAuthors,
seriesId: parsedArticle.data.seriesId,
settings: {
isTableOfContentEnabled: parsedArticle.data.enableTableOfContent,
delisted: parsedArticle.data.delisted,
disableComments: parsedArticle.data.disableComments,
},
};
return input;
};
Hashnode does not consider time when comparing dates, so we created a separate function to pass publishedAt only if it is before the current date. We use dayjs to compare the dates.
// src/shared/isPublishedAtValid.ts
import dayjs from "dayjs";
export const isPublishedAtValid = (date: string | undefined) : string | undefined => {
if(!date) return undefined
const isValid = dayjs(date).isBefore(new Date(), 'day')
return isValid ? date : undefined
}
- After receiving input from the mapper, we make an API request to modify or update the post.
Case 3: Delete Flow (Deleting a file)
We run a map on deleted_files_arr and then call the deleteArticle function within it, just like we did in the previous two cases.
Now, let's break down the deleteArticle function.
// src/github-to-hashnode/deleteArticle.ts
import { QUERY, callGraphqlAPI } from "src/shared";
import { extractInfoFromFilename, mapMdToGqlDeleteInput } from "./utils";
import { GithubToHashnodeSync, DeletedArticle } from "src/shared/types";
export const deleteArticle = async ({
file
}: GithubToHashnodeSync): Promise<DeletedArticle> => {
const { postId } = extractInfoFromFilename(file);
const input = mapMdToGqlDeleteInput(postId);
const response = await callGraphqlAPI({
query: QUERY.delete,
variables: {
input,
},
});
console.log(
`Post successfully deleted on Hashnode with slug ${response.data.removePost.post.slug}`
);
return response;
};
First, we extract
postIdfrom file name just like we do in modify function.After getting the post ID, we prepare the input for the
removePostmutation.Finally, we make an API request to delete the desired article.
Using action
Setup
You can use this action by creating a directory named github/workflows in your repository and including a file named publish.yml. In the publish.yml file, insert the following commands. These commands will help you publish your blog post on Hashnode.
name: "Publish"
on:
push:
repository_dispatch:
types: [trigger] # This is for the two way sync from Hashnode, to use this you will have to set up a serverless function as a middleware.
jobs:
print_file_job:
runs-on: ubuntu-latest
name: Hashnode Github Sync
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# This action is necessary as it is giving the details about your files in repo
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
- name: Hashnode Github Sync
uses: iammarmirza/hashnode-github-sync@v1.6
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
# This is your default github token
with:
# All of these fields are required except hashnode_event
hashnode_host: "ammarmirza.hashnode.dev" # Your hashnode host name
hashnode_token: ${{ secrets.HASHNODE_TOKEN }} # Your hashnode secret key
added_files: ${{steps.changed-files.outputs.added_files}}
modified_files: ${{steps.changed-files.outputs.modified_files}}
deleted_files: ${{steps.changed-files.outputs.deleted_files}}
hashnode_event: ${{toJson(github.event.client_payload)}}
# Hashnode_event is required in the case of two way sync
Usage Example
You can create an article by including some content and meta properties in your markdown files like this. Refer to the GitHub repo to get a list of all available meta-tags.
---
title: Test Article
delisted: true
isNewsletterActivated: false
---
# This is test article's heading
This is test article's paragraph
To be continued
This is all for the one-way sync. We'll stop here before it gets too complicated.
Thanks for reading part 1! I hope I was able to explain Hashnode GitHub Sync to you. Feel free to ask any questions in the comments below. Don't forget to like and share the article if it helps you in any way.
In the next part, we will discuss the two-way sync, which is the Hashnode to GitHub route. Stay tuned!



