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:
21 files changed
+1258
-70
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.`);
|
|
||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 @@
|
|||||||
484
|
481
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
printf '%s\n' 'gh api repos/example/project'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gh api repos/Kuddev/pebrel
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Copyright 2020 The Alacritty Project
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pebrel is faster than Ghostty.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const KITTY_KEYBOARD_PROTOCOL: bool = true;
|
||||||
|
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
|
||||||
|
const FORMAT = ThemeFormat::Kitty;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user