Compare commits

...
2 Commits
5 changed files with 553 additions and 0 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.agents/
.claude/
skills-lock.json
+1
View File
@@ -37,6 +37,7 @@ npx skills remove runtime-image -g
| [runtime-image](skills/runtime-image/SKILL.md) | 探测项目技术栈,按 runtime-base 镜像矩阵选型并生成/修正 Dockerfile |
| [skill-forge](skills/skill-forge/SKILL.md) | 交互式创建新 Skill 的流水线,一问一答采集触发词/流程/红线,产出可直接使用的 SKILL.md |
| [push-and-report](skills/push-and-report/SKILL.md) | 标准收尾流程:盘点 git 变更 → 英文 conventional commit → 推送远程 → SPMS 日报 |
| [gitea-actions](skills/gitea-actions/SKILL.md) | 按统一规范给项目生成/评审优化 Gitea Actions 构建部署工作流(buildx 推 Gitea Packages → 本机重建容器 → 清理旧版本) |
## 目录结构
+170
View File
@@ -0,0 +1,170 @@
---
name: gitea-actions
description: 按统一规范给项目生成或评审优化 Gitea Actions 构建部署工作流(.gitea/workflows/docker-deploy.yaml):推 v 开头 tag 触发 → buildx 构建推 Gitea Packages → 本机 docker 重建容器 → 清理本机与注册表旧版本;零第三方 action、INNEV_ 前缀变量/密钥、凭证回退链。凡用户要求「给项目加构建工作流 / 加个 CI / 配发版流水线 / 加 Gitea Actions / 生成 docker-deploy.yaml / 打 tag 自动构建部署 / 这个项目怎么自动发版 / 看看这个 workflow 规范不规范 / 帮我评审一下这个流水线」,或出现 推了 tag Actions 没动静 / workflow 不触发 / push 镜像 denied / unauthorized / 健康检查超时 等症状,或提到 .gitea/workflows / workflow_dispatch / buildx / Gitea Packages / self-hosted runner / INNEV_ 变量 时,务必使用本 skill——即使用户没说「Gitea Actions」这几个字。Use whenever the user wants to add, review, or standardize a Gitea Actions build/deploy workflow for a project. 边界:写/改 Dockerfile 用 runtime-image;提交推送代码+写日报用 push-and-report;手工推镜像发版用 xgent-image-push。
---
# gitea-actions — 按统一规范给项目装构建部署工作流
把实战中固化下来的 docker-deploy 流水线规范安装到任意项目。核心理念(推理链,所有约束都挂在这上面):
- **版本号即镜像 tag**:发布 = 打 v 开头 git tag,镜像 tag 与之一一对应,可回溯;推 main 不发版
- **runner 与部署目标同机**:self-hosted 实例级 runner 直连宿主 docker,构建完即本机部署
- **零外部依赖**:不用任何第三方 action,run 步骤直连 git/docker,内网 runner 无需访问 github.com
## 默认流程(不带任何附加提示词也照此执行)
检测项目是否存在 `.gitea/workflows/` 下的工作流文件(yml/yaml),自动分流:
- **不存在 → 新建模式**:按规范从零生成 `docker-deploy.yaml`
- **已存在 → 评审优化模式**:对照规范逐条审计,差异摊开,用户确认后按规范改写优化(默认不直接覆盖)
## 总流程
```
第 0 步 探测项目(绝不可跳过)→ 第 1 步 槽位定参 → 第 2 步 生成/改写(确认闸)
→ 第 3 步 验证 → 第 4 步 汇报
```
除非用户明确只要聊聊思路,完整走全部 5 步。
## 第 0 步:探测项目(绝不可跳过)
不许凭用户一句话生成——读项目文件坐实,缺料就停下来问,不猜、不翻别的项目抄。
| 探测项 | 怎么探 | 判定/用途 |
|---|---|---|
| 已有 workflow(**最先做,决定模式**) | glob `.gitea/workflows/*.yml`、`.yaml` | 有 → 评审优化模式;无 → 新建模式 |
| 项目名 | git remote basename → package.json name → 目录名,用户确认 | `{{PROJECT}}`:镜像名/容器名/builder 名 |
| Dockerfile | glob 项目内 `Dockerfile*` | 无 → die:先走 runtime-image 生成 Dockerfile——流水线对着空气构建无意义 |
| 启动模型 | 读 Dockerfile 的 FROM | runtime-node 系(容器启动时 install+build)→ 健康检查窗口放宽 ~7.5 分钟;预构建产物 → 窗口可缩短 |
| 端口 | Dockerfile EXPOSE / 项目配置 / 问用户 | `{{APP_PORT}}` 容器内端口 + `{{HOST_PORT}}` 对外端口默认值 |
| 健康检查端点 | grep 代码里 `/health`、`/healthz`、`/api/health` 等路由 | 有 → `{{HEALTH_PATH}}`;无 → warn:不虚构,生成时整块省略健康检查步,汇报大字声明 |
| 必需 .env key | 读 `.env.example` | 渲染 deploy 步的 env key 检查行;无 `.env.example` → 整块省略 |
| 特殊预构建 | 问用户(如从源码构建工具二进制) | 对应模板 `[OPTIONAL:PRE_BUILD]` 块,插入"构建并推送镜像"步之前 |
| 额外运行参数 | 读 Dockerfile/运行时要求(如 runtime-node 的 `APP_ENTRY`)、.env 是否引用宿主主机名 | 对应 `[OPTIONAL:EXTRA_ENV]` / `[OPTIONAL:ADD_HOST]` 块 |
→ 验收:模式(新建/评审优化)有 glob 结论背书;每个槽位有文件证据(路径)或用户确认,拿不准的在生成前问清。
## 第 1 步:槽位定参
读 `references/docker-deploy-template.yaml`(模板即规范,含全部机制与尾部人工准备清单),把探测结论映射到槽位:
| 槽位/可选块 | 来源 | 无对应值时 |
|---|---|---|
| `{{PROJECT}}` / `{{APP_PORT}}` / `{{HOST_PORT}}` / `{{HEALTH_PATH}}` | 第 0 步探测 | 前三个必给(问用户);HEALTH_PATH 无 → 删 `[OPTIONAL:HEALTH_CHECK]` 整块 |
| `[OPTIONAL:ENV_KEY_CHECKS]` | `.env.example` 渲染,每个必需 key 一行 grep 检查 | 整块删除 |
| `[OPTIONAL:PRE_BUILD]` | 用户确认的特殊预构建步骤 | 整块删除 |
| `[OPTIONAL:EXTRA_ENV]` / `[OPTIONAL:ADD_HOST]` | 运行时/依赖拓扑要求 | 整块删除 |
槽位写作 `{{SLOT}}`(无 `$` 前缀),与 Gitea 表达式 `${{ ... }}` 区分;渲染后模板里不应残留任何 `{{...}}`。
→ 验收:槽位对照表逐项有值或有"整块删除"结论,摊开给用户过目。
## 第 2 步:生成/改写(确认闸)
默认动作:**先把渲染后的 workflow 全文摊给用户确认,再落盘**到 `.gitea/workflows/docker-deploy.yaml`;改写已有文件时给 diff。已有 workflow 里与规范不冲突的定制(额外 job、项目专用步骤)保留,并在汇报里列出。
→ 验收:用户确认后文件落盘;保留的定制逐项在汇报出现,不静默丢弃(红线)。
## 评审优化模式(第 0 步发现已有 workflow 时走此分支)
先做完第 0、1 步得出"应该长什么样",再拿现状逐条对照审计。**只读审计,审计报告出来之前不改任何文件**。
### 审计清单(依据列挂着硬约束编号)
| # | 检查项 | 依据 | 级别 |
|---|---|---|---|
| B1 | 出现 `uses:`(第三方 action) | ① | 必须修 |
| B2 | 触发不是 push v* tag(如推 main 即构建部署) | ② | 必须修 |
| B3 | 缺 workflow_dispatch job 单跑选项 | ② | 建议修 |
| B4 | buildx 未经 buildkitd.toml 注入 registry `http=true` + docker.io mirrors | ③ | 必须修 |
| B5 | 凭证写死,或无 `INNEV_*` → `gitea.actor`/`GITHUB_TOKEN` 回退链 | ④ | 必须修 |
| B6 | cleanup 全局 prune,或容器 name 过滤无 `^...$` 锚点 | ⑤ | 必须修 |
| B7 | 镜像只打 latest、无版本 tag | ⑥ | 必须修 |
| B8 | 明文密钥 / clone 日志未 `grep -v` 过滤 token / docker login 未走 `--password-stdin` | ⑦ | 必须修(安全项,优先处理) |
| B9 | 健康检查缺失或窗口与启动模型不匹配(runtime-node 首启数分钟) | ⑧ | 建议修 |
| B10 | 自定义变量/密钥未用 INNEV_ 前缀 | 命名约定 | 建议修 |
级别定义:**必须修** = 会挂/泄密/误删邻项目产物;**建议修** = 能跑但稳定性/可维护性受损。
### 审计报告格式(固定)
- 结论一行:当前流程 → 规范流程差距概述(一致则明说"已符合规范")
- 问题表:级别 | 行号 | 问题 | 依据(约束编号) | 修法
- 已过项也要逐条报"已过"(B1-B10 逐条过,漏报和误报一样有害)
报告摊开、用户确认要修 → 转第 2 步给 diff,再进第 3 步验证。用户说"只评审不动手" → 报告即交付物。
## 第 3 步:验证
1. `bash scripts/check-workflow.sh <生成的 workflow 路径>` 规范自检 8 项,全过(SKIP 项人工知悉)
2. YAML 语法由脚本第 ⑧ 项覆盖(pyyaml 可用时)
3. 真触发(打 tag 发版)不在授权范围——**显式声明"未真机验证"**,给出手动验证路径:仓库 Actions → Run workflow 单跑 `image` 任务,绿了再全链路
完成定义 = 规范自检全过 + 用户拿到可执行的首次发布步骤;不是"文件写完了"。
→ 验收:check-workflow.sh 输出全 PASS;未验证项在汇报显式声明。
## 第 4 步:汇报
固定结构:
- 模式:新建 / 评审优化
- 探测结论:项目名 / Dockerfile / 启动模型 / 端口 / 健康端点 / env key(附证据文件路径)
- 【评审优化】审计结果:必须修 N 条 / 建议修 N 条 / 已过 N 条(附问题表),用户是否授权修正
- 写了什么:workflow 文件路径(新建 / 修改 diff);保留的项目定制清单
- 被跳过/未做的:如无健康端点省略健康检查步、无 .env.example 省略 key 检查
- 验证做到哪一步:check-workflow.sh 结果;**真触发没做就说没做**
- 待人工补:runner 实例级登记 / 宿主 daemon.json insecure-registries 登记并重启 docker / 仓库 Units 启用 Actions / 服务器按 .env.example 建 .env / 仓库 Variables+Secrets 配置(清单在生成的 workflow 尾部注释)/ 首次打 tag 发布
## 硬约束
- ① **零第三方 action**。为什么:self-hosted runner 在内网,访问不了 github.com,`uses:` 一出现 workflow 直接跑不起来;run 步骤直连 git/docker 即可。
- ② **仅 push v* tag 触发全链路,推 main 不触发**。为什么:版本号即镜像 tag 的模型——每次提交都发版会让 Packages 堆满无版本意义的镜像,且无法回溯;手动兜底用 workflow_dispatch 的 job 选项单跑。
- ③ **buildx 必须经 buildkitd.toml 注入 registry `http=true` 与 docker.io mirrors**。为什么:docker-container 驱动的 buildkitd 不继承宿主 daemon.json 的 insecure-registries/registry-mirrors——不声明 http=true 推送阶段必失败,不注入 mirrors 拉基础镜像超时。
- ④ **凭证回退链 `INNEV_REGISTRY_USER/INNEV_REGISTRY_TOKEN` → `gitea.actor`/`GITHUB_TOKEN`,`INNEV_CI_TOKEN` → `GITHUB_TOKEN`**。为什么:内置 token 同命名空间够用,配了 INNEV_* 即覆盖生效——不强制每个仓库都配密钥,也不许把凭证写死。
- ⑤ **cleanup 只动本项目名下的镜像 tag 与 `^<项目名>$` 锚点过滤的容器**。为什么:部署机上还跑着其他项目的镜像与容器,全局 prune 或无锚点过滤(名字包含项目名的 buildx_buildkit_* 容器)就是误删事故。
- ⑥ **镜像 tag = git tag 版本号 + latest,禁 latest-only**。为什么:latest 无法回溯版本;部署出问题想回退时,没有版本 tag 就没有可拉的历史镜像。
- ⑦ **密钥全部走 secrets 引用,clone 日志 `grep -v "$GIT_TOKEN"` 过滤,docker login 走 `--password-stdin`**。为什么:明文 token 进 workflow 文件 = 进 git 历史;密码进 argv 会留在日志与进程列表。
- ⑧ **健康检查窗口按启动模型放宽**。为什么:runtime-node 系容器首启要 install+build 数分钟,窗口太短会把正常启动当失败杀掉(spms 实测放宽到 ~7.5 分钟);项目没有健康端点时不虚构——省略该步并显式声明。
- ⑨ **生成/改写前先摊全文或 diff 给用户确认**。为什么:workflow 直接决定发布行为,未经确认的写入超出授权;默认动作永远是"不写"。
## 凭证与配置
本 skill 自身**无外部凭证**。生成的 workflow 引用的 `INNEV_*` 变量/密钥由人在 Gitea「仓库 → 设置 → Actions」里配置(配置清单自动生成在 workflow 尾部注释里,照单配即可)。用户在对话中贴过来的真实令牌/密钥:不落进任何文件,并提醒轮换。
## 人机权责
- 写操作:生成/覆盖 `.gitea/workflows/docker-deploy.yaml`——默认摊全文确认后落盘。
- 终态是人的按钮:首次发版(`git tag v0.1.0 && git push origin v0.1.0`)、仓库 Variables/Secrets 配置、服务器 .env 创建,agent 一律不执行。
- 待人工补:runner 登记、宿主 daemon.json insecure-registries、仓库 Units 启用 Actions、数据库迁移(如适用)——落成清单交付,不绕道、不假装完成。
## 排障对照表
| 症状 | 原因 | 怎么办 |
|---|---|---|
| 推 tag 后 Actions 没动静 | 仓库未启用 Actions Units / tag 不是 v 开头 | 仓库设置启用 Actions;tag 改 v 开头重打 |
| 镜像 push 阶段失败(http 相关报错) | buildkitd.toml 未声明 `http=true`(buildkitd 不继承宿主 insecure-registries) | 按约束③补齐注入(本规范模板已含) |
| 构建拉基础镜像超时 | docker-container 驱动不继承宿主 registry-mirrors | 经 buildkitd.toml 注入 INNEV_DOCKERHUB_MIRRORS |
| 部署机 `docker pull` 报 `HTTP response to HTTPS client` | 宿主 daemon.json 未登记 LAN 明文 registry | 登记 insecure-registries 并重启 docker(人工),或前缀改 TLS 地址 |
| 健康检查超时/失败 | runtime-node 首启 install+build 数分钟窗口太短;或 .env 缺 key | 按约束⑧放宽窗口;看容器日志确认缺哪个 key |
| cleanup 删注册表版本 403 | token 缺 write:package scope | 该步 best-effort 只告警不阻塞;补 scope 后自然恢复 |
| 容器内连宿主数据库 5432 不通 | 容器经 host-gateway 访问不到宿主系统服务端口 | .env 改指同机 docker 发布端口(如 5433)——实测踩坑 |
| git clone 源码失败 | INNEV_CI_TOKEN 缺失或 scope 不足 | 配 INNEV_CI_TOKEN(read:repository),或确认内置 GITHUB_TOKEN 够用 |
die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。
## 红线(任何情况下不违反)
- 不替用户打 tag、不替用户触发首次发布——终态是人的按钮
- 不往任何文件里写真实密钥/令牌;用户贴过来的提醒轮换
- 不静默丢弃已有 workflow 的项目定制——差异必须摊开、保留项必须进汇报
- 不虚构健康检查端点——探测不到就省略该步并显式声明,不写个 `/api/health` 假装有
- 不擅自加第三方 action——用户坚持要加时,先复述约束①的后果再让其拍板
- 不静默截断——被省略的可选块、被跳过的检查必须出现在汇报里
## 边界:这些不归本 skill 管
- 写/改/评审项目 Dockerfile(归 runtime-image;本 skill 第 0 步发现没有 Dockerfile 时指路过去)
- 提交推送代码并写日报(归 push-and-report)
- 手工构建推送业务镜像发版(归 xgent-image-push / xgent-app-release)
- 修改 runtime 基础镜像本身(归 runtime-base 仓库维护)
@@ -0,0 +1,286 @@
# =============================================================================
# gitea-actions 统一规范模板 —— 由 gitea-actions skill 渲染后落盘为项目
# .gitea/workflows/docker-deploy.yaml。本文件不直接使用。
#
# 槽位(写作 {{SLOT}},无 $ 前缀,与 Gitea 表达式 ${{ ... }} 区分):
# {{PROJECT}} 项目名——镜像名/容器名/builder 名的共同基础
# {{APP_PORT}} 容器内应用监听端口
# {{HOST_PORT}} 容器对外发布端口的默认值(仓库变量 INNEV_HOST_PORT 可覆盖)
# {{HEALTH_PATH}} 健康检查路径(如 /api/health)
# 可省略块:# [OPTIONAL:名称] ... # [/OPTIONAL] —— 条件不满足时连同标记整块删除
# =============================================================================
# Docker 化部署流水线:
# push tag v* → ① buildx 构建镜像并推送 Gitea Packages → ② 本机 docker 拉取镜像、重建并启动容器
# → ③ 清理旧版本镜像/退出容器(本机 + Gitea Packages 注册表),只保留当前部署的成品镜像。
# 发布方式:git tag v1.2.3 && git push origin v1.2.3(推 main 不再触发)。
# 手动触发(Actions → Run workflow)可用 job 选项单跑某个任务:all(全链路)/ image / deploy / cleanup。
# 零第三方 action:run 步骤直连 git/docker,runner 无需访问 github.com。
# 命名约定:自定义变量/密钥一律 INNEV_ 前缀(仓库 → 设置 → Actions 配置,清单见本文件尾部注释)。
# 凭证回退链:INNEV_REGISTRY_USER / INNEV_REGISTRY_TOKEN → gitea.actor / 内置 GITHUB_TOKEN;
# INNEV_CI_TOKEN → GITHUB_TOKEN。加了即生效。
# 镜像 tag = 版本号(git tag,如 v1.2.3)+ latest;手动 dispatch 无 tag 上下文时回退提交短 sha。
name: docker-deploy
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
job:
description: 要运行的任务(all = 构建+部署+清理)
type: choice
options: [all, image, deploy, cleanup]
default: all
required: true
permissions:
contents: read # git clone 源码(内置 GITHUB_TOKEN)
packages: write # 推 Gitea Packages(内置 GITHUB_TOKEN)
env:
INNEV_REGISTRY: ${{ vars.INNEV_REGISTRY || 'livebook:8418' }}
INNEV_IMAGE_PREFIX: ${{ vars.INNEV_IMAGE_PREFIX || 'livebook:8418/projects' }}
# docker.io 加速镜像(逗号分隔)——buildx docker-container 驱动的 buildkitd 不继承 runner 宿主
# daemon.json 的 registry-mirrors,必须经 buildkitd.toml 注入
INNEV_DOCKERHUB_MIRRORS: ${{ vars.INNEV_DOCKERHUB_MIRRORS || 'docker.fnnas.com,registry.docker-cn.com,registry.hub.docker.com,docker.1panel.live,docker.1panel.dev,docker.1ms.run' }}
INNEV_HOST_PORT: ${{ vars.INNEV_HOST_PORT || '{{HOST_PORT}}' }}
# 服务器上单独维护的 .env 绝对路径(key 与仓库 .env.example 一致);.env 不进 git/镜像
INNEV_ENV_FILE: ${{ vars.INNEV_ENV_FILE || '/etc/{{PROJECT}}/.env' }}
jobs:
# 任务 1:构建镜像并推送到本机 Gitea Packages($INNEV_IMAGE_PREFIX/{{PROJECT}})
# push 或手动选择 all/image 时运行
image:
if: github.event_name == 'push' || github.event.inputs.job == 'all' || github.event.inputs.job == 'image'
runs-on: self-hosted
steps:
# 仓库变量误带 http:// scheme 时在此剥掉,避免拼出非法镜像引用(docker tag 不允许 "://")
- name: 归一化镜像变量(剥离误配的 URL scheme)
run: |
PREFIX="${INNEV_IMAGE_PREFIX#*://}"; PREFIX="${PREFIX%/}"
echo "INNEV_REGISTRY=${INNEV_REGISTRY#*://}" >> "$GITHUB_ENV"
echo "INNEV_IMAGE_PREFIX=$PREFIX" >> "$GITHUB_ENV"
# 镜像 tag:tag 推送取版本号(如 v1.2.3);手动 dispatch(branch 上下文)回退提交短 sha
if [ "$GITHUB_REF_TYPE" = "tag" ]; then TAG="$GITHUB_REF_NAME"; else TAG="${GITHUB_SHA:0:8}"; fi
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
# 日志开头明示推送目标,变量配错时第一时间可见
echo "构建目标镜像:$PREFIX/{{PROJECT}}:$TAG"
- name: 取出源码(免 actions/checkout,纯 git)
env:
GIT_TOKEN: ${{ secrets.INNEV_CI_TOKEN || secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch "$GITHUB_REF_NAME" \
"http://x-access-token:${GIT_TOKEN}@${INNEV_REGISTRY#*://}/${{ gitea.repository }}.git" src 2>&1 | grep -v "$GIT_TOKEN" || true
[ -f src/Dockerfile ] || { echo "源码克隆失败"; exit 1; }
cd src && git checkout "$GITHUB_SHA"
- name: 登录 Gitea Packages
env:
LOGIN_USER: ${{ secrets.INNEV_REGISTRY_USER || gitea.actor }}
LOGIN_TOKEN: ${{ secrets.INNEV_REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
run: echo "$LOGIN_TOKEN" | docker login "$INNEV_REGISTRY" -u "$LOGIN_USER" --password-stdin
# buildkitd.toml 里对 $INNEV_REGISTRY 声明 http=true(强制明文 HTTP):buildkitd 不继承宿主
# daemon.json 的 insecure-registries,纯 HTTP registry 不声明会在 push 阶段失败。
# 每次重建构建器以确保 buildkitd.toml 变更生效(无外部层缓存,每次构建均为全量)。
- name: 创建 buildx 构建器(注入 docker.io 加速镜像 + 推送目标 HTTP 声明)
run: |
docker buildx rm -f {{PROJECT}}builder 2>/dev/null || true
{
echo '[registry."docker.io"]'
printf '%s' "$INNEV_DOCKERHUB_MIRRORS" | sed 's/[^,]*/"&"/g; s/,/, /g' | sed 's/^/ mirrors = [/; s/$/]/'
echo ''
echo "[registry.\"$INNEV_REGISTRY\"]"
echo ' http = true'
} > buildkitd.toml
cat buildkitd.toml
docker buildx create --use --name {{PROJECT}}builder --config buildkitd.toml
# [OPTIONAL:PRE_BUILD] 项目有特殊预构建步骤(如从源码构建工具二进制)时取消注释并改写本步,
# 否则连同标记整块删除。插入位置必须在"构建并推送镜像"步之前。
# - name: 预构建(按项目需要改写)
# run: cd src && bash scripts/<your-pre-build>.sh
# [/OPTIONAL]
# 构建上下文 = 仓库根(根级 Dockerfile)。不使用层缓存(每次全量构建)。
# 单架构原生构建——部署目标即本机,无需 QEMU 多架构。
- name: 构建并推送 {{PROJECT}} 镜像
run: |
docker buildx build --push \
--label org.opencontainers.image.source="http://$INNEV_REGISTRY/${{ gitea.repository }}" \
-t "$INNEV_IMAGE_PREFIX/{{PROJECT}}:$IMAGE_TAG" \
-t "$INNEV_IMAGE_PREFIX/{{PROJECT}}:latest" \
src
# docker-container 驱动的构建器会常驻一个 buildx_buildkit_{{PROJECT}}builder0 容器,用完即删(无论成败)。
- name: 清理 buildx 构建器容器
if: always()
run: docker buildx rm -f {{PROJECT}}builder 2>/dev/null || true
# 任务 2:把刚推送的镜像拉到本机 docker,重建并启动容器
# push 或手动选择 all/deploy 时运行;自定义 if 取代默认 success() 门控——
# 手动单跑 deploy 时 image 被跳过(needs 结果为 skipped),此处显式放行
deploy:
needs: image
if: ${{ !failure() && !cancelled() && (github.event_name == 'push' || github.event.inputs.job == 'all' || github.event.inputs.job == 'deploy') }}
runs-on: self-hosted
steps:
- name: 归一化镜像变量(剥离误配的 URL scheme)
run: |
PREFIX="${INNEV_IMAGE_PREFIX#*://}"; PREFIX="${PREFIX%/}"
echo "INNEV_REGISTRY=${INNEV_REGISTRY#*://}" >> "$GITHUB_ENV"
echo "INNEV_IMAGE_PREFIX=$PREFIX" >> "$GITHUB_ENV"
if [ "$GITHUB_REF_TYPE" = "tag" ]; then TAG="$GITHUB_REF_NAME"; else TAG="${GITHUB_SHA:0:8}"; fi
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
# docker pull/login 走宿主 daemon:LAN 明文 registry 需在宿主 daemon.json 登记
# insecure-registries(见文件尾部注释),或把 INNEV_IMAGE_PREFIX 配成 TLS 地址。
- name: 登录并拉取镜像
env:
LOGIN_USER: ${{ secrets.INNEV_REGISTRY_USER || gitea.actor }}
LOGIN_TOKEN: ${{ secrets.INNEV_REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
run: |
IMAGE="$INNEV_IMAGE_PREFIX/{{PROJECT}}:$IMAGE_TAG"
echo "$LOGIN_TOKEN" | docker login "$INNEV_REGISTRY" -u "$LOGIN_USER" --password-stdin
docker pull "$IMAGE"
# 运行期环境变量来自服务器上单独维护的 .env(key 与仓库 .env.example 一致),
# 路径由变量 INNEV_ENV_FILE 指定;.env 不进 git/镜像,仅在此做 --env-file 注入。
- name: 重建 {{PROJECT}} 容器
run: |
IMAGE="$INNEV_IMAGE_PREFIX/{{PROJECT}}:$IMAGE_TAG"
[ -f "$INNEV_ENV_FILE" ] || { echo "::error::服务器上未找到 $INNEV_ENV_FILE——请按 .env.example 的 key 创建,或用仓库变量 INNEV_ENV_FILE 指向实际路径"; exit 1; }
# [OPTIONAL:ENV_KEY_CHECKS] 项目有硬性必需的 .env key 时保留本块(按 .env.example 渲染,
# 每个 key 一行 grep 检查),否则连同标记整块删除。示例:
# grep -q '^DATABASE_URL=.' "$INNEV_ENV_FILE" || { echo "::error::$INNEV_ENV_FILE 缺少 DATABASE_URL"; exit 1; }
# [/OPTIONAL]
docker rm -f {{PROJECT}} 2>/dev/null || true
# 靠后的 -e 覆盖 --env-file 同名变量(容器端口固定 {{APP_PORT}},不受 .env 影响)。
# [OPTIONAL:EXTRA_ENV] 运行时镜像要求的额外 -e 参数(如 runtime-node 的
# -e APP_ENTRY=.next/standalone/server.js)按需添加,无则连同标记删除。
# [/OPTIONAL]
# [OPTIONAL:ADD_HOST] 容器内需解析宿主/同机服务主机名(如数据库指向宿主)时保留本行
# 并按需追加项目专用主机名(如 --add-host livebook:host-gateway),否则连同标记删除。
# 注意:容器经 host-gateway 访问的是同机 docker 发布端口(如 5433),
# 宿主系统服务端口(如 pg 5432)从容器访问不通——实测踩坑。
# [/OPTIONAL]
docker run -d --name {{PROJECT}} --restart unless-stopped \
-p "${INNEV_HOST_PORT}:{{APP_PORT}}" \
--env-file "$INNEV_ENV_FILE" \
-e HOSTNAME=0.0.0.0 \
-e PORT={{APP_PORT}} \
--add-host host.docker.internal:host-gateway \
"$IMAGE"
# [OPTIONAL:HEALTH_CHECK] 项目有健康检查端点时保留本步(渲染 {{HEALTH_PATH}}),
# 没有则连同标记整块删除,并在汇报中显式声明"无健康检查,部署后需人工确认"。
# runtime-node 类容器启动时要先 install+build(数分钟),健康检查窗口放宽到 ~7.5 分钟,
# 日志可见 install/build 进度,别当挂起杀掉;预构建产物类镜像可把 90 次循环缩短。
- name: 健康检查
run: |
for i in $(seq 1 90); do
if curl -fsS "http://127.0.0.1:${INNEV_HOST_PORT}{{HEALTH_PATH}}"; then
echo ""
echo "部署完成:http://127.0.0.1:${INNEV_HOST_PORT}(镜像 $INNEV_IMAGE_PREFIX/{{PROJECT}}:$IMAGE_TAG)"
exit 0
fi
sleep 5
done
echo "::error::健康检查失败,容器最近日志:" >&2
docker logs --tail 50 {{PROJECT}} >&2 || true
exit 1
# [/OPTIONAL]
# 任务 3:清理旧版本产物,只保留当前部署的成品镜像(当前版本 tag + latest,二者同 ID)。
# tag 全链路在部署成功后自动跑;也可手动单跑(job=cleanup,保留当前 ref 解析出的镜像 tag)。
# 只动 $INNEV_IMAGE_PREFIX/{{PROJECT}} 名下的 tag 与名为 {{PROJECT}} 的退出容器——
# 本机还跑着其他项目的镜像与容器,不做全局 prune。
cleanup:
needs: deploy
if: ${{ !failure() && !cancelled() && (github.event.inputs.job == 'cleanup' || needs.deploy.result == 'success') }}
runs-on: self-hosted
steps:
- name: 归一化镜像变量(剥离误配的 URL scheme)
run: |
PREFIX="${INNEV_IMAGE_PREFIX#*://}"; PREFIX="${PREFIX%/}"
echo "INNEV_REGISTRY=${INNEV_REGISTRY#*://}" >> "$GITHUB_ENV"
echo "INNEV_IMAGE_PREFIX=$PREFIX" >> "$GITHUB_ENV"
if [ "$GITHUB_REF_TYPE" = "tag" ]; then TAG="$GITHUB_REF_NAME"; else TAG="${GITHUB_SHA:0:8}"; fi
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
- name: 清理本机旧镜像与退出容器
run: |
KEEP="$INNEV_IMAGE_PREFIX/{{PROJECT}}:$IMAGE_TAG"
# 旧版本 tag:容器按镜像 ID 引用,删 tag 不影响正在运行的容器;
# 若某 tag 仍被容器独占引用,docker rmi 会拒绝,|| true 跳过
docker images "$INNEV_IMAGE_PREFIX/{{PROJECT}}" --format '{{.Repository}}:{{.Tag}}' | while read -r ref; do
case "$ref" in
"$KEEP" | "$INNEV_IMAGE_PREFIX/{{PROJECT}}:latest") echo "保留 $ref" ;;
*) echo "删除 $ref"; docker rmi "$ref" || true ;;
esac
done
# 退出/未启动的 {{PROJECT}} 容器(正常不会有——deploy 已 docker rm -f;name 过滤加锚点,
# 避免误伤 buildx_buildkit_{{PROJECT}}builder0 这类名字包含 {{PROJECT}} 的容器)
docker ps -aq --filter 'name=^{{PROJECT}}$' --filter status=exited --filter status=created | xargs -r docker rm
echo "当前保留的 {{PROJECT}} 镜像:"
docker images "$INNEV_IMAGE_PREFIX/{{PROJECT}}"
# 注册表侧同步清理:本机删 tag 不影响 Gitea Packages 里已推送的旧版本,版本号模型下
# 每个 tag 都会在 Packages 堆积,必须经 API 删除。列 tag 走 OCI Registry API(/v2/.../tags/list),
# 删除走 Gitea Packages API(DELETE /api/v1/packages/<owner>/<type>/<name>/<version>)。
# best-effort:全程 || true,token 缺 scope 或 API 不通只告警不阻塞流水线。
- name: 清理 Gitea Packages 旧版本(best-effort)
env:
LOGIN_USER: ${{ secrets.INNEV_REGISTRY_USER || gitea.actor }}
LOGIN_TOKEN: ${{ secrets.INNEV_REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
run: |
REPO_PATH="${INNEV_IMAGE_PREFIX#$INNEV_REGISTRY/}/{{PROJECT}}" # 如 projects/{{PROJECT}}
OWNER="${INNEV_IMAGE_PREFIX#$INNEV_REGISTRY/}"; OWNER="${OWNER%%/*}" # 如 projects
TAGS=$(curl -fsS -u "$LOGIN_USER:$LOGIN_TOKEN" \
"http://$INNEV_REGISTRY/v2/$REPO_PATH/tags/list" \
| grep -o '"[^"]*"' | grep -v '^"name"$' | grep -v '^"tags"$' | tr -d '"' \
| grep -v "^$REPO_PATH$") || { echo "::warning::列出注册表 tag 失败,跳过注册表清理"; exit 0; }
for tag in $TAGS; do
case "$tag" in
"$IMAGE_TAG" | latest) echo "注册表保留 $tag" ;;
*) echo "注册表删除 $tag"
curl -fsS -X DELETE -u "$LOGIN_USER:$LOGIN_TOKEN" \
"http://$INNEV_REGISTRY/api/v1/packages/$OWNER/container/{{PROJECT}}/$tag" \
|| echo "::warning::删除注册表版本 $tag 失败(token 需 write:package scope)" ;;
esac
done
# =============================================================================
# 一次性人工准备:
# 1. runner 用实例级(标签 self-hosted),与 Docker 同机、可直接执行宿主 docker 命令
# (job 里的 docker run/pull 操作的就是部署目标机的 daemon)。
# 2. deploy job 的 docker pull/login 走宿主 daemon:LAN 明文 registry(如 livebook:8418)需在宿主
# /etc/docker/daemon.json 的 insecure-registries 中登记并重启 docker——推送侧已由 buildkitd.toml
# http=true 覆盖,不受此限;或将 INNEV_IMAGE_PREFIX 配成 TLS 地址(如 code.innev.cn/...)。
# 3. 仓库 → 设置 → 仓库 → 功能(Units)中启用 Actions。
# 4. 首次运行后到 Packages 确认镜像包 {{PROJECT}} 已生成。
# 5. 数据库迁移与种子数据(如适用)为手动步骤——镜像不含迁移工具,在本地 checkout 里把
# 数据库地址指向同一库后执行项目自己的迁移命令。
# 6. 在部署服务器上按仓库 .env.example 的 key 新建专用 .env(默认路径 /etc/{{PROJECT}}/.env,
# 可用仓库变量 INNEV_ENV_FILE 改指别处)。
# 数据库地址指向宿主/同机服务时,容器内由 --add-host ...:host-gateway 解析;
# 注意访问的是同机 docker 发布端口(如 5433),宿主系统服务端口(如 pg 5432)从容器经
# host-gateway 访问不通——实测踩坑,切勿写错。
# 7. 日常发布:git tag v1.2.3 && git push origin v1.2.3(tag 需 v 开头才会触发流水线);
# 部署成功后 cleanup 自动删除本机与 Gitea Packages 里的旧版本镜像,无需人工干预。
#
# 仓库 → 设置 → Actions 配置清单
# 变量(Variables):
# INNEV_IMAGE_PREFIX 镜像前缀(推送/拉取地址),默认 livebook:8418/projects——
# 镜像全名 $INNEV_IMAGE_PREFIX/{{PROJECT}}:<版本tag|latest>
# INNEV_REGISTRY registry / git 端点,默认 livebook:8418(与 Gitea 同机,LAN 明文 HTTP)
# INNEV_DOCKERHUB_MIRRORS docker.io 加速镜像(逗号分隔),默认 docker.fnnas.com,registry.docker-cn.com,
# registry.hub.docker.com,docker.1panel.live,docker.1panel.dev,docker.1ms.run
# INNEV_HOST_PORT 容器对外发布端口,默认 {{HOST_PORT}}(容器内固定 {{APP_PORT}})
# INNEV_ENV_FILE 服务器上 .env 的绝对路径,默认 /etc/{{PROJECT}}/.env(见上方准备 6)
# 密钥(Secrets):
# INNEV_REGISTRY_USER docker login 账号——跨命名空间推包时必需(同命名空间可回退 gitea.actor)
# INNEV_REGISTRY_TOKEN docker login token(scope: write:package + read:package);
# cleanup 的注册表旧版本删除也用它,缺 write:package 时该步只告警不阻塞
# INNEV_CI_TOKEN git clone token(scope: read:repository);仅内置 GITHUB_TOKEN 不足时添加
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# check-workflow.sh — gitea-actions 统一规范自检(零依赖 bash+grep;YAML 语法校验需 python3+pyyaml,缺则跳过)
# 用法: check-workflow.sh <workflow.yaml>
# 逐项输出 PASS/FAIL/SKIP,有任一 FAIL 即 exit 1。
set -u
FILE="${1:-}"
fail=0
say() { printf '%s\n' "$*"; }
pass() { say "PASS $1"; }
bad() { say "FAIL $1"; fail=1; }
skip() { say "SKIP $1"; }
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
say "用法: $0 <workflow.yaml>"
exit 2
fi
# ① 零第三方 action(内网 runner 无法访问 github.com)
if grep -qE '^[[:space:]]*-?[[:space:]]*uses:' "$FILE"; then
bad "① 发现 uses:(第三方 action)——规范要求零第三方 action,run 步骤直连 git/docker"
else
pass "① 零第三方 action(无 uses:)"
fi
# ② 仅 push v* tag 触发(版本号即镜像 tag,推 main 不发版)
if grep -qE "tags:[[:space:]]*\[?[[:space:]]*['\"]?v\*" "$FILE"; then
pass "② push tag v* 触发"
else
bad "② 缺少 push tags 'v*' 触发"
fi
# ③ workflow_dispatch 带 job 单跑选项(all/image/deploy/cleanup)
if grep -q 'workflow_dispatch:' "$FILE" \
&& grep -qE 'options:[[:space:]]*\[.*all.*image.*deploy.*cleanup' "$FILE"; then
pass "③ workflow_dispatch 带 job 选项(all/image/deploy/cleanup)"
else
bad "③ workflow_dispatch 缺 job 单跑选项"
fi
# ④ buildkitd.toml 注入 registry http=true(buildkitd 不继承宿主 insecure-registries)
if grep -q 'buildkitd.toml' "$FILE" && grep -qE 'http[[:space:]]*=[[:space:]]*true' "$FILE"; then
pass "④ buildkitd.toml 注入 registry http=true"
else
bad "④ 缺 buildkitd.toml http=true 注入(LAN 明文 registry 推送阶段必失败)"
fi
# ⑤ docker login 走 --password-stdin(密码不进 argv/日志)
if grep -q 'docker login' "$FILE"; then
if grep -q 'docker login.*--password-stdin' "$FILE"; then
pass "⑤ docker login 走 --password-stdin"
else
bad "⑤ docker login 未用 --password-stdin(口令会进 argv 与日志)"
fi
else
skip "⑤ 未发现 docker login"
fi
# ⑥ cleanup 容器名过滤带 ^...$ 锚点(防误删名字包含项目名的邻项目容器)
if grep -qE "name=\^[^'\"]*\\$" "$FILE"; then
pass "⑥ cleanup name 过滤带 ^...\$ 锚点"
else
bad "⑥ cleanup name 过滤缺 ^...\$ 锚点(会误伤 buildx_buildkit_* 等容器)"
fi
# ⑦ 无明文密钥特征(常见 token 前缀 / 40 位 hex / URL 内嵌静态口令)
if grep -nE 'ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|\b[a-f0-9]{40}\b' "$FILE"; then
bad "⑦ 发现疑似明文 token——密钥必须走 secrets 引用"
elif grep -E '://[^/$"'"'"'{ ]+:[^/$"'"'"'{@ ]+@' "$FILE" | grep -vq 'x-access-token'; then
bad "⑦ 发现 URL 内嵌静态口令——token 必须走 env 变量引用"
else
pass "⑦ 无明文密钥特征"
fi
# ⑧ YAML 语法(python3+pyyaml 可用时;不可用则跳过并声明)
if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then
if python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1], encoding="utf-8"))' "$FILE" 2>/dev/null; then
pass "⑧ YAML 语法合法"
else
bad "⑧ YAML 语法错误"
fi
else
skip "⑧ python3+pyyaml 不可用,YAML 语法未校验"
fi
if [ "$fail" -ne 0 ]; then
say "---"
say "规范自检未通过:存在 FAIL 项,逐条修正后重跑。"
exit 1
fi
say "---"
say "规范自检通过(SKIP 项需人工知悉)。"