Merge pull request #654 from h4ckf0r0day/ci/harden-pr-checks
ci: harden pull request validation
This commit is contained in:
8
.github/CODEOWNERS
vendored
Normal file
8
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Repository maintainers.
|
||||
* @SGavrl @h4ckf0r0day
|
||||
|
||||
# Keep security-sensitive automation explicit even if more owners are added later.
|
||||
/.github/CODEOWNERS @SGavrl @h4ckf0r0day
|
||||
/.github/workflows/ @SGavrl @h4ckf0r0day
|
||||
/scripts/ci/ @SGavrl @h4ckf0r0day
|
||||
/deny.toml @SGavrl @h4ckf0r0day
|
||||
277
.github/workflows/ci.yml
vendored
Normal file
277
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
name: PR checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Pull requests execute contributor-controlled build scripts, proc macros,
|
||||
# tests, and binaries. They must never receive a write token or repository
|
||||
# secrets. Publishing remains isolated in the tag-only release workflows.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TERM_COLOR: always
|
||||
OPENSSL_NO_VENDOR: "1"
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
name: PR / Policy
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
base-sha: ${{ steps.base.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout merge result
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve trusted base revision
|
||||
id: base
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_sha="$EVENT_BASE_SHA"
|
||||
if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]] || [[ "$base_sha" == "0000000000000000000000000000000000000000" ]]; then
|
||||
echo "GitHub did not provide a valid base revision" >&2
|
||||
exit 1
|
||||
fi
|
||||
git cat-file -e "${base_sha}^{commit}"
|
||||
|
||||
policy_root="$RUNNER_TEMP/obscura-policy"
|
||||
mkdir -p "$policy_root"
|
||||
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
|
||||
install -m 0644 scripts/ci/pr_policy.py "$policy_root/pr_policy.py"
|
||||
install -m 0644 deny.toml "$policy_root/deny.toml"
|
||||
else
|
||||
git show "$base_sha:scripts/ci/pr_policy.py" > "$policy_root/pr_policy.py"
|
||||
git show "$base_sha:deny.toml" > "$policy_root/deny.toml"
|
||||
fi
|
||||
|
||||
# Container actions can only read paths mounted below the workspace.
|
||||
# Replace any PR-supplied file before exposing the trusted config.
|
||||
trusted_deny="$GITHUB_WORKSPACE/.github/.trusted-deny.toml"
|
||||
rm -f -- "$trusted_deny"
|
||||
install -m 0644 "$policy_root/deny.toml" "$trusted_deny"
|
||||
printf 'sha=%s\n' "$base_sha" >> "$GITHUB_OUTPUT"
|
||||
printf 'policy=%s\n' "$policy_root/pr_policy.py" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check PR policy
|
||||
env:
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: python3 "${{ steps.base.outputs.policy }}" --base "$BASE_SHA" --head "$GITHUB_SHA"
|
||||
|
||||
- name: Check dependency advisories, licenses, bans, and sources
|
||||
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
command-arguments: --config .github/.trusted-deny.toml advisories bans licenses sources
|
||||
|
||||
core:
|
||||
name: PR / Core
|
||||
needs: policy
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout merge result
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake libclang-dev libssl-dev llvm-dev pkg-config
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Install cargo-nextest
|
||||
uses: taiki-e/install-action@b20dedce73af6905cdc30d6611090c9b67557c8d # v2
|
||||
with:
|
||||
tool: nextest
|
||||
fallback: none
|
||||
|
||||
- name: Restore Rust dependency cache
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
# PR caches are restored but never saved. A contributor therefore
|
||||
# cannot poison a cache later consumed by main or a release.
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build render release
|
||||
run: cargo build --release -p obscura-cli --bins --features render
|
||||
|
||||
- name: Test render configuration
|
||||
run: cargo nextest run --release --features render --no-fail-fast
|
||||
|
||||
- name: Stage candidate binary for comparison
|
||||
if: github.event_name != 'push'
|
||||
run: install -D -m 0755 target/release/obscura "$RUNNER_TEMP/obscura-ci/obscura-pr"
|
||||
|
||||
- name: Build render and stealth release
|
||||
run: cargo build --release -p obscura-cli --bins --features render,stealth
|
||||
|
||||
- name: Test the stealth transport
|
||||
run: |
|
||||
# The gzip fixture binds loopback. Keep the ordinary suite under the
|
||||
# default SSRF policy, then opt in only for that single focused test.
|
||||
cargo nextest run --release -p obscura-net --features stealth \
|
||||
--no-fail-fast -- --skip stealth_client_decodes_gzip_response
|
||||
OBSCURA_ALLOW_PRIVATE_NETWORK=1 cargo nextest run --release \
|
||||
-p obscura-net --features stealth \
|
||||
stealth_client_decodes_gzip_response --no-fail-fast
|
||||
|
||||
- name: Build no-render release
|
||||
run: cargo build --release -p obscura-cli --bins --no-default-features
|
||||
|
||||
- name: Test no-render configuration
|
||||
run: |
|
||||
# `obscura` uses its default `api` feature for the public library; an
|
||||
# empty feature set is not a supported crate configuration. Test that
|
||||
# no-render API normally and every non-render crate with defaults
|
||||
# disabled. `obscura-render` has paint-only test fixtures, so verify
|
||||
# its feature-minimal library separately without compiling those
|
||||
# fixtures.
|
||||
cargo nextest run --release -p obscura --no-fail-fast
|
||||
cargo nextest run --release --workspace --exclude obscura \
|
||||
--exclude obscura-render \
|
||||
--no-default-features --no-fail-fast
|
||||
cargo check --release -p obscura-render --no-default-features
|
||||
|
||||
- name: Build no-render and stealth release
|
||||
run: cargo build --release -p obscura-cli --bins --no-default-features --features stealth
|
||||
|
||||
- name: Upload candidate binary
|
||||
if: github.event_name != 'push'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: obscura-pr-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/obscura-ci/obscura-pr
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
performance:
|
||||
name: PR / Performance
|
||||
if: github.event_name != 'push'
|
||||
needs: [policy, core]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout merge result
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout pinned obstacle course
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
repository: h4ckf0r0day/obscura-benchmark
|
||||
ref: 6ebac8293d7477f59e837768bfd4e74173f04f1c
|
||||
path: obscura-benchmark
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download candidate binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: obscura-pr-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/obscura-ci
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake libclang-dev libssl-dev llvm-dev pkg-config time
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Restore Rust dependency cache
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
save-if: false
|
||||
|
||||
- name: Build base revision
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ needs.policy.outputs.base-sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_root="$RUNNER_TEMP/obscura-base"
|
||||
script_root="$RUNNER_TEMP/obscura-ci-scripts"
|
||||
git worktree add --detach "$base_root" "$BASE_SHA"
|
||||
mkdir -p "$script_root"
|
||||
git show "$BASE_SHA:scripts/ci/perf_smoke.py" > "$script_root/perf_smoke.py"
|
||||
git show "$BASE_SHA:scripts/ci/compare_obstacle.py" > "$script_root/compare_obstacle.py"
|
||||
# Run from the trusted worktree so a PR-supplied .cargo/config.toml in
|
||||
# the merge checkout cannot alter the compiler or wrapper for the base.
|
||||
(
|
||||
cd "$base_root"
|
||||
CARGO_TARGET_DIR="$RUNNER_TEMP/obscura-base-target" \
|
||||
cargo build --release -p obscura-cli --bins --features render
|
||||
)
|
||||
install -D -m 0755 \
|
||||
"$RUNNER_TEMP/obscura-base-target/release/obscura" \
|
||||
"$RUNNER_TEMP/obscura-ci/obscura-base"
|
||||
chmod 0755 "$RUNNER_TEMP/obscura-ci/obscura-pr"
|
||||
|
||||
- name: Run base and candidate obstacle courses
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
results_root="$RUNNER_TEMP/obscura-ci-results"
|
||||
mkdir -p "$results_root"
|
||||
set +e
|
||||
OBSCURA_BIN="$RUNNER_TEMP/obscura-ci/obscura-base" \
|
||||
python3 obscura-benchmark/obstacle-course/run.py \
|
||||
--json --runs 3 --warmup 1 > "$results_root/obstacle-base.json"
|
||||
base_status=$?
|
||||
OBSCURA_BIN="$RUNNER_TEMP/obscura-ci/obscura-pr" \
|
||||
python3 obscura-benchmark/obstacle-course/run.py \
|
||||
--json --runs 3 --warmup 1 > "$results_root/obstacle-pr.json"
|
||||
pr_status=$?
|
||||
set -e
|
||||
printf 'Obstacle commands exited base=%s candidate=%s\n' "$base_status" "$pr_status"
|
||||
python3 -m json.tool "$results_root/obstacle-base.json" >/dev/null
|
||||
python3 -m json.tool "$results_root/obstacle-pr.json" >/dev/null
|
||||
if [[ "$pr_status" -ne 0 ]]; then
|
||||
echo "Candidate obstacle course exited with status $pr_status" >&2
|
||||
exit "$pr_status"
|
||||
fi
|
||||
|
||||
- name: Run interleaved latency and RSS smoke benchmark
|
||||
run: |
|
||||
python3 "$RUNNER_TEMP/obscura-ci-scripts/perf_smoke.py" \
|
||||
--base "$RUNNER_TEMP/obscura-ci/obscura-base" \
|
||||
--candidate "$RUNNER_TEMP/obscura-ci/obscura-pr" \
|
||||
--output "$RUNNER_TEMP/obscura-ci-results/perf-smoke.json"
|
||||
|
||||
- name: Compare obstacle correctness and latency
|
||||
run: |
|
||||
python3 "$RUNNER_TEMP/obscura-ci-scripts/compare_obstacle.py" \
|
||||
--base "$RUNNER_TEMP/obscura-ci-results/obstacle-base.json" \
|
||||
--candidate "$RUNNER_TEMP/obscura-ci-results/obstacle-pr.json"
|
||||
|
||||
- name: Upload comparison evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: pr-comparison-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/obscura-ci-results
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
12
.github/workflows/docker.yml
vendored
12
.github/workflows/docker.yml
vendored
@@ -12,16 +12,18 @@ jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -44,9 +44,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -179,10 +181,11 @@ jobs:
|
||||
cd ../no-render-stealth
|
||||
7z a ../../${{ matrix.name }}-no-render-stealth.zip obscura.exe obscura-worker.exe
|
||||
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Upload packaged binaries
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
files: |
|
||||
name: release-${{ matrix.name }}
|
||||
path: |
|
||||
${{ matrix.name }}.tar.gz
|
||||
${{ matrix.name }}-stealth.tar.gz
|
||||
${{ matrix.name }}-no-render.tar.gz
|
||||
@@ -191,3 +194,25 @@ jobs:
|
||||
${{ matrix.name }}-stealth.zip
|
||||
${{ matrix.name }}-no-render.zip
|
||||
${{ matrix.name }}-no-render-stealth.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# This job never checks out or executes repository code. The write token is
|
||||
# available only while collecting and publishing the completed artifacts.
|
||||
- name: Download packaged binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: release-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish GitHub release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: dist/*
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -141,9 +141,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-browser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-cdp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "obscura"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-dom"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
html5ever = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-js"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "obscura-net"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
45
deny.toml
Normal file
45
deny.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
# cargo-deny configuration: https://embarkstudios.github.io/cargo-deny/
|
||||
# Run: cargo deny --all-features check --config deny.toml advisories bans licenses sources
|
||||
|
||||
[advisories]
|
||||
# Real vulnerabilities fail the build. The advisories below are *unmaintained*
|
||||
# notices for transitive crates with no available fix; they are explicitly
|
||||
# accepted until the upstream parents drop them.
|
||||
ignore = [
|
||||
"RUSTSEC-2025-0141", # bincode 1.x unmaintained, transitive via deno_core
|
||||
"RUSTSEC-2025-0057", # fxhash unmaintained, transitive via selectors
|
||||
"RUSTSEC-2024-0436", # paste unmaintained, transitive via v8
|
||||
# These rendering dependencies have no safe upgrade yet. Keep their
|
||||
# unmaintained notices explicit so vulnerability advisories still fail.
|
||||
"RUSTSEC-2026-0206", # rustybuzz, transitive via cosmic-text and usvg
|
||||
"RUSTSEC-2026-0192", # ttf-parser, transitive via rendering dependencies
|
||||
]
|
||||
|
||||
[bans]
|
||||
# Duplicate transitive versions are build-time bloat, not a security issue, and
|
||||
# are largely outside our control (deno_core / v8 / reqwest trees). Surface them
|
||||
# without failing the build.
|
||||
multiple-versions = "warn"
|
||||
wildcards = "allow"
|
||||
|
||||
[licenses]
|
||||
# Permissive allow-list enforced by the PR policy job.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"Unicode-3.0",
|
||||
"MPL-2.0",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
121
scripts/ci/compare_obstacle.py
Executable file
121
scripts/ci/compare_obstacle.py
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare obstacle-course correctness and report latency changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_RESULT_BYTES = 10 * 1024 * 1024
|
||||
EXPECTED_STAGE_COUNT = 33
|
||||
|
||||
|
||||
def load_result(path: Path) -> dict:
|
||||
if path.stat().st_size > MAX_RESULT_BYTES:
|
||||
raise ValueError(f"{path} is unexpectedly large")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or not isinstance(value.get("results"), list):
|
||||
raise ValueError(f"{path} is not an obstacle-course result")
|
||||
return value
|
||||
|
||||
|
||||
def result_map(value: dict) -> dict[str, dict]:
|
||||
mapped: dict[str, dict] = {}
|
||||
for result in value["results"]:
|
||||
if not isinstance(result, dict) or not isinstance(result.get("name"), str):
|
||||
raise ValueError("obstacle result has an invalid stage")
|
||||
name = result["name"]
|
||||
if name in mapped:
|
||||
raise ValueError(f"duplicate obstacle stage: {name}")
|
||||
median_ms = result.get("median_ms")
|
||||
if (
|
||||
not isinstance(result.get("pass"), bool)
|
||||
or isinstance(median_ms, bool)
|
||||
or not isinstance(median_ms, (int, float))
|
||||
or not math.isfinite(median_ms)
|
||||
or median_ms < 0
|
||||
):
|
||||
raise ValueError(f"invalid obstacle result for {name}")
|
||||
mapped[name] = result
|
||||
return mapped
|
||||
|
||||
|
||||
def add_summary(markdown: str) -> None:
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
with open(summary, "a", encoding="utf-8") as output:
|
||||
output.write(markdown)
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--candidate", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
base = result_map(load_result(args.base))
|
||||
candidate = result_map(load_result(args.candidate))
|
||||
if len(base) != EXPECTED_STAGE_COUNT:
|
||||
raise SystemExit(
|
||||
f"base obstacle result has {len(base)} stages; expected {EXPECTED_STAGE_COUNT}"
|
||||
)
|
||||
if len(candidate) != EXPECTED_STAGE_COUNT:
|
||||
raise SystemExit(
|
||||
f"candidate obstacle result has {len(candidate)} stages; expected {EXPECTED_STAGE_COUNT}"
|
||||
)
|
||||
if set(base) != set(candidate):
|
||||
missing = sorted(set(base) - set(candidate))
|
||||
extra = sorted(set(candidate) - set(base))
|
||||
raise SystemExit(f"obstacle stage mismatch; missing={missing}, extra={extra}")
|
||||
|
||||
regressions = sorted(name for name in base if base[name]["pass"] and not candidate[name]["pass"])
|
||||
improvements = sorted(name for name in base if not base[name]["pass"] and candidate[name]["pass"])
|
||||
ratios = []
|
||||
slow = []
|
||||
for name in sorted(base):
|
||||
base_ms = float(base[name]["median_ms"])
|
||||
candidate_ms = float(candidate[name]["median_ms"])
|
||||
if base_ms > 0:
|
||||
ratio = candidate_ms / base_ms
|
||||
ratios.append(ratio)
|
||||
if ratio > 1.20 and candidate_ms - base_ms > 10:
|
||||
slow.append((name, base_ms, candidate_ms, ratio))
|
||||
|
||||
base_passed = sum(1 for result in base.values() if result["pass"])
|
||||
candidate_passed = sum(1 for result in candidate.values() if result["pass"])
|
||||
median_ratio = statistics.median(ratios) if ratios else 1.0
|
||||
lines = [
|
||||
"## Obstacle comparison\n",
|
||||
"| Metric | Base | Candidate |\n",
|
||||
"| --- | ---: | ---: |\n",
|
||||
f"| Correct stages | {base_passed}/{len(base)} | {candidate_passed}/{len(candidate)} |\n",
|
||||
f"| Median per-stage latency ratio | 1.00x | {median_ratio:.2f}x |\n",
|
||||
]
|
||||
if improvements:
|
||||
lines.append(f"\nImproved stages: {', '.join(improvements)}\n")
|
||||
if regressions:
|
||||
lines.append(f"\nRegressed stages: {', '.join(regressions)}\n")
|
||||
if slow:
|
||||
lines.append("\nStages above the reporting threshold:\n\n")
|
||||
for name, base_ms, candidate_ms, ratio in slow:
|
||||
lines.append(f"- `{name}`: {base_ms:.1f} ms to {candidate_ms:.1f} ms ({ratio:.2f}x)\n")
|
||||
print(f"::warning::obstacle latency {name}: {ratio:.2f}x base")
|
||||
add_summary("".join(lines))
|
||||
|
||||
if regressions:
|
||||
raise SystemExit("candidate introduces new obstacle-course failures")
|
||||
if candidate_passed != EXPECTED_STAGE_COUNT:
|
||||
raise SystemExit(
|
||||
f"candidate passes {candidate_passed}/{EXPECTED_STAGE_COUNT} obstacle stages"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
218
scripts/ci/perf_smoke.py
Executable file
218
scripts/ci/perf_smoke.py
Executable file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interleaved latency, peak-RSS, and result-correctness comparison."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
|
||||
ROUNDS = 5
|
||||
PROCESS_TIMEOUT_SECONDS = 45
|
||||
LATENCY_FAILURE_RATIO = 1.20
|
||||
LATENCY_FAILURE_DELTA_MS = 10.0
|
||||
RSS_FAILURE_RATIO = 1.15
|
||||
RSS_FAILURE_DELTA_KIB = 8 * 1024
|
||||
HTML = b"<!doctype html><html><body><main id='root'></main></body></html>"
|
||||
SCENARIOS = {
|
||||
"dom-build": (
|
||||
"(function(){var r=document.getElementById('root');for(var i=0;i<5000;i++){var e=document.createElement('div');e.className='row';e.textContent='item-'+i;r.appendChild(e);}return r.children.length;})()",
|
||||
5000,
|
||||
),
|
||||
"storage": (
|
||||
"(function(){for(var i=0;i<2000;i++)localStorage.setItem('k'+i,'value-'+i);var n=0;for(var j=0;j<2000;j++)n+=localStorage.getItem('k'+j).length;return n;})()",
|
||||
18890,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(HTML)))
|
||||
self.end_headers()
|
||||
self.wfile.write(HTML)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def run_once(binary: Path, url: str, expression: str, expected: int) -> tuple[float, int]:
|
||||
with tempfile.NamedTemporaryFile(prefix="obscura-rss-", delete=False) as rss_file:
|
||||
rss_path = Path(rss_file.name)
|
||||
with tempfile.NamedTemporaryFile(prefix="obscura-stdout-", delete=False) as stdout_file:
|
||||
stdout_path = Path(stdout_file.name)
|
||||
with tempfile.NamedTemporaryFile(prefix="obscura-stderr-", delete=False) as stderr_file:
|
||||
stderr_path = Path(stderr_file.name)
|
||||
command = [
|
||||
"prlimit",
|
||||
"--core=0",
|
||||
"--fsize=2097152",
|
||||
"--nofile=256",
|
||||
"--",
|
||||
"/usr/bin/time",
|
||||
"-f",
|
||||
"%M",
|
||||
"-o",
|
||||
str(rss_path),
|
||||
str(binary),
|
||||
"fetch",
|
||||
url,
|
||||
"--allow-private-network",
|
||||
"--quiet",
|
||||
"--timeout",
|
||||
"20",
|
||||
"--wait",
|
||||
"0",
|
||||
"--eval",
|
||||
expression,
|
||||
]
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout_handle,
|
||||
stderr=stderr_handle,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
process.wait(timeout=PROCESS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
raise RuntimeError(f"{binary} timed out")
|
||||
finally:
|
||||
# Remove background descendants left by an untrusted candidate binary.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||
rss_kib = int(rss_path.read_text(encoding="ascii").strip())
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"{binary} exited with status {process.returncode}")
|
||||
try:
|
||||
result = json.loads(stdout_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError(f"{binary} produced an invalid evaluation result") from error
|
||||
if result != expected:
|
||||
raise RuntimeError(
|
||||
f"{binary} returned {result!r}; expected {expected!r}"
|
||||
)
|
||||
return elapsed_ms, rss_kib
|
||||
finally:
|
||||
rss_path.unlink(missing_ok=True)
|
||||
stdout_path.unlink(missing_ok=True)
|
||||
stderr_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def median(values: list[float | int]) -> float:
|
||||
return float(statistics.median(values))
|
||||
|
||||
|
||||
def add_summary(markdown: str) -> None:
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
with open(summary, "a", encoding="utf-8") as output:
|
||||
output.write(markdown)
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--candidate", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
for binary in (args.base, args.candidate):
|
||||
if not binary.is_file():
|
||||
raise SystemExit(f"missing binary: {binary}")
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
url = f"http://127.0.0.1:{server.server_port}/"
|
||||
samples: dict[str, dict[str, dict[str, list[float | int]]]] = {}
|
||||
try:
|
||||
for scenario, (expression, expected) in SCENARIOS.items():
|
||||
samples[scenario] = {
|
||||
"base": {"latency_ms": [], "rss_kib": []},
|
||||
"candidate": {"latency_ms": [], "rss_kib": []},
|
||||
}
|
||||
for round_number in range(ROUNDS):
|
||||
order = (
|
||||
(("base", args.base), ("candidate", args.candidate))
|
||||
if round_number % 2 == 0
|
||||
else (("candidate", args.candidate), ("base", args.base))
|
||||
)
|
||||
for label, binary in order:
|
||||
latency_ms, rss_kib = run_once(binary, url, expression, expected)
|
||||
samples[scenario][label]["latency_ms"].append(latency_ms)
|
||||
samples[scenario][label]["rss_kib"].append(rss_kib)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
report: dict[str, object] = {"rounds": ROUNDS, "scenarios": {}}
|
||||
lines = [
|
||||
"## Performance smoke comparison\n\n",
|
||||
"Fails above 20% and 10 ms for latency, or above 15% and 8 MiB for RSS.\n\n",
|
||||
"| Scenario | Base latency | PR latency | Change | Base RSS | PR RSS | Change |\n",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n",
|
||||
]
|
||||
failures = []
|
||||
for scenario in SCENARIOS:
|
||||
base_latency = median(samples[scenario]["base"]["latency_ms"])
|
||||
pr_latency = median(samples[scenario]["candidate"]["latency_ms"])
|
||||
base_rss = median(samples[scenario]["base"]["rss_kib"])
|
||||
pr_rss = median(samples[scenario]["candidate"]["rss_kib"])
|
||||
latency_ratio = pr_latency / base_latency
|
||||
rss_ratio = pr_rss / base_rss
|
||||
report["scenarios"][scenario] = {
|
||||
"base_latency_ms": base_latency,
|
||||
"candidate_latency_ms": pr_latency,
|
||||
"latency_ratio": latency_ratio,
|
||||
"base_rss_kib": base_rss,
|
||||
"candidate_rss_kib": pr_rss,
|
||||
"rss_ratio": rss_ratio,
|
||||
"samples": samples[scenario],
|
||||
}
|
||||
lines.append(
|
||||
f"| {scenario} | {base_latency:.1f} ms | {pr_latency:.1f} ms | "
|
||||
f"{(latency_ratio - 1) * 100:+.1f}% | {base_rss / 1024:.1f} MiB | "
|
||||
f"{pr_rss / 1024:.1f} MiB | {(rss_ratio - 1) * 100:+.1f}% |\n"
|
||||
)
|
||||
latency_delta = pr_latency - base_latency
|
||||
rss_delta = pr_rss - base_rss
|
||||
if (
|
||||
latency_ratio > LATENCY_FAILURE_RATIO
|
||||
and latency_delta > LATENCY_FAILURE_DELTA_MS
|
||||
):
|
||||
failures.append(f"{scenario} latency is {latency_ratio:.2f}x the base")
|
||||
if rss_ratio > RSS_FAILURE_RATIO and rss_delta > RSS_FAILURE_DELTA_KIB:
|
||||
failures.append(f"{scenario} RSS is {rss_ratio:.2f}x the base")
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
add_summary("".join(lines))
|
||||
for failure in failures:
|
||||
print(f"::error::{failure}")
|
||||
if failures:
|
||||
raise SystemExit("candidate exceeds the performance regression threshold")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
253
scripts/ci/pr_policy.py
Executable file
253
scripts/ci/pr_policy.py
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fast, deterministic PR policy checks with no shell evaluation of PR data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
USES_RE = re.compile(r"^\s*-?\s*uses\s*:\s*(.+?)\s*$")
|
||||
FULL_ACTION_REF_RE = re.compile(r"^[^@]+@[0-9a-f]{40}$")
|
||||
PINNED_DOCKER_REF_RE = re.compile(r"^docker://[^@]+@sha256:[0-9a-f]{64}$")
|
||||
MAX_CONTROL_FILE_BYTES = 2 * 1024 * 1024
|
||||
REQUIRED_SECTIONS = ("What changed", "Validation", "Rendering", "Performance", "Checklist")
|
||||
ALWAYS_BLOCKED_WORKFLOW_ADDITIONS = (
|
||||
"pull_request_target",
|
||||
"workflow_run",
|
||||
)
|
||||
WRITE_PERMISSION_RE = re.compile(
|
||||
r"\b(?:actions|checks|contents|id-token|packages|pull-requests)\s*:\s*['\"]?write['\"]?(?:\s|[,}]|$)"
|
||||
)
|
||||
WRITE_ALL_RE = re.compile(r"\bpermissions\s*:\s*['\"]?write-all['\"]?(?:\s|$)")
|
||||
SECRET_REFERENCE_RE = re.compile(r"\$\{\{\s*secrets\s*(?:\.|\[)")
|
||||
MAINTAINER_PRIVILEGED_WORKFLOWS = {
|
||||
".github/workflows/docker.yml",
|
||||
".github/workflows/release.yml",
|
||||
}
|
||||
UNTRUSTED_CONTEXT_RE = re.compile(
|
||||
r"\$\{\{\s*github\.event\.(?:pull_request\.(?:title|body|head\.ref)|"
|
||||
r"issue\.(?:title|body)|comment\.body|head_commit\.message)"
|
||||
)
|
||||
BLOCKED_SUFFIXES = {".dll", ".dylib", ".exe", ".o", ".obj", ".pyc", ".so"}
|
||||
|
||||
|
||||
def git(*args: str, text: bool = False) -> bytes | str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=text,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def annotation(kind: str, message: str) -> None:
|
||||
safe = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
||||
print(f"::{kind}::{safe}")
|
||||
|
||||
|
||||
def changed_files(base: str, head: str) -> list[str]:
|
||||
raw = git("diff", "--name-only", "-z", "--diff-filter=ACMR", f"{base}...{head}")
|
||||
assert isinstance(raw, bytes)
|
||||
return [part.decode("utf-8", "surrogateescape") for part in raw.split(b"\0") if part]
|
||||
|
||||
|
||||
def event_body() -> str | None:
|
||||
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
|
||||
return None
|
||||
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
if not event_path:
|
||||
return ""
|
||||
try:
|
||||
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
body = event.get("pull_request", {}).get("body")
|
||||
return body if isinstance(body, str) else ""
|
||||
|
||||
|
||||
def check_template(errors: list[str]) -> None:
|
||||
body = event_body()
|
||||
if body is None:
|
||||
return
|
||||
for heading in REQUIRED_SECTIONS:
|
||||
pattern = re.compile(rf"(?mi)^##\s+{re.escape(heading)}\s*$")
|
||||
if not pattern.search(body):
|
||||
errors.append(f"PR description is missing the '## {heading}' section")
|
||||
|
||||
|
||||
def check_artifacts(files: list[str], errors: list[str], warnings: list[str]) -> None:
|
||||
for name in files:
|
||||
path = PurePosixPath(name)
|
||||
parts = set(path.parts)
|
||||
if "target" in parts or "__pycache__" in parts or path.suffix.lower() in BLOCKED_SUFFIXES:
|
||||
errors.append(f"generated build artifact is not allowed: {name}")
|
||||
elif path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} and not name.startswith(
|
||||
("crates/obscura-render/assets/", ".github/")
|
||||
):
|
||||
warnings.append(f"review newly committed image evidence: {name}")
|
||||
|
||||
|
||||
def check_control_symlinks(files: list[str], errors: list[str]) -> None:
|
||||
protected = [name for name in files if name.startswith((".github/", "scripts/ci/"))]
|
||||
if not protected:
|
||||
return
|
||||
raw = git("ls-files", "-s", "-z", "--", *protected)
|
||||
assert isinstance(raw, bytes)
|
||||
for record in raw.split(b"\0"):
|
||||
if record.startswith(b"120000 "):
|
||||
errors.append("symlinks are not allowed in CI control paths")
|
||||
return
|
||||
|
||||
|
||||
def check_new_vendor(base: str, files: list[str], errors: list[str]) -> None:
|
||||
roots = sorted(
|
||||
{"/".join(PurePosixPath(name).parts[:2]) for name in files if name.startswith("vendor/")}
|
||||
)
|
||||
for root in roots:
|
||||
exists = subprocess.run(
|
||||
["git", "cat-file", "-e", f"{base}:{root}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if exists.returncode != 0:
|
||||
errors.append(f"new vendored dependency requires explicit maintainer handling: {root}")
|
||||
|
||||
|
||||
def workflow_patch(base: str, head: str, files: list[str]) -> list[tuple[str, str]]:
|
||||
workflows = [name for name in files if name.startswith(".github/workflows/")]
|
||||
additions: list[tuple[str, str]] = []
|
||||
for name in workflows:
|
||||
patch = git("diff", "--unified=0", f"{base}...{head}", "--", name, text=True)
|
||||
assert isinstance(patch, str)
|
||||
additions.extend(
|
||||
(name, line[1:])
|
||||
for line in patch.splitlines()
|
||||
if line.startswith("+") and not line.startswith("+++")
|
||||
)
|
||||
return additions
|
||||
|
||||
|
||||
def check_workflows(
|
||||
base: str,
|
||||
head: str,
|
||||
files: list[str],
|
||||
errors: list[str],
|
||||
warnings: list[str],
|
||||
) -> None:
|
||||
workflows = [name for name in files if name.startswith(".github/workflows/")]
|
||||
for name in workflows:
|
||||
path = Path(name)
|
||||
if path.is_symlink():
|
||||
# check_control_symlinks reports the policy violation. Do not follow
|
||||
# the link: it may target an unbounded device or a runner-local file.
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > MAX_CONTROL_FILE_BYTES:
|
||||
errors.append(f"workflow is unexpectedly large: {name}")
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"cannot read workflow {name}: {error}")
|
||||
continue
|
||||
for line_number, line in enumerate(content.splitlines(), 1):
|
||||
match = USES_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
action = match.group(1).split("#", 1)[0].strip().strip("'\"")
|
||||
if action.startswith("./"):
|
||||
continue
|
||||
if action.startswith("docker://"):
|
||||
if not PINNED_DOCKER_REF_RE.fullmatch(action):
|
||||
errors.append(
|
||||
f"{name}:{line_number} container action is not pinned to a sha256 digest"
|
||||
)
|
||||
continue
|
||||
if not FULL_ACTION_REF_RE.fullmatch(action):
|
||||
errors.append(f"{name}:{line_number} action is not pinned to a full commit SHA")
|
||||
|
||||
for name, line in workflow_patch(base, head, files):
|
||||
compact = line.strip().lower()
|
||||
if not compact or compact.startswith("#"):
|
||||
continue
|
||||
if any(token in compact for token in ALWAYS_BLOCKED_WORKFLOW_ADDITIONS):
|
||||
errors.append(f"privileged workflow addition requires a separate maintainer-reviewed change: {line.strip()}")
|
||||
privileged = (
|
||||
WRITE_PERMISSION_RE.search(compact)
|
||||
or WRITE_ALL_RE.search(compact)
|
||||
or SECRET_REFERENCE_RE.search(compact)
|
||||
)
|
||||
if privileged:
|
||||
if name in MAINTAINER_PRIVILEGED_WORKFLOWS:
|
||||
warnings.append(f"CODEOWNER must review privileged publishing change in {name}")
|
||||
else:
|
||||
errors.append(
|
||||
f"privileged workflow addition is not allowed outside publishing workflows: {line.strip()}"
|
||||
)
|
||||
if UNTRUSTED_CONTEXT_RE.search(line):
|
||||
errors.append("untrusted GitHub metadata must not be interpolated into workflow commands")
|
||||
|
||||
|
||||
def check_scope(base: str, head: str, files: list[str], warnings: list[str]) -> None:
|
||||
shortstat = git("diff", "--shortstat", f"{base}...{head}", text=True)
|
||||
assert isinstance(shortstat, str)
|
||||
numbers = [int(value) for value in re.findall(r"\d+", shortstat)]
|
||||
changed_count = numbers[0] if numbers else len(files)
|
||||
added = numbers[1] if len(numbers) > 1 else 0
|
||||
if changed_count > 50:
|
||||
warnings.append(f"large PR changes {changed_count} files; verify that the scope is focused")
|
||||
if added > 5000:
|
||||
warnings.append(f"large PR adds {added} lines; inspect generated or vendored content")
|
||||
|
||||
runtime = any(name.startswith("crates/") and "/src/" in name for name in files)
|
||||
tests = any("/tests/" in name or name.startswith("render-repros/") for name in files)
|
||||
if runtime and not tests:
|
||||
warnings.append("runtime code changed without a focused integration-test file")
|
||||
rendering = any(name.startswith("crates/obscura-render/") for name in files)
|
||||
render_validation = any(
|
||||
name.startswith("render-repros/") or name.startswith("crates/obscura-render/tests/")
|
||||
for name in files
|
||||
)
|
||||
if rendering and not render_validation:
|
||||
warnings.append("rendering code changed without a deterministic rendering fixture")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--head", required=True)
|
||||
args = parser.parse_args()
|
||||
if not SHA_RE.fullmatch(args.base) or not SHA_RE.fullmatch(args.head):
|
||||
raise SystemExit("base and head must be full lowercase commit SHAs")
|
||||
|
||||
files = changed_files(args.base, args.head)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
check_template(errors)
|
||||
check_artifacts(files, errors, warnings)
|
||||
check_control_symlinks(files, errors)
|
||||
check_new_vendor(args.base, files, errors)
|
||||
check_workflows(args.base, args.head, files, errors, warnings)
|
||||
check_scope(args.base, args.head, files, warnings)
|
||||
|
||||
for message in sorted(set(warnings)):
|
||||
annotation("warning", message)
|
||||
for message in sorted(set(errors)):
|
||||
annotation("error", message)
|
||||
|
||||
print(f"Policy inspected {len(files)} changed files: {len(errors)} errors, {len(warnings)} warnings")
|
||||
if errors:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user