diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index de1cd62dd..026085a85 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -13,6 +13,10 @@ on: - Dockerfile.native.dockerignore - scripts/ci/native-image-smoke.sh - scripts/ci/native-release-publish.sh + - scripts/ci/native-release-rolling-tags.sh + - scripts/ci/native-release-identity.sh + - scripts/ci/resolve-release-image-rolling-tags.sh + - scripts/ci/promote-release-image-rolling-tags.sh - scripts/ci/check-native-release-tags.sh - scripts/ci/verify-release-tag-source.sh - .github/workflows/native-image.yml diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index ff8efb254..9ea8bfe02 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -15,8 +15,10 @@ name: Native release candidate # decision, November 1), with the repository's Docker Hub credentials. A # native version is a major version above every PHP release, and the # publish job refuses a tag either registry already has, so it never -# replaces a PHP image. It moves no rolling tag such as latest. It runs -# only from main, in the dockerhub-native Environment. +# replaces a PHP image. A final release then moves X.Y, X and latest +# forward to its index (operator decision, November 1); a candidate moves +# none, and the PHP Release workflow leaves latest to a newer native +# release. It runs only from main, in the dockerhub-native Environment. # # A release candidate (a version with a -rc.N suffix) publishes to GHCR only # (operator decision, October 30). Any other version publishes to both @@ -260,6 +262,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 environment: dockerhub-native + outputs: + digest: ${{ steps.index.outputs.digest }} permissions: contents: read packages: write @@ -311,3 +315,52 @@ jobs: subject-name: index.docker.io/${{ env.DOCKERHUB_IMAGE }} subject-digest: ${{ steps.index.outputs.digest }} push-to-registry: true + + # A final release (never a candidate) moves X.Y, X and latest forward to the + # attested index. This is its own job so a failed alias write can be rerun + # alone ("Re-run failed jobs") from the already published index; it first + # re-verifies that both exact tags name that index. One repository-wide + # group serializes alias writes across native releases, and the script + # rechecks each alias's current version in the registry right before + # writing, so an older release never moves an alias backward. + aliases: + name: Move the rolling tags of a final release + needs: [plan, publish] + if: github.ref == 'refs/heads/main' && needs.plan.outputs.dockerhub == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: dockerhub-native + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false + permissions: + contents: read + packages: write + steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Log in to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Reconcile the rolling tags with the attested index + env: + RELEASE_TAG: ${{ inputs.tag }} + INDEX_DIGEST: ${{ needs.publish.outputs.digest }} + RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git + run: bash scripts/ci/native-release-rolling-tags.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 305ba71b7..e2b8356dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,12 @@ jobs: name: Build and push Docker image runs-on: ubuntu-latest environment: dockerhub + # The rolling-alias lock the native release's alias job also holds, so a + # PHP and a native release never move rolling tags at the same time. + # cancel-in-progress is false: a running job is never cancelled. + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false if: >- (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || @@ -436,7 +442,8 @@ jobs: GHCR_IMAGE: ${{ env.GHCR_IMAGE }} ROLLING_SHOULD_PROMOTE: ${{ steps.rolling.outputs.rolling_should_promote }} VERIFIED_IMAGE_DIGEST: ${{ steps.exact.outputs.image_digest }} - RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git + # The resolver's one checked inventory, taken under the lock. + RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }} run: scripts/ci/promote-release-image-rolling-tags.sh - name: Write release image publish evidence @@ -464,6 +471,7 @@ jobs: ${{ steps.promote_rolling.outputs.rolling_should_promote || steps.rolling.outputs.rolling_should_promote || 'false' }} ROLLING_SUPERSEDED_BY: ${{ steps.promote_rolling.outputs.superseded_by || steps.rolling.outputs.superseded_by }} + ROLLING_LATEST_ALIAS: ${{ steps.promote_rolling.outputs.latest_alias || steps.rolling.outputs.latest_alias || 'true' }} IMAGE_DIGEST: ${{ steps.exact.outputs.image_digest || steps.build.outputs.digest }} RELEASE_COMMIT: ${{ steps.release_source.outputs.commit }} RELEASE_RUN_ID: ${{ github.run_id }} diff --git a/Dockerfile.native b/Dockerfile.native index 8d94cd081..37d897f7e 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -1,6 +1,7 @@ -# Durable Workflow native (Rust) server, published as a release candidate -# (Server #325). It is opt-in: the server refuses to start without -# DW_RUST_EXPERIMENTAL=1. The published PHP image remains the default. +# Durable Workflow native (Rust) server (Server #325), published from a +# native- tag as durableworkflow/server 3.x. A release build (its +# version passed as DW_SERVER_VERSION) starts as is; a development build +# without a release version refuses to start without DW_RUST_EXPERIMENTAL=1. # # The same image runs the takeover commands an operator uses to move a # stopped, backed-up PHP database to native: upgrade-inspect, @@ -48,6 +49,6 @@ EXPOSE 8080 # Native stops gracefully on SIGTERM; PHP's image declares SIGWINCH. STOPSIGNAL SIGTERM ENTRYPOINT ["/usr/local/bin/durable-workflow-server"] -LABEL org.opencontainers.image.title="Durable Workflow Server (native release candidate)" \ +LABEL org.opencontainers.image.title="Durable Workflow Server (native)" \ org.opencontainers.image.source="https://github.com/durable-workflow/server" \ org.opencontainers.image.licenses="MIT" diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 1b0a17be5..e035a51de 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -1,11 +1,14 @@ # Rust Server port [Server #325](https://github.com/durable-workflow/server/issues/325) owns the -rewrite and its acceptance evidence. PHP remains the default published runtime. -The unpublished crate in [`rust/`](../rust/README.md) is an opt-in development -runtime (`DW_RUST_EXPERIMENTAL=1`) with official Avro encoding and SQLite, -PostgreSQL and MySQL/MariaDB storage. Full runtime parity, the backup-first -in-place upgrade, performance qualification and cutover remain open. +rewrite and its acceptance evidence. The crate in [`rust/`](../rust/README.md) +is the native server, with official Avro encoding and SQLite, PostgreSQL and +MySQL/MariaDB storage. Its release candidates are published to GHCR; the +final 3.0.0 replaces PHP as `durableworkflow/server` 3.x and `latest` +(operator decision, November 1). Release builds need no opt-in; a +development build without a release version requires +`DW_RUST_EXPERIMENTAL=1`. What remains before 3.0.0 is listed under +"Remaining for the final release" below. Read this log top to bottom: current state and next action, the surface inventory, design decisions, then the detailed checkpoint history. Each @@ -89,26 +92,33 @@ Updated October 27, 2026. through the native router, and the ones still answering `501 rust_capability_not_implemented` must match [`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt). -- **Remaining for the release candidate:** - - (Takeover of the latest published PHP release, 2.11.8, is covered, - with its pending external payload deletions carried over; - [checkpoint](rust-server-port-checkpoints/2026-11-01-php-2118-takeover.md).) - - the scope question with the operator: prepared local activities, - workflow-bound Nexus handlers and external payload takeover; - - publishing a final native release to Docker Hub. From November 1, native - releases publish to the PHP image's own repositories, - `durableworkflow/server` and `ghcr.io/durable-workflow/server`, as major - version 3 (operator decision). Candidates 1 and 2 are on GHCR as - `ghcr.io/durable-workflow/server-native:3.0.0-rc.1` and `3.0.0-rc.2`. - Before the first final native tag reaches Docker Hub, the - published-artifact conformance scripts that default to the newest - `durableworkflow/server` tag (namespaces, principal attribution, Python, - replay, sagas and migration) must either run native or pin PHP; - - differential qualification of the published image. (Matched - performance against the recorded PHP baseline is met on one host for - release candidate 1: throughput, memory and idle long polls, with about a - twelfth of PHP's runtime CPU per workflow; - [observations](../benchmarks/server-port/rc1/observations-2026-10-30.md).) +- **Remaining for the final release** (November 1): + - **Scope (operator decision, November 1).** 3.0.0 ships without prepared + local activities, workflow-bound Nexus handlers and external payload + takeover. Native refuses each one explicitly (the local-activity routes + with PHP's refusal for a bridge without the prepared role, the Nexus + handler kinds with 501, takeover of external payload storage with its + refusal), and each is a documented 3.x gap. + - **Publication (operator decisions, November 1).** A final native release + publishes to `durableworkflow/server` and + `ghcr.io/durable-workflow/server`, and moves `3.Y`, `3` and `latest` + forward to itself. A PHP 2.x release after it moves only `2` and `2.Y`. + Docker Compose and the Helm chart follow native. + - native schema upgrades between native versions (schema 7 to 8 after + #914); + - takeover of a published 2.11.8 database, with its pending + external-payload deletions, is covered + ([checkpoint](rust-server-port-checkpoints/2026-11-01-php-2118-takeover.md)); + - moving the parity reference to published 2.11.8, then reporting the + cleanup counts always; + - Security acceptance of the post-commit external-payload deletion + (#914); + - Compose and Helm defaults switched to native; + - release candidate 3 from main, then its qualification: Security + installed acceptance, published-image parity and performance. Matched + performance was met for release candidate 1 + ([observations](../benchmarks/server-port/rc1/observations-2026-10-30.md)); + the small-host profile adds 1 vCPU plan sizes. ## API surface still missing diff --git a/docs/rust-server-upgrade-takeover.md b/docs/rust-server-upgrade-takeover.md index c54f2ed3f..41769f3d8 100644 --- a/docs/rust-server-upgrade-takeover.md +++ b/docs/rust-server-upgrade-takeover.md @@ -42,14 +42,16 @@ backup. The native image carries every command below. Native releases from 3.0.0 publish to the same repositories as PHP, `ghcr.io/durable-workflow/server` -and `durableworkflow/server` on Docker Hub, as major version 3; they move no -rolling tag such as `latest`. Release candidates (`X.Y.Z-rc.N`) are -published to GHCR only. The first two candidates were published as +and `durableworkflow/server` on Docker Hub, as major version 3. A final +release moves `3.Y`, `3` and `latest` forward to itself; a PHP 2.x release +after it moves only `2` and `2.Y`. Release candidates (`X.Y.Z-rc.N`) are +published to GHCR only and move no rolling tag. The first two candidates were published as `ghcr.io/durable-workflow/server-native`; the current one is `3.0.0-rc.2`, index `sha256:f54e89d775dbf7c6074f37836c6bb0b25f94869d23711b0706e9547c3edecb29`. It -reads the same `DB_*` settings as the PHP image. Every command needs -`DW_RUST_EXPERIMENTAL=1`: native is opt-in until it is declared qualified. +reads the same `DB_*` settings as the PHP image. A published image (a +release candidate or final release) needs no opt-in; only a development +build without a release version requires `DW_RUST_EXPERIMENTAL=1`. The inspection, the fingerprints and the takeover must all name the same stopped source. Native refuses a fingerprint that does not match the source @@ -77,7 +79,7 @@ docker pull "$IMAGE" qualified `source_layout`. ```sh - source="-e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=pgsql -e DB_HOST=db -e DB_PORT=5432 \ + source="-e DB_CONNECTION=pgsql -e DB_HOST=db -e DB_PORT=5432 \ -e DB_DATABASE=workflow -e DB_USERNAME=workflow -e DB_PASSWORD" queues="-e DW_RUST_UPGRADE_QUEUE_CONNECTION=database \ -e DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION=database -e DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE=default" @@ -105,11 +107,16 @@ docker pull "$IMAGE" 5. Start native on the taken-over database. ```sh - docker run -d -p 8080:8080 $source -e DW_AUTH_TOKEN "$IMAGE" + docker run -d -p 127.0.0.1:8080:8080 $source -e DW_AUTH_TOKEN "$IMAGE" ``` - The image runs the repair pass itself (`DW_RUST_MAINTENANCE_LOOP=1`), - because it has no separate scheduler container. + This publishes the API on the host's loopback interface only. The API is + plain HTTP with bearer credentials: for remote access, publish it only to + the intended private interface or network and terminate HTTPS in the + reverse proxy the [Server reference](server-reference.md) describes; + never expose the raw API port to an untrusted network. The image runs the + repair pass itself (`DW_RUST_MAINTENANCE_LOOP=1`), because it has no + separate scheduler container. 6. Do not restart PHP against the database: its ownership check refuses it. The native image uses the PHP image's paths: SQLite at diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 421908fc4..4dabf5c0e 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -28,7 +28,8 @@ Stop writers before inspection; the mount check does not prove that another process cannot write through a different mount or certify that writers stopped. With the development binary and existing connection environment configured, -run `DW_RUST_EXPERIMENTAL=1 durable-workflow-server upgrade-inspect`. +run `DW_RUST_EXPERIMENTAL=1 durable-workflow-server upgrade-inspect` +(a published image needs no `DW_RUST_EXPERIMENTAL`). For SQLite, set `DB_CONNECTION=sqlite` and `DB_DATABASE` to the existing file inside a read-only mount. Network databases use the existing `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` and TLS settings. Use a diff --git a/docs/server-reference.md b/docs/server-reference.md index 424fa6191..ae9d296fe 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -1402,7 +1402,11 @@ curl -H "X-Signature: COMPUTED_SIGNATURE" \ The legacy `DW_SIGNATURE_KEY` follows the same compatibility rule as the legacy bearer token. -Set `DW_AUTH_DRIVER=none` to disable authentication (development only). +Set `DW_AUTH_DRIVER=none` to disable authentication (development only). It +admits every request as an administrator. The native 3.x release image +refuses `none`, under `DW_AUTH_DRIVER` or `WORKFLOW_SERVER_AUTH_DRIVER`, +before it opens storage or binds its port; only a native development build +accepts it. ### Custom Auth Providers diff --git a/rust/README.md b/rust/README.md index fba962313..ca9106c53 100644 --- a/rust/README.md +++ b/rust/README.md @@ -18,8 +18,12 @@ The toolchain is pinned in `rust-toolchain.toml`; dependency resolution is in Development/test debug information is disabled to bound artifact size. This does not select release optimization or remove the later profiling requirement. -The HTTP executable requires `DW_RUST_EXPERIMENTAL=1` and nonempty -`DW_AUTH_TOKEN`. With no role tokens, the legacy credential keeps compatibility +The HTTP executable requires nonempty `DW_AUTH_TOKEN`. A development build +(no release version built in as `DW_SERVER_VERSION`) also requires +`DW_RUST_EXPERIMENTAL=1`; a release build from a `native-` tag does not. +Only a development build accepts `DW_AUTH_DRIVER=none` (or its legacy +`WORKFLOW_SERVER_AUTH_DRIVER` name), which admits every request as an +administrator; a release build refuses it before storage or the listener. With no role tokens, the legacy credential keeps compatibility access. Configuring any `DW_WORKER_TOKEN`, `DW_OPERATOR_TOKEN` or `DW_ADMIN_TOKEN` disables that bypass: the legacy credential becomes admin, and task polling/completion requires the worker role. Roles are exact membership; @@ -174,7 +178,7 @@ bounded lease-recovery check, not full failure or performance qualification. The same limited HTTP state machine also has a typed SQLx MySQL adapter. Use `DB_CONNECTION=mysql` (or `mariadb`), `DB_DATABASE`, `DB_USERNAME`, and the usual `DB_HOST`, `DB_PORT` and `DB_PASSWORD`; the port defaults to 3306. -`DW_RUST_EXPERIMENTAL=1` remains required. `DB_URL` takes precedence over these +A development build still requires `DW_RUST_EXPERIMENTAL=1`. `DB_URL` takes precedence over these fields and uses SQLx's MySQL URL options. The database must already exist. `schema-bootstrap` admits an empty database, this exact completed native diff --git a/rust/src/main.rs b/rust/src/main.rs index 060b7a924..a246f7af3 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -211,13 +211,23 @@ fn validate_auth_configuration() -> Result<(), Box> { auth_value("DW_AUTH_BACKWARD_COMPATIBLE")?; auth_value("WORKFLOW_SERVER_AUTH_BACKWARD_COMPATIBLE")?; if let Some(driver) = auth_setting("DW_AUTH_DRIVER", "WORKFLOW_SERVER_AUTH_DRIVER")? { - if driver != "token" && driver != "none" { - return Err("unsupported development authentication configuration".into()); - } + auth_driver_allowed(&driver, release_build())?; } Ok(()) } +/// The token driver always; the `none` driver, which admits every request as +/// an administrator, only in a development build. A release build refuses it +/// under either name before storage opens or the listener binds. +fn auth_driver_allowed(driver: &str, release: bool) -> Result<(), &'static str> { + match driver { + "token" => Ok(()), + "none" if !release => Ok(()), + "none" => Err("DW_AUTH_DRIVER=none is refused in a release build; it admits every request as an administrator"), + _ => Err("unsupported development authentication configuration"), + } +} + /// Stack for each runtime thread, workers and blocking threads alike. A /// request body may nest 511 levels, as PHP's `json_decode` allows, and its /// handlers walk it recursively; the Avro encoder (`apache-avro`) is the @@ -235,9 +245,17 @@ fn main() -> Result<(), Box> { .block_on(run()) } +/// A release build carries the version of its `native-` tag, built in as +/// `DW_SERVER_VERSION`, and starts without the opt-in. A development build +/// (no release version) still requires `DW_RUST_EXPERIMENTAL=1`, so an +/// untagged source build is never mistaken for a qualified release. +fn release_build() -> bool { + option_env!("DW_SERVER_VERSION").is_some_and(|version| !version.is_empty()) +} + async fn run() -> Result<(), Box> { - if env::var("DW_RUST_EXPERIMENTAL").as_deref() != Ok("1") { - return Err("Rust development runtime requires DW_RUST_EXPERIMENTAL=1; it is not a qualified replacement".into()); + if !release_build() && env::var("DW_RUST_EXPERIMENTAL").as_deref() != Ok("1") { + return Err("Rust development build requires DW_RUST_EXPERIMENTAL=1; release builds do not".into()); } if let Some(command) = env::args().nth(1) { if env::args().count() != 2 @@ -637,5 +655,15 @@ mod startup_tests { assert!(!unreachable(&RuntimeError::Database(sqlx::Error::RowNotFound))); assert!(!unreachable(&RuntimeError::Io(refused()))); } + + #[test] + fn a_release_build_refuses_the_none_driver() { + assert_eq!(auth_driver_allowed("token", true), Ok(())); + assert_eq!(auth_driver_allowed("token", false), Ok(())); + assert_eq!(auth_driver_allowed("none", false), Ok(())); + assert!(auth_driver_allowed("none", true).unwrap_err().contains("refused in a release build")); + assert!(auth_driver_allowed("signature", true).is_err()); + assert!(auth_driver_allowed("signature", false).is_err()); + } } diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 247d6f49d..8439378bf 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -64,4 +64,152 @@ expect "exact smoked digest" RELEASE_TAG=native-3.0.0 "${images[@]}" PUBLISH_DOC # Only a release candidate may skip Docker Hub, and the choice must be explicit. expect "only a release candidate (-rc.N) may skip Docker Hub" RELEASE_TAG=native-3.0.0 "${images[@]}" PUBLISH_DOCKERHUB=false bash "$publish" expect "PUBLISH_DOCKERHUB must be true or false" 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[@]}" +# Rolling tags: a final native release moves X.Y, X and latest forward only; +# a candidate moves none; a PHP release leaves latest to a newer native one. +# A fake docker records each call and answers inspect with the index digest. +scratch=$(mktemp -d); trap 'rm -rf "$scratch"' EXIT +# ALIAS_STATE lists "ref release-tag" for aliases that exist (an absent ref +# answers not found; "unreadable" fails the lookup); EXACT_DIGEST overrides +# what the exact tags name. +cat > "$scratch/docker" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$CALL_LOG" +ref=${!#} +if [[ "$*" == *"imagetools inspect --format {{json .Image}}"* ]]; then + label=$(awk -v r="$ref" '$1 == r { print $2 }' <<<"${ALIAS_STATE:-}") + [[ -z "$label" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } + [[ "$label" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } + printf '{"linux/amd64":{"config":{"Labels":{"dev.durable-workflow.release.tag":"%s"}}}}\n' "$label" +elif [[ "$*" == *"imagetools inspect"* ]]; then + if [[ "$ref" =~ :[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf '%s\n' "${EXACT_DIGEST:-$INDEX_DIGEST}"; else printf '%s\n' "$INDEX_DIGEST"; fi +fi +FAKE +chmod +x "$scratch/docker" +index=sha256:$(printf '%064d' 0 | tr 0 a) +rolling() { # known tags, release tag [, alias state [, exact digest]] -> moved aliases + : > "$scratch/calls"; : > "$scratch/out" + env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" GITHUB_OUTPUT="$scratch/out" \ + INDEX_DIGEST="$index" "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS="$1" RELEASE_TAG="$2" \ + ALIAS_STATE="${3:-}" EXACT_DIGEST="${4:-}" \ + bash scripts/ci/native-release-rolling-tags.sh >/dev/null 2>&1 || { echo FAILED; return; } + sed -n 's/^rolling_tags=//p' "$scratch/out" +} +want() { test "$2" = "$3" || fail "$1: expected rolling tags '$3', got '$2'."; } +want "first final" "$(rolling $'2.11.8\nnative-3.0.0-rc.2' native-3.0.0)" "3.0 3 latest" +want "candidate" "$(rolling $'2.11.8' native-3.0.1-rc.1)" "" +want "backport below a newer minor" "$(rolling $'native-3.0.0\nnative-3.1.0' native-3.0.1)" "3.0" +want "older patch" "$(rolling $'native-3.0.0\nnative-3.0.2' native-3.0.1)" "" +want "newer minor" "$(rolling $'native-3.0.2\n2.11.8' native-3.1.0)" "3.1 3 latest" +grep -q "ghcr.io/example/native@$index" "$scratch/calls" && grep -q "example/native@$index" "$scratch/calls" \ + || fail "rolling tags must be created from the verified index in both registries." +want "latest held by PHP" "$(rolling $'2.11.8' native-3.0.0 $'ghcr.io/example/native:latest 2.11.8\nexample/native:latest 2.11.8')" "3.0 3 latest" +want "rerun after a partial write" "$(rolling $'2.11.8\nnative-3.0.0' native-3.0.0 $'ghcr.io/example/native:3.0 native-3.0.0\nghcr.io/example/native:3 native-3.0.0\nghcr.io/example/native:latest native-3.0.0\nexample/native:latest 2.11.8')" "3.0 3 latest" +# Two releases interleaved: 3.0.1 read the Git tags before native-3.1.0 +# existed, and 3.1.0 has since moved 3 and latest. The older job, finishing +# last, still moves only 3.0. +newer_state=$'ghcr.io/example/native:3 native-3.1.0\nexample/native:3 native-3.1.0\nghcr.io/example/native:latest native-3.1.0\nexample/native:latest native-3.1.0' +want "older release finishing last" "$(rolling $'native-3.0.0' native-3.0.1 "$newer_state")" "3.0" +grep -q -- '--tag ghcr.io/example/native:3 \|--tag ghcr.io/example/native:latest\|--tag example/native:3 \|--tag example/native:latest' "$scratch/calls" \ + && fail "an older release moved 3 or latest backward." +# Exact tags that do not name the attested index, or an alias whose release +# cannot be read, stop the run before any alias write. +want "exact tag mismatch" "$(rolling $'2.11.8' native-3.0.0 '' "sha256:$(printf '%064d' 0 | tr 0 b)")" "FAILED" +grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after an exact-tag mismatch." +want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" +grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." +expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh + +# Release identity: only a strict native- label admits a release image, and +# only when its server reports exactly that version. +identity=scripts/ci/native-release-identity.sh +test "$(bash "$identity" expect native-3.0.0)" = 3.0.0 || fail "native-3.0.0 must admit 3.0.0." +test "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." +test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." +test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." +for label in 3.0.0 native-3.0 native-3.0.0-beta native-3.0.0x 'native-3.0.0 '; do + if bash "$identity" expect "$label" >/dev/null 2>&1; then fail "label '$label' must be refused."; fi +done +bash "$identity" check native-3.0.0 3.0.0 || fail "a matching release version must pass." +bash "$identity" check '' 0.1.0 || fail "a development image reports its crate version." +for reported in 3.0.1 3.0.0-rc.3 '' 0.1.0; do + if bash "$identity" check native-3.0.0 "$reported" >/dev/null 2>&1; then + fail "a native-3.0.0 label must not admit a binary reporting '$reported'." + fi +done + +# PHP releases: latest stays with a native final release above them, and +# moves as before when only a native candidate exists. +php_latest() { # known tags -> 'latest' if the PHP promoter writes latest + : > "$scratch/calls" + env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" RELEASE_TAG=2.11.9 \ + VERIFIED_IMAGE_DIGEST="$index" RELEASE_IMAGE_KNOWN_TAGS="$1" \ + sh scripts/ci/promote-release-image-rolling-tags.sh >/dev/null + grep -q ':latest' "$scratch/calls" && printf latest || true +} +want "PHP after a native final" "$(php_latest $'2.11.8\nnative-3.0.0')" "" +want "PHP after a native candidate only" "$(php_latest $'2.11.8\nnative-3.0.0-rc.2')" "latest" +# A failed tag lookup stops the PHP promoter and resolver before any write: +# it never reads as "no native release". +printf '#!/bin/sh\nexit 73\n' > "$scratch/git"; chmod +x "$scratch/git" +for script in promote-release-image-rolling-tags.sh resolve-release-image-rolling-tags.sh; do + : > "$scratch/calls" + if env -i PATH="$scratch:$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" RELEASE_TAG=2.11.9 \ + VERIFIED_IMAGE_DIGEST="$index" RELEASE_IMAGE_TAG_SOURCE_URL=https://example.invalid/server.git \ + sh "scripts/ci/$script" >/dev/null 2>&1; then + fail "$script must fail when the tag lookup fails." + fi + test ! -s "$scratch/calls" || fail "$script wrote to a registry after a failed tag lookup." +done +rm -f "$scratch/git" + +# One inventory for the PHP stage, taken once under the shared lock: the +# first lookup answers 2.11.9 and native-3.0.0, every later one fails (73). +cat > "$scratch/git" <<'FAKE' +#!/usr/bin/env bash +count=$(( $(cat "$GIT_COUNT" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$GIT_COUNT" +(( count == 1 )) || { echo "fatal: lookup failed" >&2; exit 73; } +printf '%s\trefs/tags/%s\n' 1111 2.11.8 2222 2.11.9 3333 native-3.0.0 +FAKE +chmod +x "$scratch/git" +php_stage() { # $1: how the promoter gets tags (inventory|lookup) -> promoter exit status + : > "$scratch/calls"; : > "$scratch/out"; echo 0 > "$scratch/git-count" + local base=(env -i PATH="$scratch:$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" GIT_COUNT="$scratch/git-count" + RELEASE_TAG=2.11.9 VERIFIED_IMAGE_DIGEST="$index" RELEASE_IMAGE_TAG_SOURCE_URL=https://example.invalid/server.git) + "${base[@]}" GITHUB_OUTPUT="$scratch/out" sh scripts/ci/resolve-release-image-rolling-tags.sh >/dev/null 2>&1 \ + || fail "the resolver's single lookup must succeed." + inventory=$(sed -n '/^release_inventory</dev/null 2>&1 || status=$? + else + "${base[@]}" sh scripts/ci/promote-release-image-rolling-tags.sh >/dev/null 2>&1 || status=$? + fi + printf '%s\n' "$status" +} +test "$(php_stage lookup)" != 0 || fail "a promoter whose second lookup fails must exit nonzero." +test ! -s "$scratch/calls" || fail "a promoter whose second lookup fails wrote to a registry." +test "$(php_stage inventory)" = 0 || fail "the promoter must succeed from the resolver's inventory." +test "$(cat "$scratch/git-count")" = 1 || fail "the PHP stage must read the tags exactly once." +grep -q -- '--tag durableworkflow/server:2.11 --tag durableworkflow/server:2 ' "$scratch/calls" \ + || fail "the PHP release must still move 2 and 2.11." +grep -q ':latest' "$scratch/calls" && fail "the PHP release moved latest past native-3.0.0." +# Without any inventory the promoter writes nothing. +: > "$scratch/calls" +if env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" RELEASE_TAG=2.11.9 \ + VERIFIED_IMAGE_DIGEST="$index" sh scripts/ci/promote-release-image-rolling-tags.sh >/dev/null 2>&1; then + fail "the promoter must refuse without a tag inventory." +fi +test ! -s "$scratch/calls" || fail "the promoter wrote without a tag inventory." +rm -f "$scratch/git" "$scratch/git-count" +# Both workflows hold the one rolling-alias lock, and the PHP promoter takes +# the resolver's inventory rather than reading the tags again. +for workflow in .github/workflows/release.yml .github/workflows/native-release.yml; do + test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' "$workflow")" = 1 \ + || fail "$workflow must hold the durable-workflow-server-rolling-aliases lock in one job." +done +grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ + || fail "the PHP promoter must use the resolver's inventory." +awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ + | grep -q RELEASE_IMAGE_TAG_SOURCE_URL && fail "the PHP promoter must not read the tags again." +printf 'Native release tags: %d release.yml pattern(s) checked; verifier, publisher and rolling tags behave as intended.\n' "${#patterns[@]}" diff --git a/scripts/ci/native-image-smoke.sh b/scripts/ci/native-image-smoke.sh index b9c29b64d..c76d72be3 100755 --- a/scripts/ci/native-image-smoke.sh +++ b/scripts/ci/native-image-smoke.sh @@ -52,11 +52,32 @@ jq -e '.User=="1000:1000" and .StopSignal=="SIGTERM" and (.ExposedPorts|has("808 and (.Env|index("DW_RUST_MAINTENANCE_LOOP=1")!=null) and ([.Env[]|select(startswith("DW_RUST_EXPERIMENTAL="))]|length)==0' <<<"$config" >/dev/null -# Without the opt-in the server does not start. -status=0 -refusal=$(docker run --rm --platform "$platform" -e DW_AUTH_TOKEN=$token "$image" 2>&1) || status=$? -test "$status" != 0 -grep -q 'requires DW_RUST_EXPERIMENTAL=1' <<<"$refusal" +# A release build (labelled with its native- tag, its version built in) +# starts without the opt-in; a development build refuses to start without +# DW_RUST_EXPERIMENTAL=1. +release_tag=$(docker image inspect --format '{{index .Config.Labels "dev.durable-workflow.release.tag"}}' "$image") +# A malformed label stops the smoke; a release image's server must report +# exactly its label's version (checked on every backend below). +release_version=$(bash "$(dirname "$0")/native-release-identity.sh" expect "$release_tag") +optin=() +if [[ -z "$release_version" ]]; then + status=0 + refusal=$(docker run --rm --platform "$platform" -e DW_AUTH_TOKEN=$token "$image" 2>&1) || status=$? + test "$status" != 0 + grep -q 'requires DW_RUST_EXPERIMENTAL=1' <<<"$refusal" + optin=(-e DW_RUST_EXPERIMENTAL=1) +else + # A release image refuses the no-authentication driver, under either name, + # before it opens storage. + for name in DW_AUTH_DRIVER WORKFLOW_SERVER_AUTH_DRIVER; do + status=0 + refusal=$(docker run --rm --platform "$platform" -v "$state:/app/database" -e DW_AUTH_TOKEN=$token \ + -e "$name=none" -e DB_DATABASE=/app/database/no-auth.sqlite "$image" 2>&1) || status=$? + test "$status" != 0 + grep -q 'refused in a release build' <<<"$refusal" + test ! -e "$state/no-auth.sqlite" + done +fi # GET from port 8080 inside the job's network, with the bearer # token when a third argument is given; prints the body and fails on any @@ -94,17 +115,19 @@ wait_ready() { serve() { local backend=$1; shift docker run -d --platform "$platform" --name "$RESOURCE_SCOPE-native-$backend" --network "$RESOURCE_SCOPE" \ - -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=$token "$@" "$image" >/dev/null + "${optin[@]}" -e DW_AUTH_TOKEN=$token "$@" "$image" >/dev/null wait_ready "$RESOURCE_SCOPE-native-$backend" local info info=$(get "$RESOURCE_SCOPE-native-$backend" /api/cluster/info "$token") + bash "$(dirname "$0")/native-release-identity.sh" check "$release_tag" "$(jq -r '.version' <<<"$info")" started=$(date +%s) docker stop --time 20 "$RESOURCE_SCOPE-native-$backend" >/dev/null local exit_code exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$RESOURCE_SCOPE-native-$backend") test "$exit_code" = 0 jq -cn --arg backend "$backend" --argjson info "$info" --argjson seconds "$(( $(date +%s) - started ))" \ - '{backend:$backend, ready:true, cluster_info_keys:($info|keys|length), sigterm_exit:0, stop_seconds:$seconds}' + '{backend:$backend, ready:true, cluster_info_keys:($info|keys|length), version:$info.version, + sigterm_exit:0, stop_seconds:$seconds}' } results=() @@ -125,7 +148,7 @@ results+=("$(serve mysql -e DB_CONNECTION=mysql -e DB_HOST="$RESOURCE_SCOPE-mysq # The takeover commands are present and refuse a source that does not exist, # without creating it in the mounted state directory. status=0 -missing=$(docker run --rm --platform "$platform" -v "$state:/app/database" -e DW_RUST_EXPERIMENTAL=1 \ +missing=$(docker run --rm --platform "$platform" -v "$state:/app/database" "${optin[@]}" \ -e DB_CONNECTION=sqlite -e DB_DATABASE=/app/database/missing.sqlite "$image" upgrade-inspect 2>&1) || status=$? test "$status" != 0 grep -qx 'upgrade_inspection_unavailable' <<<"$missing" @@ -134,7 +157,9 @@ test -e "$state/database.sqlite" digest=$(docker image inspect --format '{{.Id}}' "$image") printf '%s\n' "${results[@]}" | jq -s --arg image "$image" --arg platform "$platform" --arg id "$digest" \ + --arg tag "$release_tag" --arg version "$release_version" \ '{schema:"durable-workflow.native-image-smoke.v1", image:$image, platform:$platform, image_id:$id, - config:{user:"1000:1000", stop_signal:"SIGTERM", port:8080, opt_in_required:true}, + release:(if $version == "" then null else {tag:$tag, version:$version, no_auth_refused:true} end), + config:{user:"1000:1000", stop_signal:"SIGTERM", port:8080, opt_in_required:($version == "")}, backends:., takeover_commands:"present"}' > "$receipt" cat "$receipt" diff --git a/scripts/ci/native-release-identity.sh b/scripts/ci/native-release-identity.sh new file mode 100755 index 000000000..a39a6d6ad --- /dev/null +++ b/scripts/ci/native-release-identity.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# The release identity of a native image (Server #325), from its +# dev.durable-workflow.release.tag label. +# expect