Merge branch 'main' into fix/feedback-terminal-color-20260923
This commit is contained in:
14 files changed
+1009
-106
No files matched your search
@@ -1,114 +1,215 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in Nebula
|
||||
name: Bug Report / 问题反馈
|
||||
description: Report a reproducible problem in Pebrel / 反馈一个可复现的问题
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please search existing issues first and include enough detail for someone else to reproduce the problem.
|
||||
Thanks for taking the time to report a bug. A report we can reproduce gets fixed in the next release; one we cannot reproduce is asked for details and waits.
|
||||
感谢反馈。**能复现的问题会进下个版本;说不清的问题只能先挂起等补充。** 下拉选项会自动转成标签。
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Operating system
|
||||
label: Operating system / 操作系统
|
||||
options:
|
||||
- Windows
|
||||
- Windows (WSL)
|
||||
- macOS
|
||||
- Linux
|
||||
- Other
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os_version
|
||||
attributes:
|
||||
label: OS version and display / 系统版本与显示
|
||||
description: Windows 11 23H2 / macOS 26.0 / Ubuntu 24.04; DPI scaling and monitor count if display-related. / 系统版本;渲染问题请附缩放比例与显示器数量。
|
||||
placeholder: "Windows 11 23H2, 150% scaling, 2 monitors"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Nebula version
|
||||
description: Find it in Settings > Home, or on the latest release page.
|
||||
placeholder: "e.g. 1.0.0"
|
||||
label: Pebrel version / 版本号
|
||||
description: Settings > Home, or the release page. / 设置 > 主页,或发布页。
|
||||
placeholder: "e.g. 1.9.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: How did you install Nebula?
|
||||
label: How did you install Pebrel? / 安装方式
|
||||
options:
|
||||
- GitHub Release (.msi / .exe / portable archive)
|
||||
- Built from source (cargo build / cargo run)
|
||||
- Other
|
||||
- GitHub Release (.exe / .zip / .dmg / .AppImage / .deb)
|
||||
- Built from source / 源码构建 (cargo build)
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: shell
|
||||
attributes:
|
||||
label: Shell in the affected pane / 出问题的面板用的 Shell
|
||||
multiple: true
|
||||
options:
|
||||
- PowerShell 7 (pwsh)
|
||||
- Windows PowerShell 5.1
|
||||
- cmd
|
||||
- WSL (bash / zsh inside WSL)
|
||||
- Git Bash / MSYS2
|
||||
- zsh
|
||||
- bash
|
||||
- fish
|
||||
- nushell
|
||||
- Not sure / 不确定
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: program
|
||||
attributes:
|
||||
label: Program running in the pane / 面板里正在跑的程序
|
||||
description: What was running when it happened? / 出问题时面板里跑着什么?
|
||||
multiple: true
|
||||
options:
|
||||
- Nothing, plain shell prompt / 什么都没跑
|
||||
- Claude Code
|
||||
- Codex CLI
|
||||
- Pi
|
||||
- Gemini CLI / other AI agent — 其他 AI agent
|
||||
- Terminal multiplexer (screen etc.) — 终端复用器
|
||||
- vim / nvim / other full-screen TUI — 全屏 TUI
|
||||
- ssh client / remote shell — 远程 shell
|
||||
- A long-running command (build, ping, adb…) — 长时间运行的命令
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: Connection type / 连接方式
|
||||
options:
|
||||
- Local terminal / 本地终端
|
||||
- SSH via Pebrel host list / Pebrel 内 SSH 主机
|
||||
- SSH via command line (ssh user@host) / 手动 ssh 命令
|
||||
- WSL
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
label: Affected area / 影响范围
|
||||
description: Pick every area that applies. / 可多选。
|
||||
multiple: true
|
||||
options:
|
||||
- Local terminal / shell
|
||||
- SSH connection / authentication
|
||||
- SFTP / file tree
|
||||
- Tabs / splits / sessions
|
||||
- AI assistant / providers
|
||||
- Settings / backup / sync
|
||||
- UI / layout / rendering
|
||||
- Startup / crash
|
||||
- Other
|
||||
- Terminal rendering / scrolling / selection / garbled text — 终端渲染、滚动、选区、乱码
|
||||
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
|
||||
- SSH / proxy / jump host — SSH、代理、跳板
|
||||
- File tree / drag-and-drop upload — 文件树、拖拽上传
|
||||
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
|
||||
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
|
||||
- Markdown / LaTeX / image reader — 阅读页与内联渲染
|
||||
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
|
||||
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
|
||||
- Git / SVN
|
||||
- Startup crash / hang / cannot exit — 启动崩溃、卡死、无法退出
|
||||
- Performance / lag — 性能、卡顿
|
||||
- Installer / update / packaging — 安装包、更新、打包
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: regression
|
||||
attributes:
|
||||
label: Did it work in a previous version? / 旧版本是否正常?
|
||||
options:
|
||||
- Not sure / 不确定
|
||||
- Yes, it worked before / 旧版本正常,新版本坏了
|
||||
- No, it never worked / 一直如此
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: reproducibility
|
||||
attributes:
|
||||
label: Can you reproduce it?
|
||||
label: Can you reproduce it? / 复现概率
|
||||
options:
|
||||
- Always (100%)
|
||||
- Often (>50%)
|
||||
- Sometimes
|
||||
- Once / not sure
|
||||
- Always (100%) / 必现
|
||||
- Often (>50%) / 经常
|
||||
- Sometimes / 偶尔
|
||||
- Once / not sure / 仅一次
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Use numbered steps and include the shell or connection type when relevant.
|
||||
label: Steps to reproduce / 复现步骤
|
||||
description: |
|
||||
Numbered, from a fresh launch, ending at the exact moment it goes wrong. "It's laggy" or "it doesn't work" cannot be acted on.
|
||||
从启动开始按序编号,写到出错的那一步为止。"很卡"、"不好用"这种一句话我们没法处理。
|
||||
placeholder: |
|
||||
1. Open Nebula
|
||||
2. Create or connect to ...
|
||||
3. ...
|
||||
1. Launch Pebrel, open a new PowerShell 7 tab
|
||||
2. Run `codex` and ask it to ...
|
||||
3. Split the pane with Ctrl+Shift+D
|
||||
4. Output overlaps the input box at the bottom (see screenshot)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
label: Expected behavior / 期望结果
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
label: Actual behavior / 实际结果
|
||||
description: What you see instead, including any error text verbatim. / 实际看到什么,错误文字原样贴。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots
|
||||
description: Include relevant lines or screenshots. Remove passwords, private keys, tokens, host addresses, and other secrets.
|
||||
label: Logs / screenshots / 日志或截图
|
||||
description: |
|
||||
Required. Screenshot or short recording for anything visual; log lines for crashes, hangs and connection problems. Remove passwords, keys, tokens and host addresses first.
|
||||
必填。显示类问题贴截图或短录屏;崩溃、卡死、连接类问题贴日志。请先删掉密码、私钥、token、主机地址。
|
||||
Logs / 日志位置:
|
||||
- Windows: `%APPDATA%\Pebrel\pebrel_debug.log` and `%TEMP%\Pebrel-<pid>.log`
|
||||
- macOS: `~/Library/Application Support/Pebrel/pebrel_debug.log` and `$TMPDIR/Pebrel-<pid>.log`
|
||||
- Linux: `~/.config/pebrel/pebrel_debug.log` and `/tmp/Pebrel-<pid>.log`
|
||||
placeholder: Paste redacted logs or drag screenshots here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: settings
|
||||
attributes:
|
||||
label: Non-default settings / 改过的设置
|
||||
description: Theme, font, transparency/blur, GPU, shell profile, hooks — anything changed from defaults. Attach settings.txt if unsure. / 改过的主题、字体、透明/模糊、GPU、Shell 配置、hooks;不确定就贴 settings.txt。
|
||||
placeholder: "Acrylic blur on, opacity 85%, font JetBrains Mono 13"
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
label: Before submitting / 提交前确认
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate
|
||||
- label: I searched existing issues and did not find a duplicate / 已搜索,无重复
|
||||
required: true
|
||||
- label: I removed passwords, private keys, tokens, and other secrets from this report
|
||||
- label: I tested with the latest release / 已用最新版本试过
|
||||
required: true
|
||||
- label: I removed passwords, private keys, tokens and other secrets / 已删除敏感信息
|
||||
required: true
|
||||
@@ -1,8 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Existing issues
|
||||
url: https://github.com/Kuddev/nebula/issues
|
||||
about: Search existing reports before opening a new issue.
|
||||
- name: Latest release
|
||||
url: https://github.com/Kuddev/nebula/releases/latest
|
||||
about: Check whether the problem is already fixed in the latest version.
|
||||
- name: Existing issues / 已有 issue
|
||||
url: https://github.com/Kuddev/pebrel/issues
|
||||
about: Search existing reports before opening a new issue. / 先搜一下是否已有人反馈。
|
||||
- name: Latest release / 最新版本
|
||||
url: https://github.com/Kuddev/pebrel/releases/latest
|
||||
about: Check whether the problem is already fixed in the latest version. / 看看最新版是否已修复。
|
||||
- name: Discord
|
||||
url: https://discord.gg/VFn4rcxmhn
|
||||
about: Quick questions and discussion. / 快速提问与交流。
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new capability for Nebula
|
||||
name: Feature Request / 功能建议
|
||||
description: Suggest an improvement or new capability for Pebrel / 提出改进或新功能
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
@@ -7,12 +7,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Describe the workflow problem first, then the change you want. Concrete examples make the request easier to evaluate.
|
||||
先说现在哪里不顺手,再说希望怎么改。具体的使用场景最有说服力。
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / pain point
|
||||
description: What is hard, missing, or repetitive today?
|
||||
label: Problem / pain point — 现在的痛点
|
||||
description: What is hard, missing, or repetitive today? / 什么事难做、缺失、或者每天重复?
|
||||
placeholder: When I work with multiple terminals or hosts, I cannot ...
|
||||
validations:
|
||||
required: true
|
||||
@@ -20,50 +21,66 @@ body:
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What would you like Nebula to do?
|
||||
label: Proposed solution — 希望的做法
|
||||
description: What would you like Pebrel to do? / 希望 Pebrel 怎么做?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: List workarounds, other tools, or designs you considered.
|
||||
validations:
|
||||
required: true
|
||||
label: Alternatives considered — 现在的替代办法
|
||||
description: Workarounds, other tools, or designs you considered. / 目前怎么绕过去的,别的终端怎么做的。
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform / 平台
|
||||
description: Leave empty if it applies everywhere. / 全平台通用可不选。
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- Windows (WSL)
|
||||
- macOS
|
||||
- Linux
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Related area
|
||||
label: Related area / 相关范围
|
||||
multiple: true
|
||||
options:
|
||||
- Local terminal / shell
|
||||
- SSH / SFTP
|
||||
- Tabs / splits / sessions
|
||||
- AI assistant / providers
|
||||
- Settings / backup / sync
|
||||
- UI / UX
|
||||
- Other
|
||||
- Terminal rendering / scrolling / selection — 终端渲染、滚动、选区
|
||||
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
|
||||
- SSH / proxy / jump host — SSH、代理、跳板
|
||||
- File tree / drag-and-drop upload — 文件树、拖拽上传
|
||||
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
|
||||
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
|
||||
- Markdown / LaTeX / image reader — 阅读页与内联渲染
|
||||
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
|
||||
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
|
||||
- Git / SVN
|
||||
- Performance / lag — 性能、卡顿
|
||||
- Installer / update / packaging — 安装包、更新、打包
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
label: How important is this to you? / 对你有多重要
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my daily workflow
|
||||
- Blocking / critical for my use case
|
||||
- Nice to have / 有更好
|
||||
- Would improve my daily workflow / 每天都会用到
|
||||
- Blocking / critical for my use case / 没有就没法用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
label: Before submitting / 提交前确认
|
||||
options:
|
||||
- label: I searched existing issues for similar requests
|
||||
- label: I searched existing issues for similar requests / 已搜索,无重复
|
||||
required: true
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Issue labels
|
||||
|
||||
# Turns the dropdown answers of the issue forms (.github/ISSUE_TEMPLATE/*.yml)
|
||||
# into labels and flags bug reports that are too thin to act on. Labels are
|
||||
# only added; maintainers' manual labels are never removed.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label-from-form:
|
||||
name: label-from-form
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Map form answers to labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const body = issue.body || "";
|
||||
|
||||
// Issue forms render every field as "### <label>\n\n<value>";
|
||||
// multi-select dropdowns join their values with ", ".
|
||||
const fields = {};
|
||||
const re = /^### (.+?)\s*\n+([\s\S]*?)(?=^### |\s*$(?![\s\S]))/gm;
|
||||
for (const m of body.matchAll(re)) {
|
||||
const value = m[2].trim();
|
||||
if (value && value !== "_No response_") fields[m[1].trim()] = value;
|
||||
}
|
||||
const field = (prefix) => {
|
||||
const key = Object.keys(fields).find((k) => k.startsWith(prefix));
|
||||
return key ? fields[key] : "";
|
||||
};
|
||||
const answers = (prefix) => {
|
||||
const value = field(prefix);
|
||||
return value ? value.split(", ").map((s) => s.trim()) : [];
|
||||
};
|
||||
|
||||
// Match on the English lead of each option so wording tweaks in
|
||||
// the Chinese half never silently break the mapping.
|
||||
const OS = [
|
||||
["Windows (WSL)", ["os: windows", "os: wsl"]],
|
||||
["Windows", ["os: windows"]],
|
||||
["macOS", ["os: macos"]],
|
||||
["Linux", ["os: linux"]],
|
||||
];
|
||||
const AREA = [
|
||||
["Terminal rendering", ["area: terminal"]],
|
||||
["Shell startup", ["area: shell"]],
|
||||
["SSH", ["area: ssh"]],
|
||||
["File tree", ["area: files"]],
|
||||
["Tabs / splits", ["area: session"]],
|
||||
["AI agent", ["area: agents"]],
|
||||
["Markdown", ["area: reader"]],
|
||||
["Settings", ["area: ui"]],
|
||||
["Keyboard", ["area: input"]],
|
||||
["Git", ["area: git"]],
|
||||
["Startup crash", ["crash"]],
|
||||
["Performance", ["performance"]],
|
||||
["Installer", ["area: ci-release"]],
|
||||
];
|
||||
const PROGRAM = [
|
||||
["Claude Code", ["area: agents"]],
|
||||
["Codex", ["area: agents"]],
|
||||
["Pi", ["area: agents"]],
|
||||
["Gemini", ["area: agents"]],
|
||||
];
|
||||
const CONNECTION = [
|
||||
["SSH", ["area: ssh"]],
|
||||
["WSL", ["os: wsl"]],
|
||||
];
|
||||
const SHELL = [["WSL", ["os: wsl"]]];
|
||||
const pick = (values, table) => {
|
||||
const out = [];
|
||||
for (const v of values) {
|
||||
const hit = table.find(([lead]) => v.startsWith(lead));
|
||||
if (hit) out.push(...hit[1]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const labels = new Set([
|
||||
...pick([...answers("Operating system"), ...answers("Platform")], OS),
|
||||
...pick([...answers("Affected area"), ...answers("Related area")], AREA),
|
||||
...pick(answers("Program running in the pane"), PROGRAM),
|
||||
...pick(answers("Connection type"), CONNECTION),
|
||||
...pick(answers("Shell in the affected pane"), SHELL),
|
||||
]);
|
||||
if (answers("Did it work in a previous version").some((v) => v.startsWith("Yes"))) {
|
||||
labels.add("regression");
|
||||
}
|
||||
|
||||
// Thin bug reports: no usable steps or no evidence. Ask once, on
|
||||
// open; an edit that fills the gaps clears the label.
|
||||
const existing = new Set(issue.labels.map((l) => l.name));
|
||||
const isBug = existing.has("bug");
|
||||
const steps = field("Steps to reproduce");
|
||||
const logs = field("Logs / screenshots");
|
||||
const missing = [];
|
||||
// CJK carries more per character; weigh it double. Numbered
|
||||
// multi-line steps count as an attempt regardless of length.
|
||||
const compact = steps.replace(/\s+/g, "");
|
||||
const weight = compact.length + (compact.match(/[ -鿿豈-]/g) || []).length;
|
||||
const numbered = (steps.match(/^\s*\d+[.、)]/gm) || []).length >= 2;
|
||||
if (isBug && weight < 60 && !numbered) {
|
||||
missing.push("**Steps to reproduce / 复现步骤**: numbered steps from launch to the failing moment / 从启动开始按序写到出错那一步");
|
||||
}
|
||||
if (isBug && !logs) {
|
||||
missing.push("**Logs / screenshots / 日志或截图**: a screenshot for visual issues, `pebrel_debug.log` for crashes, hangs and connection problems / 显示类问题贴截图,崩溃卡死连接类贴日志");
|
||||
}
|
||||
if (isBug && !/\d+\.\d+/.test(field("Pebrel version"))) {
|
||||
missing.push("**Pebrel version / 版本号**: e.g. `1.9.0` (Settings > Home)");
|
||||
}
|
||||
|
||||
const toAdd = [...labels].filter((l) => !existing.has(l));
|
||||
if (missing.length) {
|
||||
if (!existing.has("needs-info")) toAdd.push("needs-info");
|
||||
if (context.payload.action === "opened") {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
"Thanks for the report. It is missing details we need before anyone can reproduce it, so it is parked as `needs-info`. Please edit the issue and fill in:",
|
||||
"感谢反馈。目前缺少复现所需的信息,已标记 `needs-info`。请**编辑 issue** 补齐以下内容,补齐后标签会自动移除:",
|
||||
"",
|
||||
...missing.map((m) => `- ${m}`),
|
||||
"",
|
||||
"Reports without these are closed after 14 days of silence. / 14 天无补充将关闭。",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
} else if (isBug && existing.has("needs-info") && context.payload.action === "edited") {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo, issue_number: issue.number, name: "needs-info",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
core.info(`form labels: ${[...labels].join(", ") || "(none)"}; adding: ${toAdd.join(", ") || "(none)"}; missing: ${missing.length}`);
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Include docs, catalogs, assets and workflow edits: every PR gets test results.
|
||||
# ready_for_review starts the scarce-runner jobs that draft pushes skip.
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
@@ -29,13 +31,50 @@ jobs:
|
||||
with:
|
||||
runtime_run: ${{ inputs.windows_diagnostics_run }}
|
||||
|
||||
# rustfmt only for now. clippy joins once the existing warning backlog is
|
||||
# cleared; several crates deny lints, so it cannot run advisory.
|
||||
lint:
|
||||
if: ${{ inputs.windows_diagnostics_run == '' }}
|
||||
name: lint
|
||||
outputs:
|
||||
native_matrix: ${{ steps.matrix.outputs.native_matrix }}
|
||||
release_matrix: ${{ steps.matrix.outputs.release_matrix }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.97.1"
|
||||
components: rustfmt
|
||||
- name: rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Plan native validation
|
||||
id: matrix
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/ci_plan.py \
|
||||
--event-name "$GITHUB_EVENT_NAME" \
|
||||
--event-path "$GITHUB_EVENT_PATH" \
|
||||
--output "$GITHUB_OUTPUT"
|
||||
|
||||
# The required lint job validates the matrix before heavy runners are
|
||||
# requested. Draft PRs omit deferred platforms entirely; ready PRs and
|
||||
# release callers require the full matrix. Deferred checks remain missing
|
||||
# on drafts instead of being reported as successful tests.
|
||||
native-tests:
|
||||
needs: lint
|
||||
if: ${{ inputs.windows_diagnostics_run == '' }}
|
||||
name: Tests (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-2022, windows-11-arm, macos-26, macos-26-intel]
|
||||
include: ${{ fromJSON(needs.lint.outputs.native_matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
@@ -46,12 +85,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
@@ -62,7 +102,7 @@ jobs:
|
||||
libx11-xcb-dev libxcb1-dev libxcb-render0-dev \
|
||||
libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev
|
||||
- name: Require the AppKit SDK used by this PR
|
||||
if: runner.os == 'macOS'
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
run: |
|
||||
sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
|
||||
test "${sdk_version%%.*}" -ge 26
|
||||
@@ -70,7 +110,7 @@ jobs:
|
||||
with:
|
||||
toolchain: "1.97.1"
|
||||
- name: Require a native Windows ARM64 compiler
|
||||
if: matrix.os == 'windows-11-arm'
|
||||
if: ${{ matrix.os == 'windows-11-arm' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') {
|
||||
@@ -90,7 +130,7 @@ jobs:
|
||||
${{ runner.os != 'macOS' && 'target/release' || '' }}
|
||||
legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }}
|
||||
- name: Prepare pinned Windows console runtime
|
||||
if: runner.os == 'Windows'
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$architecture = if ('${{ runner.arch }}' -eq 'ARM64') { 'arm64' } else { 'x64' }
|
||||
@@ -116,7 +156,7 @@ jobs:
|
||||
${{ runner.os != 'macOS' && 'target/release' || '' }}
|
||||
key: ${{ steps.rust-cache.outputs.cache-key }}
|
||||
- name: Upload native compilation timings
|
||||
if: always()
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-timings-${{ matrix.os }}
|
||||
@@ -130,12 +170,13 @@ jobs:
|
||||
# 34856491552. Both jobs remain part of this reusable workflow's result, so
|
||||
# the stable release aggregation still requires every check to succeed.
|
||||
macos-release-check:
|
||||
needs: lint
|
||||
if: ${{ inputs.windows_diagnostics_run == '' }}
|
||||
name: Release workspace (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-26, macos-26-intel]
|
||||
include: ${{ fromJSON(needs.lint.outputs.release_matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
@@ -145,6 +186,8 @@ jobs:
|
||||
RUST_TOOLCHAIN: "1.97.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -170,7 +213,7 @@ jobs:
|
||||
path: target/release
|
||||
key: ${{ steps.rust-cache.outputs.cache-key }}
|
||||
- name: Upload release check compilation timings
|
||||
if: always()
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-check-timings-${{ matrix.os }}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: PR size
|
||||
|
||||
# Labels every pull request by the number of changed source lines and fails
|
||||
# oversized ones so they get split before review. Generated and documentation
|
||||
# files do not count. Maintainers bypass the limit with the `size-exempt`
|
||||
# label (a release integration branch is the usual case).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
# Required checks must also report inside the merge queue; the size was
|
||||
# already enforced on the pull request, so this is a pass-through.
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-size-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr-size:
|
||||
name: pr-size
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const LIMIT = 1500;
|
||||
const IGNORED = [
|
||||
/^Cargo\.lock$/, /(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/,
|
||||
/^docs\//, /\.md$/, /\.snap$/, /\.svg$/, /\.png$/, /\.ico$/,
|
||||
/^assets\//, /^locales\//, /\.lock$/,
|
||||
];
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo, pull_number: pr.number, per_page: 100,
|
||||
});
|
||||
let counted = 0, ignored = 0;
|
||||
for (const f of files) {
|
||||
const lines = f.additions + f.deletions;
|
||||
if (IGNORED.some((re) => re.test(f.filename))) ignored += lines; else counted += lines;
|
||||
}
|
||||
|
||||
const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL";
|
||||
const labels = new Set(pr.labels.map((l) => l.name));
|
||||
const wanted = `size/${size}`;
|
||||
for (const l of labels) {
|
||||
if (l.startsWith("size/") && l !== wanted) {
|
||||
await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!labels.has(wanted)) {
|
||||
await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [wanted] });
|
||||
}
|
||||
|
||||
core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`);
|
||||
if (size !== "XL" || labels.has("size-exempt")) return;
|
||||
|
||||
const marker = "<!-- pr-size-guard -->";
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
...context.repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
if (!comments.some((c) => c.body && c.body.includes(marker))) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo, issue_number: pr.number,
|
||||
body: [
|
||||
marker,
|
||||
`This pull request changes **${counted}** source lines (limit ${LIMIT}; docs, lockfiles and assets excluded). Changes this large cannot be reviewed reliably, so please split it into independent pull requests — one behaviour change each, with its own tests.`,
|
||||
"",
|
||||
`本 PR 改动 **${counted}** 行源码(上限 ${LIMIT},不计文档、lockfile、资源)。这么大的改动无法可靠审查,请拆成多个相互独立的 PR,每个只做一件事并附测试。`,
|
||||
"",
|
||||
"Maintainers can add the `size-exempt` label for integration branches. / 维护者可对集成分支加 `size-exempt` 放行。",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
|
||||
@@ -4,26 +4,7 @@ run-name: >-
|
||||
Cross-platform Preview (${{ inputs.preview_id || format('run-{0}', github.run_number) }})
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Native tests cover every PR; package only when package inputs change.
|
||||
paths:
|
||||
- "**/Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "nebula_app/build.rs"
|
||||
- "nebula_app/build/**"
|
||||
- "nebula_app/res/**"
|
||||
- "nebula_app/windows/**"
|
||||
- "assets/**"
|
||||
- "packaging/**"
|
||||
- "scripts/package*"
|
||||
- "scripts/build-*"
|
||||
- "scripts/installer*"
|
||||
- "scripts/prepare-windows-runtime.ps1"
|
||||
- "scripts/preview_release.py"
|
||||
- "scripts/conformance/**"
|
||||
- "scripts/tests/test_preview_release.py"
|
||||
- ".github/workflows/preview-packages.yml"
|
||||
# Pull requests use Full native tests; packages are built after merge or on request.
|
||||
push:
|
||||
branches: [main]
|
||||
# Native tests cover every PR; package only when package inputs change.
|
||||
|
||||
+20
-6
@@ -74,6 +74,8 @@ raise any existing tracked private artifact for an explicit maintainer decision.
|
||||
`architecture/notes/`; ordinary fixes do not create a note.
|
||||
2. Keep one conceptual change per PR. A necessary extraction and its behavior
|
||||
tests may accompany the feature; unrelated rewrites and formatting may not.
|
||||
The `pr-size` check fails above 1500 changed source lines (docs, lockfiles
|
||||
and assets excluded); split instead of asking for an exemption.
|
||||
3. Put shared rules in their existing authority. UI modules adapt those rules;
|
||||
they must not fork persistence, state transitions or domain behavior.
|
||||
4. Add regression tests that fail for the defect, and test error/cancellation
|
||||
@@ -105,9 +107,11 @@ cargo check -p nebula --bin pebrel --features gpui-shell --tests --locked
|
||||
|
||||
`Full native tests` runs on every PR and merge-group update, and on `main` pushes.
|
||||
It tests the full workspace on Linux, Windows x64 / ARM64 and both macOS
|
||||
architectures. New commits cancel obsolete PR runs. Package validation has its own
|
||||
input filters and can also be dispatched manually; a package job does not replace
|
||||
the native test suite. These triggers do not configure required status checks.
|
||||
architectures. New commits cancel obsolete PR runs. Pull requests run tests and
|
||||
compile checks without building distribution packages. Package validation runs
|
||||
after matching changes reach `main`, or by explicit manual dispatch; a package
|
||||
job does not replace the native test suite. These triggers do not configure
|
||||
required status checks.
|
||||
|
||||
GitHub may show **Waiting for approval** for a first-time fork contributor. A
|
||||
maintainer must inspect the submitted changes and approve that workflow run from
|
||||
@@ -122,9 +126,17 @@ compile check into a claim that UI tests or a packaged application were run.
|
||||
|
||||
## Review and enforcement
|
||||
|
||||
`architecture-contracts` is the stable PR job name. Maintainers must enable it as
|
||||
a required check and require Code Owner approval in the target branch ruleset;
|
||||
see the [activation checklist](docs/project-constraints.md#server-side-activation).
|
||||
`architecture-contracts`, `lint`, `pr-size`, the five
|
||||
`Tests (<os>)` jobs and both `Release workspace (<os>)` jobs are required checks
|
||||
on `main`, together with Code Owner approval; see the
|
||||
[activation checklist](docs/project-constraints.md#server-side-activation).
|
||||
Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and
|
||||
Apple Silicon tests plus the Apple Silicon release-profile compile check. The
|
||||
required lint job validates the event and selects the matrix before requesting
|
||||
platform runners. Drafts omit Intel Mac and Windows ARM64 jobs entirely, so their
|
||||
required contexts may be missing until the PR is marked ready. Ready PRs run the
|
||||
full matrix; every required check must succeed before merging. The ready event
|
||||
still reruns core validation rather than reusing an earlier check conclusion.
|
||||
Local hooks are convenient, but bypassable; they are not the enforcement boundary.
|
||||
Submitting a workflow or `CODEOWNERS` file does not configure server-side rules.
|
||||
|
||||
@@ -137,6 +149,8 @@ feature work; there is no routine `--skip-architecture` option.
|
||||
## 中文摘要
|
||||
|
||||
- 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。
|
||||
- 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。
|
||||
- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。
|
||||
- 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。
|
||||
- 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。
|
||||
- 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Keep automatic package builds out of pull-request checks
|
||||
|
||||
## Status
|
||||
|
||||
Accepted by the maintainer on 2026-09-23: pull requests run tests and compile
|
||||
checks; distribution packages are built after merge or on explicit request.
|
||||
|
||||
## Context
|
||||
|
||||
Preview packaging subscribed to pull requests that changed package inputs.
|
||||
A Windows installer-menu change therefore started Linux and both macOS package
|
||||
builds alongside the independent native test matrix. These workflows requested
|
||||
the same macOS runner types while required checks were waiting for runners.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [PR #264](https://github.com/Kuddev/pebrel/pull/264) changed Windows installer
|
||||
scripts and triggered [Preview run 35825643107](https://github.com/Kuddev/pebrel/actions/runs/35825643107).
|
||||
- The Preview macOS jobs ran workspace tests, dialog tests and an optimized
|
||||
product build before creating DMGs. The native workflow independently ran
|
||||
workspace tests and checked the production feature graph.
|
||||
- The required `Release workspace` jobs execute `cargo check --release`; they
|
||||
do not generate distribution packages and remain required compile checks.
|
||||
- Runner waiting is observable. The exact hosted-runner quota or scheduling
|
||||
policy responsible for a particular delay is not established by these logs.
|
||||
|
||||
## Decision
|
||||
|
||||
Remove the `pull_request` trigger from `preview-packages.yml`. Keep its filtered
|
||||
`main` push trigger and explicit `workflow_dispatch`, including all package and
|
||||
runtime conformance validation. Public Preview publication still requires an
|
||||
explicit dispatch with publication enabled. Stable release triggers are retained.
|
||||
|
||||
The native PR and merge-group workflows retain their test coverage. This change
|
||||
does not alter required-check names or server-side protection rules.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Cancel required macOS checks to merge sooner: loses required validation.
|
||||
- Treat the release-profile compile checks as package creation: these commands
|
||||
validate a different build profile without creating installers or archives.
|
||||
- Remove package conformance tests: package behavior still needs verification
|
||||
when a package is actually requested.
|
||||
|
||||
## Consequences
|
||||
|
||||
Pull requests no longer automatically provide Preview installation artifacts.
|
||||
Maintainers can explicitly dispatch package validation for a candidate branch.
|
||||
Already queued workflows must be handled separately; changing a trigger cannot
|
||||
retroactively cancel them. This change does not remove duplicate native runs
|
||||
when a draft becomes ready, or guarantee a fixed runner wait time.
|
||||
|
||||
## Validation
|
||||
|
||||
The native-suite workflow contract verifies that Preview has only filtered main
|
||||
push and manual triggers, that stable release remains tag/manual, and that native
|
||||
PR and merge-group checks remain unfiltered. Restoring the previous PR package
|
||||
trigger must fail the new regression.
|
||||
|
||||
## Supersedes
|
||||
|
||||
The previous automatic Preview build policy for pull-request package-input changes.
|
||||
|
||||
## Revisit when
|
||||
|
||||
An explicit request for per-PR artifacts can be implemented with bounded platform
|
||||
selection and a separate runner budget without delaying mandatory validation.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Plan the native matrix before requesting runners
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for review. Preserves the existing ten required check names and
|
||||
ready-PR platform coverage; server-side rules are not changed.
|
||||
|
||||
## Context
|
||||
|
||||
The initial draft tier skipped individual steps on Intel Mac and Windows ARM64
|
||||
jobs. GitHub still assigned runners before skipping those steps. In PR #257's
|
||||
draft run 35820869856, the Intel test job waited 309 seconds before spending
|
||||
eight seconds on a job whose test steps were skipped.
|
||||
|
||||
## Evidence
|
||||
|
||||
`runs-on` belongs to a job, while the old draft conditions belonged to its steps.
|
||||
Avoiding the platform command does not avoid allocating that job's runner.
|
||||
The required `lint` check already runs on Ubuntu and can validate both formatting
|
||||
and the platform plan before expensive jobs are created.
|
||||
|
||||
## Decision
|
||||
|
||||
`scripts/ci_plan.py` is the authority for platform selection. It reads the event
|
||||
JSON and produces native-test and release-compile matrices. Draft PRs select the
|
||||
three existing core platforms and the Apple Silicon release check. Ready PRs,
|
||||
main pushes, merge groups and release callers retain all five native platforms
|
||||
and both macOS release checks.
|
||||
|
||||
The existing required `lint` job owns planning, after rustfmt. Native and release
|
||||
jobs depend on that job and consume its validated outputs. Invalid input fails
|
||||
the required check rather than silently returning an empty matrix. Checkouts
|
||||
explicitly discard Git credentials; the workflow retains read-only permissions.
|
||||
|
||||
Draft-only deferred checks are absent, not reported as successful executions.
|
||||
A ready PR must still supply every required platform result. This change does
|
||||
not copy previous runs' check conclusions. Cargo caches accelerate rebuilding;
|
||||
they are not evidence that the current validation succeeded.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Keep step-only skipping: retains the unnecessary runner allocation.
|
||||
- Move planning into an unrequired job: its failure could be less visible than
|
||||
a failure in an existing required check, especially on repeated runs.
|
||||
- Remove release-profile checks with Preview packaging: `cargo check --release`
|
||||
verifies compilation and does not create installation packages.
|
||||
- Mirror an earlier green check onto a new run: requires independently verified
|
||||
source, base, workflow, configuration and provenance rules; cache hits alone
|
||||
cannot establish those facts.
|
||||
|
||||
## Consequences
|
||||
|
||||
Heavy jobs begin after the Ubuntu lint/planning job. Formatting errors and bad
|
||||
event metadata stop before requesting platform runners. Draft PR pages may show
|
||||
deferred required contexts as missing; drafts are not mergeable, and the ready
|
||||
event runs the full matrix. Ready transitions still rerun core validation.
|
||||
|
||||
This reduces avoidable runner requests without promising a fixed queue time.
|
||||
The existing Windows diagnostic dispatch continues to use its separate workflow.
|
||||
|
||||
## Validation
|
||||
|
||||
Planner tests cover draft and ready PRs, all full-validation caller events,
|
||||
malformed input and real command-line output. Workflow contract tests require the
|
||||
existing lint check to own planning, both matrices to depend on it, draft step
|
||||
guards to be absent, and release compilation to remain present. Native PR and
|
||||
merge-group coverage and explicit failure propagation retain their regressions.
|
||||
Actual hosted-runner execution remains subject to the PR's required CI results.
|
||||
|
||||
## Supersedes
|
||||
|
||||
Step-level draft skipping in PR #257's initial native matrix.
|
||||
|
||||
## Revisit when
|
||||
|
||||
Queue and execution measurements justify a different platform schedule, or a
|
||||
separately reviewed artifact-provenance design supports build-once test shards.
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan the native CI matrices before GitHub creates runner jobs.
|
||||
|
||||
The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning
|
||||
is deliberately separate from workflow steps: a draft pull request therefore
|
||||
does not materialize jobs for the scarce platforms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Platform:
|
||||
runner: str
|
||||
draft: bool
|
||||
release_check: bool = False
|
||||
|
||||
|
||||
# One platform catalog owns both draft eligibility and release compilation.
|
||||
PLATFORMS = (
|
||||
Platform("ubuntu-24.04", draft=True),
|
||||
Platform("windows-2022", draft=True),
|
||||
Platform("macos-26", draft=True, release_check=True),
|
||||
Platform("windows-11-arm", draft=False),
|
||||
Platform("macos-26-intel", draft=False, release_check=True),
|
||||
)
|
||||
|
||||
FULL_EVENTS = frozenset(
|
||||
{"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"}
|
||||
)
|
||||
|
||||
|
||||
class PlanError(ValueError):
|
||||
"""An event cannot be converted into a safe CI plan."""
|
||||
|
||||
|
||||
def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
selected = [platform for platform in PLATFORMS if not draft or platform.draft]
|
||||
return (
|
||||
[{"os": platform.runner} for platform in selected],
|
||||
[{"os": platform.runner} for platform in selected if platform.release_check],
|
||||
)
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> None:
|
||||
raise ValueError(f"non-standard JSON constant {value}")
|
||||
|
||||
|
||||
def _read_event(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise PlanError(f"cannot read event payload {path}: {exc}") from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(raw, parse_constant=_reject_json_constant)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise PlanError(f"invalid event JSON in {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise PlanError("event payload must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
"""Return native and release matrices for one validated event."""
|
||||
|
||||
if event_name == "pull_request":
|
||||
pull_request = payload.get("pull_request")
|
||||
if not isinstance(pull_request, dict):
|
||||
raise PlanError("pull_request event must contain a pull_request object")
|
||||
draft = pull_request.get("draft")
|
||||
if not isinstance(draft, bool):
|
||||
raise PlanError("pull_request.draft must be a boolean")
|
||||
return _matrices(draft)
|
||||
|
||||
if event_name in FULL_EVENTS:
|
||||
return _matrices(draft=False)
|
||||
|
||||
supported = sorted(FULL_EVENTS | {"pull_request"})
|
||||
raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}")
|
||||
|
||||
|
||||
def _compact_output(name: str, value: list[dict[str, str]]) -> str:
|
||||
return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}"
|
||||
|
||||
|
||||
def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None:
|
||||
"""Append both outputs in one write after planning has fully succeeded."""
|
||||
|
||||
content = "\n".join(
|
||||
(
|
||||
_compact_output("native_matrix", native_matrix),
|
||||
_compact_output("release_matrix", release_matrix),
|
||||
)
|
||||
) + "\n"
|
||||
try:
|
||||
with path.open("a", encoding="utf-8", newline="\n") as output:
|
||||
output.write(content)
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc
|
||||
|
||||
|
||||
def _arguments(argv: Sequence[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--event-name", required=True)
|
||||
parser.add_argument("--event-path", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
arguments = _arguments(argv)
|
||||
try:
|
||||
payload = _read_event(arguments.event_path)
|
||||
native_matrix, release_matrix = plan_matrices(arguments.event_name, payload)
|
||||
write_outputs(arguments.output, native_matrix, release_matrix)
|
||||
except PlanError as exc:
|
||||
print(f"ci_plan: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -11,6 +11,48 @@ from scripts.ci_native_tests import main, native_commands
|
||||
|
||||
|
||||
class NativeSuiteTests(unittest.TestCase):
|
||||
def test_required_lint_plans_runners_before_native_jobs(self):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
|
||||
lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0]
|
||||
self.assertIn("name: lint", lint)
|
||||
self.assertIn("python scripts/ci_plan.py", lint)
|
||||
self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint)
|
||||
self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py"))
|
||||
for job, output in (("native-tests", "native_matrix"),
|
||||
("macos-release-check", "release_matrix")):
|
||||
body = workflow.split(f"\n {job}:\n", 1)[1]
|
||||
body = re.split(r"\n [a-z][a-z-]*:\n", body, maxsplit=1)[0]
|
||||
self.assertIn("needs: lint", body)
|
||||
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", body)
|
||||
self.assertNotIn("pull_request.draft", body)
|
||||
self.assertNotIn("matrix.tier", body)
|
||||
self.assertIn("cargo check --locked --workspace --release", workflow)
|
||||
# Native validation never needs to retain checkout credentials.
|
||||
checkouts = re.findall(r"- uses: actions/checkout@[^\n]+\n(.*?)(?= - |\Z)",
|
||||
workflow, re.S)
|
||||
self.assertTrue(checkouts)
|
||||
for checkout in checkouts:
|
||||
self.assertIn("persist-credentials: false", checkout)
|
||||
|
||||
def test_packages_run_after_merge_or_manual_dispatch_not_for_prs(self):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workflow = (root / ".github/workflows/preview-packages.yml").read_text()
|
||||
events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
|
||||
triggers = set(re.findall(r"^ ([a-z_]+):", events, re.M))
|
||||
self.assertEqual(triggers, {"push", "workflow_dispatch"})
|
||||
push = re.search(r"^ push:(.*?)(?=^ [a-z_]+:|\Z)", events, re.M | re.S)
|
||||
self.assertIsNotNone(push)
|
||||
self.assertIn("branches: [main]", push.group(1))
|
||||
self.assertIn("paths:", push.group(1))
|
||||
# Packaging still needs explicit dispatch to create a public release.
|
||||
self.assertIn("github.event_name == 'workflow_dispatch' && inputs.publish == true", workflow)
|
||||
stable = (root / ".github/workflows/release.yml").read_text()
|
||||
stable_events = stable.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
|
||||
self.assertEqual(set(re.findall(r"^ ([a-z_]+):", stable_events, re.M)),
|
||||
{"push", "workflow_dispatch"})
|
||||
self.assertIn('tags: ["v*.*.*"]', stable_events)
|
||||
|
||||
def test_every_pr_and_merge_group_runs_without_path_exclusions(self):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
|
||||
@@ -20,7 +62,12 @@ class NativeSuiteTests(unittest.TestCase):
|
||||
self.assertIsNotNone(declaration)
|
||||
self.assertNotIn("paths", declaration.group(1))
|
||||
self.assertNotIn("branches", declaration.group(1))
|
||||
self.assertNotIn("types", declaration.group(1))
|
||||
if event == "pull_request":
|
||||
# ready_for_review starts the scarce-runner jobs that draft
|
||||
# pushes skip; the default activity types must stay listed.
|
||||
self.assertIn("types: [opened, synchronize, reopened, ready_for_review]", declaration.group(1))
|
||||
else:
|
||||
self.assertNotIn("types", declaration.group(1))
|
||||
self.assertIn("branches: [main]", events)
|
||||
self.assertNotIn("branches-ignore", events)
|
||||
self.assertNotIn("pull_request_target", workflow)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "ci_plan.py"
|
||||
|
||||
|
||||
class CiPlanCliTests(unittest.TestCase):
|
||||
def run_cli(self, event_name: str, payload: object, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
event_path = output.parent / "event.json"
|
||||
event_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--event-name",
|
||||
event_name,
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def outputs(path: Path) -> dict[str, object]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
return {name: json.loads(value) for name, value in (line.split("=", 1) for line in lines)}
|
||||
|
||||
def test_draft_pr_does_not_materialize_scarce_runner_rows(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "github-output"
|
||||
result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
output.read_text(encoding="utf-8"),
|
||||
'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"}]\n'
|
||||
'release_matrix=[{"os":"macos-26"}]\n',
|
||||
)
|
||||
plan = self.outputs(output)
|
||||
self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"])
|
||||
self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"])
|
||||
|
||||
def test_ready_pr_has_all_native_and_release_checks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "github-output"
|
||||
result = self.run_cli("pull_request", {"pull_request": {"draft": False}}, output)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
plan = self.outputs(output)
|
||||
self.assertEqual(
|
||||
[row["os"] for row in plan["native_matrix"]],
|
||||
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["os"] for row in plan["release_matrix"]],
|
||||
["macos-26", "macos-26-intel"],
|
||||
)
|
||||
|
||||
def test_success_appends_outputs_without_replacing_existing_lines(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "github-output"
|
||||
output.write_text("existing=preserved\n", encoding="utf-8")
|
||||
result = self.run_cli("push", {}, output)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
lines = output.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(lines[0], "existing=preserved")
|
||||
self.assertTrue(lines[1].startswith("native_matrix=[{"))
|
||||
self.assertTrue(lines[2].startswith("release_matrix=[{"))
|
||||
|
||||
def test_merge_group_and_non_pr_events_have_full_coverage(self) -> None:
|
||||
events = ("merge_group", "push", "workflow_dispatch", "workflow_call", "schedule")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for event_name in events:
|
||||
with self.subTest(event_name=event_name):
|
||||
output = Path(directory) / f"{event_name}.out"
|
||||
result = self.run_cli(event_name, {}, output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
plan = self.outputs(output)
|
||||
self.assertEqual(
|
||||
[row["os"] for row in plan["native_matrix"]],
|
||||
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["os"] for row in plan["release_matrix"]],
|
||||
["macos-26", "macos-26-intel"],
|
||||
)
|
||||
|
||||
def test_invalid_events_fail_without_matrix_outputs(self) -> None:
|
||||
cases = (
|
||||
("pull_request", {}, "missing pull request"),
|
||||
("pull_request", {"pull_request": []}, "wrong pull request type"),
|
||||
("pull_request", {"pull_request": {}}, "missing draft"),
|
||||
("pull_request", {"pull_request": {"draft": "false"}}, "wrong draft type"),
|
||||
("pull_request", {"pull_request": {"draft": 0}}, "integer draft type"),
|
||||
("unknown", {}, "unknown event"),
|
||||
("push", [], "non-object payload"),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for event_name, payload, label in cases:
|
||||
with self.subTest(label=label):
|
||||
output = Path(directory) / f"{label.replace(' ', '-')}.out"
|
||||
output.write_text("existing=preserved\n", encoding="utf-8")
|
||||
result = self.run_cli(event_name, payload, output)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("ci_plan:", result.stderr)
|
||||
self.assertEqual(output.read_text(encoding="utf-8"), "existing=preserved\n")
|
||||
|
||||
def test_malformed_json_fails_without_creating_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
event_path = root / "broken.json"
|
||||
output = root / "github-output"
|
||||
event_path.write_text('{"pull_request":', encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--event-name",
|
||||
"pull_request",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_non_standard_json_constant_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
event_path = root / "non-standard.json"
|
||||
output = root / "github-output"
|
||||
event_path.write_text('{"value":NaN}', encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--event-name",
|
||||
"push",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,6 +16,7 @@ from scripts.stable_release import (
|
||||
)
|
||||
from scripts.preview_release import sha256
|
||||
from scripts.preview_release import MIN_ASSET_SIZE
|
||||
from scripts.ci_plan import plan_matrices
|
||||
|
||||
|
||||
VERSION = "1.6.0"
|
||||
@@ -78,8 +79,21 @@ class StableReleaseTests(unittest.TestCase):
|
||||
self.assertNotIn(" native-tests:\n", workflow)
|
||||
self.assertNotIn("uses: ./.github/workflows/linux-lua.yml", workflow)
|
||||
shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
|
||||
for platform in ("ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"):
|
||||
self.assertIn(platform, shared)
|
||||
# Platform coverage comes from the event plan, not literal runner names
|
||||
# in YAML. Verify both the consumer wiring and the full caller matrices.
|
||||
for output in ("native_matrix", "release_matrix"):
|
||||
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", shared)
|
||||
self.assertIn("runs-on: ${{ matrix.os }}", shared)
|
||||
for event in ("push", "merge_group", "workflow_call", "workflow_dispatch"):
|
||||
with self.subTest(event=event):
|
||||
native, release = plan_matrices(event, {})
|
||||
self.assertCountEqual(
|
||||
[row["os"] for row in native],
|
||||
["ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"],
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[row["os"] for row in release], ["macos-26", "macos-26-intel"],
|
||||
)
|
||||
for trigger in ("pull_request:", "merge_group:", "branches: [main]"):
|
||||
self.assertIn(trigger, shared)
|
||||
self.assertIn("run: python scripts/ci_native_tests.py", shared)
|
||||
|
||||
Reference in new issue
Block a user