feat: 添加发布工作流,支持自动构建和发布主题版本
Release / release (push) Failing after 7m33s

This commit is contained in:
chuan
2026-05-16 20:31:52 +08:00
Unverified
parent 3f47d02395
commit 1d293ac201
6 changed files with 223 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Release
on:
push:
branches:
- "**"
paths-ignore:
- "docs/**"
- "**/*.md"
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build and verify
run: bun run test
- name: Package release
run: bun run release:package
- name: Publish release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: bun scripts/publish-release.ts
+1
View File
@@ -2,5 +2,6 @@
.github-theme
.gitea-data
dist
release
node_modules
.playwright-mcp
+7
View File
@@ -18,8 +18,15 @@ bun run dev
bun run preview
bun run locale:sync
bun run test
bun run release:package
```
构建会生成 `dist/theme-github-dev.css``dist/theme-github-dev-dark.css`,并同步输出到 `.gitea/custom/public/assets/css/` 供 Gitea custom 目录使用。Docker 预览当前默认加载 `github-dev-dark`,便于检查未登录页面 dark 模式。
locale 只在 `options/locale-overrides/` 维护增量 key。构建会按当前 Gitea 版本从官方仓库获取完整 locale,合并增量后输出到 `.gitea/custom/options/locale/`
## 发布
`.github/workflows/release.yml` 会在非纯文档提交时自动构建并发布当前 `package.json` 版本。发布前会删除同名 `v{version}` release/tag,再用当前提交重新创建,适合手动维护版本号后持续覆盖当前版本。
默认使用平台提供的 `GITHUB_TOKEN`。如果 Gitea Actions 的默认 token 权限不够,可以配置 `RELEASE_TOKEN` secret。
+2
View File
@@ -7,6 +7,8 @@
"scripts": {
"build": "bun scripts/build.ts",
"locale:sync": "bun scripts/locale.ts",
"release:package": "bun run build:min && bun scripts/package-release.ts",
"release:publish": "bun scripts/publish-release.ts",
"dev": "bun scripts/build.ts --watch",
"preview": "bun run build && bun scripts/preview.ts",
"build:min": "bun scripts/build.ts --minify",
+46
View File
@@ -0,0 +1,46 @@
import { cp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { $ } from "bun";
const root = process.cwd();
const packageJson = JSON.parse(await Bun.file(join(root, "package.json")).text()) as { version: string };
const version = packageJson.version;
const name = `gitea-github-theme-v${version}`;
const releaseRoot = join(root, "release");
const packageRoot = join(releaseRoot, name);
await rm(releaseRoot, { recursive: true, force: true });
await mkdir(join(packageRoot, "custom", "public", "assets", "css"), { recursive: true });
await mkdir(join(packageRoot, "custom", "templates"), { recursive: true });
await mkdir(join(packageRoot, "custom", "options"), { recursive: true });
await cp(join(root, "dist", "theme-github-dev.css"), join(packageRoot, "custom", "public", "assets", "css", "theme-github-dev.css"));
await cp(
join(root, "dist", "theme-github-dev-dark.css"),
join(packageRoot, "custom", "public", "assets", "css", "theme-github-dev-dark.css"),
);
await cp(join(root, ".gitea", "custom", "templates"), join(packageRoot, "custom", "templates"), { recursive: true });
await cp(join(root, ".gitea", "custom", "options"), join(packageRoot, "custom", "options"), { recursive: true });
await cp(join(root, "README.md"), join(packageRoot, "README.md"));
await $`tar -C ${releaseRoot} -czf ${releaseRoot}/${name}.tar.gz ${name}`;
if (process.platform === "win32") {
await $`powershell -NoProfile -Command Compress-Archive -Path ${packageRoot} -DestinationPath ${join(releaseRoot, `${name}.zip`)} -Force`;
} else {
await $`zip -qr ${join(releaseRoot, `${name}.zip`)} ${name}`.cwd(releaseRoot);
}
const artifacts = [`${name}.tar.gz`, `${name}.zip`];
const checksumLines = [];
for (const artifact of artifacts) {
const file = Bun.file(join(releaseRoot, artifact));
const hash = new Bun.CryptoHasher("sha256");
hash.update(await file.arrayBuffer());
checksumLines.push(`${hash.digest("hex")} ${artifact}`);
}
await Bun.write(join(releaseRoot, "checksums.txt"), `${checksumLines.join("\n")}\n`);
console.log(`packaged release ${name}`);
+129
View File
@@ -0,0 +1,129 @@
import { readdir } from "node:fs/promises";
import { basename, join } from "node:path";
interface Release {
id: number;
tag_name: string;
upload_url?: string;
}
const root = process.cwd();
const packageJson = JSON.parse(await Bun.file(join(root, "package.json")).text()) as { version: string };
const version = packageJson.version;
const tag = `v${version}`;
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.RELEASE_TOKEN || process.env.GITHUB_TOKEN;
const apiUrl = (process.env.GITHUB_API_URL || `${process.env.GITHUB_SERVER_URL ?? ""}/api/v1`).replace(/\/$/, "");
const releaseDir = join(root, "release");
const releaseName = `gitea-github-theme-v${version}`;
if (!repository) {
throw new Error("GITHUB_REPOSITORY is required");
}
if (!token) {
throw new Error("GITHUB_TOKEN or RELEASE_TOKEN is required");
}
const [owner, repo] = repository.split("/");
const headers = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
function resolveApiUrl(pathOrUrl: string) {
if (/^https?:\/\//.test(pathOrUrl)) {
return pathOrUrl;
}
return `${apiUrl}${pathOrUrl}`;
}
async function request(pathOrUrl: string, init: RequestInit = {}) {
const url = resolveApiUrl(pathOrUrl);
const response = await fetch(url, {
...init,
headers: {
...headers,
...(init.headers ?? {}),
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`${init.method ?? "GET"} ${url} failed: ${response.status} ${await response.text()}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function releaseAssetUploadUrl(release: Release, artifact: string) {
if (!release.upload_url) {
return `/repos/${owner}/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(artifact)}`;
}
const baseUrl = release.upload_url.replace(/\{.*\}$/, "");
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}name=${encodeURIComponent(artifact)}`;
}
async function ignoreMissing(path: string, init: RequestInit = {}) {
try {
await request(path, init);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("404")) {
throw error;
}
}
}
const currentRelease = (await request(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`)) as Release | null;
if (currentRelease) {
await ignoreMissing(`/repos/${owner}/${repo}/releases/${currentRelease.id}`, { method: "DELETE" });
}
await ignoreMissing(`/repos/${owner}/${repo}/tags/${encodeURIComponent(tag)}`, { method: "DELETE" });
await ignoreMissing(`/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tag)}`, { method: "DELETE" });
const release = (await request(`/repos/${owner}/${repo}/releases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: process.env.GITHUB_SHA,
name: releaseName,
body: [
`Gitea GitHub theme build for ${tag}.`,
"",
"Install by extracting the archive and copying `custom/` into your Gitea custom path.",
].join("\n"),
draft: false,
prerelease: false,
}),
})) as Release;
const artifacts = (await readdir(releaseDir)).filter((file) => file.endsWith(".zip") || file.endsWith(".tar.gz") || file === "checksums.txt");
for (const artifact of artifacts) {
const path = join(releaseDir, artifact);
const file = Bun.file(path);
await request(releaseAssetUploadUrl(release, basename(path)), {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
},
body: file,
});
}
console.log(`published release ${tag} with ${artifacts.length} assets`);