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?
- 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:
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:
- 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:
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@v4Key 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:
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""
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:
- 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:
1 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 7$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: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
- 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!