Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a4b3db4
docs: record v1.13.4 release and server cleanup
Sep 14, 2026
ff03290
fix: harden media maintenance and accessible gallery lifecycle
Thregren Oct 8, 2026
55cb39b
test: report browser fixture event diagnostics
Thregren Oct 8, 2026
927af29
fix: retain Poptrox teardown without invoking jQuery setter
Thregren Oct 8, 2026
0f7a774
feat: add durable upload retry keys and stable photo metadata
Thregren Oct 8, 2026
103ea2a
新增草稿上传重试、后台权限加固与前台加载降级
Thregren Oct 8, 2026
3d82a42
完成公开照片搜索与分享导航,准备1.14.0发布
Thregren Oct 8, 2026
cbb3897
修复灯箱关闭动画与新增照片绑定竞态
Thregren Oct 8, 2026
27e0664
修正上传浏览器夹具互等并保持关闭期间导航目标
Thregren Oct 8, 2026
d30c4fe
合并图片可靠性与灯箱基础修复(PR #1)
Thregren Oct 8, 2026
801cd76
同步已合并主线基准并验证1.14.0发布
Thregren Oct 8, 2026
7090c16
合并1.14.0相册上传、搜索与浏览增强(PR #2)
Thregren Oct 8, 2026
26de140
修复维护检查点与原子字段同步,补齐多数据库兼容验证
Thregren Oct 8, 2026
8dc3cf9
兼容PHP7.4测试语法,增加非事务表与主从隔离故障验证
Thregren Oct 8, 2026
a7096b0
加固图片删除与编码失败恢复,优化上传内存和多浏览器兼容
Thregren Oct 8, 2026
53a4d9a
保护整图集事务删除,增加固定1.14.1的可信CI发布守卫
Thregren Oct 8, 2026
cc9d4bd
合并1.14.1性能、原子删除与多平台可靠性加固(PR #3)
Thregren Oct 8, 2026
6ca8bb4
补充发布API失败的安全诊断,保持权限与守卫不变
Thregren Oct 8, 2026
a0b914e
隔离手动分页测试与滚动时序,保留自动尾批次覆盖
Thregren Oct 8, 2026
55feedb
合并发布API安全诊断与测试时序收尾(PR #4)
Thregren Oct 8, 2026
cc1c96c
修复分页并发等待与超时取消恢复,保留迟到响应隔离
Thregren Oct 8, 2026
bcd4f70
原子保存图集与图片编辑排序,保护外层事务及失败前数据
Thregren Oct 8, 2026
1a0a874
终止超时转换实际进程,隔离清理临时图片并验证Windows路径
Thregren Oct 8, 2026
7247146
补强超大转换输出及清理失败回归,确保Windows测试逐项失败即停
Thregren Oct 8, 2026
7876470
准备1.14.2异常恢复补丁及受控发布
Thregren Oct 8, 2026
74ce9c1
合并1.14.2分页恢复与后台编辑一致性加固
Thregren Oct 8, 2026
c8a5a60
加入手机省流选择与只读健康诊断基础
Thregren Oct 8, 2026
77cf217
完成1.15.0维护失败重试与重建缓存更新
Thregren Oct 8, 2026
38c1868
保留旧维护失败提示并收紧异常输出
Thregren Oct 8, 2026
dd65dd5
合并1.15.0维护恢复与省流浏览
Thregren Oct 8, 2026
1521372
优化大相册读取并加入后台分页批量整理
Thregren Oct 8, 2026
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
158 changes: 158 additions & 0 deletions .github/scripts/release-guard.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use strict';
// 仅用于本次已授权的 v1.15.0;失败或部分发布必须人工核查,绝不覆盖资产。
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.15.0';
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.15.0');
insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.15.0');
insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.15.0');
}
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;
}
// 仅输出诊断白名单;禁止输出令牌、完整响应头或原始响应正文。
function formatApiError(method, endpoint, response, body, secrets = []) {
const clean = value => {
let text = String(value ?? '');
for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]');
return text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]').replace(/[\r\n\x00-\x1f\x7f]/g, ' ');
};
const route = endpoint.startsWith('https://') ? new URL(endpoint).pathname : endpoint.split('?')[0];
const details = ['x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource', 'retry-after']
.map(name => `${name}=${clean(response.headers.get(name) ?? 'unknown').slice(0, 100)}`).join(' ');
const message = typeof body?.message === 'string' ? clean(body.message).slice(0, 300) : 'unavailable';
return `GitHub API ${clean(method)} ${clean(route)}: ${response.status}; ${details}; message=${message}`;
}
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;
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(formatApiError(method, endpoint, response, errorBody, [token]));
}
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, formatApiError, VERSION, TAG, ASSETS };
if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });
153 changes: 152 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +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'
php-version: ${{ matrix.php }}
extensions: pdo_sqlite, gd, exif, dom

- uses: actions/setup-node@v4
with:
Expand All @@ -36,9 +41,36 @@ jobs:
php tests/test.php
php tests/sanitizer-fallback.php
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-version.php
php tests/media-lifetime.php
php tests/media-gd.php
php tests/media-command.php
php tests/media-temp.php
php tests/health-diagnostics.php
php tests/maintenance-state.php
for mode in scan-failure retry-image retry-fields retry-mixed retry-resync retry-repeat retry-finished retry-stale retry-empty retry-unfinished retry-other-job errors-page errors-stale resync-failure 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 post-batch-foreign post-cover-foreign post-batch-published post-cover-published; do
php tests/admin-panel.php "$mode"
done
php tests/gallery-discovery.php
php tests/gallery-performance.php
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/data-saver.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs

- name: Version consistency
run: |
Expand All @@ -53,3 +85,122 @@ jobs:
run: |
THEME_V=$(sed -n 's/.*@version \([0-9][0-9.]*\).*/\1/p' usr/themes/InfinityTime/index.php | head -1)
grep -q "^## $THEME_V$" usr/themes/InfinityTime/update.md

browser:
runs-on: ubuntu-latest
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
with:
node-version: '20'
- name: Install isolated browser test dependencies
run: |
npm install --no-save --package-lock=false playwright@1.62.1
npx playwright install --with-deps ${{ matrix.engine }}
- name: 图库基础浏览器回归
run: node tests/gallery-browser.cjs
- name: 图库加载与历史导航回归
if: ${{ !cancelled() }}
run: node tests/gallery-loading-browser.cjs
- name: 后台上传浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-upload.cjs --browser
- name: 维护失败恢复浏览器回归
if: ${{ !cancelled() }}
run: node tests/maintenance-browser.cjs
- name: 后台分页与批量整理浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-batch-browser.cjs

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

process-portability:
runs-on: windows-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, fileinfo
coverage: none
- name: Windows 外部进程与临时资源回归
shell: pwsh
run: |
php tests/media-command.php
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
php tests/media-temp.php
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Loading
Loading