From a7046050c7f9f04649d20f99fa25332d5cf7330a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:28:54 +0000 Subject: [PATCH 1/3] Rust #325: final-release tags and opt-in for native 3.0.0 Operator decisions (November 1): latest moves to native at the final 3.0.0, and 3.0.0 ships without prepared local activities, workflow-bound Nexus handlers and external payload takeover. - A final native release moves 3.Y, 3 and latest forward to its verified index in both registries (native-release-rolling-tags.sh), each alias only to the highest release it covers. A candidate moves none. - The PHP release promoter leaves latest alone once a final native-X.Y.Z tag is above it, and still moves 2 and 2.Y. Its resolve output and publish evidence name latest only when it moves. - A release build (version built in from its native- tag) starts without DW_RUST_EXPERIMENTAL; a development build still requires it. The smoke test checks whichever applies. - Docs: takeover runbook, upgrade doc, rust README, and the port doc's remaining work for the final release, with the scope decision. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image.yml | 2 + .github/workflows/native-release.yml | 16 +++- .github/workflows/release.yml | 1 + Dockerfile.native | 7 +- docs/rust-server-port.md | 58 ++++++++------ docs/rust-server-upgrade-takeover.md | 14 ++-- docs/rust-server-upgrade.md | 3 +- rust/README.md | 7 +- rust/src/main.rs | 12 ++- scripts/ci/check-native-release-tags.sh | 41 +++++++++- scripts/ci/native-image-smoke.sh | 21 +++-- scripts/ci/native-release-rolling-tags.sh | 79 +++++++++++++++++++ .../ci/promote-release-image-rolling-tags.sh | 43 ++++++++-- .../ci/resolve-release-image-rolling-tags.sh | 35 ++++++-- .../write-release-image-publish-evidence.sh | 24 ++++-- tests/Unit/ReleaseImageContinuationTest.php | 23 ++++++ 16 files changed, 315 insertions(+), 71 deletions(-) create mode 100755 scripts/ci/native-release-rolling-tags.sh diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index de1cd62dd..b23ef6a8d 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -13,6 +13,8 @@ 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/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..d3b5eb923 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 @@ -311,3 +313,13 @@ 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; an older release leaves each alias where it is. + - name: Move the rolling tags of a final release + if: needs.plan.outputs.dockerhub == 'true' + env: + RELEASE_TAG: ${{ inputs.tag }} + INDEX_DIGEST: ${{ steps.index.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 8fc6dc8d2..e319a4c65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -463,6 +463,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..e9f09443f 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, diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index cf046ccae..ab5d0b8a6 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,25 +92,32 @@ 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.7, is covered: - its layout is 2.11.3's.) - - 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; + - 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 a893b54b0..cabe23964 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" 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/rust/README.md b/rust/README.md index 679d895d9..4fcb6fdb6 100644 --- a/rust/README.md +++ b/rust/README.md @@ -18,8 +18,9 @@ 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. 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 +175,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 af13ae38d..8ff51de8b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -235,9 +235,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 diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 247d6f49d..b8a923b26 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -64,4 +64,43 @@ 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 +cat > "$scratch/docker" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$CALL_LOG" +if [[ "$*" == *"imagetools inspect"* ]]; then printf '%s\n' "$INDEX_DIGEST"; fi +FAKE +chmod +x "$scratch/docker" +index=sha256:$(printf '%064d' 0 | tr 0 a) +rolling() { # known tags, release tag -> moved aliases (as written to GITHUB_OUTPUT) + : > "$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" \ + bash scripts/ci/native-release-rolling-tags.sh >/dev/null + 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." +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 + +# 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" +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..0b3831c5a 100755 --- a/scripts/ci/native-image-smoke.sh +++ b/scripts/ci/native-image-smoke.sh @@ -52,11 +52,18 @@ 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") +optin=() +if [[ -z "$release_tag" || "$release_tag" == "" ]]; 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) +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,7 +101,7 @@ 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") @@ -125,7 +132,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" diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh new file mode 100755 index 000000000..1134d9271 --- /dev/null +++ b/scripts/ci/native-release-rolling-tags.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Move the rolling tags of a final native release (Server #325) forward to its +# verified index, in each registry. A release candidate (-rc.N) moves none. +# Each alias moves only to the highest final native release it covers: +# - X.Y to the highest native-X.Y.* release, +# - X to the highest native-X.* release, +# - latest to the highest native release, when it is also above every PHP +# release (plain X.Y.Z tags). +# A lower or older release leaves an alias where it is, so no alias moves +# backward. The PHP Release workflow leaves latest alone once a native final +# release is above it. +# Env: RELEASE_TAG (native-), INDEX_DIGEST (the verified index), +# GHCR_IMAGE, DOCKERHUB_IMAGE, and the known tags as RELEASE_IMAGE_KNOWN_TAGS +# (one per line) or RELEASE_IMAGE_TAG_SOURCE_URL (git ls-remote). DOCKER +# overrides the docker command. +set -euo pipefail +fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } +docker_bin=${DOCKER:-docker} + +[[ "${RELEASE_TAG:-}" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ + || fail "RELEASE_TAG must be native-." +version=${BASH_REMATCH[1]} +if [[ -n "${BASH_REMATCH[2]}" ]]; then + printf 'Native %s is not a final release; no rolling tag moves.\n' "$version" + printf 'rolling_tags=\n' >> "${GITHUB_OUTPUT:-/dev/null}" + exit 0 +fi +[[ "${INDEX_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "INDEX_DIGEST must be the verified index digest." +: "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" + +if [[ -n "${RELEASE_IMAGE_KNOWN_TAGS:-}" ]]; then + known=$RELEASE_IMAGE_KNOWN_TAGS +elif [[ -n "${RELEASE_IMAGE_TAG_SOURCE_URL:-}" ]]; then + known=$(git ls-remote --tags --refs "$RELEASE_IMAGE_TAG_SOURCE_URL" | sed 's#.*refs/tags/##') \ + || fail "cannot list release tags from $RELEASE_IMAGE_TAG_SOURCE_URL." +else + fail "set RELEASE_IMAGE_KNOWN_TAGS or RELEASE_IMAGE_TAG_SOURCE_URL." +fi + +natives=$( { sed -n 's/^native-\([0-9]*\.[0-9]*\.[0-9]*\)$/\1/p' <<<"$known"; printf '%s\n' "$version"; } | sort -u -V) +php_latest=$(grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$known" | sort -u -V | tail -n 1 || true) +major=${version%%.*} +minor=${version#*.}; minor="$major.${minor%%.*}" + +highest() { grep -E "^$1" <<<"$natives" | tail -n 1; } +aliases=() +[[ "$(highest "${minor//./\\.}\\.")" == "$version" ]] && aliases+=("$minor") +[[ "$(highest "$major\\.")" == "$version" ]] && aliases+=("$major") +if [[ "$(tail -n 1 <<<"$natives")" == "$version" ]] \ + && [[ -z "$php_latest" || "$(printf '%s\n%s\n' "$php_latest" "$version" | sort -V | tail -n 1)" == "$version" ]]; then + aliases+=(latest) +fi + +if ((${#aliases[@]} == 0)); then + printf 'Native %s is below a newer native release on every alias; no rolling tag moves.\n' "$version" + printf 'rolling_tags=\n' >> "${GITHUB_OUTPUT:-/dev/null}" + exit 0 +fi + +for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + args=() + for alias in "${aliases[@]}"; do args+=(--tag "$image:$alias"); done + "$docker_bin" buildx imagetools create "${args[@]}" "$image@$INDEX_DIGEST" +done + +# Each moved alias names exactly the verified index. +for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + for alias in "${aliases[@]}"; do + digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + [[ "$digest" == "$INDEX_DIGEST" ]] || fail "$image:$alias is $digest, not $INDEX_DIGEST." + done +done + +printf 'rolling_tags=%s\n' "${aliases[*]}" >> "${GITHUB_OUTPUT:-/dev/null}" +{ + printf -- '- rolling tags %s moved to `%s` in `%s` and `%s`\n' \ + "$(printf '`%s` ' "${aliases[@]}")" "$INDEX_DIGEST" "$GHCR_IMAGE" "$DOCKERHUB_IMAGE" +} >> "${GITHUB_STEP_SUMMARY:-/dev/null}" +printf 'Native %s: moved %s.\n' "$version" "${aliases[*]}" diff --git a/scripts/ci/promote-release-image-rolling-tags.sh b/scripts/ci/promote-release-image-rolling-tags.sh index 0e8995549..358e20683 100755 --- a/scripts/ci/promote-release-image-rolling-tags.sh +++ b/scripts/ci/promote-release-image-rolling-tags.sh @@ -59,6 +59,15 @@ collect_release_tags() { printf '%s\n' "$raw_tags" | normalize_tag_lines } +# Native final releases (native-X.Y.Z git tags, Server #325) own latest once +# one is above this PHP release; a PHP release then keeps its own X and X.Y +# aliases and leaves latest alone, so latest never moves back to PHP. +native_final_owns_latest() { + native_latest="$(collect_release_tags | sed -n 's/^native-\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' | sort -u -V | tail -n 1)" + [ -n "$native_latest" ] || return 1 + [ "$(printf '%s\n%s\n' "$native_latest" "$release_tag" | sort -V | tail -n 1)" != "$release_tag" ] +} + latest_known_stable_tag() { { collect_release_tags @@ -105,6 +114,11 @@ if [ -n "${RELEASE_IMAGE_KNOWN_TAGS:-}" ] || [ -n "$tag_source_url" ]; then fi fi +latest_alias="true" +if native_final_owns_latest; then + latest_alias="false" +fi + promote_image() { image="$1" source_ref="${image}:${release_tag}" @@ -112,11 +126,18 @@ promote_image() { source_ref="${image}@${verified_image_digest}" fi - "$docker_bin" buildx imagetools create \ - --tag "${image}:${minor_alias}" \ - --tag "${image}:${major_alias}" \ - --tag "${image}:latest" \ - "$source_ref" + if [ "$latest_alias" = "true" ]; then + "$docker_bin" buildx imagetools create \ + --tag "${image}:${minor_alias}" \ + --tag "${image}:${major_alias}" \ + --tag "${image}:latest" \ + "$source_ref" + else + "$docker_bin" buildx imagetools create \ + --tag "${image}:${minor_alias}" \ + --tag "${image}:${major_alias}" \ + "$source_ref" + fi } promote_image "$dockerhub_image" @@ -125,6 +146,12 @@ promote_image "$ghcr_image" write_output "rolling_should_promote" "true" write_output "artifact_status" "current" write_output "superseded_by" "" - -printf 'Promoted rolling image aliases %s, %s, and latest from exact tag %s for %s and %s.\n' \ - "$minor_alias" "$major_alias" "$release_tag" "$dockerhub_image" "$ghcr_image" +write_output "latest_alias" "$latest_alias" + +if [ "$latest_alias" = "true" ]; then + printf 'Promoted rolling image aliases %s, %s, and latest from exact tag %s for %s and %s.\n' \ + "$minor_alias" "$major_alias" "$release_tag" "$dockerhub_image" "$ghcr_image" +else + printf 'Promoted rolling image aliases %s and %s from exact tag %s for %s and %s; native release %s owns latest.\n' \ + "$minor_alias" "$major_alias" "$release_tag" "$dockerhub_image" "$ghcr_image" "$native_latest" +fi diff --git a/scripts/ci/resolve-release-image-rolling-tags.sh b/scripts/ci/resolve-release-image-rolling-tags.sh index 27a096eb9..0535f56d5 100755 --- a/scripts/ci/resolve-release-image-rolling-tags.sh +++ b/scripts/ci/resolve-release-image-rolling-tags.sh @@ -81,6 +81,15 @@ collect_release_tags() { printf '%s\n' "$raw_tags" | normalize_tag_lines } +# Native final releases (native-X.Y.Z git tags, Server #325) own latest once +# one is above this PHP release; a PHP release then keeps its own X and X.Y +# aliases and leaves latest alone, so latest never moves back to PHP. +native_final_owns_latest() { + native_latest="$(collect_release_tags | sed -n 's/^native-\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' | sort -u -V | tail -n 1)" + [ -n "$native_latest" ] || return 1 + [ "$(printf '%s\n%s\n' "$native_latest" "$release_tag" | sort -V | tail -n 1)" != "$release_tag" ] +} + if [ -z "$release_tag" ]; then fail "Release tag required" "Cannot resolve rolling image aliases without RELEASE_TAG." fi @@ -117,15 +126,27 @@ if [ -z "$latest_tag" ]; then fail "Release tag source unavailable" "No stable semver release tags were found while resolving rolling image aliases." fi -rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s' \ - "$dockerhub_image" "$minor_alias" \ - "$dockerhub_image" "$major_alias" \ - "$dockerhub_image" "latest" \ - "$ghcr_image" "$minor_alias" \ - "$ghcr_image" "$major_alias" \ - "$ghcr_image" "latest")" +if native_final_owns_latest; then + latest_alias="false" + rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s' \ + "$dockerhub_image" "$minor_alias" \ + "$dockerhub_image" "$major_alias" \ + "$ghcr_image" "$minor_alias" \ + "$ghcr_image" "$major_alias")" + printf 'Native release %s is above %s and owns latest; this release leaves latest unchanged.\n' "$native_latest" "$release_tag" +else + latest_alias="true" + rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s' \ + "$dockerhub_image" "$minor_alias" \ + "$dockerhub_image" "$major_alias" \ + "$dockerhub_image" "latest" \ + "$ghcr_image" "$minor_alias" \ + "$ghcr_image" "$major_alias" \ + "$ghcr_image" "latest")" +fi write_output "rolling_eligible" "true" +write_output "latest_alias" "$latest_alias" write_output "major_alias" "$major_alias" write_output "minor_alias" "$minor_alias" write_multiline_output "rolling_refs" "$rolling_refs" diff --git a/scripts/ci/write-release-image-publish-evidence.sh b/scripts/ci/write-release-image-publish-evidence.sh index a27e69f28..8a65257b1 100755 --- a/scripts/ci/write-release-image-publish-evidence.sh +++ b/scripts/ci/write-release-image-publish-evidence.sh @@ -21,6 +21,8 @@ rolling_promote_outcome="${ROLLING_PROMOTE_OUTCOME:-skipped}" rolling_status="${ROLLING_ARTIFACT_STATUS:-}" rolling_should_promote="${ROLLING_SHOULD_PROMOTE:-false}" superseded_by="${ROLLING_SUPERSEDED_BY:-}" +# false when a native final release owns latest (Server #325). +latest_alias="${ROLLING_LATEST_ALIAS:-true}" image_digest="${IMAGE_DIGEST:-}" release_commit="${RELEASE_COMMIT:-}" run_id="${RELEASE_RUN_ID:-}" @@ -182,13 +184,21 @@ if is_stable_semver_tag "$release_tag"; then minor_alias="${major}.${minor}" major_alias="$major" - rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s' \ - "$dockerhub_image" "$minor_alias" \ - "$dockerhub_image" "$major_alias" \ - "$dockerhub_image" "latest" \ - "$ghcr_image" "$minor_alias" \ - "$ghcr_image" "$major_alias" \ - "$ghcr_image" "latest")" + if [ "$latest_alias" = "false" ]; then + rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s' \ + "$dockerhub_image" "$minor_alias" \ + "$dockerhub_image" "$major_alias" \ + "$ghcr_image" "$minor_alias" \ + "$ghcr_image" "$major_alias")" + else + rolling_refs="$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s' \ + "$dockerhub_image" "$minor_alias" \ + "$dockerhub_image" "$major_alias" \ + "$dockerhub_image" "latest" \ + "$ghcr_image" "$minor_alias" \ + "$ghcr_image" "$major_alias" \ + "$ghcr_image" "latest")" + fi fi if ! is_stable_semver_tag "$release_tag"; then diff --git a/tests/Unit/ReleaseImageContinuationTest.php b/tests/Unit/ReleaseImageContinuationTest.php index b4d83daff..db2c2d300 100644 --- a/tests/Unit/ReleaseImageContinuationTest.php +++ b/tests/Unit/ReleaseImageContinuationTest.php @@ -185,6 +185,29 @@ public function test_rolling_aliases_use_verified_bytes_and_never_write_the_exac } } + public function test_a_native_final_release_keeps_latest_from_a_later_php_release(): void + { + $result = $this->runScript('promote-release-image-rolling-tags.sh', [ + 'RELEASE_TAG' => '2.11.9', 'VERIFIED_IMAGE_DIGEST' => self::DIGEST, + 'RELEASE_IMAGE_KNOWN_TAGS' => "2.11.8\n2.11.9\nnative-3.0.0-rc.2\nnative-3.0.0", + ]); + self::assertSame(0, $result['status'], $result['text']); + self::assertStringContainsString('latest_alias=false', $result['output']); + foreach (['durableworkflow/server', 'ghcr.io/durable-workflow/server'] as $image) { + self::assertStringContainsString('--tag '.$image.':2.11', $result['calls']); + self::assertStringContainsString('--tag '.$image.':2 ', $result['calls']); + self::assertStringNotContainsString($image.':latest', $result['calls']); + } + + $candidate = $this->runScript('promote-release-image-rolling-tags.sh', [ + 'RELEASE_TAG' => '2.11.9', 'VERIFIED_IMAGE_DIGEST' => self::DIGEST, + 'RELEASE_IMAGE_KNOWN_TAGS' => "2.11.8\n2.11.9\nnative-3.0.0-rc.2", + ]); + self::assertSame(0, $candidate['status'], $candidate['text']); + self::assertStringContainsString('latest_alias=true', $candidate['output']); + self::assertSame(2, substr_count($candidate['calls'], ':latest')); + } + public function test_invalid_alias_digest_stops_before_registry_writes(): void { $result = $this->runScript('promote-release-image-rolling-tags.sh', [ From a3f7886483e8f5eb79667208bfc86c80b7456eda Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:30:07 +0000 Subject: [PATCH 2/3] Rust #325: retry-safe, serialized aliases; release identity; no-auth refused Corrections from Security's and General's review of #919: - Rolling aliases are their own job (needs publish), rerunnable alone from the attested index: it first checks both exact tags name that index. One repository-wide concurrency group serializes alias writes across native releases, and each alias's current release (its image's release tag label) is rechecked in both registries right before writing, so an older release that read stale Git tags cannot move 3 or latest back. An alias whose release cannot be read stops the run before any write. - The PHP promoter and resolver read the tag inventory once with a checked status and derive both the PHP ordering and native ownership from it; a failed lookup exits nonzero before any registry write. - native-release-identity.sh admits a release image only by a strict native-X.Y.Z[-rc.N] label, and the smoke requires every backend's cluster info to report exactly that version. The receipt records the release tag, version and opt_in_required as they are. - A release build refuses DW_AUTH_DRIVER=none (and the legacy name) before storage opens or the listener binds; the smoke checks it. - The takeover runbook publishes the API on loopback, with TLS guidance. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image.yml | 2 + .github/workflows/native-release.yml | 51 +++++++++- Dockerfile.native | 2 +- docs/rust-server-upgrade-takeover.md | 11 ++- docs/server-reference.md | 6 +- rust/README.md | 5 +- rust/src/main.rs | 26 ++++- scripts/ci/check-native-release-tags.sh | 64 +++++++++++- scripts/ci/native-image-smoke.sh | 24 ++++- scripts/ci/native-release-identity.sh | 37 +++++++ scripts/ci/native-release-rolling-tags.sh | 97 ++++++++++++++----- .../ci/promote-release-image-rolling-tags.sh | 14 ++- .../ci/resolve-release-image-rolling-tags.sh | 10 +- 13 files changed, 300 insertions(+), 49 deletions(-) create mode 100755 scripts/ci/native-release-identity.sh diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index b23ef6a8d..026085a85 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -14,6 +14,8 @@ on: - 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 diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index d3b5eb923..9ea8bfe02 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -262,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 @@ -314,12 +316,51 @@ jobs: 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; an older release leaves each alias where it is. - - name: Move the rolling tags of a final release - if: needs.plan.outputs.dockerhub == '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: ${{ steps.index.outputs.digest }} + 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/Dockerfile.native b/Dockerfile.native index e9f09443f..37d897f7e 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -49,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-upgrade-takeover.md b/docs/rust-server-upgrade-takeover.md index cabe23964..3a1f08d29 100644 --- a/docs/rust-server-upgrade-takeover.md +++ b/docs/rust-server-upgrade-takeover.md @@ -107,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/server-reference.md b/docs/server-reference.md index 2fd08cb71..e83e21b28 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -1398,7 +1398,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 4fcb6fdb6..83d96f3cc 100644 --- a/rust/README.md +++ b/rust/README.md @@ -20,7 +20,10 @@ does not select release optimization or remove the later profiling requirement. 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. With no role tokens, the legacy credential keeps compatibility +`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; diff --git a/rust/src/main.rs b/rust/src/main.rs index 8ff51de8b..27643eb5b 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 @@ -614,5 +624,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 b8a923b26..6bd6c9245 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -68,18 +68,30 @@ expect "PUBLISH_DOCKERHUB must be true or false" RELEASE_TAG=native-2.12.0-rc.1 # 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" -if [[ "$*" == *"imagetools inspect"* ]]; then printf '%s\n' "$INDEX_DIGEST"; fi +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 -> moved aliases (as written to GITHUB_OUTPUT) +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" \ - bash scripts/ci/native-release-rolling-tags.sh >/dev/null + 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'."; } @@ -90,8 +102,41 @@ 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 @@ -103,4 +148,17 @@ php_latest() { # known tags -> 'latest' if the PHP promoter writes latest } 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" 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 0b3831c5a..c76d72be3 100755 --- a/scripts/ci/native-image-smoke.sh +++ b/scripts/ci/native-image-smoke.sh @@ -56,13 +56,27 @@ jq -e '.User=="1000:1000" and .StopSignal=="SIGTERM" and (.ExposedPorts|has("808 # 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_tag" || "$release_tag" == "" ]]; then +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 @@ -105,13 +119,15 @@ serve() { 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=() @@ -141,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