Merge pull request #271 from Kuddev/ci/preflight-and-cache-evidence-20260923

ci: validate release guards and fork PRs before native jobs
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-24 11:47:35 +08:00
commit 2f59460895
21 files changed
+1258 -70

No files matched your search

+54
View File
@@ -22,6 +22,15 @@ outputs:
cache-hit: cache-hit:
description: Whether the compiled target cache matched exactly description: Whether the compiled target cache matched exactly
value: ${{ steps.target.outputs.cache-hit }} value: ${{ steps.target.outputs.cache-hit }}
cache-matched-key:
description: The compiled target cache key actually restored by actions/cache
value: ${{ steps.target.outputs.cache-matched-key }}
legacy-fallback-invoked:
description: Whether the optional legacy cache fallback action was invoked
value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }}
legacy-fallback-outcome:
description: Outcome of the optional legacy cache fallback action
value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }}
runs: runs:
using: composite using: composite
@@ -96,6 +105,7 @@ runs:
${{ steps.identity.outputs.restore-key }} ${{ steps.identity.outputs.restore-key }}
${{ steps.identity.outputs.migration-key }} ${{ steps.identity.outputs.migration-key }}
- name: Recover an existing combined cache during migration - name: Recover an existing combined cache during migration
id: legacy-fallback
if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != '' if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
@@ -104,6 +114,50 @@ runs:
cache-bin: false cache-bin: false
cache-on-failure: true cache-on-failure: true
save-if: false save-if: false
- name: Summarize compiled target cache
id: cache-summary
if: always()
shell: bash
env:
TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }}
TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }}
SAVE_KEY: ${{ steps.identity.outputs.key }}
LEGACY_CONFIGURED: ${{ inputs.legacy-key != '' && 'true' || 'false' }}
LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }}
CACHE_WORKLOAD: ${{ inputs.key }}
RUNNER_OS_VALUE: ${{ runner.os }}
RUNNER_ARCH_VALUE: ${{ runner.arch }}
run: |
set -euo pipefail
target_cache_hit="${TARGET_CACHE_HIT:-false}"
target_cache_matched_key="${TARGET_CACHE_MATCHED_KEY:-<none>}"
save_key="${SAVE_KEY:-<none>}"
legacy_configured="${LEGACY_CONFIGURED:-false}"
legacy_outcome="${LEGACY_OUTCOME:-skipped}"
legacy_invoked=false
if [[ "$legacy_configured" == 'true' && "$legacy_outcome" != 'skipped' && -n "$legacy_outcome" ]]; then
legacy_invoked=true
fi
{
echo '### Rust compiled target cache'
echo
echo "- Workload: \`$CACHE_WORKLOAD\`"
echo "- Runner: \`$RUNNER_OS_VALUE / $RUNNER_ARCH_VALUE\`"
echo "- Target cache exact hit: \`$target_cache_hit\`"
echo "- Target cache matched key: \`$target_cache_matched_key\`"
echo "- Save key: \`$save_key\`"
echo "- Legacy fallback configured: \`$legacy_configured\`"
echo "- Legacy fallback invoked: \`$legacy_invoked\`"
echo "- Legacy fallback outcome: \`$legacy_outcome\`"
echo '- Legacy fallback status is separate and is not counted as target exact hit'
} >> "$GITHUB_STEP_SUMMARY"
{
echo "legacy-fallback-invoked=$legacy_invoked"
echo "legacy-fallback-outcome=$legacy_outcome"
} >> "$GITHUB_OUTPUT"
- name: Populate complete downloads before sharing the cache - name: Populate complete downloads before sharing the cache
if: steps.downloads.outputs.cache-hit != 'true' if: steps.downloads.outputs.cache-hit != 'true'
shell: bash shell: bash
+21
View File
@@ -36,6 +36,27 @@ jobs:
scripts.tests.test_architecture_budgets scripts.tests.test_architecture_budgets
scripts.tests.test_architecture_dependencies scripts.tests.test_architecture_dependencies
scripts.tests.test_architecture_governance scripts.tests.test_architecture_governance
scripts.tests.test_prohibited_names
- name: Check prohibited product names in the new range
env:
PROHIBITED_NAMES_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
PROHIBITED_NAMES_HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
run: |
set -euo pipefail
base="$PROHIBITED_NAMES_BASE"
head="$PROHIBITED_NAMES_HEAD"
# The first push has no before SHA. Derive its known parent when one
# exists; an unresolved/zero base must still fail closed in the checker.
if [[ -z "$base" || "$base" =~ ^0+$ ]]; then
base="$(git rev-parse "${head}^" 2>/dev/null || true)"
fi
if [[ -z "$base" || "$base" =~ ^0+$ || -z "$head" || "$head" =~ ^0+$ ]]; then
echo "::error::Cannot determine a non-zero base/head for prohibited-name range" >&2
exit 1
fi
python3 scripts/check_prohibited_names.py range \
--base "$base" \
--head "$head"
- name: Check source size and dependency direction - name: Check source size and dependency direction
env: env:
ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
+11
View File
@@ -54,6 +54,17 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
- name: Test CI contracts before matrix planning
run: |
python3 -m unittest \
scripts.tests.test_ci_plan \
scripts.tests.test_ci_native_tests \
scripts.tests.test_stable_release \
scripts.tests.test_ci_cache \
scripts.tests.test_platform_cfg \
scripts.tests.test_pr_size_workflow -v
- name: Check platform cfg budget before native jobs
run: python3 scripts/check_platform_cfg.py
- name: Plan native validation - name: Plan native validation
id: matrix id: matrix
shell: bash shell: bash
+15 -3
View File
@@ -49,6 +49,20 @@ jobs:
const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL"; const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL";
const labels = new Set(pr.labels.map((l) => l.name)); const labels = new Set(pr.labels.map((l) => l.name));
const wanted = `size/${size}`; const wanted = `size/${size}`;
const oversized = size === "XL" && !labels.has("size-exempt");
core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`);
await core.summary
.addRaw(`PR size: **${size}**, ${counted} counted lines (${ignored} ignored), limit ${LIMIT}.`)
.write();
if (oversized) {
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
}
// Fork tokens are read-only. The size decision above is still enforced;
// optional labels and comments must not turn a valid PR into a 403 failure.
if (pr.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
core.info("Fork PR: size is reported in the summary; no label/comment writes.");
return;
}
for (const l of labels) { for (const l of labels) {
if (l.startsWith("size/") && l !== wanted) { if (l.startsWith("size/") && l !== wanted) {
await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {}); await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {});
@@ -58,8 +72,7 @@ jobs:
await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [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 (!oversized) return;
if (size !== "XL" || labels.has("size-exempt")) return;
const marker = "<!-- pr-size-guard -->"; const marker = "<!-- pr-size-guard -->";
const comments = await github.paginate(github.rest.issues.listComments, { const comments = await github.paginate(github.rest.issues.listComments, {
@@ -78,4 +91,3 @@ jobs:
].join("\n"), ].join("\n"),
}); });
} }
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
+2
View File
@@ -159,6 +159,8 @@ scripts/drawer_repro.ps1
scripts/ghost_repro/ scripts/ghost_repro/
scripts/icon-build/ scripts/icon-build/
scripts/render_shell_icon.html scripts/render_shell_icon.html
# The competitor-name policy is a maintained CI and hook contract, not a local probe.
!/scripts/check_prohibited_names.py
# Only the maintained Markdown stress runner is public; its scratch variants stay local. # Only the maintained Markdown stress runner is public; its scratch variants stay local.
/scripts/markdown-memory-* /scripts/markdown-memory-*
!/scripts/markdown-memory-stress.ps1 !/scripts/markdown-memory-stress.ps1
@@ -60,6 +60,15 @@ The existing Windows diagnostic dispatch continues to use its separate workflow.
## Validation ## Validation
The required `lint` job now runs the lightweight planner/native-workflow/
stable-release and cache contract tests after `cargo fmt --all -- --check` and before
`ci_plan.py` writes either matrix. The count is 7 planner tests, 10 native
workflow tests, 17 stable-release tests and 8 cache contract tests. A final local run of the exact
command completed 42 tests (41 passed, one Windows-only case skipped) in 3.341 seconds on 2026-09-23; this is a local
measurement, not a general speed guarantee. The workflow contract test checks
that this step precedes planning and that both dynamic matrices depend on the
required lint job.
Planner tests cover draft and ready PRs, all full-validation caller events, Planner tests cover draft and ready PRs, all full-validation caller events,
malformed input and real command-line output. Workflow contract tests require the 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 existing lint check to own planning, both matrices to depend on it, draft step
@@ -0,0 +1,49 @@
# Prohibited-name range checks
## Status
Implemented for review. The existing local hook modes remain unchanged; the public CI entry point is `range --base BASE --head HEAD`.
## Context
The repository already used an ignored `scripts/check_prohibited_names.py` in staged, commit-message, and pre-push hooks. The policy must also run in the existing `architecture-contracts` check for pull requests, merge groups, and pushes to `main`, without rescanning old release history or making legitimate attribution and protocol compatibility impossible.
## Evidence
- `.github/workflows/architecture.yml` already runs on `pull_request`, `merge_group`, and `push` to `main`, with a full checkout.
- The local checker already owns the prohibited-name list and hook behavior; duplicating the list in workflow YAML would create a second policy source.
- Inline copyright/third-party attribution can contain a project name, while Cargo git/source references and protocol/external-format identifiers include key-reporting constants, multiplexer escape sequences, and theme-format variants.
- macOS run 35950152526 rejected a malformed UTF-8 filename before the checker
could run. The fixture now writes a Git tree directly, preserving the same
invalid-path regression without depending on filesystem filename support.
## Decision
- Publish the existing checker as the single policy implementation and add a fail-closed range mode.
- Resolve both revisions, compute their `merge-base`, scan ordinary commits against their first parent and merge commits with a combined diff, and scan every commit message in the resulting range. Missing, zero, unreadable, or unrelated revisions return failure. Invalid UTF-8 in paths, patches or messages fails explicitly rather than substituting another path or text.
- Keep only the checker, its contract tests and the intentional negative comparison fixture as exact path exceptions. Strip only reviewed inline attribution, Cargo git/source references, and protocol compatibility identifiers before applying the existing patterns.
- Run the checker as a step inside `architecture-contracts` and run its unit tests with the other guardrail tests. No new required check or server-side ruleset change is introduced.
## Rejected alternatives
- Scanning the whole repository would fail on historical, already-reviewed attribution and compatibility names.
- Using only `git diff base..head` would miss a prohibited name added and removed by separate commits; range mode reuses the hook's per-commit added-line scan. Comparing every merge to its first parent would replay second-parent main changes, so merge commits use combined diff instead.
- Treating an unknown base as an empty range would make an unavailable GitHub event input look green; the workflow derives only a known first-push parent and otherwise fails. A normal PR that has diverged from main uses its merge-base.
- Adding a broad `docs/`, `src/`, or product-directory exemption would hide ordinary competitor comparisons, so exemptions remain exact and line-scoped.
## Consequences
New PR/merge-group/main-push text and commit subjects are checked by the existing required job. A legitimate inline attribution, real dependency source, or supported protocol identifier remains usable. Adding another legal/protocol/dependency spelling requires a narrow fixture and policy update rather than silently bypassing the scan.
## Validation
`scripts/tests/test_prohibited_names.py` covers ordinary comparison failure, inline attribution positive/negative paths, protocol identifiers, Cargo dependency references, GitHub CLI text, range text, range commit messages, diverged related bases, unrelated/missing bases, old-history exclusion, added-then-deleted text, and merge-resolution additions. The checker also covers malformed UTF-8 paths/text and normal Unicode/control-character paths; the malformed-path fixture constructs Git tree objects without checking them out, so it also runs on filesystems that reject those bytes.
## Supersedes
None.
## Revisit when
GitHub changes the event payload fields used for base/head selection, the repository adopts a different merge queue event, or a new legal/protocol/dependency contract requires a narrowly documented exception.
@@ -0,0 +1,58 @@
# Keep the size decision independent of optional PR writes
## Status
Implemented for review.
## Context
Fork pull requests have a read-only token. The required size check counted files,
then tried to add a size label before reporting its decision. An ordinary small
contribution therefore failed with HTTP 403 instead of reporting its actual size.
## Evidence
PR 274 at `98fad00b01da9dd089581e4d4f66a251bfc1404d` changes 127 Rust source lines.
Run 35893377849 had PullRequests-read permission and failed at POST issue labels
while trying to add the ordinary `size/M` classification. Its native tests passed.
## Decision
Compute and publish the size result before optional writes. The existing 1500-line
limit, ignored documentation/assets and maintainer exemption semantics remain the
same. An oversized fork fails immediately. Forks report their result in the job
summary and do not attempt labels or comments with a read-only token. Same-repository
pull requests retain their existing labels and oversized-change guidance.
Tests execute the actual inline workflow script with fake APIs, so there is one
implementation of the counting and fork boundary. Node is already available on
the supported hosted images; the tests require it and use no npm dependencies.
## Rejected alternatives
Broadening fork token access or executing fork code in a privileged PR event is
unnecessary. Suppressing the entire size check on forks would waive its invariant.
A separate privileged labeling service adds another trust boundary and is not
needed to validate source size.
## Consequences
Fork contributors receive a genuine size-check result without write access.
Automatic size labels remain available for same-repository contributions;
maintainers may apply the ordinary size label to a fork. Such a label is not a
budget exemption, and the source count is still recomputed by the required check.
## Validation
The workflow-script tests cover a small fork, oversized fork, exact limit,
existing maintainer exemption, deleted-fork metadata and same-repository guidance.
No test calls the real GitHub write APIs. Actionlint validates the workflow.
## Supersedes
None.
## Revisit when
A metadata-only labeling service is justified, or the source-size policy changes
through a separately reviewed rule update.
@@ -0,0 +1,62 @@
# Check the platform budget before allocating native runners
## Status
Implemented for review.
## Context
The platform-condition budget ran only during packaging. A Windows-only WSL
regression introduced one additional test-level condition, leaving the source
count at 485 against a budget of 484. All ten PR checks passed because none ran
that budget checker. Preview preparation then failed before any package build.
## Evidence
- Preview run 35882296442 failed in `Check platform cfg budget` on main commit
`1d26c2dc9860a6cfb61c14b630c12d36dbf95e43`.
- The added condition guarded the WSL truecolor regression, not a new production
platform branch. Five related WSL forwarding tests repeated the same Windows
compilation condition.
- Sharing that condition at the WSL test-module boundary preserves all five test
bodies and lowers the repository count to 481 without moving source outside
the scanner's declared roots or adding an exclusion.
## Decision
- Keep the WSL forwarding regressions together under one Windows test module in
their existing owning source file. Other identity and PATH tests retain their
existing boundaries. Production environment behavior is unchanged.
- Tighten the existing budget from 484 to the measured count of 481.
- Execute the same authoritative platform checker in required lint before matrix
planning. Keep the packaging check as an independent release precondition.
- Run the checker's contract tests with the existing lightweight CI contracts.
## Rejected alternatives
Raising the budget, deleting the new regression, excluding test files from the
scanner, or ignoring the packaging failure would hide the mismatch. None is
needed. This change also does not turn the historical text-count heuristic into
a claim that architectural coupling has been proven absent.
## Consequences
The five WSL tests share one platform boundary and remain Windows-only. Budget
failures are visible before native runner allocation. Legitimate future scanner
policy changes still need their own reproducer, tests and review.
## Validation
The platform checker reports 481 against 481. Validation also compares the test
function inventory, runs the Windows identity/WSL regression group, and checks
that lint executes the gate before planning without automatic budget updates or
failure suppression.
## Supersedes
None.
## Revisit when
The text-based platform counter is replaced with a reviewed semantic contract,
or native runner planning is moved to another shared preflight owner.
+71 -65
View File
@@ -304,77 +304,83 @@ mod tests {
assert_eq!(entries.iter().filter(|entry| same_directory(entry, directory)).count(), 1); assert_eq!(entries.iter().filter(|entry| same_directory(entry, directory)).count(), 1);
} }
// WSL forwarding has one Windows compilation boundary for its whole contract.
#[cfg(windows)] #[cfg(windows)]
#[test] mod wsl {
fn wslenv_entries_match_variables() { use super::*;
// 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉
// WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。
let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect();
assert!(names.contains(&CLI_ENV), "{CLI_ENV} missing from WSLENV passthrough: {names:?}");
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
assert!(names.contains(&"COLORTERM"));
}
#[cfg(windows)] #[test]
#[test] fn wslenv_entries_match_variables() {
fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() { // 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉
let mut env = HashMap::from([ // WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。
("COLORTERM".to_owned(), "truecolor".to_owned()), let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect();
("WSLENV".to_owned(), "KEEP/p".to_owned()), assert!(
]); names.contains(&CLI_ENV),
apply(&mut env, 8); "{CLI_ENV} missing from WSLENV passthrough: {names:?}"
assert_eq!(env["COLORTERM"], "truecolor"); );
assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM")); assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p")); assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into()); assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
env.insert("COLORTERM".into(), "24bit".into()); assert!(names.contains(&"COLORTERM"));
apply(&mut env, 8); }
assert_eq!(env["COLORTERM"], "24bit");
let entries: Vec<_> =
env["WSLENV"].split(':').filter(|entry| variable_name(entry) == "COLORTERM").collect();
assert_eq!(entries, ["COLORTERM/u"]);
}
#[cfg(windows)] #[test]
#[test] fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() {
fn wslenv_keeps_foreign_entries() { let mut env = HashMap::from([
let mut env = HashMap::new(); ("COLORTERM".to_owned(), "truecolor".to_owned()),
// `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。 ("WSLENV".to_owned(), "KEEP/p".to_owned()),
env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned()); ]);
apply(&mut env, 3); apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "truecolor");
assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM"));
assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p"));
env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into());
env.insert("COLORTERM".into(), "24bit".into());
apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "24bit");
let entries: Vec<_> = env["WSLENV"]
.split(':')
.filter(|entry| variable_name(entry) == "COLORTERM")
.collect();
assert_eq!(entries, ["COLORTERM/u"]);
}
let wslenv = env.get("WSLENV").expect("WSLENV"); #[test]
let entries: Vec<&str> = wslenv.split(':').collect(); fn wslenv_keeps_foreign_entries() {
assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}"); let mut env = HashMap::new();
assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}"); // `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。
// 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。 env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned());
assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}"); apply(&mut env, 3);
}
#[cfg(windows)] let wslenv = env.get("WSLENV").expect("WSLENV");
#[test] let entries: Vec<&str> = wslenv.split(':').collect();
fn wslenv_merge_is_idempotent() { assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}");
let mut env = HashMap::new(); assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}");
apply(&mut env, 4); // 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。
let once = env.get("WSLENV").cloned().expect("WSLENV"); assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}");
apply(&mut env, 4); }
assert_eq!(env.get("WSLENV"), Some(&once));
}
#[cfg(windows)] #[test]
#[test] fn wslenv_merge_is_idempotent() {
fn wslenv_respects_foreign_flags_for_same_variable() { let mut env = HashMap::new();
let mut env = HashMap::new(); apply(&mut env, 4);
// 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`, let once = env.get("WSLENV").cloned().expect("WSLENV");
// 否则会破坏那个工具原有的转换语义。 apply(&mut env, 4);
env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned()); assert_eq!(env.get("WSLENV"), Some(&once));
apply(&mut env, 5); }
let wslenv = env.get("WSLENV").expect("WSLENV"); #[test]
let entries: Vec<&str> = wslenv.split(':').collect(); fn wslenv_respects_foreign_flags_for_same_variable() {
assert!(entries.contains(&"NEBULA_CLI/l")); let mut env = HashMap::new();
assert!(!entries.contains(&"NEBULA_CLI/p")); // 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`,
// 否则会破坏那个工具原有的转换语义。
env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned());
apply(&mut env, 5);
let wslenv = env.get("WSLENV").expect("WSLENV");
let entries: Vec<&str> = wslenv.split(':').collect();
assert!(entries.contains(&"NEBULA_CLI/l"));
assert!(!entries.contains(&"NEBULA_CLI/p"));
}
} }
} }
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""拒绝把竞品名称写入新增内容、提交说明或待推送提交。"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable
PROHIBITED_PATTERNS = (
re.compile(r"(?<![A-Za-z0-9_])tty[-_ ]?7(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])otty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])netcaxx(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])netcat[-_ ]?ty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])arxxx(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])cmux(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])ghostty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])herdr(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])kaku(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])orca(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])tmux(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])wezterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])windows[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])alacritty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])kitty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])tabby(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])warp(?:[-_ ]?terminal)?(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])wave[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])electerm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])termius(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])xshell(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])windterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])mobaxterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])iterm2?(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])hyper[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])rio[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])contour(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])extraterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])finalshell(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])royal[-_ ]?ts(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])securecrt(?![A-Za-z0-9_])", re.IGNORECASE),
)
PROHIBITED_EXAMPLES = (
"tty7",
"otty",
"netcaxx",
"netcatty",
"arxxx",
"cmux",
"ghostty",
"herdr",
"kaku",
"orca",
"tmux",
"wezterm",
"Windows Terminal",
"Alacritty",
"Kitty",
"Tabby",
"Warp",
)
# 门禁必须能维护自己的关键词表;除此之外没有源码或文档例外。
EXEMPT_PATHS = {
"scripts/check_prohibited_names.py",
# These fixtures intentionally contain both accepted and rejected names so
# the policy can test its own false-positive boundaries.
"scripts/tests/test_prohibited_names.py",
"scripts/tests/fixtures/prohibited_names/ordinary-comparison.md",
}
LEGAL_ATTRIBUTION_PATTERNS = (
re.compile(
r"^\s*(?:[#/;*=-]+\s*)?copyright\b[^;\n]*\b(?:project|contributors?|authors?)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(?:[#/;*=-]+\s*)?(?:third[- ]party|upstream)\s+(?:notice|attribution|project)\b[^;\n]*",
re.IGNORECASE,
),
)
DEPENDENCY_REFERENCE_PATTERNS = (
re.compile(r"\bgit\s*=\s*[\"']https?://[^\"']+[\"']", re.IGNORECASE),
re.compile(r"\bsource\s*=\s*[\"']git\+https?://[^\"']+[\"']", re.IGNORECASE),
)
PROTOCOL_COMPATIBILITY_PATTERNS = (
re.compile(r"\bKITTY_[A-Z0-9_]+\b"),
re.compile(r"\bkitty_(?:keyboard|seq|event|release|escape|input)[A-Za-z0-9_]*\b", re.IGNORECASE),
re.compile(r"\b(?:kitty|tmux)/(?:legacy|style|protocol)\b", re.IGNORECASE),
re.compile(r"\b(?:kitty|tmux)\s+(?:keyboard|protocol|escape|sequence)\b", re.IGNORECASE),
re.compile(r"\bThemeFormat::(?:WindowsTerminal|Kitty|Ghostty|WezTerm|Alacritty)\b"),
re.compile(
r"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, Kitty, Ghostty, WezTerm or Alacritty",
re.IGNORECASE,
),
re.compile(r"\\x1bPtmux;"),
)
def git(*args: str) -> bytes:
return subprocess.check_output(["git", "-c", "i18n.logOutputEncoding=utf-8", *args], stderr=subprocess.DEVNULL)
def prohibited(text: str) -> bool:
return any(pattern.search(text) for pattern in PROHIBITED_PATTERNS)
def source_without_allowed_occurrences(path: str, text: str) -> str:
for pattern in LEGAL_ATTRIBUTION_PATTERNS:
text = pattern.sub("", text)
if Path(path).name in {"Cargo.toml", "Cargo.lock"} and any(
pattern.search(text) for pattern in DEPENDENCY_REFERENCE_PATTERNS
):
for pattern in DEPENDENCY_REFERENCE_PATTERNS:
text = pattern.sub("", text)
for pattern in PROTOCOL_COMPATIBILITY_PATTERNS:
text = pattern.sub("", text)
return text
def prohibited_source_line(path: str, text: str) -> bool:
return prohibited(source_without_allowed_occurrences(path, text))
def changed_paths(*revision_args: str) -> list[str]:
raw = git("diff", *revision_args, "--name-only", "-z", "--diff-filter=ACMR", "--", ".")
return [path.decode("utf-8") for path in raw.split(b"\0") if path]
def added_lines(path: str, *revision_args: str) -> Iterable[tuple[int, str]]:
patch = git("diff", *revision_args, "--no-ext-diff", "--unified=0", "--", path)
for line_no, raw_line in enumerate(patch.decode("utf-8").splitlines(), 1):
if raw_line.startswith("+") and not raw_line.startswith("+++"):
yield line_no, raw_line[1:]
def scan_added_lines(*revision_args: str) -> list[str]:
hits: list[str] = []
for path in changed_paths(*revision_args):
normalized = path.replace("\\", "/")
if normalized in EXEMPT_PATHS:
continue
for line_no, line in added_lines(path, *revision_args):
if prohibited_source_line(normalized, line):
hits.append(f"{normalized}:{line_no}:{line}")
return hits
def added_patch_lines(patch: bytes, merge_parent_count: int | None) -> Iterable[tuple[int, str]]:
lines = patch.decode("utf-8").splitlines()
if merge_parent_count is None:
for line_no, raw_line in enumerate(lines, 1):
if raw_line.startswith("+") and not raw_line.startswith("+++"):
yield line_no, raw_line[1:]
return
in_hunk = False
prefix = "+" * merge_parent_count
for line_no, raw_line in enumerate(lines, 1):
if raw_line.startswith("@@@"):
in_hunk = True
continue
# Combined diff headers (including `+++ `) occur before the hunk.
if in_hunk and raw_line.startswith(prefix):
yield line_no, raw_line[merge_parent_count:]
def scan_pending_commits(revision_range: str) -> list[str]:
hits: list[str] = []
commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines()
for commit in commits:
parents = git("rev-list", "--parents", "-n", "1", commit).decode("ascii").split()
parent = parents[1] if len(parents) > 1 else None
merge_parent_count = len(parents) - 1
merge_commit = merge_parent_count > 1
if merge_commit:
# Combined diff reports only content resolved against all parents;
# comparing to one parent would replay the other branch's changes.
paths = git("diff-tree", "--cc", "--no-commit-id", "--name-only", "-z", "-r", commit)
elif parent:
# Comparing against the first parent includes a merge resolution's
# actual additions for ordinary commits.
paths = git("diff", "--name-only", "-z", "--diff-filter=ACMR", parent, commit, "--", ".")
else:
paths = git("diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-r", commit)
for raw_path in paths.split(b"\0"):
if not raw_path:
continue
path = raw_path.decode("utf-8")
normalized = path.replace("\\", "/")
if normalized in EXEMPT_PATHS:
continue
if merge_commit:
patch = git("show", "--format=", "--cc", "--no-ext-diff", "--unified=0", commit, "--", path)
elif parent:
patch = git("diff", "--no-ext-diff", "--unified=0", parent, commit, "--", path)
else:
patch = git("show", "--format=", "--no-ext-diff", "--unified=0", commit, "--", path)
for line_no, line in added_patch_lines(
patch, merge_parent_count if merge_commit else None
):
if prohibited_source_line(normalized, line):
hits.append(f"{commit}:{normalized}:{line_no}:{line}")
return hits
def scan_message(path: Path) -> list[str]:
return [
f"{line_no}:{line}"
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if prohibited(line)
]
def resolved_commit(revision: str) -> str:
if not revision or re.fullmatch(r"0+", revision):
raise ValueError(f"missing or zero revision: {revision!r}")
try:
return git("rev-parse", "--verify", f"{revision}^{{commit}}").decode("ascii").strip()
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
raise ValueError(f"cannot resolve revision {revision!r}") from exc
def checked_range(base: str, head: str) -> tuple[str, str]:
base_commit = resolved_commit(base)
head_commit = resolved_commit(head)
try:
common = git("merge-base", base_commit, head_commit).decode("ascii").strip()
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
raise ValueError(f"base {base_commit} and head {head_commit} are unrelated") from exc
if not common:
raise ValueError(f"base {base_commit} and head {head_commit} have no common ancestor")
return common, head_commit
def scan_commit_messages(revision_range: str) -> list[str]:
hits: list[str] = []
commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines()
for commit in commits:
message = git("show", "-s", "--format=%B", commit).decode("utf-8")
for line_no, line in enumerate(message.splitlines(), 1):
if prohibited(line):
hits.append(f"commit-message:{commit}:{line_no}:{line}")
return hits
def scan_range(base: str, head: str) -> list[str]:
base_commit, head_commit = checked_range(base, head)
revision_range = f"{base_commit}..{head_commit}"
hits = scan_commit_messages(revision_range)
# Scan each commit's added lines so a name added and later deleted in the
# same range cannot disappear from the final two-commit diff.
hits.extend(scan_pending_commits(revision_range))
return hits
def pending_push_range() -> str | None:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
upstream = result.stdout.decode("utf-8").strip()
return f"{upstream}..HEAD" if result.returncode == 0 and upstream else None
def report(title: str, hits: list[str]) -> int:
if not hits:
return 0
print(f"ERROR: {title}\n" + "\n".join(hits), file=sys.stderr)
return 1
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(
"usage: check_prohibited_names.py staged|message|push [message-file] | "
"range --base BASE --head HEAD",
file=sys.stderr,
)
return 2
mode = argv[1]
if mode == "staged":
return report("staged additions contain prohibited product names", scan_added_lines("--cached"))
if mode == "message":
if len(argv) != 3:
print("message mode requires the commit message path", file=sys.stderr)
return 2
return report("commit message contains prohibited product names", scan_message(Path(argv[2])))
if mode == "push":
revision_range = pending_push_range()
if revision_range is None:
print("No upstream branch; skipping pending-push name check")
return 0
message_text = git("log", "--format=%H:%s%n%b", revision_range).decode("utf-8")
hits = [
f"commit-message:{line_no}:{line}"
for line_no, line in enumerate(message_text.splitlines(), 1)
if prohibited(line)
]
# 逐提交看新增行,避免“先加入、后删除”在最终 range diff 中被抵消。
hits.extend(scan_pending_commits(revision_range))
return report("commits pending push contain prohibited product names", hits)
if mode == "range":
if len(argv) != 6 or argv[2] != "--base" or argv[4] != "--head":
print("range mode requires --base BASE --head HEAD", file=sys.stderr)
return 2
try:
hits = scan_range(argv[3], argv[5])
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"ERROR: cannot scan prohibited-name range: {exc}", file=sys.stderr)
return 1
return report("range contains prohibited product names", hits)
print(f"unknown mode: {mode}", file=sys.stderr)
return 2
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except (OSError, UnicodeError, subprocess.CalledProcessError) as exc:
print(f"ERROR: cannot read naming-check input: {exc}", file=sys.stderr)
raise SystemExit(1)
+1 -1
View File
@@ -1 +1 @@
484 481
@@ -0,0 +1 @@
printf '%s\n' 'gh api repos/example/project'
+1
View File
@@ -0,0 +1 @@
gh api repos/Kuddev/pebrel
@@ -0,0 +1 @@
Copyright 2020 The Alacritty Project
@@ -0,0 +1 @@
Pebrel is faster than Ghostty.
+3
View File
@@ -0,0 +1,3 @@
const KITTY_KEYBOARD_PROTOCOL: bool = true;
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
const FORMAT = ThemeFormat::Kitty;
+97
View File
@@ -3,6 +3,8 @@
import os import os
from pathlib import Path from pathlib import Path
import re import re
import shutil
import subprocess
import tempfile import tempfile
import textwrap import textwrap
import unittest import unittest
@@ -13,6 +15,10 @@ ACTION = Path(__file__).resolve().parents[2] / ".github/actions/rust-cache/actio
class CacheIdentityTests(unittest.TestCase): class CacheIdentityTests(unittest.TestCase):
@staticmethod
def action_text() -> str:
return ACTION.read_text(encoding="utf-8")
def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes): def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes):
source = ACTION.read_text(encoding="utf-8") source = ACTION.read_text(encoding="utf-8")
body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S) body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S)
@@ -67,6 +73,97 @@ class CacheIdentityTests(unittest.TestCase):
self.assertEqual(before["restore-key"], after["restore-key"]) self.assertEqual(before["restore-key"], after["restore-key"])
self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"]) self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"])
def test_summary_exposes_real_target_cache_outputs(self):
source = self.action_text()
self.assertIn(
"value: ${{ steps.target.outputs.cache-matched-key }}",
source,
)
self.assertIn(
"value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }}",
source,
)
self.assertIn(
"value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }}",
source,
)
self.assertIn(
"TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }}",
source,
)
self.assertIn(
"TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }}",
source,
)
self.assertIn("SAVE_KEY: ${{ steps.identity.outputs.key }}", source)
self.assertIn("CACHE_WORKLOAD: ${{ inputs.key }}", source)
self.assertIn("RUNNER_OS_VALUE: ${{ runner.os }}", source)
self.assertIn("RUNNER_ARCH_VALUE: ${{ runner.arch }}", source)
summary = source.split("- name: Summarize compiled target cache", 1)[1]
summary = summary.split("\n - name:", 1)[0]
run_body = summary.split(" run: |", 1)[1]
self.assertNotIn("${{", run_body)
for field in (
"Target cache exact hit",
"Target cache matched key",
"Save key",
"Legacy fallback invoked",
"Legacy fallback outcome",
):
self.assertIn(field, run_body)
def test_legacy_fallback_is_distinct_from_target_cache_hit(self):
source = self.action_text()
legacy = source.split("- name: Recover an existing combined cache during migration", 1)[1]
self.assertIn("id: legacy-fallback", legacy)
self.assertIn(
"if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''",
legacy,
)
self.assertIn("LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }}", source)
self.assertIn("legacy-fallback-invoked=", source)
self.assertIn("Legacy fallback status is separate and is not counted as target exact hit", source)
self.assertNotIn("LEGACY_KEY:", source)
self.assertNotIn("ACTIONS_RUNTIME_TOKEN", source)
self.assertNotIn("GITHUB_TOKEN", source)
@unittest.skipUnless(os.name != "nt" and shutil.which("bash"), "POSIX summary shell")
def test_summary_executes_exact_partial_and_legacy_cache_cases(self):
source = self.action_text().split("- name: Summarize compiled target cache", 1)[1]
script = textwrap.dedent(source.split(" run: |\n", 1)[1].split("\n - name:", 1)[0])
cases = (
("true", "current-key", "false", "skipped", "false"),
("false", "older-compatible-key", "true", "skipped", "false"),
("", "", "true", "success", "true"),
("", "", "true", "failure", "true"),
)
for hit, matched, configured, outcome, invoked in cases:
with self.subTest(hit=hit, matched=matched, outcome=outcome), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
marker = root / "must-not-be-created"
workload = f"$(touch {marker})"
environment = {
"PATH": os.environ["PATH"],
"GITHUB_STEP_SUMMARY": str(root / "summary"),
"GITHUB_OUTPUT": str(root / "outputs"),
"TARGET_CACHE_HIT": hit, "TARGET_CACHE_MATCHED_KEY": matched,
"SAVE_KEY": "new-save-key", "LEGACY_CONFIGURED": configured,
"LEGACY_OUTCOME": outcome, "CACHE_WORKLOAD": workload,
"RUNNER_OS_VALUE": "Linux", "RUNNER_ARCH_VALUE": "X64",
}
result = subprocess.run([shutil.which("bash"), "-c", script], env=environment,
text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 0, result.stderr)
summary = (root / "summary").read_text()
outputs = (root / "outputs").read_text()
self.assertIn(f"Target cache exact hit: `{hit or 'false'}`", summary)
self.assertIn(f"Target cache matched key: `{matched or '<none>'}`", summary)
self.assertIn(f"legacy-fallback-invoked={invoked}\n", outputs)
self.assertIn(f"legacy-fallback-outcome={outcome}\n", outputs)
self.assertIn(workload, summary)
self.assertFalse(marker.exists(), "summary values must never execute as shell code")
@unittest.skipUnless(os.name == "nt", "Windows cache migration") @unittest.skipUnless(os.name == "nt", "Windows cache migration")
def test_windows_migration_preserves_compiler_and_workload_boundaries(self): def test_windows_migration_preserves_compiler_and_workload_boundaries(self):
before = self.identity() before = self.identity()
+25 -1
View File
@@ -16,9 +16,33 @@ class NativeSuiteTests(unittest.TestCase):
workflow = (root / ".github/workflows/linux-lua.yml").read_text() workflow = (root / ".github/workflows/linux-lua.yml").read_text()
lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0] lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0]
self.assertIn("name: lint", lint) self.assertIn("name: lint", lint)
self.assertIn("Test CI contracts before matrix planning", lint)
for contract in (
"scripts.tests.test_ci_plan",
"scripts.tests.test_ci_native_tests",
"scripts.tests.test_stable_release",
"scripts.tests.test_ci_cache",
"scripts.tests.test_platform_cfg",
"scripts.tests.test_pr_size_workflow",
):
self.assertIn(contract, lint)
self.assertIn("python scripts/ci_plan.py", lint) self.assertIn("python scripts/ci_plan.py", lint)
self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint) self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint)
self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py")) self.assertLess(
lint.index("cargo fmt"),
lint.index("Test CI contracts before matrix planning"),
)
self.assertLess(
lint.index("Test CI contracts before matrix planning"),
lint.index("python scripts/ci_plan.py"),
)
self.assertLess(
lint.index("python3 scripts/check_platform_cfg.py"),
lint.index("python scripts/ci_plan.py"),
)
platform_step = lint.split("Check platform cfg budget before native jobs", 1)[1].split(" - name:", 1)[0]
self.assertNotIn("continue-on-error", platform_step)
self.assertNotIn("--update", platform_step)
for job, output in (("native-tests", "native_matrix"), for job, output in (("native-tests", "native_matrix"),
("macos-release-check", "release_matrix")): ("macos-release-check", "release_matrix")):
body = workflow.split(f"\n {job}:\n", 1)[1] body = workflow.split(f"\n {job}:\n", 1)[1]
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import json
from pathlib import Path
import shutil
import subprocess
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[2]
RUNNER = r'''
const fs = require('node:fs');
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const writes = [], failures = [], summaries = [];
const listFiles = () => {}, listComments = () => {};
const github = {
rest: {
pulls: {listFiles},
issues: {
listComments,
addLabels: async (value) => writes.push(['add', value]),
removeLabel: async (value) => writes.push(['remove', value]),
createComment: async (value) => writes.push(['comment', value]),
},
},
paginate: async (method) => method === listFiles ? input.files : [],
};
const summary = {addRaw(value) {summaries.push(value); return this;}, async write() {}};
const core = {info() {}, setFailed(value) {failures.push(value);}, summary};
const context = {
repo: {owner: 'base', repo: 'project'},
payload: {pull_request: {
number: 1, labels: input.labels,
head: {repo: input.deleted ? null : {full_name: input.fork ? 'fork/project' : 'base/project'}},
}},
};
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
new AsyncFunction('github', 'context', 'core', input.script)(github, context, core)
.then(() => process.stdout.write(JSON.stringify({writes, failures, summaries})))
.catch((error) => {console.error(error); process.exit(1);});
'''
class PrSizeWorkflowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.node = shutil.which('node')
if cls.node is None:
raise RuntimeError('Node.js is required for the PR size workflow contract')
workflow = (ROOT / '.github/workflows/pr-size.yml').read_text(encoding='utf-8')
# Execute the actual inline action, without duplicating its policy.
cls.script = textwrap.dedent(workflow.split(' script: |\n', 1)[1])
def run_size(self, lines, *, fork=True, exempt=False, deleted=False):
data = {
'script': self.script, 'fork': fork, 'deleted': deleted,
'labels': [{'name': 'size-exempt'}] if exempt else [],
'files': [
{'filename': 'src/example.rs', 'additions': lines, 'deletions': 0},
{'filename': 'Cargo.lock', 'additions': 9000, 'deletions': 500},
{'filename': 'docs/guide.md', 'additions': 9000, 'deletions': 500},
],
}
result = subprocess.run(
[self.node, '-e', RUNNER], input=json.dumps(data), text=True,
encoding='utf-8', capture_output=True, check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)
def test_small_fork_passes_without_write_permissions(self):
result = self.run_size(127)
self.assertEqual(result['failures'], [])
self.assertEqual(result['writes'], [])
self.assertIn('127 counted lines', result['summaries'][0])
def test_large_fork_is_rejected_before_optional_writes(self):
result = self.run_size(1501)
self.assertEqual(len(result['failures']), 1)
self.assertIn('1501', result['failures'][0])
self.assertEqual(result['writes'], [])
def test_exact_limit_and_existing_maintainer_exemption(self):
self.assertEqual(self.run_size(1500)['failures'], [])
self.assertEqual(self.run_size(1501, exempt=True)['failures'], [])
def test_same_repository_keeps_labels_and_oversize_guidance(self):
small = self.run_size(127, fork=False)
self.assertEqual(small['failures'], [])
self.assertEqual(small['writes'][0][1]['labels'], ['size/M'])
large = self.run_size(1501, fork=False)
self.assertEqual(len(large['failures']), 1)
self.assertEqual(large['writes'][0][1]['labels'], ['size/XL'])
self.assertEqual(large['writes'][1][0], 'comment')
def test_deleted_fork_can_still_be_counted_without_writes(self):
result = self.run_size(127, deleted=True)
self.assertEqual(result['failures'], [])
self.assertEqual(result['writes'], [])
if __name__ == '__main__':
unittest.main()
+345
View File
@@ -0,0 +1,345 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from scripts import check_prohibited_names
ROOT = Path(__file__).resolve().parents[2]
CHECKER = ROOT / "scripts" / "check_prohibited_names.py"
FIXTURES = ROOT / "scripts" / "tests" / "fixtures" / "prohibited_names"
def run_git(repository: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repository, check=True, stdout=subprocess.DEVNULL)
class ProhibitedNamesTests(unittest.TestCase):
def check_staged(self, repository: Path):
return subprocess.run(
[sys.executable, str(CHECKER), "staged"], cwd=repository,
text=True, encoding="utf-8", capture_output=True,
env={**os.environ, "PYTHONUTF8": "1"},
)
def test_invalid_utf8_path_cannot_turn_into_an_empty_diff(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
run_git(repository, "config", "user.name", "CI test")
run_git(repository, "config", "user.email", "ci@example.invalid")
# Git trees can contain these bytes even when the host filesystem
# cannot. Never require a checkout or create an invalid OS filename.
def write_object(kind, contents):
return subprocess.check_output(
["git", "hash-object", "-w", "-t", kind, "--stdin"],
input=contents, cwd=repository,
).decode("ascii").strip()
empty_tree = write_object("tree", b"")
base = subprocess.check_output(
["git", "commit-tree", empty_tree], input=b"baseline\n", cwd=repository,
).decode("ascii").strip()
blob = write_object("blob", b"Ghostty\n")
tree = write_object("tree", b"100644 bad-\xff.rs\0" + bytes.fromhex(blob))
head = subprocess.check_output(
["git", "commit-tree", tree, "-p", base],
input=b"invalid path fixture\n", cwd=repository,
).decode("ascii").strip()
result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
cwd=repository, text=True, encoding="utf-8", capture_output=True,
env={**os.environ, "PYTHONUTF8": "1"},
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("utf-8", result.stderr.lower())
def test_invalid_utf8_text_and_message_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
path = repository / "source.rs"
path.write_bytes(b"safe \xff\n")
run_git(repository, "add", "source.rs")
staged = self.check_staged(repository)
message = subprocess.run(
[sys.executable, str(CHECKER), "message", str(path)], cwd=repository,
text=True, encoding="utf-8", capture_output=True,
env={**os.environ, "PYTHONUTF8": "1"},
)
for result in (staged, message):
self.assertNotEqual(result.returncode, 0)
self.assertIn("utf-8", result.stderr.lower())
def test_valid_unicode_and_control_character_paths_are_scanned(self) -> None:
names = ["路径.rs"]
if os.name != "nt":
names.append("control\tline\n.rs")
for name in names:
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
path = repository / name
path.write_text("safe\n", encoding="utf-8")
run_git(repository, "add", "--", name)
self.assertEqual(self.check_staged(repository).returncode, 0)
path.write_text("Ghostty\n", encoding="utf-8")
run_git(repository, "add", "--", name)
result = self.check_staged(repository)
self.assertEqual(result.returncode, 1)
self.assertIn("Ghostty", result.stderr)
def test_names_do_not_match_inside_unrelated_words(self) -> None:
self.assertFalse(check_prohibited_names.prohibited("spotty network; tty72; netcatty_adapter"))
self.assertTrue(check_prohibited_names.prohibited("Use Otty or TTY-7"))
def test_ordinary_competitor_comparison_is_rejected(self) -> None:
text = (FIXTURES / "ordinary-comparison.md").read_text(encoding="utf-8")
self.assertTrue(check_prohibited_names.prohibited_source_line("comparison.md", text))
def test_inline_legal_attribution_is_allowed_but_comparison_is_not(self) -> None:
text = (FIXTURES / "legal-notice.txt").read_text(encoding="utf-8")
self.assertFalse(check_prohibited_names.prohibited_source_line("source.rs", text))
self.assertTrue(
check_prohibited_names.prohibited_source_line("README.md", "Compare with Alacritty.")
)
self.assertTrue(
check_prohibited_names.prohibited_source_line(
"source.rs", "Copyright (c) Alacritty contributors; compare with Ghostty."
)
)
def test_protocol_identifiers_are_allowed(self) -> None:
text = (FIXTURES / "protocol.rs").read_text(encoding="utf-8")
self.assertFalse(check_prohibited_names.prohibited_source_line("src/protocol.rs", text))
def test_github_cli_text_is_not_a_competitor_name(self) -> None:
command = (FIXTURES / "github-cli.sh").read_text(encoding="utf-8")
documentation = (FIXTURES / "github-cli-docs.sh").read_text(encoding="utf-8")
self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", command))
self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", documentation))
def test_real_cargo_git_dependency_is_allowed_but_readme_reference_is_not(self) -> None:
dependency = 'gpui = { git = "https://github.com/ghostty/ghostty", rev = "deadbeef" }'
self.assertFalse(check_prohibited_names.prohibited_source_line("Cargo.toml", dependency))
self.assertTrue(check_prohibited_names.prohibited_source_line("README.md", dependency))
def test_staged_hook_configuration_is_not_exempt(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
(repository / ".pre-commit-config.yaml").write_text(
"# Compare with Ghostty.\nrepos: []\n", encoding="utf-8"
)
run_git(repository, "add", ".pre-commit-config.yaml")
result = subprocess.run(
[sys.executable, str(CHECKER), "staged"], cwd=repository,
text=True, capture_output=True,
)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(".pre-commit-config.yaml", result.stderr)
def test_theme_format_message_does_not_exempt_an_appended_comparison(self) -> None:
message = (
"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, "
"Kitty, Ghostty, WezTerm or Alacritty"
)
self.assertFalse(check_prohibited_names.prohibited_source_line("src/theme.rs", message))
self.assertTrue(check_prohibited_names.prohibited_source_line(
"src/theme.rs", message + "; copied from Ghostty"
))
def test_range_scans_added_text_and_new_commit_messages(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
run_git(repository, "config", "user.name", "CI test")
run_git(repository, "config", "user.email", "ci@example.invalid")
(repository / "README.md").write_text("safe\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "baseline")
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
(repository / "comparison.md").write_text("Compare with Ghostty.\n", encoding="utf-8")
run_git(repository, "add", "comparison.md")
run_git(repository, "commit", "-qm", "ordinary comparison")
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
cwd=repository,
text=True,
capture_output=True,
env={**os.environ, "PYTHONUTF8": "1"},
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Ghostty", result.stderr)
def test_range_scans_a_commit_message_even_when_text_is_legal(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
run_git(repository, "config", "user.name", "CI test")
run_git(repository, "config", "user.email", "ci@example.invalid")
(repository / "README.md").write_text("safe\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "baseline")
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
(repository / "README.md").write_text("still safe\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "Compare with Ghostty")
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("commit-message", result.stderr)
def test_range_uses_merge_base_and_keeps_added_then_deleted_text(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
run_git(repository, "config", "user.name", "CI test")
run_git(repository, "config", "user.email", "ci@example.invalid")
(repository / "README.md").write_text("Historical Ghostty attribution\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "baseline")
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
run_git(repository, "branch", "main")
run_git(repository, "switch", "main")
(repository / "README.md").write_text("safe on main\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "advance main")
run_git(repository, "switch", "-c", "feature", base)
(repository / "README.md").write_text("safe on feature\n", encoding="utf-8")
run_git(repository, "add", "README.md")
run_git(repository, "commit", "-qm", "advance feature")
clean_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
clean = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", "main", "--head", clean_head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertEqual(clean.returncode, 0, clean.stderr)
(repository / "temporary.md").write_text("Ghostty\n", encoding="utf-8")
run_git(repository, "add", "temporary.md")
run_git(repository, "commit", "-qm", "temporary comparison")
(repository / "temporary.md").unlink()
run_git(repository, "add", "-u")
run_git(repository, "commit", "-qm", "remove temporary comparison")
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("temporary.md", result.stderr)
unrelated_tree = subprocess.check_output(["git", "write-tree"], cwd=repository, text=True).strip()
unrelated = subprocess.check_output(
["git", "commit-tree", unrelated_tree, "-m", "unrelated"],
cwd=repository,
text=True,
).strip()
unrelated_result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", unrelated, "--head", clean_head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(unrelated_result.returncode, 0)
self.assertIn("cannot scan prohibited-name range", unrelated_result.stderr)
def test_range_scans_merge_resolution_without_replaying_second_parent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
run_git(repository, "config", "user.name", "CI test")
run_git(repository, "config", "user.email", "ci@example.invalid")
(repository / "shared.md").write_text("base\n", encoding="utf-8")
run_git(repository, "add", "shared.md")
run_git(repository, "commit", "-qm", "baseline")
run_git(repository, "branch", "main")
run_git(repository, "switch", "-c", "feature")
(repository / "shared.md").write_text("feature\n", encoding="utf-8")
run_git(repository, "add", "shared.md")
run_git(repository, "commit", "-qm", "feature change")
feature_parent = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
run_git(repository, "switch", "main")
(repository / "shared.md").write_text("main Ghostty\n", encoding="utf-8")
run_git(repository, "add", "shared.md")
run_git(repository, "commit", "-qm", "main change")
main_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
run_git(repository, "switch", "feature")
merge = subprocess.run(
["git", "merge", "--no-ff", "main", "-m", "merge main"],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(merge.returncode, 0, merge.stdout + merge.stderr)
(repository / "shared.md").write_text("resolved safe\n", encoding="utf-8")
run_git(repository, "add", "shared.md")
run_git(repository, "commit", "-qm", "resolve safe merge")
safe_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
safe_result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", main_head, "--head", safe_head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertEqual(safe_result.returncode, 0, safe_result.stderr)
run_git(repository, "switch", "-c", "feature-bad", feature_parent)
merge_bad = subprocess.run(
["git", "merge", "--no-ff", "main", "-m", "merge main with bad resolution"],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(merge_bad.returncode, 0, merge_bad.stdout + merge_bad.stderr)
(repository / "shared.md").write_text("resolved Ghostty\n", encoding="utf-8")
run_git(repository, "add", "shared.md")
run_git(repository, "commit", "-qm", "resolve bad merge")
bad_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
bad_result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", main_head, "--head", bad_head],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(bad_result.returncode, 0)
self.assertIn("shared.md", bad_result.stderr)
def test_range_fails_closed_for_an_unknown_base(self) -> None:
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory)
run_git(repository, "init", "-q")
result = subprocess.run(
[sys.executable, str(CHECKER), "range", "--base", "missing", "--head", "HEAD"],
cwd=repository,
text=True,
capture_output=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("cannot scan prohibited-name range", result.stderr)
if __name__ == "__main__":
unittest.main()