Compare commits

..
14 changed files with 327 additions and 327 deletions
+1 -146
View File
@@ -405,151 +405,6 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
@@ -616,7 +471,7 @@ jobs:
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
needs: [build-alpine, build-debian, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
-8
View File
@@ -70,14 +70,6 @@ jobs:
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
+1 -1
View File
@@ -28,7 +28,7 @@ const backend = read(path.resolve(__dirname, '../app.js'));
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
const version = read(path.join(root, 'version.yaml'))
?.toString()
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
export const runtimeBuild = Object.freeze({
instanceId: randomUUID(),
+8 -8
View File
@@ -14,35 +14,35 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
gh workflow run build-docker-image.yml --ref develop
```
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包只使用 `upgrade-develop` 这一个 prerelease。`develop` 和 `debian-dev` 镜像读取其中的 `latest.json`,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。新批次先以带构建 ID 的文件名上传并校验,再切换 `latest.json`;索引仍保留开发版本供客户端判断更新。开发渠道不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 prerelease;不删除正式包或无法分类的 release。构建或上传失败不会删除上一套可用包。旧资产被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
每个正式版本只使用一个 GitHub prerelease `upgrade-v<version>`,其中存放完整平台包、校验文件、安装器和 `latest.json`;在线正式渠道 `upgrade-master` 只保留指向最新版本的 `latest.json`,兼容现有客户端。不再创建 `upgrade-build-v<version>-<run-id>-<run-attempt>` 构建批次 release。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及该版本 release 中带构建 ID 的固定资产 URL。相同版本的镜像 tag 重新发布时,先上传并校验新资产,再更新版本索引和正式渠道索引;同一提交重跑也允许产生新内容。旧批次资产保留在同一版本 release,已选中旧索引的下载可继续完成,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和带构建 ID 的独立安装器;两者都来自该版本 release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同一 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl-<build-id>.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
ql reload system
```
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
```
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
@@ -58,7 +58,7 @@ node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --downlo
安装到空目录:
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
node install-bundle-<build-id>.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
```
+23
View File
@@ -0,0 +1,23 @@
# 青龙 2.24.0
## 新增功能
- Node.js 原生部署支持 Python venv 隔离环境。Python 依赖可使用包名、Git URL,以及位于脚本或订阅仓库中的 requirements 文件和 pyproject.toml;拉取订阅不会自动安装仓库声明的依赖。
- 新增通知审计日志,便于查看通知发送结果和失败原因。
- 提供经过平台校验的升级包,支持在线暂存、离线安装及失败回退。升级过程保留账号、配置、环境变量、任务和脚本等数据。
## 修复与优化
- 默认 Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。ARM64 升级包使用原生构建环境。
- 修复额外调度规则校验、单实例任务运行期间的新触发排队、订阅同步、Python 依赖安装与脚本发现等问题。
- 修复系统日志清理时活跃日志文件被移除、页面加载时序和升级流程中的异常情况,并补齐相关中英文提示。
- 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0。
## 镜像与升级提醒
- `2.24.0` 的 Alpine 镜像支持 amd64、arm/v6、arm/v7、arm64、ppc64le、s390x 和 386;`2.24.0-debian` 支持 amd64、arm64、ppc64le 和 s390x。Debian 暂不提供 arm/v7。
- 2.23.0 是 Python 3.10 兼容镜像的最后一个版本;2.24.0 不再更新该变体。需要 Python 3.10 的用户请先评估迁移方案。
- 升级前停止任务并备份完整持久化数据目录,记录原镜像摘要和启动配置。升级后检查脚本、依赖、订阅与通知;如需回退,请同时恢复与旧版本匹配的数据备份。
- 主 npm 包 `@whyour/qinglong` 与独立远程 CLI 包 `@whyour/qinglong-cli` 均提供 `ql` 命令。在已有面板的主机上安装远程 CLI 时,使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。
本版本以 2.23.0 为上一正式版本。开发候选的源码、npm 包与 11 个发布镜像架构已完成隔离验收;正式产物发布后仍以各目标渠道的实际构建结果为准。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.24.0-dev.0",
"version": "2.24.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
+168 -114
View File
@@ -18,11 +18,12 @@ const platforms = [
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository, options = {}) {
const { version, releaseTag = `upgrade-${commit}` } = options;
const { version, releaseTag = `upgrade-${commit}`, assetBuildId } = options;
if (
!/^[a-f0-9]{40}$/.test(commit) ||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
!/^[\w.+-]+$/.test(releaseTag)
!/^[\w.+-]+$/.test(releaseTag) ||
(assetBuildId && !/^\d+-\d+$/.test(assetBuildId))
)
throw new Error('Invalid release source');
const packages = {};
@@ -47,11 +48,48 @@ function releaseIndex(directory, commit, repository, options = {}) {
sha256: record.sha256,
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
releaseTag,
)}/${filename}`,
)}/${
assetBuildId
? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`)
: filename
}`,
};
}
return { format: 2, sourceCommit: commit, packages };
}
function stageReleaseAssets(directory, commit, buildId, channel) {
const staged = fs.mkdtempSync(path.join(directory, `.upgrade-${channel}-`));
const assets = [];
try {
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const archiveName = filename.replace(/\.tar\.gz$/, `-${buildId}.tar.gz`);
const archive = path.join(staged, archiveName);
fs.linkSync(path.join(directory, filename), archive);
const record = JSON.parse(
fs.readFileSync(path.join(directory, filename + '.json')),
);
fs.writeFileSync(
archive + '.json',
JSON.stringify({
...record,
filename: archiveName,
}) + '\n',
);
assets.push(archive, archive + '.json');
}
const installer = path.join(staged, `install-bundle-${buildId}.cjs`);
fs.linkSync(path.join(directory, 'install-bundle.cjs'), installer);
assets.push(installer);
return { staged, assets };
} catch (error) {
fs.rmSync(staged, { recursive: true, force: true });
throw error;
}
}
function stageDevelopmentAssets(directory, commit, buildId) {
return stageReleaseAssets(directory, commit, buildId, 'develop');
}
function publishBundles({
directory,
commit,
@@ -75,13 +113,12 @@ function publishBundles({
// unchanged. Only the version/channel pointer is overwritten, never bytes
// that an in-flight download has already selected by hash.
const development = branch === 'develop';
const tag = `upgrade-build-${
development ? 'develop-' : ''
}v${version}-${buildId}`;
const tag = development ? 'upgrade-develop' : `upgrade-v${version}`;
const index = {
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
assetBuildId: buildId,
}),
version,
buildId,
@@ -93,7 +130,7 @@ function publishBundles({
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
tag,
)}/install-bundle.cjs`,
)}/install-bundle-${buildId}.cjs`,
};
fs.writeFileSync(
path.join(directory, 'latest.json'),
@@ -113,7 +150,9 @@ function publishBundles({
}
};
const notes =
`${development ? 'Development' : 'Verified'} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
`${
development ? 'Development' : 'Verified'
} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
Object.entries(index.packages)
.map(([target, entry]) => `- [${target}](${entry.url})`)
.join('\n') +
@@ -147,121 +186,136 @@ function publishBundles({
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
ensureRelease(
tag,
`${development ? 'Development platform upgrade' : 'Platform upgrade'} v${version} (${buildId})`,
);
const assets = platforms
.flatMap((target) => {
const archive = path.join(
directory,
`qinglong-${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
})
.concat(
path.join(directory, 'install-bundle.cjs'),
path.join(directory, 'latest.json'),
);
const published = lookup(tag);
const missing = [];
const matches = (asset, file) => {
const bytes = fs.readFileSync(file);
return (
asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}`
);
};
for (const file of assets) {
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (!matches(previous, file))
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
const uploaded = lookup(tag);
if (
!assets.every((file) =>
matches(
uploaded.assets.find((a) => a.name === path.basename(file)),
file,
),
)
)
throw new Error('Published platform bundle verification failed');
// Each channel serializes its image builds and pointer writes as one release.
// A rerun of an earlier workflow is allowed to replace the same
// version/commit; an obsolete source commit must never advance either alias.
const aliases = development
? ['upgrade-develop']
: [`upgrade-v${version}`, 'upgrade-master'];
for (const ref of aliases) {
if (!currentSource()) {
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
const staged = development
? stageDevelopmentAssets(directory, commit, buildId)
: stageReleaseAssets(directory, commit, buildId, `v${version}`);
try {
ensureRelease(
ref,
ref === `upgrade-${branch}`
? `Upgrade channel ${branch}`
: `Platform upgrade v${version}`,
tag,
development ? 'Upgrade channel develop' : `Platform upgrade v${version}`,
);
gh(
'release',
'upload',
ref,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
const assets = staged.assets;
const published = lookup(tag);
const missing = [];
const matches = (asset, file) => {
const bytes = fs.readFileSync(file);
return (
asset?.size === bytes.length &&
asset.digest === `sha256:${sha256(bytes)}`
);
};
for (const file of assets) {
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (!matches(previous, file))
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
const uploaded = lookup(tag);
if (
!matches(
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
path.join(directory, 'latest.json'),
!assets.every((file) =>
matches(
uploaded.assets.find((a) => a.name === path.basename(file)),
file,
),
)
)
throw new Error('Published channel index verification failed');
}
// Only prune development releases after the complete new batch and its
// channel pointer are verified. Production versions and aliases are retained.
if (development && currentSource()) {
const pages = JSON.parse(
gh(
'api',
`repos/${repository}/releases?per_page=100`,
'--paginate',
'--slurp',
),
);
for (const release of pages.flat()) {
if (
release.tag_name !== tag &&
/^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test(
release.tag_name,
) &&
release.prerelease === true &&
release.draft === false
) {
if (!currentSource()) break;
gh(
'release',
'delete',
release.tag_name,
'--repo',
repository,
'--yes',
throw new Error('Published platform bundle verification failed');
// Each channel serializes its image builds and pointer writes as one release.
// A rerun of an earlier workflow is allowed to replace the same
// version/commit; an obsolete source commit must never advance either alias.
const aliases = development ? ['upgrade-develop'] : [tag, 'upgrade-master'];
for (const ref of aliases) {
if (!currentSource()) {
console.log(
'Newer source exists; leaving version and channel unchanged',
);
return false;
}
ensureRelease(
ref,
ref === `upgrade-${branch}`
? `Upgrade channel ${branch}`
: `Platform upgrade v${version}`,
);
gh(
'release',
'upload',
ref,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
if (
!matches(
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
path.join(directory, 'latest.json'),
)
)
throw new Error('Published channel index verification failed');
}
// Only prune development releases after the complete new batch and its
// channel pointer are verified. Production versions and aliases are retained.
if (development && currentSource()) {
const currentAssets = new Set(assets.map((file) => path.basename(file)));
for (const asset of lookup(tag).assets) {
if (
!currentAssets.has(asset.name) &&
(/^qinglong-[a-f0-9]{40}-linux-[\w-]+-\d+-\d+\.tar\.gz(?:\.json)?$/.test(
asset.name,
) ||
/^install-bundle-\d+-\d+\.cjs$/.test(asset.name))
) {
if (!currentSource()) break;
gh(
'api',
'-X',
'DELETE',
`repos/${repository}/releases/assets/${asset.id}`,
);
}
}
const pages = JSON.parse(
gh(
'api',
`repos/${repository}/releases?per_page=100`,
'--paginate',
'--slurp',
),
);
for (const release of pages.flat()) {
if (
release.tag_name !== tag &&
/^upgrade-build-develop-(?:v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-)?\d+-\d+$/.test(
release.tag_name,
) &&
release.prerelease === true &&
release.draft === false
) {
if (!currentSource()) break;
gh(
'release',
'delete',
release.tag_name,
'--repo',
repository,
'--yes',
);
}
}
}
return true;
} finally {
fs.rmSync(staged.staged, { recursive: true, force: true });
}
return true;
}
module.exports = { releaseIndex, platforms, publishBundles };
if (require.main === module) {
-2
View File
@@ -51,8 +51,6 @@ test('release images receive the same-run artifact and verify it before use', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
const job = workflow.jobs[name];
assert.equal(job.needs, 'build-static');
-2
View File
@@ -273,8 +273,6 @@ test('Gitee workflows verify both mirrors and do not gate image publication', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
}
+1 -3
View File
@@ -12,12 +12,10 @@ const publication = workflow.jobs.publish.steps.find(
(step) => step.name === 'Publish npm package with OIDC',
).run;
test('npm publication waits for every release image, including Python 3.10', () => {
test('npm publication waits for every supported release image', () => {
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
])
assert.ok(workflow.jobs.publish.needs.includes(name), name);
});
+111 -30
View File
@@ -66,6 +66,17 @@ function fixture(t) {
const gh = (...args) => {
state.calls.push(args);
if (args[0] === 'api') {
if (args[1] === '-X' && args[2] === 'DELETE') {
if (state.failDelete) throw new Error('Delete unavailable');
const id = Number(args[3].split('/').at(-1));
for (const release of state.releases.values())
for (const asset of release.values())
if (asset.id === id) {
release.delete(asset.name);
return '';
}
return notFound();
}
const route = args[1];
if (route.endsWith('/git/ref/heads/master')) return state.head;
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
@@ -127,7 +138,11 @@ function fixture(t) {
digest: `sha256:${sha256(bytes)}`,
});
}
if (tag === 'upgrade-develop' && state.corruptChannel)
if (
files.some((file) => path.basename(file) === 'latest.json') &&
((tag === 'upgrade-develop' && state.corruptChannel) ||
tag === state.corruptIndexTag)
)
release.get('latest.json').digest = 'sha256:wrong';
if (files.length > 1) {
if (state.corruptUploadedBatch)
@@ -166,12 +181,15 @@ test('publishes every platform and installer before advancing version and legacy
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
const tag = 'upgrade-build-v2.24.0-100-1';
const tag = 'upgrade-v2.24.0';
assert.equal(f.state.releases.get(tag).size, 24);
assert.equal(f.state.releases.get('upgrade-master').size, 1);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-100-1'));
for (const entry of [...Object.values(index.packages), index.installer]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
assert.ok(filename.includes('-100-1.'));
const bytes = f.state.releases.get(tag).get(filename).bytes;
assert.equal(bytes.length, entry.size);
assert.equal(sha256(bytes), entry.sha256);
@@ -181,7 +199,7 @@ test('publishes every platform and installer before advancing version and legacy
);
assert.deepEqual(
writes.map((a) => a[2]),
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
[tag, tag, 'upgrade-master'],
);
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
@@ -194,10 +212,10 @@ for (const sameCommit of [false, true])
f.publish();
const before = f.index();
const oldAssets = new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => name !== 'latest.json'),
);
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
f.state.head = commit;
@@ -219,13 +237,14 @@ for (const sameCommit of [false, true])
assert.notEqual(after.installer.sha256, before.installer.sha256);
assert.deepEqual(
new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => oldAssets.has(name)),
),
oldAssets,
);
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 47);
});
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
@@ -236,7 +255,8 @@ test('retrying a publication is idempotent, but changed bytes require a new buil
(a) =>
a[0] === 'release' &&
a[1] === 'upload' &&
a[2] === 'upgrade-build-v2.24.0-100-1',
a[2] === 'upgrade-v2.24.0' &&
!a.includes('--clobber'),
).length;
assert.equal(count(), 1);
f.publish();
@@ -246,6 +266,41 @@ test('retrying a publication is idempotent, but changed bytes require a new buil
assert.equal(f.index().buildId, '100-1');
});
test('a new version keeps the previous version release while master advances', (t) => {
const f = fixture(t);
f.publish();
const previous = f.index('upgrade-v2.24.0');
const commit = 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'next version', '2.25.0');
assert.equal(
f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
true,
);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
assert.equal(f.state.releases.get('upgrade-v2.25.0').size, 24);
assert.equal(f.state.releases.get('upgrade-master').size, 1);
assert.equal(f.index().version, '2.25.0');
assert.ok(!f.state.releases.has('upgrade-build-v2.25.0-101-1'));
});
test('a failed version index verification leaves master on the previous version', (t) => {
const f = fixture(t);
f.publish();
const previous = f.index();
const commit = 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'next version', '2.25.0');
f.state.corruptIndexTag = 'upgrade-v2.25.0';
assert.throws(
() => f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
/Published channel index verification failed/,
);
assert.deepEqual(f.index(), previous);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
});
for (const fault of ['missing', 'corrupt', 'wrong-version'])
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
const f = fixture(t);
@@ -285,7 +340,7 @@ test('stale commits do not publish; a source change during upload cannot advance
const before = f.index();
f.state.head = 'b'.repeat(40);
assert.equal(f.publish({ buildId: '101-1' }), false);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
f.state.head = f.options.commit;
f.state.changeHeadAfterUpload = true;
assert.equal(f.publish({ buildId: '102-1' }), false);
@@ -366,24 +421,29 @@ test('automatic development publication keeps its prerelease version separate fr
...Object.values(development.packages),
development.installer,
])
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-dev.0-100-1/'));
assert.ok(entry.url.includes('/upgrade-develop/'));
for (const entry of Object.values(development.packages))
assert.ok(entry.url.endsWith('-100-1.tar.gz'));
assert.ok(development.installer.url.endsWith('/install-bundle-100-1.cjs'));
assert.ok(
f.state.calls.some(
(args) =>
args[0] === 'release' &&
args[1] === 'create' &&
args[2] === 'upgrade-build-develop-v2.24.0-dev.0-100-1' &&
args[args.indexOf('--title') + 1] ===
'Development platform upgrade v2.24.0-dev.0 (100-1)',
args[2] === 'upgrade-develop' &&
args[args.indexOf('--title') + 1] === 'Upgrade channel develop',
),
);
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 24);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-dev.0-100-1').size,
24,
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
assert.ok(!f.state.releases.has('upgrade-build-develop-100-1'));
const archiveName = path.basename(development.packages['linux-x64-musl'].url);
const sidecar = JSON.parse(
f.state.releases.get('upgrade-develop').get(`${archiveName}.json`).bytes,
);
assert.equal(sidecar.filename, archiveName);
});
test('development builds cannot publish obsolete development source', (t) => {
@@ -433,14 +493,21 @@ for (const fault of [
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
const oldTag = 'upgrade-build-develop-100-1';
const legacyTag = 'upgrade-build-develop-v2.24.0-99-1';
f.state.releases.set(oldTag, new Map());
f.state.releases.set(legacyTag, new Map());
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
// Never prune production, channel aliases, legacy/unclassified releases,
// drafts or a non-prerelease even when its name resembles a dev build.
const protectedTags = [
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
'v2.24.0',
'upgrade-build-develop-v2.24.0-10-1',
'upgrade-build-develop-v2.24.0-11-1',
'upgrade-build-develop-10-1',
'upgrade-build-develop-11-1',
];
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
f.state.releaseMetadata = {
@@ -458,7 +525,7 @@ for (const fault of [
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
const deletion = f.state.calls.findIndex(
(a) => a[0] === 'release' && a[1] === 'delete',
(a) => a[0] === 'api' && a[1] === '-X' && a[2] === 'DELETE',
);
const aliasEdit = f.state.calls.findLastIndex(
(a) => a[0] === 'release' && a[1] === 'edit',
@@ -472,9 +539,15 @@ for (const fault of [
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
),
);
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
assert.ok(f.state.releases.has('upgrade-develop'));
assert.ok(!f.state.releases.has('upgrade-build-develop-101-1'));
}
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
assert.equal(f.state.releases.has(legacyTag), Boolean(fault));
assert.equal(
f.state.releases.get('upgrade-develop').has(oldAsset),
Boolean(fault),
);
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
@@ -485,17 +558,21 @@ test('failed development cleanup is retryable without overwriting immutable asse
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
f.state.failDelete = true;
assert.throws(
() => f.publish({ ...options, buildId: '101-1' }),
/Delete unavailable/,
);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
f.state.failDelete = false;
f.publish({ ...options, buildId: '101-1' });
assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset));
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
});
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
@@ -504,10 +581,14 @@ test('a newer development commit during upload preserves the published channel a
f.write(options.commit, 'development');
f.publish(options);
const before = f.index('upgrade-develop');
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
f.state.changeDevelopHeadAfterUpload = true;
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
assert.deepEqual(f.index('upgrade-develop'), before);
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
assert.ok(
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
);
+2 -2
View File
@@ -32,7 +32,7 @@ test('a running process retains its own build identity when installed files chan
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0-dev.0');
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
fs.writeFileSync(
path.join(root, 'static/build-info.json'),
@@ -45,7 +45,7 @@ test('a running process retains its own build identity when installed files chan
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
);
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
assert.equal(running.version, '2.24.0');
assert.equal(running.version, '2.24.0-dev.0');
assert.equal(running.sourceCommit, 'a'.repeat(40));
assert.equal(
running.backendSha256,
-2
View File
@@ -225,8 +225,6 @@ test('static backfill uses existing snapshots and skips all source, build and im
'verify-gitee-mirrors',
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
+11 -8
View File
@@ -1,9 +1,12 @@
version: 2.24.0-dev.0
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.24.0-dev.0.md
publishTime: "2026-10-25 19:22:54"
version: 2.24.0
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
publishTime: "2026-10-29 02:14:58"
changeLog: |
1. 开发候选 2.24.0-dev.0,尚未正式发布;以 2.23.0 为上一正式版本
2. Alpine 与 Debian 镜像统一使用 Node.js 24,升级 sqlite3 至 1.1.4
3. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7 镜像
4. ARM64 升级包在原生 runner 构建,避免 x64 QEMU 下的归档异常
5. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
3. 新增通知审计日志,完善通知失败记录与配置提示
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知