diff --git a/.changeset/core-cross-environment-start-guard.md b/.changeset/core-cross-environment-start-guard.md new file mode 100644 index 0000000000..b00c223b46 --- /dev/null +++ b/.changeset/core-cross-environment-start-guard.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': minor +--- + +Stamp the creating client's environment into the queue message's `runInput`, and refuse a queue delivery whose run was created in a different environment than the consuming deployment runs in, so one run ID can no longer be forked into two environments. diff --git a/.changeset/event-count-guard.md b/.changeset/event-count-guard.md new file mode 100644 index 0000000000..cc10ff8fca --- /dev/null +++ b/.changeset/event-count-guard.md @@ -0,0 +1,9 @@ +--- +'workflow': minor +'@workflow/core': minor +'@workflow/world-vercel': minor +'@workflow/world': minor +'@workflow/errors': minor +--- + +Strengthen the event-creation precondition guard: replay-context writes now also send the number of loaded events, so a snapshot that is missing an event is rejected instead of committing a divergent event log, and a rejection restarts the replay in-process (consuming the events a world may attach to the rejection) rather than re-committing the rejected payload or re-invoking over the queue. `@workflow/world-local` and `@workflow/world-postgres` do not implement the check. diff --git a/.changeset/report-replay-recovery.md b/.changeset/report-replay-recovery.md new file mode 100644 index 0000000000..800eb76b55 --- /dev/null +++ b/.changeset/report-replay-recovery.md @@ -0,0 +1,7 @@ +--- +'@workflow/core': patch +'@workflow/world': patch +'@workflow/world-vercel': patch +--- + +Report replay-divergence counts on event writes that recover or exhaust replay retries. diff --git a/.changeset/resilient-resume-hook.md b/.changeset/resilient-resume-hook.md new file mode 100644 index 0000000000..8fc24b48de --- /dev/null +++ b/.changeset/resilient-resume-hook.md @@ -0,0 +1,7 @@ +--- +"@workflow/core": minor +"workflow": minor +"@workflow/world": minor +--- + +Make `resumeHook()` resilient to transient `hook_received` event write failures (429/5xx) by carrying the payload on the queue message for the runtime to materialize. Returned `Hook` gets a new `resilientResume: true` flag when this fallback path is taken. diff --git a/.changeset/skip-changeset-release-deploys.md b/.changeset/skip-changeset-release-deploys.md new file mode 100644 index 0000000000..88d2fa197f --- /dev/null +++ b/.changeset/skip-changeset-release-deploys.md @@ -0,0 +1,4 @@ +--- +--- + +Stop deploying the `changeset-release/main` branch, and run that PR's Vercel e2e lanes against main's production deployment instead. diff --git a/.changeset/world-get-environment.md b/.changeset/world-get-environment.md new file mode 100644 index 0000000000..831f713702 --- /dev/null +++ b/.changeset/world-get-environment.md @@ -0,0 +1,5 @@ +--- +'@workflow/world': minor +--- + +Add an optional `World.getEnvironment()` hook and an optional `environment` field on `RunInputSchema`, so the environment a run was created in can travel with its queue message. diff --git a/.changeset/world-local-bounded-deliveries.md b/.changeset/world-local-bounded-deliveries.md new file mode 100644 index 0000000000..47499e6a22 --- /dev/null +++ b/.changeset/world-local-bounded-deliveries.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-local': patch +--- + +Bound stalled local queue deliveries and redeliver their durable messages instead of waiting indefinitely. diff --git a/.changeset/world-vercel-get-environment.md b/.changeset/world-vercel-get-environment.md new file mode 100644 index 0000000000..7b7295ac81 --- /dev/null +++ b/.changeset/world-vercel-get-environment.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': minor +--- + +Implement `getEnvironment()`, reporting the environment this client's writes are attributed to from the same resolution that produces the `x-vercel-environment` request header. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 537b35f826..9a400c7a25 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -46,6 +46,7 @@ jobs: needs: ci-scope if: >- github.event_name == 'pull_request' && + !startsWith(github.head_ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'workflow-server-test') && needs.ci-scope.outputs.fast-path != 'true' timeout-minutes: 5 @@ -103,7 +104,14 @@ jobs: name: Benchmark (${{ matrix.target.world }}, ${{ matrix.target.app }}) runs-on: ubuntu-latest needs: ci-scope + # `changeset-release/*` PRs are not deployed (every project's vercel.json + # sets `git.deploymentEnabled` false for that branch, because the branch is + # force-pushed to main's HEAD SHA and a same-SHA preview deployment + # clobbers the per-SHA Vercel commit status), so there is nothing to wait + # for below. Benchmarking them would also be meaningless: the code is + # main's, so it would only compare main's baseline against itself. if: >- + !startsWith(github.head_ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'workflow-server-test') && (needs.ci-scope.outputs.fast-path != 'true' || github.event_name == 'workflow_dispatch') @@ -185,6 +193,7 @@ jobs: if: >- always() && !cancelled() && github.event_name == 'pull_request' && + !startsWith(github.head_ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'workflow-server-test') && needs.ci-scope.outputs.fast-path != 'true' timeout-minutes: 10 diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml index bf0b86d6a2..baf2e899bd 100644 --- a/.github/workflows/docs-checks.yml +++ b/.github/workflows/docs-checks.yml @@ -46,6 +46,13 @@ jobs: docs-preview-smoke: name: Docs Preview Smoke Checks runs-on: ubuntu-latest + # `changeset-release/*` PRs are not deployed (every project's vercel.json + # sets `git.deploymentEnabled` false for that branch, because the branch is + # force-pushed to main's HEAD SHA and a same-SHA preview deployment + # clobbers the per-SHA Vercel commit status), so there is no preview to + # smoke-test. Their docs content is identical to main's, which this job + # already checked against production on the push that produced the bump. + if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} timeout-minutes: 5 permissions: id-token: write diff --git a/.github/workflows/tarballs-checks.yml b/.github/workflows/tarballs-checks.yml index e4df6fa34d..f2945c6698 100644 --- a/.github/workflows/tarballs-checks.yml +++ b/.github/workflows/tarballs-checks.yml @@ -17,6 +17,13 @@ jobs: tarballs-preview-smoke: name: Tarballs Preview Smoke Checks runs-on: ubuntu-latest + # `changeset-release/*` PRs are not deployed (every project's vercel.json + # sets `git.deploymentEnabled` false for that branch, because the branch is + # force-pushed to main's HEAD SHA and a same-SHA preview deployment + # clobbers the per-SHA Vercel commit status), so there is no preview to + # smoke-test. The version-bumped tarballs get this same check on the push + # to main that follows the version PR merge. + if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd4967820a..f45b94544a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -379,8 +379,18 @@ jobs: - name: Build CLI run: pnpm turbo run build --filter='@workflow/cli' + # `changeset-release/*` PRs (the changesets "Version Packages" PR) are + # deliberately not deployed: the branch is force-pushed and can point at + # main's HEAD SHA, and Vercel keeps one commit status per project per + # SHA, so a same-SHA preview deployment overwrites the production one + # that the step below reads the deployment ID from. Every project's + # vercel.json therefore sets `git.deploymentEnabled` false for that + # branch, which leaves these PRs with no deployment of their own to wait + # for. Their content is main plus a version-bump commit, so they run + # against the production deployment main already produced instead. - name: Waiting for the Vercel deployment id: waitForDeployment + if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 with: project-slug: ${{ matrix.app.project-slug }} @@ -388,6 +398,24 @@ jobs: check-interval: 15 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + # The base SHA, not the PR head: that is the main commit whose + # production deployment carries the code under test. Waiting on it also + # means a production build still in flight is waited out instead of + # testing whatever happens to be aliased to production. The commit-status + # ID this action reads is unambiguous for main SHAs once this repo stops + # deploying `changeset-release/main` (see above) — that branch was the + # only one that ever deployed a commit main also deployed. + - name: Waiting for the Vercel deployment (changeset-release PR) + id: prodDeployment + if: ${{ startsWith(github.head_ref, 'changeset-release/') }} + uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 + with: + project-slug: ${{ matrix.app.project-slug }} + timeout: 1000 + check-interval: 15 + environment: production + sha: ${{ github.event.pull_request.base.sha }} + - name: Record E2E start time id: e2eStart run: echo "ms=$(($(date +%s) * 1000))" >> "$GITHUB_OUTPUT" @@ -396,10 +424,12 @@ jobs: run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-prod-$APP_NAME.json" env: NODE_OPTIONS: "--enable-source-maps" - DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url }} - VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id }} + DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url || steps.prodDeployment.outputs.deployment-url }} + VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }} APP_NAME: ${{ matrix.app.name }} - WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + # changeset-release PRs test main's production deployment, so they + # must be treated as a production run everywhere downstream. + WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }} WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} WORKFLOW_VERCEL_TEAM: "team_nO2mCG4W8IxPIeKoSsqwAxxB" WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }} @@ -413,8 +443,11 @@ jobs: # a single pre-minted token that would expire mid-suite. # See: https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/trusted-sources # Point PRs at the protected workflow-server preview; unset on main - # so production runs hit the public vercel-workflow.com URL. - VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref != 'refs/heads/main' && secrets.VERCEL_WORKFLOW_SERVER_URL || '' }} + # so production runs hit the public vercel-workflow.com URL. Also + # unset for changeset-release PRs: they test a production deployment + # that is wired to the production workflow-server, and the harness + # has to read run state from the same server the app writes it to. + VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref != 'refs/heads/main' && !startsWith(github.head_ref, 'changeset-release/') && secrets.VERCEL_WORKFLOW_SERVER_URL || '' }} - name: Capture runtime logs on failure if: failure() @@ -423,8 +456,8 @@ jobs: WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} WORKFLOW_VERCEL_TEAM: "team_nO2mCG4W8IxPIeKoSsqwAxxB" WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }} - WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} - VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id }} + WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }} + VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }} E2E_START_MS: ${{ steps.e2eStart.outputs.ms }} run: node .github/scripts/fetch-e2e-runtime-logs.mjs @@ -485,8 +518,11 @@ jobs: - name: Build CLI run: pnpm turbo run build --filter='@workflow/cli' + # See the note on e2e-vercel-prod: `changeset-release/*` PRs have no + # deployment of their own and test main's production deployment. - name: Waiting for the Vercel deployment id: waitForDeployment + if: ${{ !startsWith(github.head_ref, 'changeset-release/') }} uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 with: project-slug: "example-nextjs-workflow-turbopack" @@ -494,21 +530,34 @@ jobs: check-interval: 15 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + - name: Waiting for the Vercel deployment (changeset-release PR) + id: prodDeployment + if: ${{ startsWith(github.head_ref, 'changeset-release/') }} + uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 + with: + project-slug: "example-nextjs-workflow-turbopack" + timeout: 1000 + check-interval: 15 + environment: production + sha: ${{ github.event.pull_request.base.sha }} + - name: Run Multi-Region E2E Tests run: pnpm vitest run packages/core/e2e/e2e-region.test.ts --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-multi-region-$APP_NAME.json" env: NODE_OPTIONS: "--enable-source-maps" - DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url }} - VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id }} + DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url || steps.prodDeployment.outputs.deployment-url }} + VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }} APP_NAME: "nextjs-turbopack" - WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + # changeset-release PRs test main's production deployment, so they + # must be treated as a production run everywhere downstream. + WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }} WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} WORKFLOW_VERCEL_TEAM: "team_nO2mCG4W8IxPIeKoSsqwAxxB" WORKFLOW_VERCEL_PROJECT: "prj_yjkM7UdHliv8bfxZ1sMJQf1pMpdi" WORKFLOW_VERCEL_PROJECT_SLUG: "example-nextjs-workflow-turbopack" # See the note on e2e-vercel-prod: PRs point at the protected # workflow-server preview; unset on main for production. - VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref != 'refs/heads/main' && secrets.VERCEL_WORKFLOW_SERVER_URL || '' }} + VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref != 'refs/heads/main' && !startsWith(github.head_ref, 'changeset-release/') && secrets.VERCEL_WORKFLOW_SERVER_URL || '' }} - name: Capture runtime logs on failure if: failure() @@ -517,8 +566,8 @@ jobs: WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} WORKFLOW_VERCEL_TEAM: "team_nO2mCG4W8IxPIeKoSsqwAxxB" WORKFLOW_VERCEL_PROJECT: "prj_yjkM7UdHliv8bfxZ1sMJQf1pMpdi" - WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} - VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id }} + WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }} + VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }} run: node .github/scripts/fetch-e2e-runtime-logs.mjs - name: Upload E2E results diff --git a/AGENTS.md b/AGENTS.md index 23bffde13b..78a02fb262 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,14 @@ Both branches trigger the release workflow (`.github/workflows/release.yml`) on When backporting changes to `stable`, any conflicts involving docs app files (outside of `docs/content/`) or `skills/` files should be resolved by keeping the `stable` branch version (discarding the incoming change from `main`). Conflicts in `docs/content/` should be resolved normally. The backport GitHub Action handles this automatically. +#### The `changeset-release/main` branch is never deployed + +Every Vercel project rooted in this repo sets `git.deploymentEnabled` to `false` for `changeset-release/main` in its `vercel.json`. **When you add a new Vercel project, add that key to its `vercel.json` too.** + +The changesets action force-pushes `changeset-release/main`, and it can point at exactly main's HEAD SHA. Vercel keeps one commit status per project per SHA, so a preview deployment of that branch overwrites the production deployment's status for the same commit — and `vercel/wait-for-deployment-action`, which reads the deployment ID out of that status, then hands a production e2e run a preview deployment ID, forking the run across environments. + +Because those PRs have no deployment of their own, CI treats them specially: the Vercel e2e lanes in `tests.yml` run `vercel/wait-for-deployment-action` a second way — `environment: production` with `sha` pinned to the PR's base SHA — so they test main's production deployment and run as `production`. (The commit-status ID that action reads is unambiguous for main SHAs precisely because this repo no longer deploys `changeset-release/main`, the only branch that ever deployed a commit main also deployed.) The deployment-dependent jobs in `docs-checks.yml`, `tarballs-checks.yml`, and `benchmarks.yml` are skipped. Anything new that waits on a deployment needs the same treatment. + ### Changesets - `workflow` and `@workflow/core` use changesets' "fixed" versioning strategy — they always have the same version number diff --git a/docs/app/[lang]/(home)/components/cta.tsx b/docs/app/[lang]/(home)/components/cta.tsx index cc9cddd8fd..574fbd865e 100644 --- a/docs/app/[lang]/(home)/components/cta.tsx +++ b/docs/app/[lang]/(home)/components/cta.tsx @@ -2,11 +2,11 @@ import { Button } from '@vercel/geistdocs/components/button'; import Link from 'next/link'; export const CTA = () => ( -
-

+
+

Create your first workflow today.

-
diff --git a/docs/app/[lang]/(home)/components/features.tsx b/docs/app/[lang]/(home)/components/features.tsx index ece1bfbcba..48c027532b 100644 --- a/docs/app/[lang]/(home)/components/features.tsx +++ b/docs/app/[lang]/(home)/components/features.tsx @@ -17,12 +17,10 @@ const data = [ ]; export const Features = () => ( -
+
{data.map((item) => (
-

- {item.title} -

+

{item.title}

{item.description}

))} diff --git a/docs/app/[lang]/(home)/components/frameworks.tsx b/docs/app/[lang]/(home)/components/frameworks.tsx index d727e0cad9..c063e3b801 100644 --- a/docs/app/[lang]/(home)/components/frameworks.tsx +++ b/docs/app/[lang]/(home)/components/frameworks.tsx @@ -719,16 +719,16 @@ export const Next = (props: ComponentProps<'svg'>) => ( export const Frameworks = () => { return ( -
+
-

+

Universally Compatible

Works with the frameworks you already use with more coming soon.

-
+
{ return ( -
+
-

+

{title}

-

+

use workflow {' '} diff --git a/docs/app/[lang]/(home)/components/implementation.tsx b/docs/app/[lang]/(home)/components/implementation.tsx index eb665bfce7..271410c43a 100644 --- a/docs/app/[lang]/(home)/components/implementation.tsx +++ b/docs/app/[lang]/(home)/components/implementation.tsx @@ -27,7 +27,7 @@ export async function userSignup(email) { }, { code: `import { Resend } from 'resend'; -import { FatalError } from 'workflow'; +import { FatalError } from 'workflow'; export async function sendWelcomeEmail(email) { "use step" @@ -52,22 +52,22 @@ export async function sendWelcomeEmail(email) { ]; export const Implementation = () => ( -

+
-

+

Effortless setup

With a simple declarative API to define and use your workflows.

-
+
{data.map((item) => (
-

+

{item.caption}

{ ); return ( -
-
-

+
+
+

Reliability-as-code

@@ -154,7 +154,7 @@ export const Intro = async () => { code with simple directives.

-
+
+
diff --git a/docs/app/[lang]/(home)/components/intro/workflow.tsx b/docs/app/[lang]/(home)/components/intro/workflow.tsx index 7dd7db61c0..4be4e05959 100644 --- a/docs/app/[lang]/(home)/components/intro/workflow.tsx +++ b/docs/app/[lang]/(home)/components/intro/workflow.tsx @@ -154,7 +154,7 @@ export const WorkflowExample = ({ }; return ( -
+
diff --git a/docs/app/[lang]/(home)/components/observability.tsx b/docs/app/[lang]/(home)/components/observability.tsx index 3721b2eacf..9d671ae7a8 100644 --- a/docs/app/[lang]/(home)/components/observability.tsx +++ b/docs/app/[lang]/(home)/components/observability.tsx @@ -49,7 +49,7 @@ const rows = [ ]; export const Observability = () => ( -
+

Observability. Inspect every run end‑to‑end. Pause, replay, and time‑travel through steps with traces, diff --git a/docs/app/[lang]/(home)/components/run-anywhere.tsx b/docs/app/[lang]/(home)/components/run-anywhere.tsx index 98aff92911..05f6b23f07 100644 --- a/docs/app/[lang]/(home)/components/run-anywhere.tsx +++ b/docs/app/[lang]/(home)/components/run-anywhere.tsx @@ -116,10 +116,10 @@ const code = `export async function welcome(userId: string) { }`; export const RunAnywhere = () => ( -
+
-
-

+
+

Run anywhere, no lock‑in

@@ -127,7 +127,7 @@ export const RunAnywhere = () => ( fully portable. For zero-config, secure, and scalable workflows, deploy on Vercel.

-
+
-
+
{[DigitalOcean, AWS].map((Logo, index) => (
( }} />
-
+
{[Docker, Vercel].map((Logo, index) => (
( -
-
-

+
+
+

Get started

-

+

See Workflow SDK in action with one of the example templates.

-
+
{data.map((item) => (
@@ -236,11 +236,11 @@ function TweetCard({ url, name, username, image, tweet }: Tweet) { } export const TweetWall = () => ( -
-

+
+

What builders say about Workflow SDK

-
+
{TWEETS.map((tweet) => (
diff --git a/docs/app/[lang]/(home)/components/use-cases-client.tsx b/docs/app/[lang]/(home)/components/use-cases-client.tsx index 2f1dcb1bb9..b7ce0f0483 100644 --- a/docs/app/[lang]/(home)/components/use-cases-client.tsx +++ b/docs/app/[lang]/(home)/components/use-cases-client.tsx @@ -27,12 +27,12 @@ export const UseCasesClient = ({ useCases }: { useCases: UseCase[] }) => { }; return ( -
-
-

+
+
+

Build anything with