Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/native-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 55 additions & 2 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
9 changes: 5 additions & 4 deletions Dockerfile.native
Original file line number Diff line number Diff line change
@@ -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-<version> 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,
Expand Down Expand Up @@ -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"
60 changes: 35 additions & 25 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
25 changes: 16 additions & 9 deletions docs/rust-server-upgrade-takeover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/rust-server-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/server-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,23 @@ fn validate_auth_configuration() -> Result<(), Box<dyn Error>> {
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
Expand All @@ -235,9 +245,17 @@ fn main() -> Result<(), Box<dyn Error>> {
.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<dyn Error>> {
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
Expand Down Expand Up @@ -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());
}
}

Loading
Loading