fix: make MCP bootstrap client-neutral by default Owner integration: preserve all prior CI contracts, document explicit host selection on Windows, and make the Bash manifest regression UTF-8 deterministic.
334 lines
13 KiB
YAML
334 lines
13 KiB
YAML
# reverse-skill CI:路由回归 + 结构一致性 + 供应链 pin gate + 冒烟
|
||
# 矩阵:windows-latest(原生 powershell 5.1) + ubuntu-latest(pwsh + powershell shim)
|
||
# 触发:所有分支(含 fork 的改进分支),PR 也触发
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
pull_request:
|
||
|
||
jobs:
|
||
routing-tests:
|
||
name: routing tests (${{ matrix.os }})
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
os: [windows-latest, ubuntu-latest]
|
||
runs-on: ${{ matrix.os }}
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
# 脚本内部以 `powershell` 调用子进程;Linux runner 只有 pwsh,做个 shim
|
||
- name: powershell shim (linux)
|
||
if: runner.os == 'Linux'
|
||
shell: bash
|
||
run: sudo ln -sf "$(command -v pwsh)" /usr/local/bin/powershell
|
||
|
||
- name: Routing regression (benchmark)
|
||
shell: pwsh
|
||
run: ./skills/scripts/test-routing.ps1
|
||
|
||
- name: Routing coherence + supply-chain pin gate
|
||
shell: pwsh
|
||
run: ./skills/scripts/verify-routing-coherence.ps1
|
||
|
||
- name: Bootstrap supply-chain regression
|
||
shell: pwsh
|
||
run: ./skills/scripts/test-bootstrap-supply-chain.ps1
|
||
|
||
- name: Bootstrap supply-chain regression (Windows PowerShell 5.1)
|
||
if: runner.os == 'Windows'
|
||
shell: powershell
|
||
run: ./skills/scripts/test-bootstrap-supply-chain.ps1
|
||
|
||
- name: Parse contracts (route-scope + IDA lock)
|
||
shell: pwsh
|
||
run: ./skills/scripts/test-parse-contracts.ps1
|
||
|
||
- name: Journal PR title safety
|
||
shell: pwsh
|
||
run: ./skills/scripts/test-workflow-title-safety.ps1
|
||
|
||
- name: Client-neutral bootstrap/discovery (Windows PowerShell 5.1)
|
||
if: runner.os == 'Windows'
|
||
shell: powershell
|
||
run: ./skills/scripts/test-client-neutral-bootstrap.ps1
|
||
|
||
- name: Offline sample case contract (Windows PowerShell 5.1)
|
||
if: runner.os == 'Windows'
|
||
shell: powershell
|
||
run: |
|
||
$scratch = Join-Path $env:RUNNER_TEMP ("reverse-skill-offline-" + [guid]::NewGuid().ToString('n'))
|
||
New-Item -ItemType Directory -Force -Path $scratch | Out-Null
|
||
$sample = Join-Path $scratch 'sample.apk'
|
||
Set-Content -Path $sample -Value 'fixture' -Encoding ASCII
|
||
|
||
./skills/scripts/case-init.ps1 `
|
||
-Hint "offline apk" `
|
||
-CaseName "offline-sample" `
|
||
-ProjectRoot $scratch `
|
||
-Preset offline-sample `
|
||
-Sample $sample
|
||
$scope = Join-Path $scratch 'work/offline-sample/scope.md'
|
||
$raw = Get-Content $scope -Raw
|
||
if ($raw -notmatch '(?m)^- mode: offline\r?$') { throw 'offline sample did not keep offline network mode' }
|
||
if ($raw -notmatch '(?m)^- ready_for_act: true\r?$') { throw 'offline sample did not become ready_for_act' }
|
||
./skills/scripts/case-guard.ps1 -CaseRoot (Join-Path $scratch 'work/offline-sample')
|
||
|
||
try {
|
||
./skills/scripts/case-init.ps1 `
|
||
-Hint "missing offline apk" `
|
||
-CaseName "missing-sample" `
|
||
-ProjectRoot $scratch `
|
||
-Preset offline-sample `
|
||
-Sample (Join-Path $scratch 'missing.apk')
|
||
throw 'missing offline sample was accepted'
|
||
} catch {
|
||
if ($_.Exception.Message -eq 'missing offline sample was accepted') { throw }
|
||
}
|
||
|
||
./skills/scripts/case-init.ps1 `
|
||
-Hint "pending offline apk" `
|
||
-CaseName "force-auth" `
|
||
-ProjectRoot $scratch `
|
||
-Sample $sample
|
||
& powershell -NoProfile -ExecutionPolicy Bypass -File ./skills/scripts/case-guard.ps1 `
|
||
-CaseRoot (Join-Path $scratch 'work/force-auth') `
|
||
-Force
|
||
$guardExit = $LASTEXITCODE
|
||
if ($guardExit -eq 0) { throw '-Force bypassed auth.status hard gate' }
|
||
$global:LASTEXITCODE = 0
|
||
|
||
- name: Smoke (verify + parse + quick route)
|
||
shell: pwsh
|
||
run: ./skills/scripts/smoke.ps1
|
||
|
||
- name: INDEX.md up-to-date check
|
||
shell: pwsh
|
||
run: ./skills/scripts/extract-summaries.ps1 -Check
|
||
|
||
- name: All JSON manifests valid
|
||
shell: pwsh
|
||
run: |
|
||
Get-Content skills/scripts/bootstrap-manifest.json -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||
Get-Content kali/scripts/bootstrap-manifest.json -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||
Get-Content skills/config/routing.json -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||
Get-Content skills/tests/routing-benchmark.json -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||
Write-Host "All JSON valid"
|
||
|
||
- name: Doc fact tables vs manifest (verify-doc-facts)
|
||
shell: pwsh
|
||
run: ./skills/scripts/verify-doc-facts.ps1
|
||
|
||
sh-syntax:
|
||
name: shell script syntax check
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
- name: bash -n all .sh
|
||
shell: bash
|
||
run: |
|
||
set -e
|
||
while IFS= read -r f; do
|
||
bash -n "$f"
|
||
echo "syntax OK: $f"
|
||
done < <(git ls-files '*.sh')
|
||
|
||
- name: Client-neutral bootstrap/discovery (Bash)
|
||
shell: bash
|
||
run: bash skills/scripts/test-client-neutral-bootstrap.sh
|
||
|
||
- name: Structured routing parity (Bash)
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
bash skills/scripts/test-routing.sh
|
||
bash skills/scripts/test-bootstrap-manifest.sh
|
||
|
||
scratch="$(mktemp -d)"
|
||
trap 'rm -rf "$scratch"' EXIT
|
||
|
||
# Fresh Linux journey: no pwsh required, artifacts stay in caller project.
|
||
caller="$scratch/caller-project"
|
||
mkdir -p "$caller"
|
||
printf 'fixture' > "$scratch/sample.apk"
|
||
(
|
||
cd "$caller"
|
||
bash "$GITHUB_WORKSPACE/skills/scripts/master-route.sh" --hint "offline apk"
|
||
bash "$GITHUB_WORKSPACE/skills/scripts/case-init.sh" \
|
||
--hint "offline apk" \
|
||
--case-name "caller-default" \
|
||
--preset offline-sample \
|
||
--sample "$scratch/sample.apk"
|
||
)
|
||
test -f "$caller/work/caller-default/scope.md"
|
||
grep -Eq '^- project_root: .*/caller-project$' "$caller/work/caller-default/scope.md"
|
||
grep -Eq '^- mode: offline$' "$caller/work/caller-default/scope.md"
|
||
grep -Eq '^- ready_for_act: true$' "$caller/work/caller-default/scope.md"
|
||
bash skills/scripts/case-guard.sh --case-root "$caller/work/caller-default"
|
||
test ! -e "$GITHUB_WORKSPACE/work/caller-default"
|
||
|
||
if bash skills/scripts/case-init.sh \
|
||
--hint "missing offline apk" \
|
||
--case-name "missing-sample" \
|
||
--project-root "$caller" \
|
||
--preset offline-sample \
|
||
--sample "$scratch/missing.apk"; then
|
||
echo "missing offline sample was accepted" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Compatibility: legacy --package-root still pins the work root.
|
||
bash skills/scripts/case-init.sh \
|
||
--hint "authorized web review" \
|
||
--case-name "network-default" \
|
||
--package-root "$scratch/project" \
|
||
--auth-granted \
|
||
--target-url "https://example.test/"
|
||
grep -Eq '^- mode: authorized_target_only$' "$scratch/project/work/network-default/scope.md"
|
||
grep -Eq '^- ready_for_act: true$' "$scratch/project/work/network-default/scope.md"
|
||
bash skills/scripts/case-guard.sh --case-root "$scratch/project/work/network-default"
|
||
|
||
if bash skills/scripts/case-init.sh \
|
||
--hint "offline apk" \
|
||
--case-name "../case-escape" \
|
||
--package-root "$scratch/project" \
|
||
--preset offline-sample \
|
||
--sample "$scratch/sample.apk"; then
|
||
echo "case-init accepted an unsafe case name" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if bash skills/scripts/case-init.sh \
|
||
--hint "authorized web review" \
|
||
--case-name "invalid-network" \
|
||
--package-root "$scratch/project" \
|
||
--auth-granted \
|
||
--network-profile "internet" \
|
||
--target-url "https://example.test/"; then
|
||
echo "case-init accepted an unsupported network profile" >&2
|
||
exit 1
|
||
fi
|
||
|
||
bash skills/scripts/case-init.sh \
|
||
--hint "authorized web review" \
|
||
--case-name "uppercase-network" \
|
||
--package-root "$scratch/project" \
|
||
--auth-granted \
|
||
--network-profile "AUTHORIZED_TARGET_ONLY" \
|
||
--target-url "https://example.test/"
|
||
grep -Eq '^- mode: authorized_target_only$' "$scratch/project/work/uppercase-network/scope.md"
|
||
|
||
bash skills/scripts/case-init.sh \
|
||
--hint "pending review" \
|
||
--case-name "guard-section" \
|
||
--package-root "$scratch/project" \
|
||
--target-url "https://example.test/"
|
||
cat >> "$scratch/project/work/guard-section/scope.md" <<'FAKE_FIELDS'
|
||
|
||
## notes
|
||
- status: granted
|
||
- mode: authorized_target_only
|
||
- ready_for_act: true
|
||
FAKE_FIELDS
|
||
if bash skills/scripts/case-guard.sh --case-root "$scratch/project/work/guard-section"; then
|
||
echo "case-guard accepted fields outside their contract sections" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# --force is compatibility-only and must not bypass the hard auth gate.
|
||
(
|
||
cd "$caller"
|
||
bash "$GITHUB_WORKSPACE/skills/scripts/case-init.sh" \
|
||
--hint "pending offline apk" \
|
||
--case-name "force-auth" \
|
||
--sample "$scratch/sample.apk"
|
||
)
|
||
if bash skills/scripts/case-guard.sh --case-root "$caller/work/force-auth" --force; then
|
||
echo "case-guard --force bypassed auth.status hard gate" >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: PowerShell syntax check (PSParser, all tracked .ps1)
|
||
shell: pwsh
|
||
run: |
|
||
$files = git ls-files '*.ps1'
|
||
if (-not $files) { Write-Host 'No .ps1 files tracked'; exit 0 }
|
||
$failed = 0
|
||
foreach ($f in $files) {
|
||
$tokens = $null
|
||
$errors = $null
|
||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f), [ref]$tokens, [ref]$errors) | Out-Null
|
||
if ($errors -and $errors.Count -gt 0) {
|
||
$failed++
|
||
Write-Host "::error file=$f::$($errors.Count) parse error(s)"
|
||
foreach ($e in $errors) {
|
||
Write-Host (" L{0}:{1} {2}" -f $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber, $e.Message)
|
||
}
|
||
} else {
|
||
Write-Host "syntax OK: $f"
|
||
}
|
||
}
|
||
if ($failed -gt 0) { exit 1 }
|
||
|
||
- name: Non-ASCII .ps1 must carry a UTF-8 BOM (Windows PowerShell 5.1 safety)
|
||
shell: pwsh
|
||
run: |
|
||
# PS 5.1 parses a BOM-less .ps1 as the system ANSI codepage, garbling
|
||
# Chinese/em-dash string literals. Every non-ASCII script MUST have a BOM.
|
||
$files = git ls-files '*.ps1'
|
||
if (-not $files) { Write-Host 'No .ps1 files tracked'; exit 0 }
|
||
$bad = 0
|
||
foreach ($f in $files) {
|
||
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $f))
|
||
$hasNonAscii = $false
|
||
foreach ($b in $bytes) { if ($b -ge 0x80) { $hasNonAscii = $true; break } }
|
||
if (-not $hasNonAscii) { continue }
|
||
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
|
||
if ($hasBom) {
|
||
Write-Host "BOM OK: $f"
|
||
} else {
|
||
$bad++
|
||
Write-Host "::error file=$f::non-ASCII .ps1 lacks a UTF-8 BOM; add one so literals survive Windows PowerShell 5.1"
|
||
}
|
||
}
|
||
if ($bad -gt 0) { Write-Host "$bad file(s) need a UTF-8 BOM"; exit 1 }
|
||
Write-Host 'All non-ASCII .ps1 carry a UTF-8 BOM'
|
||
|
||
leak-scan:
|
||
name: field-journal leak scan
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
- name: Scan field-journal for un-anonymized secrets
|
||
shell: pwsh
|
||
run: ./skills/scripts/scan-leaks.ps1 -Path skills/field-journal
|
||
|
||
case-contract:
|
||
name: case contract test (ctf-demo)
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
- name: Review examples/ctf-demo under strict contract
|
||
shell: bash
|
||
run: python3 skills/case-review/scripts/review_case.py examples/ctf-demo --verify-hashes --strict
|
||
|
||
version-check:
|
||
name: version consistency
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
- name: VERSION matches latest CHANGELOG release
|
||
shell: pwsh
|
||
run: |
|
||
$v = (Get-Content VERSION -Raw).Trim()
|
||
$cl = Get-Content CHANGELOG.md -Raw
|
||
$m = [regex]::Match($cl, '(?m)^## \[(\d+\.\d+\.\d+)\]')
|
||
if (-not $m.Success) { Write-Error 'No version header found in CHANGELOG'; exit 1 }
|
||
$latest = $m.Groups[1].Value
|
||
if ($v -ne $latest) {
|
||
Write-Error "VERSION ($v) does not match latest CHANGELOG release ($latest)"
|
||
exit 1
|
||
}
|
||
Write-Host "Version OK: $v"
|