Skip to content

Commit 09de690

Browse files
committed
feat(infra): add update-hashnode workflow to re-push edited articles
The cross-post script is intentionally idempotent (skip-if-exists), so edits to a published article never propagate to Hashnode. This adds a workflow_dispatch path that takes a slug, looks up the existing post by canonicalUrl, and re-pushes the current source markdown via the updatePost mutation. Use it whenever an already-cross-posted article gets a substantive edit.
1 parent bffae37 commit 09de690

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Update Hashnode post
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
slug:
7+
description: 'Slug to re-push to Hashnode (uses current source markdown)'
8+
required: true
9+
dry_run:
10+
description: 'Dry run (look up post, do not update)'
11+
type: boolean
12+
default: true
13+
14+
jobs:
15+
update:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: '20'
23+
cache: 'npm'
24+
25+
- run: npm ci
26+
27+
- name: Update Hashnode
28+
env:
29+
HASHNODE_TOKEN: ${{ secrets.HASHNODE_TOKEN }}
30+
HASHNODE_PUBLICATION_ID: ${{ secrets.HASHNODE_PUBLICATION_ID }}
31+
run: |
32+
ARGS="--slug=${{ github.event.inputs.slug }}"
33+
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
34+
ARGS="$ARGS --dry-run"
35+
fi
36+
node scripts/update-hashnode.mjs $ARGS

scripts/update-hashnode.mjs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env node
2+
// Re-publish the current source markdown to existing Hashnode posts.
3+
// Use this when the source article has been edited after cross-posting.
4+
//
5+
// Usage:
6+
// node scripts/update-hashnode.mjs --slug=foo
7+
// node scripts/update-hashnode.mjs --slug=foo --slug=bar
8+
// node scripts/update-hashnode.mjs --slug=foo --dry-run
9+
10+
import { loadArticle, resolvePosts } from './lib/post-source.mjs';
11+
12+
const API = 'https://gql.hashnode.com';
13+
14+
async function gql(query, variables) {
15+
const res = await fetch(API, {
16+
method: 'POST',
17+
headers: {
18+
'Content-Type': 'application/json',
19+
Authorization: process.env.HASHNODE_TOKEN,
20+
},
21+
body: JSON.stringify({ query, variables }),
22+
});
23+
const text = await res.text();
24+
if (!res.ok) throw new Error(`Hashnode HTTP ${res.status}: ${text}`);
25+
const body = JSON.parse(text);
26+
if (body.errors) throw new Error(`Hashnode GraphQL: ${JSON.stringify(body.errors)}`);
27+
return body.data;
28+
}
29+
30+
async function resolvePublicationId() {
31+
if (process.env.HASHNODE_PUBLICATION_ID) return process.env.HASHNODE_PUBLICATION_ID;
32+
const data = await gql(`
33+
query Me { me { publications(first: 50) { edges { node { id } } } } }
34+
`);
35+
const edges = data?.me?.publications?.edges || [];
36+
if (edges.length === 0) throw new Error('No Hashnode publications found');
37+
return edges[0].node.id;
38+
}
39+
40+
async function findPost(publicationId, canonicalUrl) {
41+
let cursor = null;
42+
while (true) {
43+
const data = await gql(
44+
`
45+
query PubPosts($id: ObjectId!, $first: Int!, $after: String) {
46+
publication(id: $id) {
47+
posts(first: $first, after: $after) {
48+
edges { node { id url canonicalUrl } }
49+
pageInfo { hasNextPage endCursor }
50+
}
51+
}
52+
}
53+
`,
54+
{ id: publicationId, first: 50, after: cursor },
55+
);
56+
const conn = data?.publication?.posts;
57+
if (!conn) return null;
58+
const match = (conn.edges || []).find((e) => e.node.canonicalUrl === canonicalUrl);
59+
if (match) return { id: match.node.id, url: match.node.url };
60+
if (!conn.pageInfo?.hasNextPage) return null;
61+
cursor = conn.pageInfo.endCursor;
62+
}
63+
}
64+
65+
async function updatePost(id, article) {
66+
const data = await gql(
67+
`
68+
mutation Update($input: UpdatePostInput!) {
69+
updatePost(input: $input) { post { id url } }
70+
}
71+
`,
72+
{
73+
input: {
74+
id,
75+
contentMarkdown: article.body_markdown,
76+
},
77+
},
78+
);
79+
const post = data?.updatePost?.post;
80+
if (!post?.url) throw new Error(`updatePost returned no post: ${JSON.stringify(data)}`);
81+
return post;
82+
}
83+
84+
function parseArgs(argv) {
85+
const out = { slugs: [], dryRun: false };
86+
for (const a of argv) {
87+
if (a === '--dry-run') out.dryRun = true;
88+
else if (a.startsWith('--slug=')) out.slugs.push(a.slice(7));
89+
}
90+
return out;
91+
}
92+
93+
async function main() {
94+
const args = parseArgs(process.argv.slice(2));
95+
if (args.slugs.length === 0) {
96+
console.error('Usage: node scripts/update-hashnode.mjs --slug=<slug> [--slug=<slug>...] [--dry-run]');
97+
process.exit(1);
98+
}
99+
if (!process.env.HASHNODE_TOKEN) {
100+
console.error('HASHNODE_TOKEN not set');
101+
process.exit(1);
102+
}
103+
104+
const pubId = await resolvePublicationId();
105+
let failed = false;
106+
107+
for (const slug of args.slugs) {
108+
console.log(`\n=== ${slug} ===`);
109+
const files = resolvePosts({ slug });
110+
const article = loadArticle(files[0]);
111+
console.log(` canonical: ${article.canonical_url}`);
112+
console.log(` body: ${article.body_markdown.length} chars`);
113+
114+
const existing = await findPost(pubId, article.canonical_url);
115+
if (!existing) {
116+
console.error(` no existing Hashnode post matching canonicalUrl`);
117+
failed = true;
118+
continue;
119+
}
120+
console.log(` found: ${existing.url}`);
121+
122+
if (args.dryRun) {
123+
console.log(` [dry-run] would update`);
124+
continue;
125+
}
126+
127+
try {
128+
const updated = await updatePost(existing.id, article);
129+
console.log(` ✓ updated: ${updated.url}`);
130+
} catch (e) {
131+
console.error(` ✗ failed: ${e.message}`);
132+
failed = true;
133+
}
134+
}
135+
if (failed) process.exit(1);
136+
}
137+
138+
main().catch((e) => {
139+
console.error(e);
140+
process.exit(1);
141+
});

0 commit comments

Comments
 (0)