Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/scripts/release-guard.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use strict';
// 仅用于本次已授权的 v1.14.1;失败或部分发布必须人工核查,绝不覆盖资产。
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const VERSION = '1.14.1';
const TAG = `v${VERSION}`;
const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip'];
function insist(ok, message) { if (!ok) throw new Error(message); }
function guardRun(event, repository, mainSha, run, workflow) {
insist(event.repository?.full_name === repository && event.repository?.fork === false, '仓库不匹配或为 fork');
for (const r of [event.workflow_run, run]) {
insist(r && r.repository?.full_name === repository && r.head_repository?.full_name === repository, 'CI 来自其他仓库');
insist(r.event === 'push' && r.head_branch === 'main' && r.status === 'completed' && r.conclusion === 'success', 'CI 必须是 main push 成功');
insist(/^[a-f0-9]{40}$/.test(r.head_sha) && r.head_sha === mainSha, 'CI SHA 已过时或不合法');
insist(r.name === 'CI' && r.path === '.github/workflows/ci.yml', 'CI 工作流不匹配');
}
insist(run.id === event.workflow_run.id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 标识不一致');
insist(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 标识不匹配');
return mainSha;
}
function guardVersions(theme, plugin, release) {
insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.1');
insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.1');
insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.1');
}
function guardExisting(tagSha, release, sha, digests) {
if (!tagSha && !release) return 'create';
insist(tagSha === sha, '已有 tag SHA 冲突或 release 缺 tag');
insist(release && release.tag_name === TAG && !release.draft && !release.prerelease, '部分发布或 release 状态冲突,需人工核查');
insist(release.assets?.length === ASSETS.length, '发布资产数量不完整或冲突');
for (const name of ASSETS) {
const matches = release.assets.filter(a => a.name === name);
insist(matches.length === 1 && matches[0].state === 'uploaded' && matches[0].size > 0, '发布资产缺失或未完成');
insist(matches[0].digest === `sha256:${digests[name]}`, `发布资产摘要冲突:${name}`);
}
return 'complete';
}
function buildArchives(temp, dist) {
const digests = {};
for (const [kind, asset] of [['themes', ASSETS[0]], ['plugins', ASSETS[1]]]) {
const prefix = `usr/${kind}/InfinityTime/`;
const files = execFileSync('git', ['ls-files', '-z', '--', prefix], { encoding: 'utf8' }).split('\0').filter(Boolean).sort();
const stage = path.join(temp, kind); fs.mkdirSync(stage);
const names = [];
for (const file of files) {
const relative = file.slice(prefix.length);
if (path.basename(file) === '.DS_Store' || (kind === 'plugins' && relative.startsWith('data/'))) continue;
insist(fs.lstatSync(file).isFile() && !/[\r\n]/.test(file), '发布包包含非普通文件或异常路径');
const name = `InfinityTime/${relative}`;
const destination = path.join(stage, name);
fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(file, destination);
fs.chmodSync(destination, 0o644); fs.utimesSync(destination, 946684800, 946684800); names.push(name);
}
insist(names.length > 0, '发布包为空');
execFileSync('zip', ['-X', '-q', path.join(dist, asset), '-@'], { cwd: stage, input: names.join('\n') + '\n', env: { ...process.env, TZ: 'UTC' } });
execFileSync('unzip', ['-t', path.join(dist, asset)]);
digests[asset] = crypto.createHash('sha256').update(fs.readFileSync(path.join(dist, asset))).digest('hex');
}
return digests;
}
async function main() {
insist(process.env.GITHUB_EVENT_NAME === 'workflow_run', '仅允许 workflow_run');
const repo = process.env.GITHUB_REPOSITORY;
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
const token = process.env.GH_TOKEN;
insist(repo === 'Thregren/InfinityTime' && token, '缺少令牌或目标仓库错误');
const root = `https://api.github.com/repos/${repo}`;
// 公开仓库 Actions 元数据匿名读取;不增加 actions 权限,限流/拒绝即失败。
async function api(endpoint, { method = 'GET', body, optional = false, binary = false } = {}) {
const url = endpoint.startsWith('https://') ? endpoint : `${root}${endpoint}`;
insist(url.startsWith(`${root}/`) || url.startsWith(`https://uploads.github.com/repos/${repo}/`), '非预期 API 目标');
const response = await fetch(url, { signal: AbortSignal.timeout(30000), method, headers: { ...(endpoint.startsWith('/actions/') && method === 'GET' ? {} : { Authorization: `Bearer ${token}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': binary ? 'application/zip' : 'application/json' } : {}) }, body: body ? (binary ? body : JSON.stringify(body)) : undefined });
if (optional && response.status === 404) return null;
insist(response.ok, `GitHub API ${method} ${endpoint}: ${response.status}`);
return response.json();
}
const verifyDownloads = async release => {
for (const name of ASSETS) {
const asset = release.assets.find(item => item.name === name);
const expected = `https://github.com/${repo}/releases/download/${TAG}/${name}`;
insist(asset.browser_download_url === expected, '下载地址不是目标发布资产');
// 公共下载不携带令牌;下载后的真实字节必须匹配本次确定性构建。
const response = await fetch(expected, { signal: AbortSignal.timeout(30000) });
insist(response.ok, `发布资产无法下载:${name} ${response.status}`);
const digest = crypto.createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex');
insist(digest === digests[name], `发布资产下载摘要冲突:${name}`);
}
};
const latestMain = async () => (await api('/git/ref/heads/main')).object.sha;
const sha = await latestMain();
const run = await api(`/actions/runs/${event.workflow_run.id}`);
const workflow = await api(`/actions/workflows/${run.workflow_id}`);
guardRun(event, repo, sha, run, workflow);
insist(execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() === sha, 'checkout SHA 不匹配');
guardVersions(fs.readFileSync('usr/themes/InfinityTime/index.php', 'utf8'), fs.readFileSync('usr/plugins/InfinityTime/Plugin.php', 'utf8'), fs.readFileSync('usr/themes/InfinityTime/releases.json', 'utf8'));
// 只打包 git 跟踪的普通文件;固定时间与顺序,支持幂等摘要验证。
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'infinitytime-release-'));
const dist = path.join(temp, 'dist'); fs.mkdirSync(dist);
const digests = {};
try {
Object.assign(digests, buildArchives(temp, dist));
const getTagSha = async () => {
const ref = await api(`/git/ref/tags/${TAG}`, { optional: true });
if (!ref) return null;
let object = ref.object;
for (let depth = 0; object.type === 'tag' && depth < 5; depth++) object = (await api(`/git/tags/${object.sha}`)).object;
insist(object.type === 'commit', 'tag 不是 commit'); return object.sha;
};
let release = await api(`/releases/tags/${TAG}`, { optional: true });
const tagSha = await getTagSha();
if (guardExisting(tagSha, release, sha, digests) === 'complete') {
await verifyDownloads(release);
console.log(`已完整发布且 SHA/两包摘要一致:${release.html_url}`); return;
}
const changelog = fs.readFileSync('usr/themes/InfinityTime/update.md', 'utf8');
const notes = changelog.split(`## ${VERSION}\n`)[1]?.split(/^## /m)[0]?.trim();
insist(notes, '缺少本版本 changelog');
// 所有打包与只读验证完成后,再验证 main,随后才执行不可覆盖的创建操作。
insist(await latestMain() === sha, '创建 tag 前 main 已改变');
await api('/git/refs', { method: 'POST', body: { ref: `refs/tags/${TAG}`, sha } });
release = await api('/releases', { method: 'POST', body: { tag_name: TAG, target_commitish: sha, name: `InfinityTime ${TAG}`, body: notes, draft: true, prerelease: false } });
for (const name of ASSETS) {
const upload = `${release.upload_url.split('{')[0]}?name=${encodeURIComponent(name)}`;
const asset = await api(upload, { method: 'POST', binary: true, body: fs.readFileSync(path.join(dist, name)) });
insist(asset.name === name && asset.state === 'uploaded' && asset.digest === `sha256:${digests[name]}`, '上传资产摘要校验失败');
}
insist(await latestMain() === sha, '发布前 main 已改变,保留 draft 待核查');
insist(await getTagSha() === sha, '发布前 tag SHA 改变');
release = await api(`/releases/${release.id}`);
guardExisting(sha, { ...release, draft: false }, sha, digests);
await api(`/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
release = await api(`/releases/${release.id}`);
guardExisting(await getTagSha(), release, sha, digests);
await verifyDownloads(release);
console.log(`发布成功:${release.html_url};SHA=${sha};两份 ZIP SHA-256 已验证`);
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
}
module.exports = { guardRun, guardVersions, guardExisting, buildArchives, VERSION, TAG, ASSETS };
if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });
92 changes: 88 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ permissions:
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.2', '8.4']
steps:
- uses: actions/checkout@v4

- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_sqlite
php-version: ${{ matrix.php }}
extensions: pdo_sqlite, gd, exif, dom

- uses: actions/setup-node@v4
with:
Expand All @@ -39,6 +43,14 @@ jobs:
php tests/repository.php
php tests/migration.php
php tests/schema-sqlite.php
php tests/field-sync.php
php tests/image-delete.php
php tests/media-lifetime.php
php tests/media-gd.php
php tests/maintenance-state.php
for mode in rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do
php tests/maintenance-panel.php "$mode"
done
php tests/admin-workflow.php
php tests/admin-panel.php
for mode in get-maintenance post-no-token post-wrong-token post-array-token post-token-unavailable post-non-album post-foreign-album post-contributor-published; do
Expand All @@ -48,6 +60,8 @@ jobs:
php tests/upload-fallback.php
php tests/theme-output.php
node tests/admin-upload.cjs
node tests/maintenance-client.cjs
node --test tests/release-guards.cjs
node tests/gallery.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs
Expand All @@ -68,7 +82,13 @@ jobs:

browser:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
engine: [chromium, firefox, webkit]
env:
PLAYWRIGHT_BROWSER: ${{ matrix.engine }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -77,7 +97,7 @@ jobs:
- name: Install isolated browser test dependencies
run: |
npm install --no-save --package-lock=false playwright@1.62.1
npx playwright install --with-deps chromium
npx playwright install --with-deps ${{ matrix.engine }}
- name: 图库基础浏览器回归
run: node tests/gallery-browser.cjs
- name: 图库加载与历史导航回归
Expand All @@ -86,3 +106,67 @@ jobs:
- name: 后台上传浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-upload.cjs --browser

typecho-integration:
runs-on: ubuntu-latest
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.2', '8.4']
typecho:
- version: '1.2.1'
ref: '206880ba714d80bfb0638aeacb49c58b8fb1b327'
- version: '1.3.0'
ref: 'e9f23304bf20c0c355ef1df9634d42148cbc53b5'
database: [sqlite, mysql, pgsql]
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: infinitytime_test
MYSQL_ROOT_PASSWORD: integration-test-only
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -pintegration-test-only"
--health-interval=10s --health-timeout=5s --health-retries=10
postgres:
image: postgres:17
env:
POSTGRES_DB: infinitytime_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: integration-test-only
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres -d infinitytime_test"
--health-interval=10s --health-timeout=5s --health-retries=10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: typecho/typecho
ref: ${{ matrix.typecho.ref }}
path: .typecho-test
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo_sqlite, sqlite3, pdo_mysql, mysqli, pdo_pgsql, pgsql
coverage: none
- name: PHP 代码与集成测试语法
run: find usr tests -name '*.php' -print0 | xargs -0 -n1 php -l
- name: 真实 Typecho 数据库集成
env:
TYPECHO_ROOT: ${{ github.workspace }}/.typecho-test
IT_DB_HOST: 127.0.0.1
IT_DB_NAME: infinitytime_test
IT_DB_USER: ${{ matrix.database == 'mysql' && 'root' || 'postgres' }}
IT_DB_PASSWORD: integration-test-only
run: |
case '${{ matrix.database }}' in
sqlite) adapters='Pdo_SQLite SQLite' ;;
mysql) adapters='Pdo_Mysql Mysqli' ;;
pgsql) adapters='Pdo_Pgsql Pgsql' ;;
esac
for adapter in $adapters; do
php tests/typecho-integration.php "$adapter"
done
74 changes: 74 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ on:
push:
tags:
- 'v*'
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]

permissions:
contents: write

jobs:
release:
if: github.event_name == 'push'
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -55,3 +63,69 @@ jobs:
dist/infinitytime-theme.zip
dist/infinitytime-plugin.zip
body_path: dist/RELEASE_NOTES.md

# 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.1。
release-1-14-1:
if: >-
github.event_name == 'workflow_run' &&
github.repository == 'Thregren/InfinityTime' &&
github.event.repository.fork == false &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: release-v1.14.1
cancel-in-progress: false
steps:
- name: 在 checkout 前验证可信 main 和 CI 身份
id: trusted
env:
GH_TOKEN: ${{ github.token }}
run: |
node <<'NODE'
const fs = require('node:fs');
(async () => {
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
const repo = process.env.GITHUB_REPOSITORY;
const assert = (ok, message) => { if (!ok) throw new Error(message); };
assert(repo === 'Thregren/InfinityTime' && event.repository.full_name === repo && event.repository.fork === false, '目标仓库不匹配');
const get = async path => {
const response = await fetch(`https://api.github.com/repos/${repo}${path}`, { signal: AbortSignal.timeout(30000), headers: { ...(path.startsWith('/actions/') ? {} : { Authorization: `Bearer ${process.env.GH_TOKEN}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } });
assert(response.ok, `GitHub API ${response.status}`);
return response.json();
};
const main = await get('/git/ref/heads/main');
const sha = main.object.sha;
assert(main.object.type === 'commit' && /^[a-f0-9]{40}$/.test(sha), 'main SHA 无效');
const id = event.workflow_run.id;
assert(Number.isSafeInteger(id) && id > 0, 'CI run ID 无效');
const run = await get(`/actions/runs/${id}`);
for (const item of [event.workflow_run, run]) {
assert(item.repository.full_name === repo && item.head_repository.full_name === repo, 'CI 仓库不匹配');
assert(item.event === 'push' && item.head_branch === 'main' && item.status === 'completed' && item.conclusion === 'success', 'CI 不满足发布条件');
assert(item.head_sha === sha, 'CI 不是当前 main');
assert(item.name === 'CI' && item.path === '.github/workflows/ci.yml', 'CI path 不匹配');
}
assert(run.id === id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 不匹配');
const workflow = await get(`/actions/workflows/${run.workflow_id}`);
assert(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 不匹配');
fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`);
})().catch(error => { console.error(error.message); process.exitCode = 1; });
NODE

- name: Checkout 已验证的精确 main SHA
uses: actions/checkout@v4
with:
ref: ${{ steps.trusted.outputs.sha }}
persist-credentials: false

- name: 离线发布 guard 回归
run: node --test tests/release-guards.cjs

- name: 验证、打包并发布唯一授权版本
env:
GH_TOKEN: ${{ github.token }}
run: node .github/scripts/release-guard.cjs
Loading
Loading