Lawtee Blog

How to Publish a Hugo Blog Using GitHub Issues

How to Publish a Hugo Blog Using GitHub Issues

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?

Prerequisites

Implementation Steps

1. Configure GitHub Actions Workflows

We need two workflow files:

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:

  1name: Sync Issues to Hugo Content
  2
  3on:
  4  issues:
  5    types: [opened]
  6  workflow_dispatch:
  7
  8permissions:
  9  contents: write
 10
 11concurrency:
 12  group: "issues-to-hugo"
 13  cancel-in-progress: false
 14
 15defaults:
 16  run:
 17    shell: bash
 18
 19jobs:
 20  convert_issues:
 21    name: Convert GitHub Issues
 22    runs-on: ubuntu-latest
 23    if: github.event_name == 'workflow_dispatch' || github.event_name == 'issues' && contains(github.event.issue.labels.*.name, '发布') && github.actor != 'IssueBot'
 24    steps:
 25      - name: Checkout repository
 26        uses: actions/checkout@v4
 27        with:
 28          fetch-depth: 0
 29          clean: true
 30          
 31      - name: Setup Python
 32        uses: actions/setup-python@v4
 33        with:
 34          python-version: '3.11'
 35      
 36      - name: Install dependencies
 37        run: |
 38          pip install requests PyGithub==2.4.0 beautifulsoup4
 39
 40      - name: Create content/posts directory
 41        run: |
 42          mkdir -p content/posts
 43          echo "Created content/posts directory"
 44
 45      - name: Convert issues to Hugo content
 46        id: convert
 47        env:
 48          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
 49        run: |
 50          find content/posts -type f 2>/dev/null | sort > /tmp/original_files.txt || touch /tmp/original_files.txt
 51          if [ -s /tmp/original_files.txt ]; then
 52            xargs sha1sum < /tmp/original_files.txt > /tmp/original_hashes.txt
 53          else
 54            touch /tmp/original_hashes.txt
 55          fi
 56          
 57          python .github/workflows/issue_to_hugo.py \
 58            --repo "${{ github.repository }}" \
 59            --output "content/posts" \
 60            --debug
 61          
 62          find content/posts -type f 2>/dev/null | sort > /tmp/new_files.txt || touch /tmp/new_files.txt
 63          if [ -s /tmp/new_files.txt ]; then
 64            xargs sha1sum < /tmp/new_files.txt > /tmp/new_hashes.txt
 65          else
 66            touch /tmp/new_hashes.txt
 67          fi
 68          
 69          if cmp -s /tmp/original_hashes.txt /tmp/new_hashes.txt; then
 70            echo "needs_build=false" >> $GITHUB_OUTPUT
 71            echo "🔄 No content changes, skipping commit"
 72          else
 73            echo "needs_build=true" >> $GITHUB_OUTPUT
 74            echo "✅ Content changes detected, proceeding with commit"
 75          fi
 76          
 77      - name: Commit changes (if any)
 78        if: steps.convert.outputs.needs_build == 'true'
 79        env:
 80          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
 81        run: |
 82          git config --global user.name "IssueBot"
 83          git config --global user.email "actions@users.noreply.github.com"
 84          git remote set-url origin https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository }}.git
 85          
 86          if [ -n "${{ github.base_ref }}" ]; then
 87            DEFAULT_BRANCH="${{ github.base_ref }}"
 88          else
 89            DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
 90          fi
 91          
 92          if [ -z "$DEFAULT_BRANCH" ]; then
 93            DEFAULT_BRANCH="master"
 94          fi
 95          
 96          git checkout $DEFAULT_BRANCH
 97          
 98          git add content/posts
 99          
100          if ! git diff-index --quiet HEAD --; then
101            git commit -m "Automated: Sync GitHub Issues as content"
102            echo "Pushing changes to branch: $DEFAULT_BRANCH"
103            git push origin $DEFAULT_BRANCH
104          else
105            echo "No changes to commit"
106          fi
107
108      - name: Trigger Hugo build
109        if: steps.convert.outputs.needs_build == 'true'
110        env:
111          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
112        run: |
113          curl -X POST \
114            -H "Accept: application/vnd.github.v3+json" \
115            -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
116            https://api.github.com/repos/${{ github.repository }}/dispatches \
117            -d '{"event_type": "trigger-hugo-build"}'

Key Points:

1.2 Create hugo.yml

Add the following content to .github/workflows/hugo.yml to build and deploy the Hugo site:

 1name: Deploy Hugo site to Pages
 2
 3on:
 4  push:
 5    branches: ["master"]
 6  workflow_dispatch:
 7  repository_dispatch:
 8    types: [trigger-hugo-build]
 9
10permissions:
11  contents: read
12  pages: write
13  id-token: write
14
15concurrency:
16  group: "pages"
17  cancel-in-progress: false
18
19defaults:
20  run:
21    shell: bash
22
23jobs:
24  build:
25    runs-on: ubuntu-latest
26    env:
27      HUGO_VERSION: 0.128.0
28    steps:
29      - name: Install Hugo CLI
30        run: |
31          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
32          && sudo dpkg -i ${{ runner.temp }}/hugo.deb
33      - name: Install Dart Sass
34``````yaml
35run: sudo snap install dart-sass
36- name: Checkout
37  uses: actions/checkout@v4
38  with:
39    submodules: recursive
40- name: Setup Pages
41  id: pages
42  uses: actions/configure-pages@v5
43- name: Install Node.js dependencies
44  run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
45- name: Build with Hugo
46  env:
47    HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
48    HUGO_ENVIRONMENT: production
49  run: |
50    hugo \
51      --minify \
52      --baseURL "${{ steps.pages.outputs.base_url }}/"
53- name: Upload artifact
54  uses: actions/upload-pages-artifact@v3
55  with:
56    path: ./public
57deploy:
58  environment:
59    name: github-pages
60    url: ${{ steps.deployment.outputs.page_url }}
61  runs-on: ubuntu-latest
62  needs: build
63  steps:
64    - name: Deploy to GitHub Pages
65      id: deployment
66      uses: actions/deploy-pages@v4

Key Points:

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:

  1import os
  2import argparse
  3import re
  4import requests
  5import json
  6import logging
  7from urllib.parse import unquote
  8from datetime import datetime
  9from github import Github, Auth
 10from bs4 import BeautifulSoup
 11
 12CATEGORY_MAP = ["Life", "Technology", "Legal", "Moments", "Society"]
 13PUBLISH_LABEL = "Publish"
 14
 15def setup_logger(debug=False):
 16    logger = logging.getLogger('issue-to-hugo')
 17    level = logging.DEBUG if debug else logging.INFO
 18    logger.setLevel(level)
 19    
 20    handler = logging.StreamHandler()
 21    formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 22    handler.setFormatter(formatter)
 23    logger.addHandler(handler)
 24    
 25    return logger
 26
 27def extract_cover_image(body):
 28    """Extract and remove the cover image (first image in the body)"""
 29    img_pattern = r"!\[.*?\]\((https?:\/\/[^\)]+)\)"
 30    match = re.search(img_pattern, body)
 31    
 32    if match:
 33        img_url = match.group(1)
 34        body = body.replace(match.group(0), "")
 35        return img_url, body
 36    return None, body
 37
 38def safe_filename(filename):
 39    """Generate a safe filename, preserving or inferring the file extension"""
 40    clean_url = re.sub(r"\?.*$", "", filename)
 41    basename = os.path.basename(clean_url)
 42    decoded_name = unquote(basename)
 43    
 44    name, ext = os.path.splitext(decoded_name)
 45    safe_name = re.sub(r"[^a-zA-Z0-9\-_]", "_", name)
 46    if not ext.lower() in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
 47        ext = ""
 48    if len(safe_name) > 100 - len(ext):
 49        safe_name = safe_name[:100 - len(ext)]
 50    
 51    return safe_name + ext
 52
 53def download_image(url, output_path, token=None):
 54    """Download image to the specified path, determine extension based on content type, and add GitHub authentication headers"""
 55    try:
 56        headers = {}
 57        if token:
 58            headers['Authorization'] = f'token {token}'
 59        
 60        response = requests.get(url, stream=True, headers=headers)
 61        if response.status_code == 200:
 62            content_type = response.headers.get("content-type", "").lower()
 63            ext = ".jpg"
 64            if "image/png" in content_type:
 65                ext = ".png"
 66            elif "image/jpeg" in content_type or "image/jpg" in content_type:
 67                ext = ".jpg"
 68            elif "image/gif" in content_type:
 69                ext = ".gif"
 70            elif "image/webp" in content_type:
 71                ext = ".webp"
 72            
 73            base, current_ext = os.path.splitext(output_path)
 74            if current_ext.lower() not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
 75                output_path = base + ext
 76            else:
 77                output_path = base + current_ext
 78            
 79            with open(output_path, 'wb') as f:
 80                f.write(response.content)
 81            logging.info(f"Image downloaded successfully: {url} -> {output_path}")
 82            return output_path
 83        else:
 84            logging.error(f"Image download failed, status code: {response.status_code}, URL: {url}")
 85    except Exception as e:
 86        logging.error(f"Failed to download image: {url} - {e}")
 87    return None
 88
 89def replace_image_urls(body, issue_number, output_dir, token=None):
 90    """Replace remote images in the body with local images"""
 91    img_pattern = r"!\[(.*?)\]\((https?:\/\/[^\)]+)\)"
 92    
 93    def replacer(match):
 94        alt_text = match.group(1)
 95        img_url = match.group(2)
 96        filename = f"{issue_number}_{safe_filename(img_url)}"
 97        output_path = os.path.join(output_dir, filename)
 98        final_path = download_image(img_url, output_path, token)
 99        if final_path:
100            final_filename = os.path.basename(final_path)
101            return f"![{alt_text}]({final_filename})"
102        return match.group(0)
103    
104    return re.sub(img_pattern, replacer, body, flags=re.IGNORECASE)
105
106def sanitize_markdown(content):
107    """Sanitize unsafe content in Markdown"""
108    if not content:
109        return ""
110    
111    soup = BeautifulSoup(content, "html.parser")
112    allowed_tags = ["p", "a", "code", "pre", "blockquote", "ul", "ol", "li", "strong", "em", "img", "h1", "h2", "h3", "h4", "h5", "h6"]
113    for tag in soup.find_all(True):
114        if tag.name not in allowed_tags:
115            tag.unwrap()
116    
117    return str(soup)
118
119def extract_tags_from_body(body, logger):
120    """Extract tags from the last line of the body using $tag$ format and return the cleaned body"""
121    if not body:
122        logger.debug("Body is empty, no tags to extract")
123        return [], body
124    
125    body = body.replace('\r\n', '\n').rstrip()
126    lines = body.split('\n')
127    if not lines:
128        logger.debug("No lines in body, no tags to extract")
129        return [], body
130    
131    last_line = lines[-1].strip()
132    logger.debug(f"Last line for tag extraction: '{last_line}'")
133    
134    tags = re.findall(r'\$(.+?)\$', last_line, re.UNICODE)
135    tags = [tag.strip() for tag in tags if tag.strip()]
136    
137    if tags:
138        logger.debug(f"Extracted tags: {tags}")
139        body = '\n'.join(lines[:-1]).rstrip()
140    else:
141        logger.debug("No tags found in last li
142```")
143    
144    return tags, body
145
146def convert_issue(issue, output_dir, token, logger):
147    """Convert a single issue to Hugo content"""
148    try:
149        labels = [label.name for label in issue.labels]
150        if PUBLISH_LABEL not in labels or issue.state != "open":
151            logger.debug(f"Skipping issue #{issue.number} - not marked for publishing")
152            return False
153        
154        pub_date = issue.created_at.strftime("%Y%m%d")
155        slug = f"{pub_date}_{issue.number}"
156        post_dir = os.path.join(output_dir, slug)
157        
158        if os.path.exists(post_dir):
159            logger.info(f"Skipping issue #{issue.number} - directory {post_dir} already exists")
160            return False
161        
162        os.makedirs(post_dir, exist_ok=True)
163        
164        body = issue.body or ""
165        logger.debug(f"Raw issue body: '{body}'")
166        cover_url, body = extract_cover_image(body)
167        tags, body = extract_tags_from_body(body, logger)
168        body = sanitize_markdown(body)
169        body = replace_image_urls(body, issue.number, post_dir, token)
170        logger.info(f"Image processing completed for issue #{issue.number}")
171        
172        categories = [tag for tag in labels if tag in CATEGORY_MAP]
173        category = categories[0] if categories else "Life"
174        
175        cover_name = None
176        if cover_url:
177            try:
178                cover_filename = f"cover_{safe_filename(cover_url)}"
179                cover_path = os.path.join(post_dir, cover_filename)
180                final_cover_path = download_image(cover_url, cover_path, token)
181                if final_cover_path:
182                    cover_name = os.path.basename(final_cover_path)
183                    logger.info(f"Cover image downloaded successfully: {cover_url} > {cover_name}")
184                else:
185                    logger.error(f"Failed to download cover image: {cover_url}")
186            except Exception as e:
187                logger.error(f"Failed to download cover image: {cover_url} - {e}")
188        
189        title_escaped = issue.title.replace('"', '\\"')
190        category_escaped = category.replace('"', '\\"')
191        frontmatter_lines = [
192            "---",
193            f'title: "{title_escaped}"',
194            f"date: \"{issue.created_at.strftime('%Y-%m-%d')}\"",
195            f"slug: \"{slug}\"",
196            f"categories: [\"{category_escaped}\"]",
197            f"tags: {json.dumps(tags, ensure_ascii=False)}"
198        ]
199        
200        if cover_name:
201            frontmatter_lines.append(f"image: \"{cover_name}\"")
202        
203        frontmatter_lines.append("---\n")
204        frontmatter = "\n".join(frontmatter_lines)
205        
206        md_file = os.path.join(post_dir, "index.md")
207        with open(md_file, "w", encoding="utf-8") as f:
208            f.write(frontmatter + body)
209        
210        logger.info(f"Successfully converted issue #{issue.number} to {md_file}")
211        return True
212    except Exception as e:
213        logger.exception(f"Critical error occurred while converting issue #{issue.number}")
214        error_file = os.path.join(output_dir, f"ERROR_{issue.number}.tmp")
215        with open(error_file, "w") as f:
216            f.write(f"Conversion failed: {str(e)}")
217        return False
218
219def main():
220    args = parse_arguments()
221    logger = setup_logger(args.debug)
222    
223    token = args.token or os.getenv("GITHUB_TOKEN")
224    if not token:
225        logger.error("Missing GitHub token")
226        return
227    
228    try:
229        auth = Auth.Token(token)
230        g = Github(auth=auth)
231        repo = g.get_repo(args.repo)
232        logger.info(f"Connected to GitHub repository: {args.repo}")
233    except Exception as e:
234        logger.error(f"Failed to connect to GitHub: {str(e)}")
235        return
236    
237    os.makedirs(args.output, exist_ok=True)
238    logger.info(f"Output directory: {os.path.abspath(args.output)}")
239    
240    processed_count = 0
241    error_count = 0
242    
243    try:
244        issues = repo.get_issues(state="open")
245        total_issues = issues.totalCount
246        logger.info(f"Starting to process {total_issues} open issues")
247        
248        for issue in issues:
249            if issue.pull_request:
250                continue
251            try:
252                if convert_issue(issue, args.output, token, logger):
253                    processed_count += 1
254            except Exception as e:
255                error_count += 1
256                logger.error(f"Error processing issue #{issue.number}: {str(e)}")
257                try:
258                    error_comment = f"⚠️ Failed to convert to Hugo content, please check format errors:\n\n```\n{str(e)}\n```"
259                    if len(error_comment) > 65536:
260                        error_comment = error_comment[:65000] + "\n```\n...(content too long, partially omitted)"
261                    
262                    issue.create_comment(error_comment)
263                    try:
264                        error_label = repo.get_label("conversion-error")
265                    except:
266                        error_label = repo.create_label("conversion-error", "ff0000")
267                    issue.add_to_labels(error_label)
268                except Exception as inner_e:
269                    logger.error(f"Error creating comment or adding label: {inner_e}")
270    except Exception as e:
271        logger.exception(f"Error fetching issues: {e}")
272        
273    summary = f"Processing completed! Successfully converted {processed_count} issues, {error_count} errors"
274    if processed_count == 0:
275        logger.info(summary + " - No content changes to process")
276    else:
277        logger.info(summary)
278        
279    if args.debug:
280        logger.debug("Content directory status:")
281        logger.debug(os.listdir(args.output))
282
283def parse_arguments():
284    parser = argparse.ArgumentParser(description='Convert GitHub issues to Hugo content')
285    parser.add_argument('--token', type=str, default=None, help='GitHub access token')
286    parser.add_argument('--repo', type=str, required=True, help='GitHub repository in format owner/repo')
287    parser.add_argument('--output', type=str, default='content/posts', help='Output directory')
288    parser.add_argument('--debug', action='store_true', help='Enable debug logging')
289    return parser.parse_args()
290
291if __name__ == "__main__":
292    main()

Key Points:

2. Setting Up PAT

  1. Create PAT:

    • Go to GitHub SettingsDeveloper settingsPersonal access tokensTokens (classic).
    • Create a new PAT, check the boxes for repo (including access to private repositories) and workflow (to trigger workflows).
    • Copy the generated token.
  2. Add to Repository:

    • Navigate to the repository (e.g., h2dcc/lawtee.github.io) → SettingsSecrets and variablesActionsSecrets.
    • Add a new secret named PAT_TOKEN and paste the PAT value.

3. Writing an Issue to Publish a Blog Post

Compose your blog post in a GitHub Issue using the following format:

  1. Add a “Publish” Label:
    • Create a new Issue and add the 发布 label along with category labels (e.g., 技术, 生活).
  2. 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: ![Image](URL)).
    • 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$).
  3. Example Issue:
    1![Image](https://github.com/user-attachments/assets/5cc86d74-ff70-401f-820d-520a99a504b9)
    2## My First Hugo Blog Post
    3This is a blog post published via a GitHub Issue.
    4Supports **Markdown** format, and images will be automatically downloaded locally.
    5<!--more-->
    6![Another Image](https://github.com/user-attachments/assets/926e7e9b-d279-4db9-bbb9-60bdcedd1804)
    7$Hugo$ $博客$ $Hugo Post$
  4. Submit: Save the Issue to trigger the deploy.yml workflow.

4. Workflow Execution Process

  1. Trigger deploy.yml:

    • When an Issue is opened (with the “发布” label) or triggered manually.
    • The script issue_to_hugo.py runs:
      • 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 master branch.
    • Triggers hugo.yml via a repository_dispatch event.
  2. Trigger hugo.yml:

    • Builds the Hugo site (hugo --minify).
    • Deploys to GitHub Pages.

5. Verifying Publication

  1. Check Actions Logs:

    • Open the GitHub Actions tab.
    • Confirm Sync Issues to Hugo Content runs:
      • Logs show 图片下载成功 (Images downloaded successfully) and 成功转换 issue #X (Successfully converted issue #X).
      • Commits to master (e.g., Automated: Sync GitHub Issues as content).
    • Confirm Deploy Hugo site to Pages runs:
      • Check the Build with Hugo and Deploy to GitHub Pages steps.
  2. Check Generated Files:

    • Open content/posts/YYYYMMDD_X/:
      • index.md contains frontmatter (title, date, slug, categories, tags, image) and the body.
      • Local image files exist (e.g., X_uuid.jpg).
    • Example index.md:
       1---
       2title: "My First Hugo Blog Post"
       3date: "2025-10-27"
       4slug: "20251027_2"
       5categories: ["技术"]
       6tags: ["Hugo", "博客", "Hugo Post"]
       7image: "cover_5cc86d74-ff70-401f-820d-520a99a504b9.jpg"
       8---
       9## My First Hugo Blog Post
      10This is a blog post published via a GitHub Issue.
      11Supports **Markdown** format, and images are automatically downloaded locally.
      12<!--more-->
      13![Another Image](2_926e7e9b-d279-4db9-bbb9-60bdcedd1804.jpg)
  3. Visit the Site:

    • Go to the GitHub Pages site (e.g., https://h2dcc.github.io or the Pages URL for private repositories).
    • Confirm the new post appears, images load correctly, and tags and categories are accurate.

6. Common Issues and Solutions

7. Optimization Suggestions

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!

#hugo #github issue

Comments