diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..670349d --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index b73bda0..736d6b1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .github-theme .gitea-data dist +release node_modules .playwright-mcp diff --git a/README.md b/README.md index 8081f3f..56f17ab 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/package.json b/package.json index 1b26bbf..beead6c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/package-release.ts b/scripts/package-release.ts new file mode 100644 index 0000000..b7a7c0f --- /dev/null +++ b/scripts/package-release.ts @@ -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}`); diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts new file mode 100644 index 0000000..e202d00 --- /dev/null +++ b/scripts/publish-release.ts @@ -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`);