diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml new file mode 100644 index 000000000..808b6f341 --- /dev/null +++ b/.github/workflows/native-image-parity.yml @@ -0,0 +1,89 @@ +name: Native image parity + +# Differential qualification of a native server image (Server #325). It +# records the shared fixture corpus on SQLite against the published PHP +# reference, embedded PHP and the native image itself, and compares the three +# records, as the Shared Server fixtures corpus part does for a source build. +# +# Dispatch it with a published native image by digest to qualify that +# artifact. A pull request that changes the comparison itself builds the image +# from its own source and compares that build, so the comparison is exercised +# before it is relied on. Nothing is pushed. + +on: + workflow_dispatch: + inputs: + native_image: + description: 'Native image by digest, for example ghcr.io/durable-workflow/server-native@sha256:' + required: true + pull_request: + paths: + - scripts/conformance/server-parity/native-image-corpus.sh + - scripts/conformance/server-parity/update-validation-sqlite.sh + - scripts/conformance/server-parity/php-maintenance-window.sh + - .github/workflows/native-image-parity.yml + +permissions: + contents: read + +concurrency: + group: native-image-parity-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + corpus: + name: Compare the native image over the shared fixture corpus (SQLite) + runs-on: ubuntu-latest + timeout-minutes: 75 + env: + # The name the shared PHP maintenance window expects. + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + LOCAL_IMAGE: dw-native-corpus-${{ github.run_id }}-${{ github.run_attempt }}:corpus + steps: + - name: Check out + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Pull the pinned images, retrying registry errors + run: | + set -euo pipefail + bash scripts/ci/pull-images.sh \ + "$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee + + - name: Set up Docker Buildx + if: github.event_name == 'pull_request' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Build this pull request's native image into the local daemon + if: github.event_name == 'pull_request' + timeout-minutes: 45 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: Dockerfile.native + platforms: linux/amd64 + pull: true + provenance: false + load: true + push: false + tags: ${{ env.LOCAL_IMAGE }} + + - name: Record and compare the corpus against the native image + env: + NATIVE_IMAGE: ${{ github.event_name == 'pull_request' && env.LOCAL_IMAGE || inputs.native_image }} + run: bash scripts/conformance/server-parity/native-image-corpus.sh "$NATIVE_IMAGE" + + - name: Remove this job's containers and network + if: always() + run: | + docker rm -f "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" || true + docker network rm "$RESOURCE_SCOPE" || true + + - name: Keep the comparison evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-image-parity + path: parity-evidence + retention-days: 14 + if-no-files-found: ignore diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index ecb295a0b..de1cd62dd 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -13,8 +13,11 @@ on: - Dockerfile.native.dockerignore - scripts/ci/native-image-smoke.sh - scripts/ci/native-release-publish.sh + - scripts/ci/check-native-release-tags.sh + - scripts/ci/verify-release-tag-source.sh - .github/workflows/native-image.yml - .github/workflows/native-release.yml + - .github/workflows/release.yml permissions: contents: read @@ -44,6 +47,9 @@ jobs: - name: Check out uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check that native tags start no PHP release + run: bash scripts/ci/check-native-release-tags.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 767b8b16c..ee87d19eb 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,12 +1,20 @@ name: Native release candidate -# Publishes the native (Rust) server image for an existing immutable release -# tag (Server #325). Each architecture builds on its own runner and pushes by +# Publishes the native (Rust) server image for an existing immutable native +# Git tag, native- (Server #325). A native- tag never matches the PHP +# Release workflow's tag filter, so it starts no PHP release; the image tag is +# the version without the prefix. Each architecture builds on its own runner and pushes by # digest only; that exact digest is pulled back and smoke-tested there, so a # failed smoke leaves no tag. The publish job then refuses tags that already # exist, gives both smoked digests # the release tag in both registries as one index, verifies it, and attests # its build provenance. The PHP image and its Release workflow are unchanged. +# +# Its credentials are its own: the dockerhub-native Environment and the +# NATIVE_DOCKERHUB_* secret names. While they are absent the workflow fails +# closed: each job signs in to Docker Hub before it builds anything, so a +# dispatch stops there and pushes nothing. It can never resolve the PHP +# release's Environment or repository credentials. run-name: Native ${{ inputs.tag }} at ${{ inputs.release_commit }} @@ -14,7 +22,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Existing immutable release tag to publish natively (for example 2.12.0-rc.1)' + description: 'Existing immutable native Git tag, native- (for example native-2.12.0-rc.1); the image tag drops native-' required: true release_commit: description: 'Full source commit of that tag' @@ -37,7 +45,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 75 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -62,6 +70,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG_PREFIX: native- RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail @@ -74,8 +83,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 @@ -125,7 +134,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04-arm timeout-minutes: 75 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -150,6 +159,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG_PREFIX: native- RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail @@ -162,8 +172,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 @@ -214,7 +224,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -232,8 +242,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 31dac1aa8..7a3a20418 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -416,7 +416,8 @@ jobs: mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$MYSQL_IMAGE" \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1101,7 +1102,8 @@ jobs: mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$POSTGRES_IMAGE" \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1800,7 +1802,8 @@ jobs: bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: diff --git a/rust/src/config_audit.rs b/rust/src/config_audit.rs index 1e583efed..97085af75 100644 --- a/rust/src/config_audit.rs +++ b/rust/src/config_audit.rs @@ -685,10 +685,10 @@ pub const SETTINGS: &[Setting] = &[ Setting { name: "DW_V2_LIMIT_HISTORY_TRANSACTION_SIZE", legacy: Some("WORKFLOW_V2_LIMIT_HISTORY_TRANSACTION_SIZE"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_MEMO_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_MEMO_SIZE_BYTES"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PAYLOAD_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_PAYLOAD_SIZE_BYTES"), disposition: Honored }, - Setting { name: "DW_V2_LIMIT_PENDING_ACTIVITIES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_ACTIVITIES"), disposition: Refused(Int(2000), STRUCTURAL) }, - Setting { name: "DW_V2_LIMIT_PENDING_CHILDREN", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_CHILDREN"), disposition: Ignored }, + Setting { name: "DW_V2_LIMIT_PENDING_ACTIVITIES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_ACTIVITIES"), disposition: Honored }, + Setting { name: "DW_V2_LIMIT_PENDING_CHILDREN", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_CHILDREN"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PENDING_SIGNALS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_SIGNALS"), disposition: Honored }, - Setting { name: "DW_V2_LIMIT_PENDING_TIMERS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_TIMERS"), disposition: Ignored }, + Setting { name: "DW_V2_LIMIT_PENDING_TIMERS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_TIMERS"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PENDING_UPDATES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_UPDATES"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_SEARCH_ATTRIBUTE_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_SEARCH_ATTRIBUTE_SIZE_BYTES"), disposition: Ignored }, Setting { name: "DW_V2_LIMIT_WARNING_THRESHOLD_PERCENT", legacy: Some("WORKFLOW_V2_LIMIT_WARNING_THRESHOLD_PERCENT"), disposition: Ignored }, diff --git a/rust/src/runtime/memo_limits.rs b/rust/src/runtime/memo_limits.rs index 595b91f33..d29d02596 100644 --- a/rust/src/runtime/memo_limits.rs +++ b/rust/src/runtime/memo_limits.rs @@ -84,6 +84,13 @@ fn configured(suffix: &str, default: usize, invalid: &'static str) -> Result, + /// Open child workflows a run may hold (DW_V2_LIMIT_PENDING_CHILDREN). + pub pending_children: Option, + /// Pending timers a run may hold (DW_V2_LIMIT_PENDING_TIMERS). + pub pending_timers: Option, /// Received signals a run may hold (DW_V2_LIMIT_PENDING_SIGNALS). pub pending_signals: Option, /// Accepted updates a run may hold (DW_V2_LIMIT_PENDING_UPDATES). @@ -106,6 +113,9 @@ pub struct StructuralLimits { impl Default for StructuralLimits { fn default() -> Self { Self { + pending_activities: Some(2000), + pending_children: Some(1000), + pending_timers: Some(2000), pending_signals: Some(5000), pending_updates: Some(500), payload_bytes: Some(2_097_152), @@ -121,6 +131,9 @@ impl StructuralLimits { let defaults = Self::default(); let invalid = "invalid_structural_limit_configuration"; Ok(Self { + pending_activities: configured("PENDING_ACTIVITIES", defaults.pending_activities.unwrap(), invalid)?, + pending_children: configured("PENDING_CHILDREN", defaults.pending_children.unwrap(), invalid)?, + pending_timers: configured("PENDING_TIMERS", defaults.pending_timers.unwrap(), invalid)?, pending_signals: configured("PENDING_SIGNALS", defaults.pending_signals.unwrap(), invalid)?, pending_updates: configured("PENDING_UPDATES", defaults.pending_updates.unwrap(), invalid)?, payload_bytes: configured("PAYLOAD_SIZE_BYTES", defaults.payload_bytes.unwrap(), invalid)?, @@ -131,6 +144,14 @@ impl StructuralLimits { } } +/// The pending rows a completion command adds one of. +#[derive(Clone, Copy, Debug)] +pub(super) enum Pending { + Activities, + Children, + Timers, +} + /// One exceeded ceiling, as `StructuralLimitExceededException` reports it. #[derive(Clone, Debug)] pub(super) struct Exceeded { @@ -146,6 +167,32 @@ impl Exceeded { Self { kind, value: int(value), configured: int(configured), message } } + /// `guardPendingActivities`, `guardPendingChildren` and + /// `guardPendingTimers` (Workflow #933): a run's pending rows of that kind + /// at or above the ceiling. + pub(super) fn pending(kind: Pending, limit: Option, count: i64) -> Option { + let (limit, count) = (limit?, usize::try_from(count).unwrap_or(0)); + let (name, noun) = match kind { + Pending::Activities => ("pending_activity_count", "pending activities"), + Pending::Children => ("pending_child_count", "pending child workflows"), + Pending::Timers => ("pending_timer_count", "pending timers"), + }; + (count >= limit).then(|| Self::new(name, count, limit, + format!("Structural limit exceeded: {count} {noun} (limit {limit})."))) + } + + /// The worker's refusal of a completion that met this ceiling, as + /// `WorkerController` answers `StructuralLimitExceededException`: 422, + /// nothing recorded. + pub(super) fn worker_refusal(&self, task_id: &str, attempt: &serde_json::Value) -> super::RuntimeError { + super::RuntimeError::Protocol { + status: axum::http::StatusCode::UNPROCESSABLE_ENTITY, + response: serde_json::json!({"task_id":task_id,"workflow_task_attempt":attempt,"outcome":"rejected", + "recorded":false,"error":self.message,"reason":"structural_limit_exceeded","limit_kind":self.kind, + "current_value":self.value,"configured_limit":self.configured}), + } + } + /// `guardPendingSignals`: received signals at or above the ceiling. pub(super) fn pending_signals(limit: Option, count: i64) -> Option { let (limit, count) = (limit?, usize::try_from(count).unwrap_or(0)); diff --git a/rust/src/runtime/operator_metrics.rs b/rust/src/runtime/operator_metrics.rs index 3409aede0..7da7254c9 100644 --- a/rust/src/runtime/operator_metrics.rs +++ b/rust/src/runtime/operator_metrics.rs @@ -1623,9 +1623,9 @@ fn empty_repair_snapshot() -> Value { fn structural_limits(connection: &str, limits: &super::StructuralLimits, memo: Option) -> Value { let shown = |limit: Option| limit.unwrap_or(0); let configured = json!({ - "pending_activity_count": 2000, - "pending_child_count": 1000, - "pending_timer_count": 2000, + "pending_activity_count": shown(limits.pending_activities), + "pending_child_count": shown(limits.pending_children), + "pending_timer_count": shown(limits.pending_timers), "pending_signal_count": shown(limits.pending_signals), "pending_update_count": shown(limits.pending_updates), "command_batch_size": 1000, diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 4bebaffac..98781bd4a 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -1123,6 +1123,36 @@ where Self::enqueue_next_message(tx, write.run_id, queue).await } + /// `StructuralLimits::guardPendingActivities`, `guardPendingChildren` and + /// `guardPendingTimers` before the bridge writes one more (Workflow #933): + /// a run already holding the limit refuses the whole completion. + async fn guard_pending( + tx: &mut Transaction<'_, DB>, + run_id: &str, + kind: super::memo_limits::Pending, + limits: &super::memo_limits::StructuralLimits, + task_id: &str, + attempt: &Value, + ) -> Result<()> { + use super::memo_limits::Pending; + let (limit, sql) = match kind { + Pending::Activities => (limits.pending_activities, + "SELECT COUNT(*) FROM activity_executions WHERE workflow_run_id=$1 AND status IN ('pending','running')"), + Pending::Timers => (limits.pending_timers, + "SELECT COUNT(*) FROM workflow_run_timers WHERE workflow_run_id=$1 AND status='pending'"), + Pending::Children => (limits.pending_children, + "SELECT COUNT(*) FROM workflow_links l JOIN workflow_runs r ON r.id=l.child_workflow_run_id WHERE l.parent_workflow_run_id=$1 AND l.link_type='child_workflow' AND r.status NOT IN ('completed','failed','cancelled','terminated')"), + }; + if limit.is_none() { + return Ok(()); + } + let count = Self::scalar(sql).bind(run_id).fetch_one(&mut **tx).await?; + match super::memo_limits::Exceeded::pending(kind, limit, count) { + Some(exceeded) => Err(exceeded.worker_refusal(task_id, attempt)), + None => Ok(()), + } + } + async fn advance_signal_sequences(tx: &mut Transaction<'_, DB>, write: &SignalWrite<'_>) -> Result<()> { Self::query("UPDATE workflow_runs SET last_command_sequence=$1 WHERE id=$2") .bind(write.command_sequence) @@ -2962,6 +2992,7 @@ where created.push(self.continue_run(&mut tx, &run, &task, command, sequence).await?); } } else if command["type"] == "start_child_workflow" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Children, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; created.push(Self::start_child(&mut tx, &run, task_id, command, sequence).await?); } else if command["type"] == super::local_activities::COMMAND { @@ -2998,6 +3029,7 @@ where self.upsert_search_attributes(&mut tx, &run, &task, command, index, sequence) .await?; } else if command["type"] == "schedule_activity" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Activities, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; let metadata = super::selections::metadata(command, "activity")?; super::selections::check_sequence(&metadata, sequence)?; @@ -3091,6 +3123,7 @@ where .execute(&mut *tx) .await?; } else if command["type"] == "start_timer" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Timers, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; let metadata = super::selections::metadata(command, "timer")?; super::selections::check_sequence(&metadata, sequence)?; diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index c1dfa961b..2b97f2278 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -206,6 +206,8 @@ mod start_request; mod command_requests; #[path = "execution/bridge_adapters.rs"] mod bridge_adapters; +#[path = "execution/pending_limits.rs"] +mod pending_limits; #[path = "execution/describe_actions.rs"] mod describe_actions; #[path = "execution/run_deadlines.rs"] diff --git a/rust/tests/execution/pending_limits.rs b/rust/tests/execution/pending_limits.rs new file mode 100644 index 000000000..befb278eb --- /dev/null +++ b/rust/tests/execution/pending_limits.rs @@ -0,0 +1,62 @@ +use super::*; +use durable_workflow_server::runtime::StructuralLimits; + +// Workflow #933: external-worker completions meet the pending-count limits +// the embedded executor applies. A command past its limit refuses the whole +// completion with PHP's `structural_limit_exceeded` answer, nothing of it is +// written, and the task keeps its lease. + +fn command(kind: &str) -> Value { + match kind { + "activity" => json!({"type":"schedule_activity","activity_type":"activity-echo","arguments":envelope(Payload::Array(vec![]))}), + "timer" => json!({"type":"start_timer","delay_seconds":60}), + _ => child_command(1), + } +} + +#[tokio::test] +async fn a_pending_count_past_its_limit_refuses_the_whole_completion() { + for (kind, limit_kind, noun, limits) in [ + ("activity", "pending_activity_count", "pending activities", + StructuralLimits { pending_activities: Some(1), ..StructuralLimits::default() }), + ("timer", "pending_timer_count", "pending timers", + StructuralLimits { pending_timers: Some(1), ..StructuralLimits::default() }), + ("child", "pending_child_count", "pending child workflows", + StructuralLimits { pending_children: Some(1), ..StructuralLimits::default() }), + ] { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap().with_structural_limits(limits)); + register(&app, "worker", json!(["echo", "child"]), json!(["activity-echo"])).await; + let run = start(&app, &format!("pending-{kind}")).await; + let task = poll(&app, "worker", "workflow").await; + let before = run_history(&app, &run["workflow_id"], &run["run_id"]).await; + + // The first command meets a limit of one; the second passes it. + let (status, body) = finish_task(&app, &task, json!([command(kind), command(kind)])).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{kind}: {body}"); + assert_eq!(body, json!({"task_id":task["task_id"],"workflow_task_attempt":task["workflow_task_attempt"], + "outcome":"rejected","recorded":false,"error":format!("Structural limit exceeded: 1 {noun} (limit 1)."), + "reason":"structural_limit_exceeded","limit_kind":limit_kind,"current_value":1,"configured_limit":1}), "{kind}"); + assert_eq!(run_history(&app, &run["workflow_id"], &run["run_id"]).await, before, "{kind}: nothing written"); + + // The lease is kept, and one command within the limit is admitted. + let (status, body) = finish_task(&app, &task, json!([command(kind)])).await; + assert_eq!(status, StatusCode::OK, "{kind}: {body}"); + } +} + +#[tokio::test] +async fn the_snapshot_reports_the_pending_limits_it_enforces() { + let database = TestDatabase::new().await; + let limits = StructuralLimits { pending_activities: Some(7), pending_children: Some(8), pending_timers: Some(9), + ..StructuralLimits::default() }; + let app = router(database.open().await.unwrap().with_structural_limits(limits)); + let (status, metrics) = request(&app, "GET", "/api/system/operator-metrics", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{metrics}"); + let (status, health) = request(&app, "GET", "/api/system/health", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{health}"); + for limits in [&metrics["operator_metrics"]["structural_limits"]["configured"], &health["health"]["structural_limits"]["configured"]] { + assert_eq!((&limits["pending_activity_count"], &limits["pending_child_count"], &limits["pending_timer_count"]), + (&json!(7), &json!(8), &json!(9)), "{limits}"); + } +} diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh new file mode 100755 index 000000000..ee85da9c6 --- /dev/null +++ b/scripts/ci/check-native-release-tags.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Native release-candidate tags are their own Git tags, native- +# (Server #325). Check that: +# - the PHP Release workflow's push-tag filter matches no native- tag, so a +# native tag starts no PHP release, and still matches plain versions; +# - the tag verifier accepts native- only when asked for it; +# - the native publisher accepts only native- tags. +# Each script is stopped at its input checks, before any API or registry call. +set -euo pipefail +fail() { printf 'Native release tag check failed: %s\n' "$1" >&2; exit 1; } + +# The tag patterns under release.yml's push trigger. +mapfile -t patterns < <(awk ' + /^ push:/ { push = 1; next } + push && /^ [a-z_]+:/ { push = 0 } + push && /^ tags:/ { tags = 1; next } + tags && /^ - / { sub(/^ - /, ""); gsub(/\x27|"/, ""); print; next } + tags { tags = 0 } +' .github/workflows/release.yml) +test "${#patterns[@]}" -ge 1 || fail "release.yml has no push tag patterns." + +# A GitHub filter pattern as an anchored extended regular expression: +# [..] and + keep their meaning, * matches anything but /, the rest is literal. +matches() { + local pattern=$1 tag=$2 regex + regex=$(printf '%s' "$pattern" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g') + [[ $tag =~ ^${regex}$ ]] +} +for pattern in "${patterns[@]}"; do + for tag in native-2.12.0-rc.1 native-2.11.4 native-3.0.0; do + if matches "$pattern" "$tag"; then fail "release.yml tag pattern $pattern matches $tag."; fi + done +done +for tag in 2.11.4 2.12.0-rc.1; do + matched=0 + for pattern in "${patterns[@]}"; do + if matches "$pattern" "$tag"; then matched=1; fi + done + test "$matched" = 1 || fail "no release.yml tag pattern matches the plain version $tag." +done + +expect() { + local message=$1; shift + local output + if output=$(env -i PATH="$PATH" "$@" 2>&1); then fail "expected a refusal containing: $message"; fi + grep -qF -- "$message" <<<"$output" || fail "expected '$message', got: $output" +} +verify=scripts/ci/verify-release-tag-source.sh +# An invalid commit stops the verifier right after the tag format is accepted. +expect "RELEASE_COMMIT must be" RELEASE_TAG=2.12.0-rc.1 RELEASE_COMMIT=x bash "$verify" +expect "RELEASE_COMMIT must be" RELEASE_TAG=native-2.12.0-rc.1 RELEASE_TAG_PREFIX=native- RELEASE_COMMIT=x bash "$verify" +expect "must be an exact native-release version" RELEASE_TAG=2.12.0-rc.1 RELEASE_TAG_PREFIX=native- RELEASE_COMMIT=x bash "$verify" +expect "must be an exact release version" RELEASE_TAG=native-2.12.0-rc.1 RELEASE_COMMIT=x bash "$verify" +expect "may only be empty or native-" RELEASE_TAG=x-2.12.0 RELEASE_TAG_PREFIX=x- RELEASE_COMMIT=x bash "$verify" + +publish=scripts/ci/native-release-publish.sh +images=(GHCR_IMAGE=ghcr.io/example/native DOCKERHUB_IMAGE=example/native) +expect "must be native-" RELEASE_TAG=2.12.0-rc.1 "${images[@]}" bash "$publish" +# No digests stops the publisher right after the native tag is accepted. +expect "exact smoked digest" RELEASE_TAG=native-2.12.0-rc.1 "${images[@]}" bash "$publish" +printf 'Native release tags: %d release.yml pattern(s) checked; verifier and publisher accept native- only as intended.\n' "${#patterns[@]}" diff --git a/scripts/ci/native-release-publish.sh b/scripts/ci/native-release-publish.sh index e5b6df6bd..36cc8e588 100755 --- a/scripts/ci/native-release-publish.sh +++ b/scripts/ci/native-release-publish.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash # Give the smoked native platform digests (Server #325) one release tag in # both registries, as one index, and verify it. +# RELEASE_TAG is the native Git tag, native-; the image tag +# is that version without the native- prefix. # Env: RELEASE_TAG, GHCR_IMAGE, DOCKERHUB_IMAGE, and DIGEST_AMD64 and # DIGEST_ARM64 from the build jobs, which push by digest and fail unless that # exact digest passed its smoke test. Writes digest= to $GITHUB_OUTPUT. set -euo pipefail fail() { printf 'Native release publication failed: %s\n' "$1" >&2; exit 1; } -[[ "${RELEASE_TAG:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] || fail "RELEASE_TAG must be an exact release version." +[[ "${RELEASE_TAG:-}" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ + || fail "RELEASE_TAG must be native-." +image_tag=${BASH_REMATCH[1]} : "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" digests=("${DIGEST_AMD64:-}" "${DIGEST_ARM64:-}") @@ -18,35 +22,35 @@ done # Only a registry's own "not found" counts as absent; any other failure to # look (authentication, network) stops here rather than risk an overwrite. for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - if lookup=$(docker buildx imagetools inspect "$image:$RELEASE_TAG" 2>&1); then - fail "$image:$RELEASE_TAG already exists." + if lookup=$(docker buildx imagetools inspect "$image:$image_tag" 2>&1); then + fail "$image:$image_tag already exists." fi grep -qiE 'not found|manifest unknown|name unknown' <<<"$lookup" \ - || fail "could not confirm that $image:$RELEASE_TAG is absent: $(head -c 300 <<<"$lookup")" + || fail "could not confirm that $image:$image_tag is absent: $(head -c 300 <<<"$lookup")" done for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - docker buildx imagetools create --tag "$image:$RELEASE_TAG" \ + docker buildx imagetools create --tag "$image:$image_tag" \ "$image@${digests[0]}" "$image@${digests[1]}" done # Both registries hold the same index, naming exactly the smoked digests. index="" for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - raw=$(docker buildx imagetools inspect --raw "$image:$RELEASE_TAG") - digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$RELEASE_TAG") + raw=$(docker buildx imagetools inspect --raw "$image:$image_tag") + digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$image_tag") if test -z "$index"; then index=$digest; fi - test "$digest" = "$index" || fail "$image:$RELEASE_TAG is $digest, not $index." + test "$digest" = "$index" || fail "$image:$image_tag is $digest, not $index." jq -e --arg amd64 "${digests[0]}" --arg arm64 "${digests[1]}" ' [.manifests[] | {digest, platform: "\(.platform.os)/\(.platform.architecture)"}] | sort_by(.platform) == [{digest: $amd64, platform: "linux/amd64"}, {digest: $arm64, platform: "linux/arm64"}]' <<<"$raw" >/dev/null \ - || fail "$image:$RELEASE_TAG does not name exactly the smoked platform digests." + || fail "$image:$image_tag does not name exactly the smoked platform digests." done printf 'digest=%s\n' "$index" >> "${GITHUB_OUTPUT:-/dev/null}" { printf '### Native %s\n\n' "$RELEASE_TAG" - printf -- '- index `%s`, in `%s` and `%s`\n' "$index" "$GHCR_IMAGE:$RELEASE_TAG" "$DOCKERHUB_IMAGE:$RELEASE_TAG" + printf -- '- index `%s`, in `%s` and `%s`\n' "$index" "$GHCR_IMAGE:$image_tag" "$DOCKERHUB_IMAGE:$image_tag" printf -- '- linux/amd64 `%s`, linux/arm64 `%s`, each smoke-tested on its own architecture\n' "${digests[0]}" "${digests[1]}" } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" printf '%s\n' "$index" diff --git a/scripts/ci/verify-release-tag-source.sh b/scripts/ci/verify-release-tag-source.sh index 17c0a7be3..b7b0cbdd6 100755 --- a/scripts/ci/verify-release-tag-source.sh +++ b/scripts/ci/verify-release-tag-source.sh @@ -12,8 +12,13 @@ release_commit="${RELEASE_COMMIT:-}" repository="${GITHUB_REPOSITORY:-}" gh_cli="${GH_CLI:-gh}" -[[ "$release_tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ - || fail "RELEASE_TAG must be an exact release version." +# A native release-candidate tag is its own Git tag, native-, so it +# never starts the PHP Release workflow; it is verified the same way. +release_tag_prefix="${RELEASE_TAG_PREFIX:-}" +[[ -z "$release_tag_prefix" || "$release_tag_prefix" = native- ]] \ + || fail "RELEASE_TAG_PREFIX may only be empty or native-." +[[ "$release_tag" =~ ^${release_tag_prefix}[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ + || fail "RELEASE_TAG must be an exact ${release_tag_prefix}release version." [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] \ || fail "RELEASE_COMMIT must be a full lowercase Git commit SHA." [[ "$repository" =~ ^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$ ]] \ diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh new file mode 100755 index 000000000..3880b33ab --- /dev/null +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Differential qualification of a native server image (Server #325): record +# the shared fixture corpus on SQLite against the published PHP reference, +# embedded PHP and the given native image, and compare the three records, as +# the Shared Server fixtures corpus part does for a native source build. +# +# native-image-corpus.sh +# +# The reference is a digest-pinned image (name@sha256:...), such as a +# published native release-candidate index or a pull request's local build +# tag. The native server runs from the image itself: its entrypoint, its user +# and its maintenance loop, with SQLite in its /app/database volume. +# +# Containers and the network are named from RESOURCE_SCOPE exactly as the +# corpus part names them, dw-parity---, because the shared +# PHP maintenance window finds its container by that name. So +# update-validation-sqlite.sh records the separate update-validation fixture +# unchanged. The workflow removes them in an always step. Evidence lands in +# parity-evidence/, with a receipt naming the image. +set -euo pipefail +shopt -s inherit_errexit +test "$#" = 1 || { echo "usage: $0 " >&2; exit 2; } +native=$1 +[[ $native =~ @sha256:[0-9a-f]{64}$ || $native =~ ^dw-native-[a-z0-9-]+:[a-z0-9.-]+$ ]] \ + || { echo "The native image must be pinned by digest (name@sha256:...) or be this job's local build." >&2; exit 2; } +scope="dw-parity-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-parity}" +[[ "${RESOURCE_SCOPE:-$scope}" == "$scope" ]] \ + || { echo "RESOURCE_SCOPE must be $scope, the name the PHP maintenance window expects." >&2; exit 2; } +export RESOURCE_SCOPE="$scope" +export DW_PARITY_RUNNER_REVISION +DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) +image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + +# CI's corpus selection: the reviewed corpus without update-validation and +# service-calls, which record separately below with PHP's maintenance loop +# stopped (Server #723, #796), plus the pending candidates it carries. +selection=(--exclude-fixture tests/Fixtures/ServerParity/update-validation.json + --exclude-fixture tests/Fixtures/ServerParity/service-calls.json) +for pending in patch-insertion-activity-pending patch-insertion-activity-completed patch-created-activity-pending \ + patch-created-activity-completed workflow-waiting-history-type workflow-waiting-history-message workflow-task-transient-retry; do + selection+=(--include-fixture "tests/Fixtures/ServerParityPending/$pending.json") +done + +mkdir -p parity-state parity-evidence +chmod 0777 parity-state parity-evidence +touch parity-state/php.sqlite parity-state/embedded.sqlite +chmod 0666 parity-state/*.sqlite +bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress +docker network create "$RESOURCE_SCOPE" >/dev/null + +# The native image under test, as an operator runs it. The shared state +# directory is also its /state, where fixtures put their external payload +# storage roots (file:///state/...), as on every corpus server. +docker run -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ + --cpus=1 --memory=256m --memory-swap=256m -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token \ + -e DB_DATABASE=/app/database/rust.sqlite -v "$PWD/parity-state:/app/database" -v "$PWD/parity-state:/state" \ + "$native" >/dev/null + +# Published PHP and embedded PHP, as the corpus part runs them. +for target in php embedded; do + docker run --rm --user=1000:1000 --entrypoint php \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE="/state/$target.sqlite" \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ + "$image" artisan server:bootstrap --force >> parity-evidence/bootstrap.txt +done +docker run -d --name "$RESOURCE_SCOPE-server" --network "$RESOURCE_SCOPE" --network-alias=parity-server \ + --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token "$image" >/dev/null +for target in server rust; do + ready=0 + for _ in $(seq 1 30); do + if docker run --rm --user=1000:1000 --entrypoint curl --network "$RESOURCE_SCOPE" "$image" \ + -fsS --connect-timeout 2 --max-time 5 "http://parity-$target:8080/api/ready" > "parity-evidence/$target-readiness.json"; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 +done +docker run -d --name "$RESOURCE_SCOPE-php-maintenance" --network "$RESOURCE_SCOPE" \ + --user=1000:1000 --cpus=0.25 --memory=256m --memory-swap=256m --entrypoint sh \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ + "$image" -c 'while :; do php artisan workflow:v2:repair-pass --json; php artisan schedule:evaluate --limit=100 --json; sleep 1; done' >/dev/null + +mkdir -p parity-consumers +curl --fail --location --retry 3 --silent --output parity-consumers/dw.phar https://github.com/durable-workflow/cli/releases/download/2.2.0/dw.phar +printf '%s %s\n' "$(jq -r '.visibility.cli.phar_sha256' tests/Fixtures/ServerParity/visibility-current-runs.json)" parity-consumers/dw.phar \ + | sha256sum -c > parity-evidence/cli-artifact.txt +export DW_PARITY_CLI_PHAR=/source/parity-consumers/dw.phar + +http_flags=(--network "$RESOURCE_SCOPE" -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source + -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION -e DW_PARITY_CLI_PHAR) +record_http() { + local target=$1 url=$2 + docker run --rm --user=1000:1000 --entrypoint node "${http_flags[@]}" "$image" scripts/conformance/server-parity.mjs record \ + --mode http --url "$url" --target "$target" --output "/evidence/$target.json" "${selection[@]}" +} +record_embedded() { + docker run --rm --user=1000:1000 --entrypoint node \ + -v "$PWD:/source:ro" -v "$PWD/parity-state:/state" -v "$PWD/parity-evidence:/evidence" -w /source \ + -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ + --mode embedded --application-root /app --target embedded --output /evidence/embedded.json "${selection[@]}" +} +status=0 +record_http php http://parity-server:8080 || status=$? +record_embedded || status=$? +native_status=0 +record_http rust http://parity-rust:8080 || native_status=$? +compare_status=0 +docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ + scripts/conformance/server-parity.mjs compare /evidence/php.json /evidence/rust.json /evidence/embedded.json "${selection[@]}" \ + > parity-evidence/comparison.json || compare_status=$? +update_validation_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 || update_validation_status=$? +service_calls_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 service-calls \ + || service_calls_status=$? + +native_id=$(docker image inspect --format '{{.Id}}' "$native") +jq -n --arg native "$native" --arg id "$native_id" --arg php "$image" --arg revision "$DW_PARITY_RUNNER_REVISION" \ + --argjson reference "$status" --argjson nativeStatus "$native_status" --argjson compare "$compare_status" \ + --argjson updateValidation "$update_validation_status" --argjson serviceCalls "$service_calls_status" \ + '{schema:"durable-workflow.native-image-corpus.v1", native_image:$native, native_image_id:$id, php_reference:$php, + runner_revision:$revision, backend:"sqlite", + status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, update_validation:$updateValidation, + service_calls:$serviceCalls}}' \ + > parity-evidence/native-image-corpus.json +cat parity-evidence/native-image-corpus.json +test "$status" -eq 0 +test "$native_status" -eq 0 +test "$compare_status" -eq 0 +test "$update_validation_status" -eq 0 +test "$service_calls_status" -eq 0 diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index e716953a4..6380f4d2c 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -4,6 +4,19 @@ "pinned_image": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "review": "composer_trust_test.py fails on any Composer sink not listed here with the same count.", "sinks": [ + { + "path": ".github/workflows/native-image-parity.yml", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image, pulled ahead with pull-images.sh's registry retries so a registry error cannot fail the job's first Composer run; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, { "path": ".github/workflows/phpunit-feature.yml", "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", @@ -105,6 +118,29 @@ } ] }, + { + "path": ".github/workflows/server-parity.yml", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 3, + "confinement": "Pinned Composer 2.10.3 image, pulled ahead with pull-images.sh's registry retries so a registry error cannot fail the job's first Composer run; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", @@ -859,6 +895,17 @@ } ] }, + { + "path": "scripts/conformance/server-parity/native-image-corpus.sh", + "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "count": 1, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either, an entrypoint, an env file, /checked-ca or --volumes-from.", + "guards": [ + { + "type": "composer-wrapper" + } + ] + }, { "path": "scripts/conformance/server-parity/query-authority-cohort.sh", "line": "bash scripts/ci/run-composer.sh --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \\",