diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/review_tests/execution/concurrency.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/review_tests/execution/concurrency.rs index 023e73bb6..821ea22fa 100644 --- a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/review_tests/execution/concurrency.rs +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/review_tests/execution/concurrency.rs @@ -1,5 +1,24 @@ use super::*; +#[test] +fn max_queued_concurrency_preserves_reusable_workflow_coverage() { + let documents = vec![ + document( + ".github/workflows/caller.yml", + "on: push\njobs:\n typecheck:\n uses: ./.github/workflows/callee.yml\n", + ), + document( + ".github/workflows/callee.yml", + "on: workflow_call\nconcurrency:\n group: workflow-checks\n queue: max\njobs:\n typecheck:\n concurrency:\n group: job-checks\n queue: max\n runs-on: ubuntu-latest\n steps:\n - run: tsc --noEmit -p queued-concurrency/tsconfig.json\n", + ), + ]; + + assert_eq!( + scanned_projects(documents, &["queued-concurrency"]), + BTreeSet::from(["queued-concurrency/tsconfig.json".to_string()]) + ); +} + #[test] fn resolved_workflow_concurrency_groups_gate_reusable_activations() { let documents = vec![ diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/concurrency.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/concurrency.rs index 52895db66..06b093a86 100644 --- a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/concurrency.rs +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/concurrency.rs @@ -20,14 +20,15 @@ pub(crate) fn job_concurrency_shape_valid(value: Option<&Value>) -> bool { || value.as_mapping().is_some_and(|concurrency| { concurrency.keys().all(|key| { key.as_str() - .is_some_and(|key| matches!(key, "group" | "cancel-in-progress")) + .is_some_and(|key| matches!(key, "group" | "queue" | "cancel-in-progress")) }) && concurrency.get("group").is_some_and(|value| { value.as_str().is_some_and(|value| { valid_concurrency_group(value, JOB_CONCURRENCY_CONTEXTS) }) - }) && concurrency - .get("cancel-in-progress") - .is_none_or(|value| cancel_in_progress_valid(value, JOB_CONCURRENCY_CONTEXTS)) + }) && concurrency.get("queue").is_none_or(queue_valid) + && concurrency.get("cancel-in-progress").is_none_or(|value| { + cancel_in_progress_valid(value, JOB_CONCURRENCY_CONTEXTS) + }) }) }) } @@ -41,14 +42,15 @@ pub(crate) fn workflow_concurrency_shape_valid(value: Option<&Value>) -> bool { || value.as_mapping().is_some_and(|concurrency| { concurrency.keys().all(|key| { key.as_str() - .is_some_and(|key| matches!(key, "group" | "cancel-in-progress")) + .is_some_and(|key| matches!(key, "group" | "queue" | "cancel-in-progress")) }) && concurrency.get("group").is_some_and(|value| { value.as_str().is_some_and(|value| { valid_concurrency_group(value, WORKFLOW_CONCURRENCY_CONTEXTS) }) - }) && concurrency.get("cancel-in-progress").is_none_or(|value| { - cancel_in_progress_valid(value, WORKFLOW_CONCURRENCY_CONTEXTS) - }) + }) && concurrency.get("queue").is_none_or(queue_valid) + && concurrency.get("cancel-in-progress").is_none_or(|value| { + cancel_in_progress_valid(value, WORKFLOW_CONCURRENCY_CONTEXTS) + }) }) }) } @@ -74,6 +76,10 @@ fn valid_concurrency_group(value: &str, allowed_contexts: &[&str]) -> bool { } } +fn queue_valid(value: &Value) -> bool { + value.as_str() == Some("max") +} + fn concurrency_valid_for_inputs(value: Option<&Value>, inputs: &InputState) -> bool { let Some(group) = value.and_then(concurrency_group) else { return value.is_none(); diff --git a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/tests.rs b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/tests.rs index 73833f5e9..0e52679ef 100644 --- a/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/tests.rs +++ b/crates/no-mistakes/src/codebase/rules/tsconfig_gate_coverage/workflow/reusable/validation/workflow/tests.rs @@ -75,6 +75,7 @@ fn workflow_defaults_and_concurrency_follow_workflow_context_rules() { for yaml in [ "defaults:\n run:\n shell: bash\n working-directory: packages/app", "concurrency: checks-${{ github.ref }}", + "concurrency:\n group: checks\n queue: max", "concurrency:\n group: checks-${{ vars.ENVIRONMENT }}\n cancel-in-progress: '${{ inputs.cancel }}'", "concurrency:\n group: checks\n cancel-in-progress: \"${{ fromJSON('false') }}\"", ] { @@ -93,6 +94,9 @@ fn workflow_defaults_and_concurrency_follow_workflow_context_rules() { "concurrency:\n group: checks\n cancel-in-progress: '${{ secrets.CANCEL }}'", "concurrency:\n group: checks\n cancel-in-progress: \"${{ 'false' }}\"", "concurrency:\n group: checks\n cancel-in-progress: \"${{ fromJSON('\\\"false\\\"') }}\"", + "concurrency:\n group: checks\n queue: min", + "concurrency:\n group: checks\n queue: '${{ inputs.queue }}'", + "concurrency:\n group: checks\n queue: 100", ] { let value = workflow(yaml); assert!( @@ -111,6 +115,7 @@ fn job_defaults_and_concurrency_follow_job_context_rules() { "defaults:\n run:\n shell: 'bash ${{ vars.SHELL_FLAGS }}'\n working-directory: 'packages/${{ inputs.package }}'", "defaults:\n run:\n shell: 'bash ${{ env.SHELL }}'", "concurrency: checks-${{ github.ref }}", + "concurrency:\n group: checks\n queue: max", "concurrency:\n group: checks-${{ needs.setup.outputs.key }}\n cancel-in-progress: '${{ matrix.cancel }}'", "concurrency:\n group: checks-${{ strategy.job-index }}\n cancel-in-progress: '${{ inputs.cancel }}'", "concurrency: checks-${{ vars.ENVIRONMENT }}", @@ -130,6 +135,9 @@ fn job_defaults_and_concurrency_follow_job_context_rules() { "concurrency:\n group: checks\n cancel-in-progress: '${{ secrets.CANCEL }}'", "concurrency:\n group: checks\n cancel-in-progress: \"${{ 'false' }}\"", "concurrency:\n group: checks\n cancel-in-progress: \"${{ fromJSON('\\\"false\\\"') }}\"", + "concurrency:\n group: checks\n queue: min", + "concurrency:\n group: checks\n queue: '${{ matrix.queue }}'", + "concurrency:\n group: checks\n queue: true", ] { let value = workflow(yaml); assert!( diff --git a/docs/rules/tsconfig-gate-coverage.md b/docs/rules/tsconfig-gate-coverage.md index 9bf153f96..bd1b8c6ff 100644 --- a/docs/rules/tsconfig-gate-coverage.md +++ b/docs/rules/tsconfig-gate-coverage.md @@ -238,6 +238,8 @@ coverage. Workflow- and job-level concurrency groups are likewise rechecked for every active input or matrix state; a known empty or non-string result cannot provide coverage, while an unresolved dynamic result remains conservative. +Both concurrency scopes also accept GitHub's static `queue: max` FIFO policy; +other queue values and expressions invalidate the workflow schema. Action `with` values are rechecked for every active input, matrix, and environment state; statically known arrays or objects cannot be string action inputs and stop later steps from providing coverage.