mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 21:25:59 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a722fa80d5 | |||
| 9ecb500cbf |
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
|||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
|||||||
@@ -111,10 +111,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
shell: bash
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
- name: Check io_uring lane --lib precondition
|
||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Copyright 2026 RustFS Team
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
# Reports the existing required checks for paths excluded by ci.yml.
|
|
||||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
|
||||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
|
||||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
branches: [ main ]
|
|
||||||
paths:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quick-checks:
|
|
||||||
name: Quick Checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run shared quick checks
|
|
||||||
uses: ./.github/actions/quick-checks
|
|
||||||
|
|
||||||
test-and-lint:
|
|
||||||
name: Test and Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
|
||||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
|
||||||
# stays meaningful for docs-only changes.
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Satisfy required check for docs-only changes
|
|
||||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
|
||||||
+77
-74
@@ -37,25 +37,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
|
||||||
# reports the required "Test and Lint" check for PRs skipped here.
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -88,6 +69,32 @@ jobs:
|
|||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
|
classify-changes:
|
||||||
|
name: Select CI scope
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
mode: ${{ steps.scope.outputs.mode }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Select scope using the base revision's policy
|
||||||
|
id: scope
|
||||||
|
env:
|
||||||
|
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||||
|
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||||
|
else
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Base CI policy unavailable; running the full matrix."
|
||||||
|
fi
|
||||||
|
|
||||||
typos:
|
typos:
|
||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -100,7 +107,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fail early with compile-free checks shared with docs-only CI.
|
# Fail early with compile-free checks for every pull request.
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -116,9 +123,9 @@ jobs:
|
|||||||
uses: ./.github/actions/quick-checks
|
uses: ./.github/actions/quick-checks
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Workspace Test and Lint
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -289,45 +296,6 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Record the reason before this job completes as FAILURE. A separate
|
|
||||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
|
||||||
# required check's failure verdict.
|
|
||||||
- name: Annotate early-stop reason
|
|
||||||
if: >-
|
|
||||||
failure() && github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
|
||||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
|
||||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
|
||||||
cancel-after-test-and-lint-failure:
|
|
||||||
name: Cancel siblings after Test and Lint failure
|
|
||||||
if: >-
|
|
||||||
failure() && needs.test-and-lint.result == 'failure'
|
|
||||||
&& github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
needs: [ test-and-lint ]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
steps:
|
|
||||||
- name: Cancel remaining jobs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -340,8 +308,8 @@ jobs:
|
|||||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -408,8 +376,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -449,8 +417,8 @@ jobs:
|
|||||||
|
|
||||||
connect-short-credential-boundary:
|
connect-short-credential-boundary:
|
||||||
name: Connect Short Credential Boundary
|
name: Connect Short Credential Boundary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -507,8 +475,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
strategy:
|
strategy:
|
||||||
@@ -561,8 +529,8 @@ jobs:
|
|||||||
|
|
||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -684,8 +652,8 @@ jobs:
|
|||||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -1212,9 +1180,44 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
required-checks:
|
||||||
|
name: Test and Lint
|
||||||
|
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- typos
|
||||||
|
- quick-checks
|
||||||
|
- test-and-lint
|
||||||
|
- test-ilm-integration-serial
|
||||||
|
- test-and-lint-rio-v2
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- test-and-lint-protocols
|
||||||
|
- build-rustfs-debug-binary
|
||||||
|
- uring-integration
|
||||||
|
- e2e-tests
|
||||||
|
- s3-implemented-tests
|
||||||
|
- s3-lifecycle-behavior-tests
|
||||||
|
- build-rustfs-debug-binary-rio-v2
|
||||||
|
- e2e-tests-rio-v2
|
||||||
|
- e2e-full
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Require the expected result of every CI lane
|
||||||
|
env:
|
||||||
|
CI_NEEDS: ${{ toJSON(needs) }}
|
||||||
|
shell: bash
|
||||||
|
run: python3 scripts/ci_gate.py verify
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs:
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- required-checks
|
||||||
- typos
|
- typos
|
||||||
- quick-checks
|
- quick-checks
|
||||||
- test-and-lint
|
- test-and-lint
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
|||||||
let miss = env.raw_get(bucket, miss_key).await?;
|
let miss = env.raw_get(bucket, miss_key).await?;
|
||||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||||
}
|
}
|
||||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
let (listed, _, _) = tokio::try_join!(
|
||||||
|
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||||
|
)?;
|
||||||
|
assert!(listed);
|
||||||
|
|
||||||
let status = env.status_json(bucket).await?;
|
let status = env.status_json(bucket).await?;
|
||||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||||
|
|||||||
+22
-20
@@ -3,7 +3,7 @@
|
|||||||
**Use this when:** a check is red and you need to know whether it blocks the merge, which workflow and job produced it, and how to reproduce it locally.
|
**Use this when:** a check is red and you need to know whether it blocks the merge, which workflow and job produced it, and how to reproduce it locally.
|
||||||
**Source of truth:** the live `main` ruleset (command below) for required status; `.github/workflows/<file>.yml` for triggers, `paths`, `timeout-minutes`, and cron; `.config/nextest.toml` for e2e profile filters; `.github/scheduled-validations.json` for the freshness-watchdog list.
|
**Source of truth:** the live `main` ruleset (command below) for required status; `.github/workflows/<file>.yml` for triggers, `paths`, `timeout-minutes`, and cron; `.config/nextest.toml` for e2e profile filters; `.github/scheduled-validations.json` for the freshness-watchdog list.
|
||||||
|
|
||||||
A job blocks a merge only when its exact check name is in the live `main` ruleset. A workflow name, a `merge_group` trigger, or a red PR check does not make a job required by itself.
|
A job blocks a merge when its exact check name is required by the live `main` ruleset, or when its result is required by the `Test and Lint` aggregate. A workflow name, a `merge_group` trigger, or an unrelated red PR check does not make a job required by itself.
|
||||||
|
|
||||||
## Required merge checks
|
## Required merge checks
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@ The `main` ruleset (`6436880`) requires exactly these contexts, with `strict_req
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CLA Check` | `cla.yml` | Contributor agreement |
|
| `CLA Check` | `cla.yml` | Contributor agreement |
|
||||||
| `Quick Checks` | `ci.yml` job `quick-checks` | Formatting and repository guard scripts |
|
| `Quick Checks` | `ci.yml` job `quick-checks` | Formatting and repository guard scripts |
|
||||||
| `Test and Lint` | `ci.yml` job `test-and-lint` | Clippy, workspace nextest (`ci` profile, excluding `e2e_test`), doctests, migration-gate count (`scripts/check_migration_gate_count.sh`) |
|
| `Test and Lint` | `ci.yml` job `required-checks` | Exact expected results for every CI validation job, including workspace checks, critical E2E, feature lanes, and event-specific full suites |
|
||||||
|
|
||||||
For PRs limited to the `paths-ignore` list in `ci.yml`, `ci-docs-only.yml` reports `Quick Checks` and `Test and Lint` under the same names; it runs the quick checks and `scripts/check_no_planning_docs.sh`, not a Rust build or tests. `scripts/check_ci_paths_sync.sh` keeps the two path lists aligned.
|
Every PR enters `ci.yml`. The `classify-changes` job uses the base revision of `scripts/ci_gate.py` to select a conservative documentation-only path: root Markdown/licenses, `AGENTS.md`, Markdown under `docs/` or `.agents/skills/`, and documentation images. Unknown paths, unavailable Git history, an empty diff, or a missing base policy select the full matrix. Renames include their deleted source path. Documentation-only PRs still run Quick Checks and Typos; the aggregate requires the expensive jobs to be skipped exactly as selected.
|
||||||
|
|
||||||
|
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
|
||||||
|
|
||||||
Verify the live rule before changing merge policy:
|
Verify the live rule before changing merge policy:
|
||||||
|
|
||||||
@@ -24,25 +26,25 @@ gh api repos/rustfs/rustfs/rulesets/6436880 \
|
|||||||
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
||||||
```
|
```
|
||||||
|
|
||||||
Promotion rule: never promote a report-only lane to required from one green run. Require at least 14 days and 30 representative PRs with at least 99% complete execution, then update the ruleset and this file together.
|
The aggregate requires the validation lanes already selected by `ci.yml`; this closes the gap where a failing critical lane left the required workspace check green. Independent workflows remain report-only unless separately required. Before adding a new expensive lane or moving existing PR coverage to a schedule, collect representative execution and regression evidence, establish ownership and a working scheduled replacement, and update this reference with the resulting policy.
|
||||||
|
|
||||||
## Pull request and merge matrix
|
## Pull request and merge matrix
|
||||||
|
|
||||||
"Report-only" means visible and actionable but not in the required list. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
"Via aggregate" means a wrong result fails the required `Test and Lint` check. "Report-only" means visible and actionable but outside both the required list and aggregate. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
||||||
|
|
||||||
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
||||||
| PR, non-doc change | `Test and Lint` | `ci.yml` `test-and-lint` | Required | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
| PR, non-doc change | `Workspace Test and Lint` | `ci.yml` `test-and-lint` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
||||||
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Report-only | `typos` |
|
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
|
||||||
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Report-only | exact command in the job |
|
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
|
||||||
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Report-only | `cargo nextest run` with the job's `--features` |
|
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
|
||||||
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Report-only | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
||||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Report-only; prerequisite for the black-box jobs | `cargo build -p rustfs --bins` |
|
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `cargo build -p rustfs --bins --features e2e-test-hooks` |
|
||||||
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Report-only | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||||
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||||
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
||||||
@@ -52,8 +54,8 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
|||||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||||
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
| PR, documentation-only selection | `Quick Checks`, `Typos`, `Test and Lint` | `ci.yml` `quick-checks`, `typos`, `required-checks` | Required directly or via aggregate | Quick Checks commands; `python3 scripts/ci_gate.py --self-test` |
|
||||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||||
|
|
||||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||||
|
|
||||||
@@ -67,11 +69,11 @@ the serialized cluster fault-domain suites for scheduled soak signal.
|
|||||||
|
|
||||||
## Scheduled validation
|
## Scheduled validation
|
||||||
|
|
||||||
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has not run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has no recent attempt or completed successful scheduled run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
||||||
|
|
||||||
| Workflow (cadence) | Jobs | Verdict and artifacts | In freshness list | Reproduce |
|
| Workflow (cadence) | Jobs | Verdict and artifacts | In freshness list | Reproduce |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | per-job | yes | dispatch `ci.yml` |
|
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | strict aggregate; the full E2E lane runs on dispatch, merge groups, and main pushes | yes | dispatch `ci.yml` |
|
||||||
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
||||||
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
||||||
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
||||||
@@ -88,7 +90,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
|||||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on missing or stale attempts or completed successes | n/a | dispatch |
|
||||||
|
|
||||||
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci.yml's pull_request paths-ignore and ci-docs-only.yml's paths must be equal.
|
|
||||||
#
|
|
||||||
# ci-docs-only.yml exists to report the required checks for pull requests that
|
|
||||||
# ci.yml skips. The two lists are the complement of each other, so any drift
|
|
||||||
# breaks one of two ways, both silent:
|
|
||||||
#
|
|
||||||
# - an entry only in ci.yml's paths-ignore: a PR touching only those files
|
|
||||||
# triggers neither workflow, nobody reports "Test and Lint" or "Quick
|
|
||||||
# Checks", and the PR waits on a required check forever;
|
|
||||||
# - an entry only in ci-docs-only.yml's paths: both workflows run, which is
|
|
||||||
# merely wasteful — but it also means the lists no longer describe the same
|
|
||||||
# intent, and the next edit is made against a wrong assumption.
|
|
||||||
#
|
|
||||||
# The push paths-ignore in ci.yml is deliberately NOT compared: no required
|
|
||||||
# check is reported for push events, so it does not have to pair with anything.
|
|
||||||
#
|
|
||||||
# Also asserts ci-docs-only.yml still declares both companion job names, since a
|
|
||||||
# rename there produces exactly the permanent-pending failure above.
|
|
||||||
#
|
|
||||||
# Usage: scripts/check_ci_paths_sync.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
CI=".github/workflows/ci.yml"
|
|
||||||
DOCS=".github/workflows/ci-docs-only.yml"
|
|
||||||
|
|
||||||
# Print the quoted list items that follow $2 within the block introduced by $1.
|
|
||||||
# Both files keep these as a flat list of quoted scalars, so no YAML parser is
|
|
||||||
# needed and the script stays dependency-free like its check_* siblings.
|
|
||||||
extract() {
|
|
||||||
local file="$1" event="$2" key="$3"
|
|
||||||
awk -v event="$event" -v key="$key" '
|
|
||||||
$0 ~ "^ " event ":[[:space:]]*$" { in_event = 1; next }
|
|
||||||
in_event && /^ [a-z_]+:[[:space:]]*$/ { in_event = 0 }
|
|
||||||
in_event && $0 ~ "^ " key ":[[:space:]]*$" { in_list = 1; next }
|
|
||||||
in_list {
|
|
||||||
if ($0 ~ /^ - /) {
|
|
||||||
item = $0
|
|
||||||
sub(/^ - /, "", item)
|
|
||||||
gsub(/^"|"$/, "", item)
|
|
||||||
print item
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if ($0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) in_list = 0
|
|
||||||
}
|
|
||||||
' "$file" | sort
|
|
||||||
}
|
|
||||||
|
|
||||||
ci_list="$(extract "$CI" "pull_request" "paths-ignore")"
|
|
||||||
docs_list="$(extract "$DOCS" "pull_request" "paths")"
|
|
||||||
|
|
||||||
if [ -z "$ci_list" ] || [ -z "$docs_list" ]; then
|
|
||||||
echo "ERROR: could not read one of the path lists — did the file structure change?" >&2
|
|
||||||
echo " $CI pull_request.paths-ignore: $(printf '%s' "$ci_list" | grep -c . || true) entries" >&2
|
|
||||||
echo " $DOCS pull_request.paths: $(printf '%s' "$docs_list" | grep -c . || true) entries" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
status=0
|
|
||||||
|
|
||||||
if ! diff_out="$(diff <(printf '%s\n' "$ci_list") <(printf '%s\n' "$docs_list"))"; then
|
|
||||||
echo "ERROR: $CI pull_request paths-ignore and $DOCS paths have drifted." >&2
|
|
||||||
echo " '<' is only in $CI, '>' is only in $DOCS:" >&2
|
|
||||||
printf '%s\n' "$diff_out" | sed 's/^/ /' >&2
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for job_name in "Test and Lint" "Quick Checks"; do
|
|
||||||
if ! grep -q "name: ${job_name}\$" "$DOCS"; then
|
|
||||||
echo "ERROR: $DOCS no longer declares a job named '${job_name}'." >&2
|
|
||||||
echo " It is a required status check; without a companion job here, a" >&2
|
|
||||||
echo " docs-only PR waits on it forever." >&2
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$status" -ne 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "OK: ci.yml and ci-docs-only.yml path lists agree ($(printf '%s\n' "$ci_list" | wc -l | tr -d ' ') entries)"
|
|
||||||
@@ -542,7 +542,7 @@ def yaml_scalar_continues(lines: list[str], index: int, indent: int) -> bool:
|
|||||||
def check_quick_checks(root: Path) -> list[str]:
|
def check_quick_checks(root: Path) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
bypass_key = r'''(?:if|continue-on-error|needs|"if"|"continue-on-error"|"needs"|'if'|'continue-on-error'|'needs')\s*:'''
|
bypass_key = r'''(?:if|continue-on-error|needs|"if"|"continue-on-error"|"needs"|'if'|'continue-on-error'|'needs')\s*:'''
|
||||||
for name in ("ci.yml", "ci-docs-only.yml"):
|
for name in ("ci.yml",):
|
||||||
relative = f".github/workflows/{name}"
|
relative = f".github/workflows/{name}"
|
||||||
path = root / relative
|
path = root / relative
|
||||||
job = yaml_block(path.read_text().splitlines(), "quick-checks", 2) if path.is_file() else None
|
job = yaml_block(path.read_text().splitlines(), "quick-checks", 2) if path.is_file() else None
|
||||||
@@ -1165,7 +1165,6 @@ class SelfTests(unittest.TestCase):
|
|||||||
".github/workflows/ci.yml": caller.replace(
|
".github/workflows/ci.yml": caller.replace(
|
||||||
" steps:", " if: github.event_name != 'pull_request' || github.event.action != 'closed'\n steps:"
|
" steps:", " if: github.event_name != 'pull_request' || github.event.action != 'closed'\n steps:"
|
||||||
),
|
),
|
||||||
".github/workflows/ci-docs-only.yml": caller,
|
|
||||||
".github/actions/quick-checks/action.yml": action,
|
".github/actions/quick-checks/action.yml": action,
|
||||||
}
|
}
|
||||||
for relative, source in sources.items():
|
for relative, source in sources.items():
|
||||||
@@ -1173,7 +1172,7 @@ class SelfTests(unittest.TestCase):
|
|||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(source)
|
path.write_text(source)
|
||||||
self.assertEqual(check_quick_checks(root), [])
|
self.assertEqual(check_quick_checks(root), [])
|
||||||
for relative in (".github/workflows/ci.yml", ".github/workflows/ci-docs-only.yml"):
|
for relative in (".github/workflows/ci.yml",):
|
||||||
source = sources[relative]
|
source = sources[relative]
|
||||||
mutations = {
|
mutations = {
|
||||||
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Select safe documentation-only CI and verify the complete required job set."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ALWAYS_JOBS = ("classify-changes", "typos", "quick-checks")
|
||||||
|
CODE_JOBS = (
|
||||||
|
"test-and-lint", "test-ilm-integration-serial", "test-and-lint-rio-v2",
|
||||||
|
"connect-short-credential-boundary", "test-and-lint-protocols",
|
||||||
|
"build-rustfs-debug-binary", "uring-integration", "e2e-tests",
|
||||||
|
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
|
||||||
|
)
|
||||||
|
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full")
|
||||||
|
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_path(path: str) -> bool:
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
if not parts or path.startswith("/") or any(part in (".", "..") for part in parts) or any(ord(c) < 32 for c in path):
|
||||||
|
return False
|
||||||
|
if parts[-1] == "AGENTS.md":
|
||||||
|
return True
|
||||||
|
if len(parts) == 1 and (path.endswith(".md") or path == "LICENSE" or path.startswith("LICENSE-")):
|
||||||
|
return True
|
||||||
|
if path.startswith(("docs/", ".agents/skills/")) and path.endswith(".md"):
|
||||||
|
return True
|
||||||
|
return path.startswith("docs/") and path.endswith((".png", ".jpg", ".svg"))
|
||||||
|
|
||||||
|
|
||||||
|
def select_mode(event: str, base: str, head: str, root: Path) -> str:
|
||||||
|
if event != "pull_request" or not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
|
||||||
|
return "full"
|
||||||
|
try:
|
||||||
|
changed = subprocess.check_output(
|
||||||
|
["git", "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", base, head, "--"],
|
||||||
|
cwd=root, stderr=subprocess.PIPE,
|
||||||
|
).decode("utf-8")
|
||||||
|
except (subprocess.CalledProcessError, UnicodeError):
|
||||||
|
return "full"
|
||||||
|
paths = changed.rstrip("\0").split("\0") if changed else []
|
||||||
|
return "docs" if paths and all(documentation_path(path) for path in paths) else "full"
|
||||||
|
|
||||||
|
|
||||||
|
def expected_results(mode: str, event: str, ref: str) -> dict[str, str]:
|
||||||
|
if event not in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"):
|
||||||
|
raise ValueError(f"unsupported CI event: {event!r}")
|
||||||
|
if mode not in ("docs", "full") or (mode == "docs" and event != "pull_request"):
|
||||||
|
raise ValueError(f"invalid CI selection: {mode!r} for {event!r}")
|
||||||
|
expected = {job: "success" for job in ALWAYS_JOBS}
|
||||||
|
expected.update({job: "success" if mode == "full" else "skipped" for job in CODE_JOBS})
|
||||||
|
rio = mode == "full" and event in ("schedule", "workflow_dispatch")
|
||||||
|
expected.update({job: "success" if rio else "skipped" for job in OPTIONAL_JOBS[:2]})
|
||||||
|
full = mode == "full" and (event in ("merge_group", "workflow_dispatch") or (event == "push" and ref == "refs/heads/main"))
|
||||||
|
expected["e2e-full"] = "success" if full else "skipped"
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
def verify_results(needs: object, event: str, ref: str) -> list[str]:
|
||||||
|
if not isinstance(needs, dict):
|
||||||
|
return ["needs must be a job-result object"]
|
||||||
|
selection = needs.get("classify-changes", {})
|
||||||
|
outputs = selection.get("outputs", {}) if isinstance(selection, dict) else {}
|
||||||
|
mode = outputs.get("mode") if isinstance(outputs, dict) else None
|
||||||
|
try:
|
||||||
|
expected = expected_results(mode, event, ref)
|
||||||
|
except ValueError as error:
|
||||||
|
return [str(error)]
|
||||||
|
errors = []
|
||||||
|
if set(needs) != set(expected):
|
||||||
|
errors.append(f"job set differs: missing={sorted(set(expected) - set(needs))}, unexpected={sorted(set(needs) - set(expected))}")
|
||||||
|
for job, required in expected.items():
|
||||||
|
result = needs.get(job, {})
|
||||||
|
actual = result.get("result") if isinstance(result, dict) else None
|
||||||
|
if actual != required:
|
||||||
|
errors.append(f"{job}: expected {required}, got {actual!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def check_workflow(root: Path) -> list[str]:
|
||||||
|
# Reuse the repository's canonical-indentation checker; actionlint validates YAML syntax.
|
||||||
|
from check_test_wiring import yaml_block, yaml_scalar_continues
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
lines = (root / ".github/workflows/ci.yml").read_text().splitlines()
|
||||||
|
jobs = yaml_block(lines, "jobs", 0) or []
|
||||||
|
names = set()
|
||||||
|
for index, line in enumerate(jobs):
|
||||||
|
if not re.match(r"^ \S", line) or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
header = re.fullmatch(r''' (["']?)([A-Za-z_][A-Za-z0-9_-]*)\1\s*:\s*(?:#.*)?''', line)
|
||||||
|
if header is None:
|
||||||
|
errors.append("CI job declarations must use single-line job IDs")
|
||||||
|
continue
|
||||||
|
name = header[2]
|
||||||
|
if name in names:
|
||||||
|
errors.append(f"duplicate CI job ID: {name}")
|
||||||
|
names.add(name)
|
||||||
|
jobs[index] = f" {name}:"
|
||||||
|
required = set(ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS)
|
||||||
|
if names - NON_VALIDATION_JOBS != required:
|
||||||
|
errors.append("CI verification jobs and the required gate contract differ")
|
||||||
|
for job in required:
|
||||||
|
block = yaml_block(jobs, job, 2) or []
|
||||||
|
if any(re.match(r"\s+(?:- )?[\"']?continue-on-error[\"']?\s*:", line) for line in block):
|
||||||
|
errors.append(f"{job} cannot convert a validation failure into success")
|
||||||
|
gate = yaml_block(jobs, "required-checks", 2) or []
|
||||||
|
def scalar(block, key, indent):
|
||||||
|
prefix = " " * indent + key + ": "
|
||||||
|
matches = [index for index, line in enumerate(block) if line.startswith(prefix)]
|
||||||
|
if len(matches) != 1:
|
||||||
|
return None
|
||||||
|
index = matches[0]
|
||||||
|
if yaml_scalar_continues(block, index, indent):
|
||||||
|
return None
|
||||||
|
return block[index][len(prefix):]
|
||||||
|
|
||||||
|
display_names = {}
|
||||||
|
for job in names:
|
||||||
|
block = [re.sub(r'''^ (?:'name'|"name")\s*:\s*''', " name: ", line)
|
||||||
|
for line in yaml_block(jobs, job, 2) or []]
|
||||||
|
value = scalar(block, "name", 4)
|
||||||
|
display = re.fullmatch(r'''(?:"([^"\\]*)"|'([^']*)'|([^'"#][^#]*?))(?:\s+#.*)?\s*''', (value or "").strip())
|
||||||
|
if display is None or (display[3] is not None and display[3].startswith(tuple("|>*&!{[?"))):
|
||||||
|
errors.append(f"{job} must use a verifiable single-line display name")
|
||||||
|
continue
|
||||||
|
name = next(value for value in display.groups() if value is not None)
|
||||||
|
if "${{" in name and (job != "test-and-lint-protocols" or name != "Test and Lint (${{ matrix.features.name }})"):
|
||||||
|
errors.append(f"{job} has an unverifiable dynamic display name")
|
||||||
|
display_names[job] = name
|
||||||
|
|
||||||
|
dependencies = yaml_block(gate, "needs", 4) or []
|
||||||
|
declared = [line.strip().removeprefix("- ") for line in dependencies if line.strip()]
|
||||||
|
if set(declared) != required or len(declared) != len(required):
|
||||||
|
errors.append("required-checks must directly depend on every verification job exactly once")
|
||||||
|
if display_names.get("required-checks") != "Test and Lint" or list(display_names.values()).count("Test and Lint") != 1:
|
||||||
|
errors.append("Test and Lint must uniquely name the aggregate gate")
|
||||||
|
if scalar(gate, "if", 4) != "always() && (github.event_name != 'pull_request' || github.event.action != 'closed')":
|
||||||
|
errors.append("required-checks must run after failed or skipped dependencies")
|
||||||
|
if scalar(gate, "shell", 8) != "bash" or scalar(gate, "run", 8) != "python3 scripts/ci_gate.py verify" or scalar(gate, "CI_NEEDS", 10) != "${{ toJSON(needs) }}":
|
||||||
|
errors.append("required-checks must verify the actual needs results")
|
||||||
|
if any(re.match(r'''\s+(?:- )?(?:["']?continue-on-error["']?\s*:|["']?if["']?\s*:)''', line) and not line.startswith(" if:") for line in gate):
|
||||||
|
errors.append("required-checks cannot ignore failures")
|
||||||
|
pr = yaml_block(lines, "pull_request", 2) or []
|
||||||
|
if any(line.strip().startswith(("paths:", "paths-ignore:")) for line in pr):
|
||||||
|
errors.append("all pull requests must enter the single CI workflow")
|
||||||
|
if (root / ".github/workflows/ci-docs-only.yml").exists():
|
||||||
|
errors.append("the duplicate required-status companion must be removed")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
class SelfTests(unittest.TestCase):
|
||||||
|
def test_documentation_paths_do_not_hide_build_or_fixture_changes(self):
|
||||||
|
for path in ("README.md", "AGENTS.md", "crates/utils/AGENTS.md", "docs/testing/README.md", "docs/diagram.svg", ".agents/skills/example/SKILL.md"):
|
||||||
|
self.assertTrue(documentation_path(path), path)
|
||||||
|
for path in ("", "src/lib.rs", "crates/foo/tests/fixtures/data.md", "Cargo.lock", "build.rs", "deploy/chart.yaml", ".github/workflows/ci.yml", "scripts/dev_build.sh", "assets/logo.png", "docs/test.rs", "README.md\n", "../README.md"):
|
||||||
|
self.assertFalse(documentation_path(path), path)
|
||||||
|
|
||||||
|
def test_git_range_includes_deleted_source_and_rename_origins(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.check_output(["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=CI Test", "-c", "user.email=ci@example.invalid", *args], cwd=root, stderr=subprocess.PIPE).decode().strip()
|
||||||
|
git("init", "-q")
|
||||||
|
(root / "server.rs").write_text("fn main() {}\n")
|
||||||
|
(root / "README.md").write_text("old\n")
|
||||||
|
git("add", "."); git("commit", "-qm", "base")
|
||||||
|
base = git("rev-parse", "HEAD")
|
||||||
|
(root / "README.md").write_text("new\n")
|
||||||
|
git("add", "."); git("commit", "-qm", "docs")
|
||||||
|
docs = git("rev-parse", "HEAD")
|
||||||
|
self.assertEqual(select_mode("pull_request", base, docs, root), "docs")
|
||||||
|
(root / "server.rs").rename(root / "server.md")
|
||||||
|
git("add", "-A"); git("commit", "-qm", "rename source")
|
||||||
|
head = git("rev-parse", "HEAD")
|
||||||
|
self.assertEqual(select_mode("pull_request", base, head, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", docs, docs, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", "0" * 40, head, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", "--output=bad", head, root), "full")
|
||||||
|
self.assertEqual(select_mode("merge_group", base, docs, root), "full")
|
||||||
|
|
||||||
|
def test_event_contract_requires_complete_candidate_and_optional_lanes(self):
|
||||||
|
ordinary = expected_results("full", "pull_request", "refs/pull/1/merge")
|
||||||
|
self.assertEqual({job for job, state in ordinary.items() if state == "skipped"}, set(OPTIONAL_JOBS))
|
||||||
|
docs = expected_results("docs", "pull_request", "refs/pull/1/merge")
|
||||||
|
self.assertEqual({job for job, state in docs.items() if state == "success"}, set(ALWAYS_JOBS))
|
||||||
|
for event in ("schedule", "workflow_dispatch", "merge_group", "push"):
|
||||||
|
result = expected_results("full", event, "refs/heads/main")
|
||||||
|
self.assertEqual(result["e2e-full"], "skipped" if event == "schedule" else "success")
|
||||||
|
self.assertEqual(result["e2e-tests-rio-v2"], "success" if event in ("schedule", "workflow_dispatch") else "skipped")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
expected_results("docs", event, "refs/heads/main")
|
||||||
|
|
||||||
|
def test_every_wrong_result_missing_job_or_selection_fails_closed(self):
|
||||||
|
for mode, event in (("full", "pull_request"), ("docs", "pull_request"), ("full", "schedule"), ("full", "workflow_dispatch"), ("full", "merge_group")):
|
||||||
|
good = {job: {"result": value} for job, value in expected_results(mode, event, "refs/heads/main").items()}
|
||||||
|
good["classify-changes"]["outputs"] = {"mode": mode}
|
||||||
|
self.assertEqual(verify_results(good, event, "refs/heads/main"), [])
|
||||||
|
for job in good:
|
||||||
|
for value in ("success", "skipped", "failure", "cancelled", "neutral", "", None):
|
||||||
|
if value == good[job]["result"]:
|
||||||
|
continue
|
||||||
|
with self.subTest(mode=mode, event=event, job=job, result=value):
|
||||||
|
bad = {**good, job: {**good[job], "result": value}}
|
||||||
|
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||||
|
self.assertTrue(verify_results({key: value for key, value in good.items() if key != job}, event, "refs/heads/main"))
|
||||||
|
missing_result = {key: value for key, value in good[job].items() if key != "result"}
|
||||||
|
self.assertTrue(verify_results({**good, job: missing_result}, event, "refs/heads/main"))
|
||||||
|
self.assertTrue(verify_results({**good, "unknown-job": {"result": "success"}}, event, "refs/heads/main"))
|
||||||
|
for selection in ({}, {"mode": ""}, {"mode": True}, []):
|
||||||
|
bad = {**good, "classify-changes": {"result": "success", "outputs": selection}}
|
||||||
|
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||||
|
|
||||||
|
def test_repository_wiring_and_missing_dependency_regression(self):
|
||||||
|
self.assertEqual(check_workflow(ROOT), [])
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".github/workflows").mkdir(parents=True)
|
||||||
|
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||||
|
path = root / ".github/workflows/ci.yml"
|
||||||
|
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||||
|
before, gate = source.split(" required-checks:\n", 1)
|
||||||
|
path.write_text(before + " required-checks:\n" + gate.replace(f" - {job}\n", "", 1))
|
||||||
|
self.assertTrue(check_workflow(root), job)
|
||||||
|
for old, new in (
|
||||||
|
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify || true"),
|
||||||
|
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify\n || true"),
|
||||||
|
("CI_NEEDS: ${{ toJSON(needs) }}", "CI_NEEDS: '{}'"),
|
||||||
|
("name: Test and Lint\n", "name: Unrequired result\n"),
|
||||||
|
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " shell: echo {0}\n run: python3 scripts/ci_gate.py verify"),
|
||||||
|
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " run: python3 scripts/ci_gate.py verify"),
|
||||||
|
(" run: python3 scripts/ci_gate.py verify", ' "if": false\n run: python3 scripts/ci_gate.py verify'),
|
||||||
|
):
|
||||||
|
path.write_text(source.replace(old, new))
|
||||||
|
self.assertTrue(check_workflow(root), new)
|
||||||
|
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||||
|
for field in ("continue-on-error", '"continue-on-error"', "'continue-on-error'"):
|
||||||
|
path.write_text(source.replace(f" {job}:\n", f" {job}:\n {field}: true\n", 1))
|
||||||
|
self.assertTrue(check_workflow(root), (job, field))
|
||||||
|
before, block = source.split(f" {job}:\n", 1)
|
||||||
|
block = block.replace(" - name:", f" - {field}: true\n name:", 1)
|
||||||
|
path.write_text(before + f" {job}:\n" + block)
|
||||||
|
self.assertTrue(check_workflow(root), (job, field, "step"))
|
||||||
|
path.write_text(source + "\n cancel-after-test-and-lint-failure:\n runs-on: ubuntu-latest\n")
|
||||||
|
self.assertTrue(check_workflow(root))
|
||||||
|
|
||||||
|
def test_job_ids_and_display_names_cannot_hide_validation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".github/workflows").mkdir(parents=True)
|
||||||
|
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||||
|
path = root / ".github/workflows/ci.yml"
|
||||||
|
for header in ("typos", "'typos'", '"typos"'):
|
||||||
|
path.write_text(source.replace(" typos:\n", f" {header}: # spelling\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], header)
|
||||||
|
for name in ("Test and Lint # required", "'Test and Lint'", '"Test and Lint" # required'):
|
||||||
|
path.write_text(source.replace(" name: Test and Lint\n", f" name: {name}\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], name)
|
||||||
|
for key in ("'name'", '"name"'):
|
||||||
|
path.write_text(source.replace(" name: Typos\n", f" {key}: Typos\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], key)
|
||||||
|
for header in ("new_test", "NewTest", "_new_test", "'new_test'", '"new_test"', '"new\\u005ftest"'):
|
||||||
|
path.write_text(source + f"\n {header}:\n name: New test\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||||
|
self.assertTrue(check_workflow(root), header)
|
||||||
|
path.write_text(source + "\n 'typos':\n name: Duplicate\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||||
|
self.assertIn("duplicate CI job ID: typos", check_workflow(root))
|
||||||
|
for name in (
|
||||||
|
"Test and Lint", "Test and Lint # duplicate", "'Test and Lint'",
|
||||||
|
'"Test and Lint" # duplicate', '"Test\\u0020and Lint"',
|
||||||
|
">-\n Test and Lint", "|-\n Test and Lint", "Test and\n Lint",
|
||||||
|
"*required_name", "&required_name Test and Lint", "!!str Test and Lint",
|
||||||
|
"${{ 'Test and Lint' }}", '"${{ github.event.inputs.check_name }}"',
|
||||||
|
):
|
||||||
|
path.write_text(source.replace(" name: Typos\n", f" name: {name}\n"))
|
||||||
|
self.assertTrue(check_workflow(root), name)
|
||||||
|
path.write_text(source.replace(" name: Typos\n", ""))
|
||||||
|
self.assertIn("typos must use a verifiable single-line display name", check_workflow(root))
|
||||||
|
|
||||||
|
def test_verify_command_preserves_failures(self):
|
||||||
|
good = {job: {"result": value} for job, value in expected_results("full", "pull_request", "refs/pull/1/merge").items()}
|
||||||
|
good["classify-changes"]["outputs"] = {"mode": "full"}
|
||||||
|
failed = {**good, "e2e-tests": {"result": "failure"}}
|
||||||
|
for needs, code in ((json.dumps(good), 0), (json.dumps(failed), 1), ("{}", 1), ("{", 1)):
|
||||||
|
with self.subTest(needs=needs):
|
||||||
|
env = dict(os.environ, CI_NEEDS=needs, GITHUB_EVENT_NAME="pull_request", GITHUB_REF="refs/pull/1/merge")
|
||||||
|
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), "verify"], env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, code, result.stderr)
|
||||||
|
self.assertIn("ERROR:" if code else "CI contract passed", result.stderr if code else result.stdout)
|
||||||
|
|
||||||
|
def test_actual_selector_bootstrap_uses_base_policy_and_fails_closed(self):
|
||||||
|
from check_test_wiring import yaml_block
|
||||||
|
jobs = yaml_block((ROOT / ".github/workflows/ci.yml").read_text().splitlines(), "jobs", 0)
|
||||||
|
selector = yaml_block(jobs, "classify-changes", 2)
|
||||||
|
body = "\n".join(line[10:] for line in selector[selector.index(" run: |") + 1:])
|
||||||
|
for event, changed, base_sha, available, broken, expected in (
|
||||||
|
("pull_request", "README.md", "b" * 40, True, False, "docs"),
|
||||||
|
("pull_request", "src/server.rs", "b" * 40, True, False, "full"),
|
||||||
|
("pull_request", "README.md", "b" * 40, False, False, "full"),
|
||||||
|
("merge_group", "README.md", "b" * 40, False, False, "full"),
|
||||||
|
("pull_request", "README.md", "b" * 40, True, True, None),
|
||||||
|
("pull_request", "README.md", "", True, True, "full"),
|
||||||
|
):
|
||||||
|
with self.subTest(event=event, changed=changed, available=available, broken=broken), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "scripts").mkdir()
|
||||||
|
(root / "scripts/ci_gate.py").write_text("raise SystemExit(71)\n")
|
||||||
|
(root / "python3").symlink_to(sys.executable)
|
||||||
|
base = root / "base-policy.py"
|
||||||
|
base.write_text("raise SystemExit(29)\n" if broken else Path(__file__).read_text())
|
||||||
|
git = root / "git"
|
||||||
|
git.write_text('''#!/bin/sh
|
||||||
|
if [ "$1" = show ]; then
|
||||||
|
[ "$2" = "$CI_BASE_SHA:scripts/ci_gate.py" ] || exit 19
|
||||||
|
[ "$BASE_AVAILABLE" = yes ] || exit 128
|
||||||
|
cat "$BASE_POLICY"
|
||||||
|
elif [ "$1" = diff ]; then
|
||||||
|
printf '%s\\0' "$CHANGED_PATH"
|
||||||
|
else
|
||||||
|
exit 20
|
||||||
|
fi
|
||||||
|
''')
|
||||||
|
git.chmod(0o755)
|
||||||
|
output = root / "output"
|
||||||
|
output.touch()
|
||||||
|
env = dict(os.environ, GITHUB_EVENT_NAME=event, CI_BASE_SHA=base_sha, GITHUB_SHA="c" * 40,
|
||||||
|
RUNNER_TEMP=str(root), GITHUB_OUTPUT=str(output), BASE_POLICY=str(base),
|
||||||
|
BASE_AVAILABLE="yes" if available else "no", CHANGED_PATH=changed,
|
||||||
|
PATH=f"{root}{os.pathsep}{os.environ['PATH']}")
|
||||||
|
result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", body], cwd=root, env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 29 if expected is None else 0, result.stderr)
|
||||||
|
self.assertEqual(output.read_text(), "" if expected is None else f"mode={expected}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if sys.argv[1:] == ["--self-test"]:
|
||||||
|
return not unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(SelfTests)).wasSuccessful()
|
||||||
|
if sys.argv[1:] == ["select"]:
|
||||||
|
mode = select_mode(os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("CI_BASE_SHA", ""), os.environ.get("GITHUB_SHA", ""), Path.cwd())
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||||||
|
output.write(f"mode={mode}\n")
|
||||||
|
print(f"CI selection: {mode}")
|
||||||
|
return 0
|
||||||
|
if sys.argv[1:] == ["verify"]:
|
||||||
|
try:
|
||||||
|
errors = verify_results(json.loads(os.environ["CI_NEEDS"]), os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("GITHUB_REF", ""))
|
||||||
|
except (KeyError, ValueError) as error:
|
||||||
|
errors = [str(error)]
|
||||||
|
elif sys.argv[1:] == ["--check-workflow"]:
|
||||||
|
errors = check_workflow(ROOT)
|
||||||
|
else:
|
||||||
|
print("usage: ci_gate.py {select|verify|--check-workflow|--self-test}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
for error in errors:
|
||||||
|
print(f"ERROR: {error}", file=sys.stderr)
|
||||||
|
if not errors:
|
||||||
|
print("CI contract passed")
|
||||||
|
return bool(errors)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user