如何使用 GitHub Issue 发布 Hugo 博客

近年来,静态博客的发布方式层出不穷,但许多方法要么复杂,要么难以长期坚持。本文介绍一种简单高效的方式:通过 GitHub Issue 作为 Hugo 博客的发布端,利用 GitHub Actions 自动将 Issue 转换为 Hugo 内容并部署到 GitHub Pages。这种方法尤其适合喜欢用 GitHub 移动端 App 随时随地发布博客的用户。本教程基于老 T 的实践经验,解决了私有仓库图片下载、标签提取等问题,适合公开和私有仓库。
为什么选择 GitHub Issue?
- 便捷性:GitHub 移动端 App 在国内网络环境下比网页版更稳定,发布只需几次点击,比微信朋友圈还简单。
- 无限制:Issue 几乎没有数量或容量限制,适合博客内容存储。
- 自动化:通过 GitHub Actions,可以将 Issue 自动转换为 Hugo 内容,并触发站点构建。
- 灵活性:支持图片、标签、分类,适合短篇“说说”或长篇文章。
前提条件
- 一个 Hugo 博客项目,已配置好 GitHub Pages(公开或私有仓库)。
- GitHub 个人访问令牌(PAT),具有
repo权限(私有仓库需要)和workflow权限(触发工作流)。在 GitHub Settings → Developer settings → Personal access tokens 创建。 - 基本的 GitHub Actions 和 Hugo 知识。
- 仓库结构包含
content/posts/目录,用于存放生成的文章。
实现步骤
1. 配置 GitHub Actions 工作流
我们需要两个工作流文件:
deploy.yml:将 Issue 转换为 Hugo 内容并提交到仓库。hugo.yml:构建 Hugo 站点并部署到 GitHub Pages。
1.1 创建 deploy.yml
在 .github/workflows/deploy.yml 中添加以下内容,用于处理 Issue 转换为 Hugo 内容,并触发 Hugo 构建:
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 "🔄 没有内容变更,将跳过提交"
72 else
73 echo "needs_build=true" >> $GITHUB_OUTPUT
74 echo "✅ 检测到内容变更,将执行提交"
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 "正在推送变更到分支: $DEFAULT_BRANCH"
103 git push origin $DEFAULT_BRANCH
104 else
105 echo "没有需要提交的变更"
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"}'关键点:
- 触发条件:Issue 打开时带有“发布”标签,或手动触发。
- 防止递归:
github.actor != 'IssueBot'确保 IssueBot 的提交不会再次触发工作流。 - 提交检测:仅在内容变更时提交(通过文件哈希比较)。
- 触发 Hugo 构建:通过
repository_dispatch事件 (trigger-hugo-build) 调用 Hugo 工作流。
1.2 创建 hugo.yml
在 .github/workflows/hugo.yml 中添加以下内容,用于构建和部署 Hugo 站点:
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 run: sudo snap install dart-sass
35 - name: Checkout
36 uses: actions/checkout@v4
37 with:
38 submodules: recursive
39 - name: Setup Pages
40 id: pages
41 uses: actions/configure-pages@v5
42 - name: Install Node.js dependencies
43 run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
44 - name: Build with Hugo
45 env:
46 HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
47 HUGO_ENVIRONMENT: production
48 run: |
49 hugo \
50 --minify \
51 --baseURL "${{ steps.pages.outputs.base_url }}/"
52 - name: Upload artifact
53 uses: actions/upload-pages-artifact@v3
54 with:
55 path: ./public
56 deploy:
57 environment:
58 name: github-pages
59 url: ${{ steps.deployment.outputs.page_url }}
60 runs-on: ubuntu-latest
61 needs: build
62 steps:
63 - name: Deploy to GitHub Pages
64 id: deployment
65 uses: actions/deploy-pages@v4关键点:
- 触发条件:
push到master分支、手动触发,或repository_dispatch事件 (trigger-hugo-build)。 - 权限:确保
pages: write和id-token: write用于 GitHub Pages 部署。
1.3 添加转换脚本 issue_to_hugo.py
在 .github/workflows/issue_to_hugo.py 中添加以下 Python 脚本,用于将 Issue 转换为 Hugo 内容,支持私有仓库图片下载和标签提取:
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 = ["生活", "技术", "法律", "瞬间", "社会"]
13PUBLISH_LABEL = "发布"
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 """提取并删除封面图(正文第一张图片)"""
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 """生成安全的文件名,保留或推断文件扩展名"""
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 """下载图片到指定路径,基于内容类型确定扩展名,并添加 GitHub 认证头"""
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"图片下载成功: {url} -> {output_path}")
82 return output_path
83 else:
84 logging.error(f"图片下载失败,状态码: {response.status_code}, URL: {url}")
85 except Exception as e:
86 logging.error(f"下载图片失败: {url} - {e}")
87 return None
88
89def replace_image_urls(body, issue_number, output_dir, token=None):
90 """替换正文中的远程图片为本地图片"""
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 """清理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 """从正文最后一行提取标签,使用 $tag$ 格式,并返回清理后的正文"""
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 line")
142
143 return tags, body
144
145def convert_issue(issue, output_dir, token, logger):
146 """转换单个issue为Hugo内容"""
147 try:
148 labels = [label.name for label in issue.labels]
149 if PUBLISH_LABEL not in labels or issue.state != "open":
150 logger.debug(f"跳过 issue #{issue.number} - 未标记为发布")
151 return False
152
153 pub_date = issue.created_at.strftime("%Y%m%d")
154 slug = f"{pub_date}_{issue.number}"
155 post_dir = os.path.join(output_dir, slug)
156
157 if os.path.exists(post_dir):
158 logger.info(f"跳过 issue #{issue.number} - 目录 {post_dir} 已存在")
159 return False
160
161 os.makedirs(post_dir, exist_ok=True)
162
163 body = issue.body or ""
164 logger.debug(f"Raw issue body: '{body}'")
165 cover_url, body = extract_cover_image(body)
166 tags, body = extract_tags_from_body(body, logger)
167 body = sanitize_markdown(body)
168 body = replace_image_urls(body, issue.number, post_dir, token)
169 logger.info(f"图片处理完成,{issue.number} 号 issue")
170
171 categories = [tag for tag in labels if tag in CATEGORY_MAP]
172 category = categories[0] if categories else "生活"
173
174 cover_name = None
175 if cover_url:
176 try:
177 cover_filename = f"cover_{safe_filename(cover_url)}"
178 cover_path = os.path.join(post_dir, cover_filename)
179 final_cover_path = download_image(cover_url, cover_path, token)
180 if final_cover_path:
181 cover_name = os.path.basename(final_cover_path)
182 logger.info(f"封面图下载成功:{cover_url} > {cover_name}")
183 else:
184 logger.error(f"封面图下载失败:{cover_url}")
185 except Exception as e:
186 logger.error(f"封面图下载失败:{cover_url} - {e}")
187
188 title_escaped = issue.title.replace('"', '\\"')
189 category_escaped = category.replace('"', '\\"')
190 frontmatter_lines = [
191 "---",
192 f'title: "{title_escaped}"',
193 f"date: \"{issue.created_at.strftime('%Y-%m-%d')}\"",
194 f"slug: \"{slug}\"",
195 f"categories: [\"{category_escaped}\"]",
196 f"tags: {json.dumps(tags, ensure_ascii=False)}"
197 ]
198
199 if cover_name:
200 frontmatter_lines.append(f"image: \"{cover_name}\"")
201
202 frontmatter_lines.append("---\n")
203 frontmatter = "\n".join(frontmatter_lines)
204
205 md_file = os.path.join(post_dir, "index.md")
206 with open(md_file, "w", encoding="utf-8") as f:
207 f.write(frontmatter + body)
208
209 logger.info(f"成功转换 issue #{issue.number} 到 {md_file}")
210 return True
211 except Exception as e:
212 logger.exception(f"转换 issue #{issue.number} 时发生严重错误")
213 error_file = os.path.join(output_dir, f"ERROR_{issue.number}.tmp")
214 with open(error_file, "w") as f:
215 f.write(f"Conversion failed: {str(e)}")
216 return False
217
218def main():
219 args = parse_arguments()
220 logger = setup_logger(args.debug)
221
222 token = args.token or os.getenv("GITHUB_TOKEN")
223 if not token:
224 logger.error("Missing GitHub token")
225 return
226
227 try:
228 auth = Auth.Token(token)
229 g = Github(auth=auth)
230 repo = g.get_repo(args.repo)
231 logger.info(f"已连接至 GitHub 仓库:{args.repo}")
232 except Exception as e:
233 logger.error(f"连接GitHub失败: {str(e)}")
234 return
235
236 os.makedirs(args.output, exist_ok=True)
237 logger.info(f"输出目录: {os.path.abspath(args.output)}")
238
239 processed_count = 0
240 error_count = 0
241
242 try:
243 issues = repo.get_issues(state="open")
244 total_issues = issues.totalCount
245 logger.info(f"开始处理 {total_issues} 个打开状态的 issue")
246
247 for issue in issues:
248 if issue.pull_request:
249 continue
250 try:
251 if convert_issue(issue, args.output, token, logger):
252 processed_count += 1
253 except Exception as e:
254 error_count += 1
255 logger.error(f"处理 issue #{issue.number} 时出错: {str(e)}")
256 try:
257 error_comment = f"⚠️ 转换为Hugo内容失败,请检查格式错误:\n\n```\n{str(e)}\n```"
258 if len(error_comment) > 65536:
259 error_comment = error_comment[:65000] + "\n```\n...(内容过长,部分已省略)"
260
261 issue.create_comment(error_comment)
262 try:
263 error_label = repo.get_label("conversion-error")
264 except:
265 error_label = repo.create_label("conversion-error", "ff0000")
266 issue.add_to_labels(error_label)
267 except Exception as inner_e:
268 logger.error(f"创建评论或添加标签时出错: {inner_e}")
269 except Exception as e:
270 logger.exception(f"获取issues时出错: {e}")
271
272 summary = f"处理完成!成功转换 {processed_count} 个issues,{error_count} 个错误"
273 if processed_count == 0:
274 logger.info(summary + " - 没有需要处理的内容变更")
275 else:
276 logger.info(summary)
277
278 if args.debug:
279 logger.debug("内容目录状态:")
280 logger.debug(os.listdir(args.output))
281
282def parse_arguments():
283 parser = argparse.ArgumentParser(description='Convert GitHub issues to Hugo content')
284 parser.add_argument('--token', type=str, default=None, help='GitHub access token')
285 parser.add_argument('--repo', type=str, required=True, help='GitHub repository in format owner/repo')
286 parser.add_argument('--output', type=str, default='content/posts', help='Output directory')
287 parser.add_argument('--debug', action='store_true', help='Enable debug logging')
288 return parser.parse_args()
289
290if __name__ == "__main__":
291 main()关键点:
- 图片下载:
download_image使用 PAT 认证头(Authorization: token {token}),支持私有仓库附件下载。 - 标签提取:从 Issue 正文最后一行提取
$tag$格式标签(支持空格,如$搞啥 呢$)。 - 重复检查:跳过已存在的文章目录(
YYYYMMDD_X)。 - Markdown 生成:生成标准 Hugo frontmatter 和内容,图片转为本地路径。
2. 设置 PAT
创建 PAT:
- 访问 GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)。
- 创建新 PAT,勾选
repo(包括私有仓库访问)和workflow(触发工作流)。 - 复制生成的 token。
添加到仓库:
- 转到仓库(e.g.,
h2dcc/lawtee.github.io)→ Settings → Secrets and variables → Actions → Secrets。 - 添加新 secret,命名为
PAT_TOKEN,粘贴 PAT 值。
- 转到仓库(e.g.,
3. 编写 Issue 发布博客
在 GitHub Issue 中按以下格式编写博客文章:
- 添加“发布”标签:
- 创建新 Issue,添加标签
发布和分类标签(如技术、生活)。
- 创建新 Issue,添加标签
- 正文格式:
- 标题:Issue 标题作为文章标题。
- 封面图:正文第一张图片作为封面(Markdown 格式:
)。 - 正文:Markdown 格式,支持图片、标题、链接等。
- 标签:最后一行以
$tag$格式添加标签(支持空格,如$Hugo Post$)。
- 示例 Issue:
1 2## 我的第一篇 Hugo 博客 3这是一篇通过 GitHub Issue 发布的博客。 4支持 **Markdown** 格式,图片会自动下载到本地。 5<!--more--> 6 7$Hugo$ $博客$ $Hugo Post$ - 提交:保存 Issue,触发
deploy.yml工作流。
4. 工作流运行流程
触发
deploy.yml:- Issue 打开(带有“发布”标签)或手动触发。
- 脚本
issue_to_hugo.py运行:- 提取标题、分类(从标签)、正文、封面图、标签。
- 下载图片(使用 PAT 认证,适用于私有仓库)。
- 生成 Markdown 文件到
content/posts/YYYYMMDD_X/index.md。 - 提交到
master分支。
- 触发
hugo.yml通过repository_dispatch事件。
触发
hugo.yml:- 构建 Hugo 站点(
hugo --minify)。 - 部署到 GitHub Pages。
- 构建 Hugo 站点(
5. 验证发布
检查 Actions 日志:
- 打开 GitHub Actions 选项卡。
- 确认
Sync Issues to Hugo Content运行:- 日志显示
图片下载成功和成功转换 issue #X。 - 提交到
master(e.g.,Automated: Sync GitHub Issues as content)。
- 日志显示
- 确认
Deploy Hugo site to Pages运行:- 检查
Build with Hugo和Deploy to GitHub Pages步骤。
- 检查
检查生成文件:
- 打开
content/posts/YYYYMMDD_X/:index.md包含 frontmatter(title,date,slug,categories,tags,image)和正文。- 本地图片文件(e.g.,
X_uuid.jpg)存在。
- 示例
index.md:1--- 2title: "我的第一篇 Hugo 博客" 3date: "2025-10-27" 4slug: "20251027_2" 5categories: ["技术"] 6tags: ["Hugo", "博客", "Hugo Post"] 7image: "cover_5cc86d74-ff70-401f-820d-520a99a504b9.jpg" 8--- 9## 我的第一篇 Hugo 博客 10这是一篇通过 GitHub Issue 发布的博客。 11支持 **Markdown** 格式,图片会自动下载到本地。 12<!--more--> 13
- 打开
访问站点:
- 访问 GitHub Pages 站点(e.g.,
https://h2dcc.github.io或私有仓库的 Pages URL)。 - 确认新文章显示,图片加载正常,标签和分类正确。
- 访问 GitHub Pages 站点(e.g.,
6. 常见问题及解决
- 图片下载失败(404):
- 原因:私有仓库图片需要 PAT 认证。
- 解决:确保
PAT_TOKEN有repo权限,脚本已添加认证头。
- Hugo 构建未触发:
- 原因:
repository_dispatch事件失败。 - 解决:检查
deploy.yml的Trigger Hugo build步骤日志,确认curl请求返回 204。
- 原因:
- 标签未提取:
- 原因:标签格式错误或不在最后一行。
- 解决:确保标签以
$tag$格式在最后一行,空格需包含在$内(如$搞啥 呢$)。
- 重复文章:
- 原因:脚本会跳过已有目录(
YYYYMMDD_X)。 - 解决:删除
content/posts/YYYYMMDD_X/后重新运行。
- 原因:脚本会跳过已有目录(
7. 优化建议
- 单 Issue 处理:修改
deploy.yml和issue_to_hugo.py支持仅处理触发 Issue,减少运行时间。 - 错误通知:在 Issue 中添加评论,通知转换失败原因。
- 移动端优化:使用 GitHub 移动端 App + Shortcut Maker,简化发布流程。
- 日志查看:启用
--debug模式,检查详细日志。
总结
通过 GitHub Issue 发布 Hugo 博客,只需几个步骤即可实现从移动端快速发布到自动部署的全流程。相比传统方式,这种方法简单、高效,尤其适合需要随时记录灵感的博主。无论是公开还是私有仓库,配合 PAT 认证和 GitHub Actions,图片、标签、分类都能完美处理。快去试试吧!
#github issue #hugo博客 #自动化部署 #github actions #静态博客