diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3096352..623a96aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] push: branches: [main] @@ -9,14 +10,24 @@ permissions: contents: read concurrency: - group: wardnet-ci-${{ github.repository }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Only opened/synchronize validation shares the PR group. State-only + # re-entry uses an independent run group so it cannot cancel an unchanged + # current head; synchronize may cancel only a genuinely superseded head. + group: wardnet-ci-${{ github.repository }}-${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'synchronize' }} jobs: rust: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout identity + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 135966fd..61ab664f 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -4,6 +4,7 @@ on: # Bounded smoke fuzzing on every PR that touches fuzzable surfaces or the # harness. Time budget is capped low to keep CI cost predictable. pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "src/**" - "crates/**" @@ -18,17 +19,27 @@ permissions: contents: read concurrency: - group: wardnet-fuzz-${{ github.repository }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'schedule' && format('schedule-{0}', github.ref) || format('run-{0}', github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Keep a fixed workflow identity. Only opened/synchronize validation shares + # the PR group; state-only re-entry is isolated so an unchanged head is not + # cancelled. A synchronize event may cancel only the superseded PR head. + group: wardnet-fuzz-${{ github.repository }}-${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'synchronize' }} jobs: fuzz: # One runner covers every bounded target; a matrix would consume four org slots. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-24.04 env: FUZZ_SECONDS: ${{ github.event_name == 'pull_request' && '60' || '300' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout identity + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" - name: Install nightly toolchain uses: dtolnay/rust-toolchain@efcb852328a9f50117170cc43094fb6f09eaf1ae # nightly diff --git a/docs/doctoring/ci-queue-concurrency.md b/docs/doctoring/ci-queue-concurrency.md new file mode 100644 index 00000000..3d3bef26 --- /dev/null +++ b/docs/doctoring/ci-queue-concurrency.md @@ -0,0 +1,27 @@ +# CI queue concurrency evidence + +Wardnet's repository-owned `CI` and `Fuzz` workflows coalesce only **superseded pull-request heads**. `opened` and `synchronize` validation share a fixed, workflow-specific PR concurrency group; only a `synchronize` event may cancel work in that group. `reopened` and `ready_for_review` use run-specific groups so a state-only transition cannot discard validation for an unchanged head. `converted_to_draft` and `closed` are deliberately not cancellation triggers. + +Draft work is still prevented from consuming a local Rust/fuzz runner when the event itself observes a Draft PR: the job-level guard skips `opened`, `synchronize`, `reopened`, or `ready_for_review` events whose current PR state is Draft. A later Draft `synchronize` event may cancel an older PR-group run because the commit has genuinely superseded that older head, then skips its own job. Merely converting an unchanged current head to Draft does not cancel that head's already queued/running evidence. This preserves the repository invariant that only superseded heads may be cancelled. + +`Fuzz` remains path-filtered to Wardnet's fuzzable surfaces and harness. Because Draft/closed state transitions are no longer used as cancellation signals, that path filter cannot suppress a required state-transition cancellation event. The fixed `wardnet-ci` and `wardnet-fuzz` prefixes also prevent a PR from changing a workflow display name to escape or collide with another workflow's concurrency group. + +This is a queue-pressure and evidence-integrity policy, not a substitute for required security or review gates. A cancelled, skipped, queued, or stale predecessor run is never promoted to success for a newer head. The workflow must still obtain terminal evidence on the exact current head before merge. + +## Research basis + +Juloori et al. (2025) study large-scale continuous-integration queue scheduling at Uber. Their SubmitQueue measurements show that unnecessary or prematurely aborted speculative builds can materially increase resource consumption and waiting time; the paper reports that prioritizing likely-needed builds and pruning low-value speculation reduced CI resource use and p95 waiting time in the evaluated monorepos. Wardnet does **not** copy SubmitQueue's probabilistic scheduler. The relevant design inference is narrower: when a newer commit makes an older PR execution non-authoritative, retaining both executions consumes scarce CI capacity without improving exact-head evidence. A PR state transition alone is not treated as supersession. + +GitHub Actions' concurrency contract provides the platform mechanism for that bounded inference: jobs or workflow runs sharing a concurrency group may be cancelled when a newer run in the group is enqueued and `cancel-in-progress` is enabled. Wardnet therefore uses immutable workflow-specific prefixes, repository identity, and the PR number for the opened/synchronize validation lineage; state-only and non-PR executions fall back to `run_id` so unrelated or unchanged-head evidence lanes are not coalesced. + +## Traceability and licensing + +The research paper is published in the Proceedings of the 47th International Conference on Software Engineering (ICSE 2025), and the authors' arXiv version is licensed CC BY 4.0, which permits redistribution with attribution. The redistribution-permitted full-text PDF is required in Wardnet's evidence pack at `docs/papers/ci-at-scale-lean-green-fast-arxiv-2501.03440.pdf` before this process-change candidate is merge-ready; citation-only evidence does not satisfy that packaging gate. + +### References + +GitHub. (n.d.). *Control the concurrency of workflows and jobs*. GitHub Docs. https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency + +Juloori, D., Lin, Z., Williams, M., Shin, E., & Mahajan, S. (2025). CI at scale: Lean, green, and fast. *Proceedings of the 47th International Conference on Software Engineering*. https://doi.org/10.48550/arXiv.2501.03440 + +ArXiv full text and license: https://arxiv.org/abs/2501.03440 ; https://creativecommons.org/licenses/by/4.0/ diff --git a/docs/papers/ci-at-scale-lean-green-fast-arxiv-2501.03440.pdf b/docs/papers/ci-at-scale-lean-green-fast-arxiv-2501.03440.pdf new file mode 100644 index 00000000..71d76c3e Binary files /dev/null and b/docs/papers/ci-at-scale-lean-green-fast-arxiv-2501.03440.pdf differ diff --git a/tests/workflow_queue_contract.rs b/tests/workflow_queue_contract.rs index 0e1c4659..4d7659cd 100644 --- a/tests/workflow_queue_contract.rs +++ b/tests/workflow_queue_contract.rs @@ -11,15 +11,54 @@ fn workflow_text(name: &str) -> String { #[test] fn local_pr_workflows_cancel_only_superseded_heads_of_the_same_pull_request() { - for (name, fixed_group) in [("ci.yml", "wardnet-ci"), ("fuzz.yml", "wardnet-fuzz")] { + for (name, expected_group) in [ + ( + "ci.yml", + "group: wardnet-ci-${{ github.repository }}-${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }}", + ), + ( + "fuzz.yml", + "group: wardnet-fuzz-${{ github.repository }}-${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }}", + ), + ] { + let workflow = workflow_text(name); + assert!(workflow.contains(expected_group)); + assert!(!workflow.contains("group: ${{ github.workflow }}")); + assert!(workflow.contains( + "cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'synchronize' }}" + )); + assert!(workflow.contains("types: [opened, synchronize, reopened, ready_for_review]")); + assert!(!workflow.contains("converted_to_draft")); + assert!(!workflow.contains("closed]")); + assert!(workflow.contains( + "if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}" + )); + } +} + +#[test] +fn local_pr_workflows_bind_checkout_to_the_exact_source_head() { + for name in ["ci.yml", "fuzz.yml"] { let workflow = workflow_text(name); - assert!(workflow.contains(&format!( - "group: {fixed_group}-${{{{ github.repository }}}}-${{{{ github.event_name == 'pull_request'" - ))); - assert!(workflow.contains("format('pr-{0}', github.event.pull_request.number)")); - assert!( - workflow.contains("cancel-in-progress: ${{ github.event_name == 'pull_request' }}") - ); + assert!(workflow.contains("ref: ${{ github.event.pull_request.head.sha || github.sha }}")); + assert!(workflow.contains( + "EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}" + )); + assert!(workflow.contains("test \"$(git rev-parse HEAD)\" = \"$EXPECTED_HEAD_SHA\"")); + } +} + +#[test] +fn fuzz_keeps_path_filtered_validation_without_state_transition_cancellation() { + let workflow = workflow_text("fuzz.yml"); + assert!(workflow.contains(" paths:\n")); + for path in [ + "src/**", + "crates/**", + "fuzz/**", + ".github/workflows/fuzz.yml", + ] { + assert!(workflow.contains(path)); } }