mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f22f8d44 | ||
|
|
f2c2f5e4f8 | ||
|
|
2efc5e4503 |
@@ -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
|
||||
@@ -652,7 +507,7 @@ jobs:
|
||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||
else
|
||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||
exit 1
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease_version:
|
||||
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
@@ -93,8 +93,26 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
platform-smoke:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: cli/package-lock.json
|
||||
- run: npm ci --prefix cli --no-audit --no-fund
|
||||
- run: npm run build:cli
|
||||
- run: node --test cli/test/remote/platformSmoke.test.cjs
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
needs: [package, platform-smoke]
|
||||
if: >-
|
||||
github.repository == 'whyour/qinglong' &&
|
||||
((github.ref == 'refs/heads/master' &&
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
||||
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
|
||||
```
|
||||
|
||||
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
|
||||
Credentials/token are stored in `~/.config/qinglong/cli.json` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
|
||||
|
||||
### Direct access token
|
||||
|
||||
@@ -128,11 +128,11 @@ npm run test:cli
|
||||
node cli/scripts/verify-package.cjs
|
||||
```
|
||||
|
||||
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
||||
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
||||
|
||||
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
|
||||
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
|
||||
|
||||
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
|
||||
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
|
||||
|
||||
## Panel-internal tools
|
||||
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
||||
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
|
||||
```
|
||||
|
||||
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
||||
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
||||
|
||||
### 直接使用访问令牌
|
||||
|
||||
@@ -128,11 +128,11 @@ npm run test:cli
|
||||
node cli/scripts/verify-package.cjs
|
||||
```
|
||||
|
||||
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
||||
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
||||
|
||||
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
||||
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
||||
|
||||
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
|
||||
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
|
||||
|
||||
## 面板内部工具
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"ql": "dist/npm/ql.js"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "Authenticated remote management CLI for QingLong 2.x",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
|
||||
@@ -17,20 +17,28 @@ export function configPath(): string {
|
||||
export function readConfig(): StoredConfig {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = fs.openSync(
|
||||
configPath(),
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
const file = configPath();
|
||||
const pathStat = fs.lstatSync(file);
|
||||
if (!pathStat.isFile())
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。',
|
||||
),
|
||||
);
|
||||
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
|
||||
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
||||
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.mode & 0o077 ||
|
||||
(process.getuid && stat.uid !== process.getuid())
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino
|
||||
) {
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,8 +175,8 @@ export const english: Record<string, string> = {
|
||||
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
|
||||
'API 请求被拒绝(code %s),请检查%s。':
|
||||
'API request rejected (code %s). Check %s.',
|
||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
|
||||
'CLI config must be an owned private regular file (0600).',
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。':
|
||||
'CLI config must be a regular file, not a symbolic link.',
|
||||
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
|
||||
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
||||
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
||||
|
||||
@@ -296,11 +296,13 @@ test('malformed successful responses are rejected, including expired authenticat
|
||||
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
||||
});
|
||||
|
||||
test('config permissions, malformed content and symlinks fail closed', async (t) => {
|
||||
test('config content and symlinks are checked independently of permission bits', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
fs.chmodSync(f.file, 0o644);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
const sharedMode = await f.invoke(['task', 'list', '--json']);
|
||||
assert.equal(sharedMode.code, 0, sharedMode.err);
|
||||
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
|
||||
fs.chmodSync(f.file, 0o600);
|
||||
fs.writeFileSync(f.file, '{broken');
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
@@ -312,12 +314,73 @@ test('config permissions, malformed content and symlinks fail closed', async (t)
|
||||
const target = path.join(f.dir, 'target');
|
||||
fs.renameSync(f.file, target);
|
||||
fs.symlinkSync(target, f.file);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
|
||||
failure(linked, 1);
|
||||
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
|
||||
await f.login();
|
||||
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
|
||||
});
|
||||
|
||||
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
const preload = path.join(f.dir, 'windows-mode.cjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const fs = require('node:fs');
|
||||
const originalFstatSync = fs.fstatSync;
|
||||
const originalOpenSync = fs.openSync;
|
||||
fs.openSync = function (file, flags, ...args) {
|
||||
return originalOpenSync(file, typeof flags === 'number'
|
||||
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
|
||||
};
|
||||
fs.fstatSync = function (...args) {
|
||||
const stat = originalFstatSync(...args);
|
||||
stat.mode = (stat.mode & ~0o777) | 0o666;
|
||||
return stat;
|
||||
};\n`,
|
||||
);
|
||||
const env = { NODE_OPTIONS: `--require=${preload}` };
|
||||
const status = await f.invoke(['auth', 'status', '--json'], env);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||
const list = await f.invoke(['task', 'list', '--json'], env);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 1);
|
||||
const target = path.join(f.dir, 'target');
|
||||
fs.renameSync(f.file, target);
|
||||
fs.symlinkSync(target, f.file);
|
||||
failure(await f.invoke(['task', 'list', '--json'], env), 1);
|
||||
});
|
||||
|
||||
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
const preload = path.join(f.dir, 'arm-mode.cjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'arm' });
|
||||
const fs = require('node:fs');
|
||||
const originalFstatSync = fs.fstatSync;
|
||||
fs.fstatSync = function (...args) {
|
||||
const stat = originalFstatSync(...args);
|
||||
stat.mode = (stat.mode & ~0o777) | 0o666;
|
||||
stat.uid = 999999;
|
||||
return stat;
|
||||
};\n`,
|
||||
);
|
||||
const env = { NODE_OPTIONS: `--require=${preload}` };
|
||||
const status = await f.invoke(['auth', 'status', '--json'], env);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||
const list = await f.invoke(['task', 'list', '--json'], env);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 1);
|
||||
});
|
||||
|
||||
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const dist = path.join(f.dir, 'standalone');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
||||
|
||||
test('saved login works on the host filesystem', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
|
||||
const config = path.join(dir, 'cli.json');
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (url.pathname === '/panel/open/auth/token') {
|
||||
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
|
||||
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
|
||||
res.end(JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'smoke-token',
|
||||
expiration: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
assert.equal(req.headers.authorization, 'Bearer smoke-token');
|
||||
assert.equal(url.pathname, '/panel/open/crons');
|
||||
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_CLI_CONFIG: config,
|
||||
QL_CLIENT_ID: 'smoke-id',
|
||||
QL_CLIENT_SECRET: 'smoke-secret',
|
||||
QL_URL: '',
|
||||
QL_ACCESS_TOKEN: '',
|
||||
};
|
||||
const invoke = (args) => new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [entry, ...args], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (data) => (out += data));
|
||||
child.stderr.on('data', (data) => (err += data));
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, out, err }));
|
||||
});
|
||||
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
||||
const login = await invoke(['login', '--url', url, '--json']);
|
||||
assert.equal(login.code, 0, login.err);
|
||||
assert.equal(JSON.parse(login.out).data.authenticated, true);
|
||||
assert.equal(fs.lstatSync(config).isFile(), true);
|
||||
const status = await invoke(['auth', 'status', '--json']);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.authenticated, true);
|
||||
const list = await invoke(['task', 'list', '--json']);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 0);
|
||||
});
|
||||
@@ -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-<run-id>-<run-attempt>`,tag 不带版本号;索引中仍保留开发版本供客户端判断更新。开发批次不会改写正式版本或 `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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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
-116
@@ -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 = development
|
||||
? `upgrade-build-develop-${buildId}`
|
||||
: `upgrade-build-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,123 +186,136 @@ function publishBundles({
|
||||
console.log('Newer source exists; leaving version and channel unchanged');
|
||||
return false;
|
||||
}
|
||||
ensureRelease(
|
||||
tag,
|
||||
development
|
||||
? `Development platform upgrade (${buildId})`
|
||||
: `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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ test('npm publication consumes the exact archive whose isolated installation pas
|
||||
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
|
||||
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
|
||||
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
|
||||
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
|
||||
assert.match(publication.run, /npm publish "\.\/\$archive" --ignore-scripts/);
|
||||
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -77,7 +75,7 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
|
||||
assert.equal(fs.existsSync(path.join(root, 'published')), published);
|
||||
if (published)
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
|
||||
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
|
||||
'./npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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-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-100-1' &&
|
||||
args[args.indexOf('--title') + 1] ===
|
||||
'Development platform upgrade (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-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) => {
|
||||
@@ -435,7 +495,12 @@ for (const fault of [
|
||||
f.publish(options);
|
||||
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 = [
|
||||
@@ -460,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',
|
||||
@@ -474,10 +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-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);
|
||||
@@ -488,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-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-100-1'));
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-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) => {
|
||||
@@ -507,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-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'),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知
|
||||
|
||||
Reference in New Issue
Block a user