Skip to content

feat(deploy): --external-id and --force for deploy idempotency - #4663

Open
0ski wants to merge 1 commit into
oskar/feat-external-id-apifrom
oskar/feat-external-id-deploy
Open

feat(deploy): --external-id and --force for deploy idempotency#4663
0ski wants to merge 1 commit into
oskar/feat-external-id-apifrom
oskar/feat-external-id-deploy

Conversation

@0ski

@0ski 0ski commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

A deploy can carry an opaque external id (commit SHA, CI run id, release tag). Repeating an id that already deployed returns the existing version as a no-op instead of rebuilding; an id with a build in flight is rejected with 409 naming that version; a failed id rebuilds freely. --force is non-destructive to deployments that already succeeded - both persist and the higher version wins - but cancels a build still in flight, so one id never has two live builds racing to define it. Cancelling writes a terminal status and appends a finalized event, which aborts a build the platform drives; a build it does not drive keeps running but can never land, and the CLI says so. Ids are deliberately not unique - reuse is resolved in application code by highest version, never timestamps. The no-op path mints no build credentials and no event stream (TRI-12923).

What that means for callers: a --force rebuild leaves two deployments holding one id, and runs triggered with it go to the higher version once the rebuild lands, so the takeover needs no separate promotion. Until a successful build exists for an id, runs triggered with it park and then expire rather than falling back to current - a failed build is therefore visible to the caller as expired runs, not as runs on the wrong release.

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2e065ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
trigger.dev Patch
@internal/dashboard-agent Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/python Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/testcontainers Patch
@internal/cache Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38842a54-3a7a-465f-902a-0541d6ae98e1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@0ski
0ski marked this pull request as ready for review August 18, 2026 13:15
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread .changeset/deploy-external-id.md Outdated
A deploy can carry an opaque external id (commit SHA, CI run id, release tag). Repeating an id that already deployed returns the existing version as a no-op instead of rebuilding; an id with a build in flight is rejected with 409 naming that version; a failed id rebuilds freely. --force is non-destructive to deployments that already succeeded - both persist and the higher version wins - but cancels a build still in flight, so one id never has two live builds racing to define it. Cancelling writes a terminal status and appends a finalized event, which aborts a build the platform drives; a build it does not drive keeps running but can never land, and the CLI says so. Ids are deliberately not unique - reuse is resolved in application code by highest version, never timestamps. The no-op path mints no build credentials and no event stream (TRI-12923).

What that means for callers: a --force rebuild leaves two deployments holding one id, and runs triggered with it go to the higher version once the rebuild lands, so the takeover needs no separate promotion. Until a successful build exists for an id, runs triggered with it park and then expire rather than falling back to current - a failed build is therefore visible to the caller as expired runs, not as runs on the wrong release.
@0ski
0ski force-pushed the oskar/feat-external-id-deploy branch from 18ec657 to 2e065ce Compare August 18, 2026 18:50
@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@2e065ce

trigger.dev

npm i https://pkg.pr.new/trigger.dev@2e065ce

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@2e065ce

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@2e065ce

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@2e065ce

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@2e065ce

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@2e065ce

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@2e065ce

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@2e065ce

commit: 2e065ce

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +50 to +69
const candidates = await prisma.workerDeployment.findMany({
where: { environmentId, externalId },
select: {
id: true,
friendlyId: true,
shortCode: true,
version: true,
status: true,
contentHash: true,
imageReference: true,
imagePlatform: true,
externalId: true,
promotions: {
where: { label: CURRENT_DEPLOYMENT_LABEL },
select: { id: true },
},
},
orderBy: { id: "desc" },
take: MAX_CANDIDATES,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reusing the same deploy id many times can silently rebuild or leave two builds racing

Only the 20 most recent deployments for a given id are examined (take: MAX_CANDIDATES at apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts:68) when deciding whether to reuse or cancel, so older records for that id are invisible, meaning a repeat deploy can rebuild instead of doing nothing, and a forced deploy can leave an older build still running for the same id.
Impact: A user who deploys the same id more than twenty times may get an unexpected rebuild, or end up with two builds competing to define that id.

How the candidate cap interacts with reuse resolution

resolveExternalIdReuse fetches at most 20 rows for (environmentId, externalId) ordered by id desc, then partitions them into in-flight and DEPLOYED sets (apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts:71-92). Since ids are deliberately non-unique (each --force adds another row, and failed attempts add rows too), a single external id can accumulate more than 20 rows over time. Once it does:

  • A DEPLOYED row that falls outside the newest 20 is not seen, so a plain repeat deploy returns { action: "build" } and rebuilds instead of short-circuiting to the existing version.
  • An in-flight row outside the newest 20 is not passed to cancelSupersededDeployments (apps/webapp/app/v3/services/initializeDeployment.server.ts:191-199), so --force leaves it running, breaking the stated invariant that one id never has two live builds.

Consider filtering/ordering in SQL (e.g. query in-flight rows separately without a cap, and select the highest DEPLOYED version) rather than truncating the candidate set client-side.

Prompt for agents
In apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts, the reuse decision is made from at most MAX_CANDIDATES (20) rows fetched with orderBy id desc for a given (environmentId, externalId). External ids are intentionally not unique — every --force rebuild and every failed attempt adds another row — so a frequently reused id can exceed 20 rows. When it does, older rows become invisible to the resolver: a DEPLOYED row outside the newest 20 causes an unnecessary rebuild instead of the intended no-op, and an in-flight row outside the newest 20 is never handed to cancelSupersededDeployments, so --force can leave a second live build defining the same id. Consider pushing the partitioning into the database: query in-flight rows (status not in FINAL_DEPLOYMENT_STATUSES) for the id without a cap (or with a much higher bound), and separately fetch the highest-version DEPLOYED row, instead of truncating a single unfiltered candidate list.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +171 to +187
if (reuse.action === "short-circuit") {
span.setAttribute("outcome", "existing");

logger.debug("Reusing deployed external id, skipping build", {
environmentId: environment.id,
projectId: environment.projectId,
externalId: payload.externalId,
version: reuse.deployment.version,
});

return {
outcome: "existing",
deployment: reuse.deployment,
imageRef: reuse.deployment.imageReference ?? "",
isPromoted: reuse.isPromoted,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Promotion intent is silently dropped on the no-op path

When an external id short-circuits to an already DEPLOYED version, nothing touches promotions: a version that was originally deployed with --skip-promotion stays unpromoted even if the repeat deploy omits --skip-promotion. The CLI only warns ("Promote it from the dashboard") and sets needsPromotion in the GitHub Actions output. Worth confirming this is the desired contract for CI pipelines that expect a plain deploy to make the version current — a deliberate promote on the short-circuit path may be a friendlier behavior.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants