In recent years, various methods for publishing static blogs have emerged, but many are either complex or difficult to maintain long-term. This article introduces a simple and efficient approach: using GitHub Issues as the publishing endpoint for a Hugo blog, leveraging GitHub Actions to automatically convert Issues into Hugo content and deploy to GitHub Pages. This method is particularly suitable for users who prefer using the GitHub mobile app to publish blog posts anytime, anywhere. Based on Lao T’s practical experience, this tutorial addresses issues such as downloading images from private repositories and extracting tags, and is suitable for both public and private repositories.
Why Choose GitHub Issues?
- Convenience: The GitHub mobile app is more stable than the web version in domestic network environments, and publishing requires just a few clicks—simpler than posting on WeChat Moments.
- No Limits: Issues have virtually no quantity or capacity restrictions, making them ideal for storing blog content.
- Automation: Through GitHub Actions, Issues can be automatically converted into Hugo content and trigger site builds.
- Flexibility: Supports images, tags, and categories, suitable for short “thoughts” or long articles.
Prerequisites
- A Hugo blog project already configured with GitHub Pages (public or private repository).
- A GitHub Personal Access Token (PAT) with
repopermissions (required for private repositories) andworkflowpermissions (to trigger workflows). Create it in GitHub Settings → Developer settings → Personal access tokens. - Basic knowledge of GitHub Actions and Hugo.
- A repository structure that includes a
content/posts/directory for storing generated articles.
Implementation Steps
1. Configure GitHub Actions Workflows
We need two workflow files:
deploy.yml: Converts Issues into Hugo content and commits it to the repository.hugo.yml: Builds the Hugo site and deploys it to GitHub Pages.
1.1 Create deploy.yml
Add the following content to .github/workflows/deploy.yml to handle converting Issues into Hugo content and triggering the Hugo build:
name: Sync Issues to Hugo Content
on:
issues:
types: [opened]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: "issues-to-hugo"
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
convert_issues:
name: Convert GitHub Issues
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'issues' && contains(github.event.issue.labels.*.name, '发布') && github.actor != 'IssueBot'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
clean: true
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install requests PyGithub==2.4.0 beautifulsoup4
- name: Create content/posts directory
run: |
mkdir -p content/posts
echo "Created content/posts directory"
- name: Convert issues to Hugo content
id: convert
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
find content/posts -type f 2>/dev/null | sort > /tmp/original_files.txt || touch /tmp/original_files.txt
if [ -s /tmp/original_files.txt ]; then
xargs sha1sum < /tmp/original_files.txt > /tmp/original_hashes.txt
else
touch /tmp/original_hashes.txt
fi
python .github/workflows/issue_to_hugo.py \
--repo "${{ github.repository }}" \
--output "content/posts" \
--debug
find content/posts -type f 2>/dev/null | sort > /tmp/new_files.txt || touch /tmp/new_files.txt
if [ -s /tmp/new_files.txt ]; then
xargs sha1sum < /tmp/new_files.txt > /tmp/new_hashes.txt
else
touch /tmp/new_hashes.txt
fi
if cmp -s /tmp/original_hashes.txt /tmp/new_hashes.txt; then
echo "needs_build=false" >> $GITHUB_OUTPUT
echo "🔄 No content changes, skipping commit"
else
echo "needs_build=true" >> $GITHUB_OUTPUT
echo "✅ Content changes detected, proceeding with commit"
fi
- name: Commit changes (if any)
if: steps.convert.outputs.needs_build == 'true'
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
git config --global user.name "IssueBot"
git config --global user.email "actions@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository }}.git
if [ -n "${{ github.base_ref }}" ]; then
DEFAULT_BRANCH="${{ github.base_ref }}"
else
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
fi
if [ -z "$DEFAULT_BRANCH" ]; then
DEFAULT_BRANCH="master"
fi
git checkout $DEFAULT_BRANCH
git add content/posts
if ! git diff-index --quiet HEAD --; then
git commit -m "Automated: Sync GitHub Issues as content"
echo "Pushing changes to branch: $DEFAULT_BRANCH"
git push origin $DEFAULT_BRANCH
else
echo "No changes to commit"
fi
- name: Trigger Hugo build
if: steps.convert.outputs.needs_build == 'true'
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/dispatches \
-d '{"event_type": "trigger-hugo-build"}'
Key Points:
- Trigger Conditions: When an Issue is opened with the “发布” label, or manually triggered.
- Prevent Recursion:
github.actor != 'IssueBot'ensures that commits by IssueBot do not trigger the workflow again. - Change Detection: Commits only occur when content changes (via file hash comparison).
- Trigger Hugo Build: Calls the Hugo workflow via the
repository_dispatchevent (trigger-hugo-build).
1.2 Create hugo.yml
Add the following content to .github/workflows/hugo.yml to build and deploy the Hugo site:
name: Deploy Hugo site to Pages
on:
push:
branches: ["master"]
workflow_dispatch:
repository_dispatch:
types: [trigger-hugo-build]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.128.0
steps:
- name: Install Hugo CLI
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&& sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Install Dart Sass
``````yaml
run: sudo snap install dart-sass
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
- name: Install Node.js dependencies
run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
- name: Build with Hugo
env:
HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
HUGO_ENVIRONMENT: production
run: |
hugo \
--minify \
--baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./public
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Key Points:
- Trigger conditions:
pushto themasterbranch, manual triggers, orrepository_dispatchevents (trigger-hugo-build). - Permissions: Ensure
pages: writeandid-token: writefor GitHub Pages deployment.
1.3 Add Conversion Script issue_to_hugo.py
Add the following Python script in .github/workflows/issue_to_hugo.py to convert Issues into Hugo content, supporting private repository image downloads and tag extraction:
import os
import argparse
import re
import requests
import json
import logging
from urllib.parse import unquote
from datetime import datetime
from github import Github, Auth
from bs4 import BeautifulSoup
CATEGORY_MAP = ["Life", "Technology", "Legal", "Moments", "Society"]
PUBLISH_LABEL = "Publish"
def setup_logger(debug=False):
logger = logging.getLogger('issue-to-hugo')
level = logging.DEBUG if debug else logging.INFO
logger.setLevel(level)
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def extract_cover_image(body):
"""Extract and remove the cover image (first image in the body)"""
img_pattern = r"!\[.*?\]\((https?:\/\/[^\)]+)\)"
match = re.search(img_pattern, body)
if match:
img_url = match.group(1)
body = body.replace(match.group(0), "")
return img_url, body
return None, body
def safe_filename(filename):
"""Generate a safe filename, preserving or inferring the file extension"""
clean_url = re.sub(r"\?.*$", "", filename)
basename = os.path.basename(clean_url)
decoded_name = unquote(basename)
name, ext = os.path.splitext(decoded_name)
safe_name = re.sub(r"[^a-zA-Z0-9\-_]", "_", name)
if not ext.lower() in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
ext = ""
if len(safe_name) > 100 - len(ext):
safe_name = safe_name[:100 - len(ext)]
return safe_name + ext
def download_image(url, output_path, token=None):
"""Download image to the specified path, determine extension based on content type, and add GitHub authentication headers"""
try:
headers = {}
if token:
headers['Authorization'] = f'token {token}'
response = requests.get(url, stream=True, headers=headers)
if response.status_code == 200:
content_type = response.headers.get("content-type", "").lower()
ext = ".jpg"
if "image/png" in content_type:
ext = ".png"
elif "image/jpeg" in content_type or "image/jpg" in content_type:
ext = ".jpg"
elif "image/gif" in content_type:
ext = ".gif"
elif "image/webp" in content_type:
ext = ".webp"
base, current_ext = os.path.splitext(output_path)
if current_ext.lower() not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
output_path = base + ext
else:
output_path = base + current_ext
with open(output_path, 'wb') as f:
f.write(response.content)
logging.info(f"Image downloaded successfully: {url} -> {output_path}")
return output_path
else:
logging.error(f"Image download failed, status code: {response.status_code}, URL: {url}")
except Exception as e:
logging.error(f"Failed to download image: {url} - {e}")
return None
def replace_image_urls(body, issue_number, output_dir, token=None):
"""Replace remote images in the body with local images"""
img_pattern = r"!\[(.*?)\]\((https?:\/\/[^\)]+)\)"
def replacer(match):
alt_text = match.group(1)
img_url = match.group(2)
filename = f"{issue_number}_{safe_filename(img_url)}"
output_path = os.path.join(output_dir, filename)
final_path = download_image(img_url, output_path, token)
if final_path:
final_filename = os.path.basename(final_path)
return f""
return match.group(0)
return re.sub(img_pattern, replacer, body, flags=re.IGNORECASE)
def sanitize_markdown(content):
"""Sanitize unsafe content in Markdown"""
if not content:
return ""
soup = BeautifulSoup(content, "html.parser")
allowed_tags = ["p", "a", "code", "pre", "blockquote", "ul", "ol", "li", "strong", "em", "img", "h1", "h2", "h3", "h4", "h5", "h6"]
for tag in soup.find_all(True):
if tag.name not in allowed_tags:
tag.unwrap()
return str(soup)
def extract_tags_from_body(body, logger):
"""Extract tags from the last line of the body using $tag$ format and return the cleaned body"""
if not body:
logger.debug("Body is empty, no tags to extract")
return [], body
body = body.replace('\r\n', '\n').rstrip()
lines = body.split('\n')
if not lines:
logger.debug("No lines in body, no tags to extract")
return [], body
last_line = lines[-1].strip()
logger.debug(f"Last line for tag extraction: '{last_line}'")
tags = re.findall(r'\$(.+?)\$', last_line, re.UNICODE)
tags = [tag.strip() for tag in tags if tag.strip()]
if tags:
logger.debug(f"Extracted tags: {tags}")
body = '\n'.join(lines[:-1]).rstrip()
else:
logger.debug("No tags found in last li
```")
return tags, body
def convert_issue(issue, output_dir, token, logger):
"""Convert a single issue to Hugo content"""
try:
labels = [label.name for label in issue.labels]
if PUBLISH_LABEL not in labels or issue.state != "open":
logger.debug(f"Skipping issue #{issue.number} - not marked for publishing")
return False
pub_date = issue.created_at.strftime("%Y%m%d")
slug = f"{pub_date}_{issue.number}"
post_dir = os.path.join(output_dir, slug)
if os.path.exists(post_dir):
logger.info(f"Skipping issue #{issue.number} - directory {post_dir} already exists")
return False
os.makedirs(post_dir, exist_ok=True)
body = issue.body or ""
logger.debug(f"Raw issue body: '{body}'")
cover_url, body = extract_cover_image(body)
tags, body = extract_tags_from_body(body, logger)
body = sanitize_markdown(body)
body = replace_image_urls(body, issue.number, post_dir, token)
logger.info(f"Image processing completed for issue #{issue.number}")
categories = [tag for tag in labels if tag in CATEGORY_MAP]
category = categories[0] if categories else "Life"
cover_name = None
if cover_url:
try:
cover_filename = f"cover_{safe_filename(cover_url)}"
cover_path = os.path.join(post_dir, cover_filename)
final_cover_path = download_image(cover_url, cover_path, token)
if final_cover_path:
cover_name = os.path.basename(final_cover_path)
logger.info(f"Cover image downloaded successfully: {cover_url} > {cover_name}")
else:
logger.error(f"Failed to download cover image: {cover_url}")
except Exception as e:
logger.error(f"Failed to download cover image: {cover_url} - {e}")
title_escaped = issue.title.replace('"', '\\"')
category_escaped = category.replace('"', '\\"')
frontmatter_lines = [
"---",
f'title: "{title_escaped}"',
f"date: \"{issue.created_at.strftime('%Y-%m-%d')}\"",
f"slug: \"{slug}\"",
f"categories: [\"{category_escaped}\"]",
f"tags: {json.dumps(tags, ensure_ascii=False)}"
]
if cover_name:
frontmatter_lines.append(f"image: \"{cover_name}\"")
frontmatter_lines.append("---\n")
frontmatter = "\n".join(frontmatter_lines)
md_file = os.path.join(post_dir, "index.md")
with open(md_file, "w", encoding="utf-8") as f:
f.write(frontmatter + body)
logger.info(f"Successfully converted issue #{issue.number} to {md_file}")
return True
except Exception as e:
logger.exception(f"Critical error occurred while converting issue #{issue.number}")
error_file = os.path.join(output_dir, f"ERROR_{issue.number}.tmp")
with open(error_file, "w") as f:
f.write(f"Conversion failed: {str(e)}")
return False
def main():
args = parse_arguments()
logger = setup_logger(args.debug)
token = args.token or os.getenv("GITHUB_TOKEN")
if not token:
logger.error("Missing GitHub token")
return
try:
auth = Auth.Token(token)
g = Github(auth=auth)
repo = g.get_repo(args.repo)
logger.info(f"Connected to GitHub repository: {args.repo}")
except Exception as e:
logger.error(f"Failed to connect to GitHub: {str(e)}")
return
os.makedirs(args.output, exist_ok=True)
logger.info(f"Output directory: {os.path.abspath(args.output)}")
processed_count = 0
error_count = 0
try:
issues = repo.get_issues(state="open")
total_issues = issues.totalCount
logger.info(f"Starting to process {total_issues} open issues")
for issue in issues:
if issue.pull_request:
continue
try:
if convert_issue(issue, args.output, token, logger):
processed_count += 1
except Exception as e:
error_count += 1
logger.error(f"Error processing issue #{issue.number}: {str(e)}")
try:
error_comment = f"⚠️ Failed to convert to Hugo content, please check format errors:\n\n```\n{str(e)}\n```"
if len(error_comment) > 65536:
error_comment = error_comment[:65000] + "\n```\n...(content too long, partially omitted)"
issue.create_comment(error_comment)
try:
error_label = repo.get_label("conversion-error")
except:
error_label = repo.create_label("conversion-error", "ff0000")
issue.add_to_labels(error_label)
except Exception as inner_e:
logger.error(f"Error creating comment or adding label: {inner_e}")
except Exception as e:
logger.exception(f"Error fetching issues: {e}")
summary = f"Processing completed! Successfully converted {processed_count} issues, {error_count} errors"
if processed_count == 0:
logger.info(summary + " - No content changes to process")
else:
logger.info(summary)
if args.debug:
logger.debug("Content directory status:")
logger.debug(os.listdir(args.output))
def parse_arguments():
parser = argparse.ArgumentParser(description='Convert GitHub issues to Hugo content')
parser.add_argument('--token', type=str, default=None, help='GitHub access token')
parser.add_argument('--repo', type=str, required=True, help='GitHub repository in format owner/repo')
parser.add_argument('--output', type=str, default='content/posts', help='Output directory')
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
return parser.parse_args()
if __name__ == "__main__":
main()
Key Points:
- Image Download:
download_imageuses PAT authentication header (Authorization: token {token}), supporting private repository attachment downloads. - Tag Extraction: Extracts
$tag$format tags from the last line of the Issue body (supports spaces, e.g.,$what's up$). - Duplicate Check: Skips existing article directories (
YYYYMMDD_X). - Markdown Generation: Generates standard Hugo frontmatter andContent, images converted to local paths.
2. Setting Up PAT
Create PAT:
- Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic).
- Create a new PAT, check the boxes for
repo(including access to private repositories) andworkflow(to trigger workflows). - Copy the generated token.
Add to Repository:
- Navigate to the repository (e.g.,
h2dcc/lawtee.github.io) → Settings → Secrets and variables → Actions → Secrets. - Add a new secret named
PAT_TOKENand paste the PAT value.
- Navigate to the repository (e.g.,
3. Writing an Issue to Publish a Blog Post
Compose your blog post in a GitHub Issue using the following format:
- Add a “Publish” Label:
- Create a new Issue and add the
发布label along with category labels (e.g.,技术,生活).
- Create a new Issue and add the
- Body Format:
- Title: The Issue title serves as the blog post title.
- Cover Image: The first image in the body acts as the cover (in Markdown:
). - Body: Written in Markdown format, supporting images, headings, links, etc.
- Tags: Add tags in the last line using the
$tag$format (spaces are allowed, e.g.,$Hugo Post$).
- Example Issue:
 ## My First Hugo Blog Post This is a blog post published via a GitHub Issue. Supports **Markdown** format, and images will be automatically downloaded locally. <!--more-->  $Hugo$ $博客$ $Hugo Post$ - Submit: Save the Issue to trigger the
deploy.ymlworkflow.
4. Workflow Execution Process
Trigger
deploy.yml:- When an Issue is opened (with the “发布” label) or triggered manually.
- The script
issue_to_hugo.pyruns:- Extracts the title, categories (from labels), body, cover image, and tags.
- Downloads images (using PAT authentication for private repositories).
- Generates a Markdown file at
content/posts/YYYYMMDD_X/index.md. - Commits to the
masterbranch.
- Triggers
hugo.ymlvia arepository_dispatchevent.
Trigger
hugo.yml:- Builds the Hugo site (
hugo --minify). - Deploys to GitHub Pages.
- Builds the Hugo site (
5. Verifying Publication
Check Actions Logs:
- Open the GitHub Actions tab.
- Confirm
Sync Issues to Hugo Contentruns:- Logs show
图片下载成功(Images downloaded successfully) and成功转换 issue #X(Successfully converted issue #X). - Commits to
master(e.g.,Automated: Sync GitHub Issues as content).
- Logs show
- Confirm
Deploy Hugo site to Pagesruns:- Check the
Build with HugoandDeploy to GitHub Pagessteps.
- Check the
Check Generated Files:
- Open
content/posts/YYYYMMDD_X/:index.mdcontains frontmatter (title,date,slug,categories,tags,image) and the body.- Local image files exist (e.g.,
X_uuid.jpg).
- Example
index.md:--- title: "My First Hugo Blog Post" date: "2025-10-27" slug: "20251027_2" categories: ["技术"] tags: ["Hugo", "博客", "Hugo Post"] image: "cover_5cc86d74-ff70-401f-820d-520a99a504b9.jpg" --- ## My First Hugo Blog Post This is a blog post published via a GitHub Issue. Supports **Markdown** format, and images are automatically downloaded locally. <!--more--> 
- Open
Visit the Site:
- Go to the GitHub Pages site (e.g.,
https://h2dcc.github.ioor the Pages URL for private repositories). - Confirm the new post appears, images load correctly, and tags and categories are accurate.
- Go to the GitHub Pages site (e.g.,
6. Common Issues and Solutions
- Image Download Fails (404):
- Cause: Private repository images require PAT authentication.
- Solution: Ensure
PAT_TOKENhasrepopermissions and the script includes authentication headers.
- Hugo Build Not Triggered:
- Cause:
repository_dispatchevent failure. - Solution: Check the
Trigger Hugo buildstep logs indeploy.ymlto confirm thecurlrequest returns 204.
- Cause:
- Tags Not Extracted:
- Cause: Incorrect tag format or not placed in the last line.
- Solution: Ensure tags are in the
$tag$format on the last line, with spaces included within$(e.g.,$搞啥 呢$).
- Duplicate Posts:
- Cause: The script skips existing directories (
YYYYMMDD_X). - Solution: Delete
content/posts/YYYYMMDD_X/and rerun the process.
- Cause: The script skips existing directories (
7. Optimization Suggestions
- Single Issue Handling: Modify
deploy.ymlandissue_to_hugo.pyto process only the triggering Issue, reducing runtime. - Error Notifications: Add comments to the Issue to notify users of conversion failures.
- Mobile Optimization: Use the GitHub mobile app + Shortcut Maker to simplify the publishing process.
- Log Inspection: Enable
--debugmode to check detailed logs.
Summary
Publishing Hugo blog posts via GitHub Issues enables a streamlined workflow from quick mobile publishing to automatic deployment. Compared to traditional methods, this approach is simple, efficient, and ideal for bloggers who need to capture ideas on the go. Whether for public or private repositories, with PAT authentication and GitHub Actions, images, tags, and categories are handled seamlessly. Give it a try!
