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
6 changes: 6 additions & 0 deletions .changeset/empty-dev-env-vars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---

Fix stale and empty project environment values in `trigger dev`, and support empty values in `syncEnvVars()`.
10 changes: 7 additions & 3 deletions .github/workflows/pr_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 🤖 PR Checks

on:
pull_request:
types: [opened, synchronize, reopened]
types: [opened, synchronize, reopened, ready_for_review]
merge_group:

concurrency:
Expand Down Expand Up @@ -91,6 +91,10 @@ jobs:
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'vitest.internal.config.ts'
- 'vitest.internal-reports.config.mjs'
- 'internal-test-projects.mts'
- 'test-timings.json'
# The whole webapp app tree, not just its routes, and that is the whole reason this
# filter exists. Two tests in @internal/observability-map read it: integration.test.ts
# scans the live route tree, and webappSymbols.test.ts walks all of apps/webapp/app and
Expand All @@ -110,8 +114,8 @@ jobs:
# Deliberately NOT here: this package's own paths, and packages/plugins/src and
# internal-packages/rbac/src, the other two trees webappSymbols.test.ts reads.
# `internal` above already matches `internal-packages/**` and the shared packages, and
# `unit-tests-internal.yml` runs `turbo run test --filter "@internal/*"`, which picks up
# @internal/observability-map and runs the same vitest suite. Listing them here as well
# `unit-tests-internal.yml` loads every @internal/* test project through
# vitest.internal.config.ts, including the same observability-map suite. Listing them here as well
# ran the suite twice on every PR touching them, which was this filter's own doing.
#
# Also deliberately NOT here: pr_checks.yml, package.json, pnpm-lock.yaml,
Expand Down
108 changes: 87 additions & 21 deletions .github/workflows/unit-tests-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests: Internal"
# Single big machine instead of a 12-job matrix: the internal suites are serial
# (fileParallelism: false) and container-wait-bound, so 12 in-machine shard processes
# fit comfortably in 32 vCPUs while paying the setup cost (install, prisma generate,
# image pulls) once instead of 12 times.
# Two Docker daemons and disks, with twelve serial test processes per runner.
# Global shard indices ensure every file runs exactly once across both machines.
strategy:
fail-fast: false
matrix:
machine: [1, 2]
runs-on: warp-ubuntu-latest-x64-32x
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHARD_TOTAL: 12
MACHINE_INDEX: ${{ matrix.machine }}
SHARDS_PER_MACHINE: 12
SHARD_TOTAL: 24
steps:
- name: 🔧 Disable IPv6
run: |
Expand Down Expand Up @@ -104,24 +108,32 @@ jobs:
run: pnpm run generate

- name: 🏗️ Build test dependencies
# Build once up-front so the parallel shard runs below (turbo --only) never race
# to build or cache-restore the same outputs concurrently.
# Build once up-front so shard processes never race to build or
# cache-restore the same outputs concurrently.
run: pnpm exec turbo run build --filter "@internal/*..."

- name: 🧪 Run Internal Unit Tests (${{ env.SHARD_TOTAL }} in-machine shards)
- name: 🧪 Run Internal Unit Tests (machine ${{ matrix.machine }}/2)
run: |
# Same shard partitioning as the old 12-job matrix (DurationShardingSequencer
# keys off --shard=i/N), but as parallel local processes. --only skips the
# ^build dependency handled by the step above.
# Each process discovers all internal projects, then takes its globally
# balanced share of files. Keep one test file active per process.
first=$(( (MACHINE_INDEX - 1) * SHARDS_PER_MACHINE + 1 ))
last=$(( MACHINE_INDEX * SHARDS_PER_MACHINE ))
status=0
declare -a pids
for i in $(seq 1 "$SHARD_TOTAL"); do
pnpm exec turbo run test --only --concurrency=1 --filter "@internal/*" -- \
--run --reporter=default --reporter=blob --shard="$i/$SHARD_TOTAL" --passWithNoTests \
> "/tmp/internal-shard-$i.log" 2>&1 &
for i in $(seq "$first" "$last"); do
(
started=$SECONDS
result=0
pnpm exec vitest run --config vitest.internal.config.ts \
--sequence.concurrent=false --reporter=default --reporter=blob \
--shard="$i/$SHARD_TOTAL" --passWithNoTests \
> "/tmp/internal-shard-$i.log" 2>&1 || result=$?
echo "::notice::internal shard $i/$SHARD_TOTAL finished in $((SECONDS - started))s (exit $result)"
exit "$result"
) &
pids[i]=$!
done
for i in $(seq 1 "$SHARD_TOTAL"); do
for i in $(seq "$first" "$last"); do
if ! wait "${pids[i]}"; then
status=1
echo "::error::internal unit test shard $i/$SHARD_TOTAL failed"
Expand All @@ -132,13 +144,67 @@ jobs:
done
exit "$status"

- name: Gather all reports
- name: Upload blob reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: internal-blob-report-${{ matrix.machine }}
path: .vitest-reports/*
include-hidden-files: true
if-no-files-found: error
retention-days: 1

merge-reports:
name: "📊 Merge Internal Reports"
if: ${{ !cancelled() }}
needs: [unitTests]
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false

- name: ⎔ Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.33.2

- name: ⎔ Setup node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.18.0

- name: Download blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: .vitest-reports
pattern: internal-blob-report-*
merge-multiple: true

- name: Check all shard reports are present
run: |
mkdir -p .vitest-reports
find . -type f -path '*/.vitest-reports/blob-*.json' \
-exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \;
for i in $(seq 1 24); do
test -s ".vitest-reports/blob-$i-24.json"
done

- name: 📊 Merge reports
run: >-
pnpm dlx vitest@4.1.7 run --config vitest.internal-reports.config.mjs --merge-reports
--reporter=default --reporter=json --outputFile.json=internal-test-results.json

- name: Validate merged test timings
run: |
cp test-timings.json "$RUNNER_TEMP/checked-test-timings.json"
node internal-packages/testcontainers/scripts/update-test-timings.mjs \
internal-test-results.json "$RUNNER_TEMP/checked-test-timings.json"

- name: Upload internal test timings
if: ${{ !cancelled() }}
run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: internal-test-results
path: internal-test-results.json
if-no-files-found: error
retention-days: 14
6 changes: 6 additions & 0 deletions .server-changes/envvar-empty-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Support empty-string environment values across the dashboard, API, and Vercel sync behind a feature flag, disabled by default.
6 changes: 6 additions & 0 deletions .server-changes/preview-branch-auto-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Automatically archive preview branches after a configurable period without deployments, with protected branch names and a preview of affected branches.
170 changes: 170 additions & 0 deletions apps/supervisor/src/workloadManager/runCrd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,173 @@ describe("runnerTokenSecretName", () => {
);
});
});

/**
* Ties every shared workload-manager create-option to the Runner that the
* run-crd producer builds, so a new per-run option cannot be added and quietly
* dropped. It is the class of drift that lost TRIGGER_SNAPSHOT_ROUTE: the field
* was optional, three pod backends set it, and runCrd never did, which the type
* checker was happy with.
*
* The property, stated once: every create-option field either changes the
* Runner `runnerBodyFor` builds, or is listed in RUN_CRD_EXCLUDED with a reason.
* Optionality is not an excuse; a silent drop is. It is checked by behaviour,
* not by scanning source: for each field the test sets a probe value and asks
* whether the built Runner changes. A comment, a log line, or a reference from
* another method cannot fool that, where a textual `opts.<field>` scan could.
*/
describe("run-crd carries every shared create-option or excludes it on purpose", () => {
/**
* Every key of WorkloadManagerCreateOptions. Adding a field to the interface
* without adding it here is a compile error (see the exhaustiveness assertion
* below): a new option is acknowledged here or the build breaks.
*/
const CREATE_OPTION_KEYS = [
"image",
"machine",
"version",
"nextAttemptNumber",
"dequeuedAt",
"placementTags",
"dequeueResponseMs",
"pollingIntervalMs",
"warmStartCheckMs",
"envId",
"envType",
"orgId",
"projectId",
"deploymentFriendlyId",
"deploymentVersion",
"runtime",
"deploymentToken",
"runId",
"runFriendlyId",
"snapshotId",
"snapshotFriendlyId",
"snapshotRoute",
"traceContext",
"annotations",
"hasPrivateLink",
] as const satisfies readonly (keyof WorkloadManagerCreateOptions)[];

type ListedKey = (typeof CREATE_OPTION_KEYS)[number];
type MissingKey = Exclude<keyof WorkloadManagerCreateOptions, ListedKey>;
const KEYS_ARE_EXHAUSTIVE: [MissingKey] extends [never] ? true : ["unlisted keys", MissingKey] =
true;

/**
* Create-option fields that do not change the Runner `runnerBodyFor` builds,
* each with the reason it is not a spec field. Every such field must appear
* here, so not carrying one is always a decision on the record rather than an
* omission the type checker allowed. Some of these still shape creation by
* another route, the runner's name or its token Secret; they just are not
* values in the spec.
*
* snapshotRoute is superseded transport rather than a gap to fill: snapshot
* routing is becoming server-owned, so the runner-facing route is being
* removed rather than built into the Runner. This entry, the field on the
* create options, and the pod backends that set it come out together when that
* lands.
*/
const RUN_CRD_EXCLUDED: Partial<Record<ListedKey, string>> = {
snapshotRoute:
"Superseded transport, not a run-crd gap: snapshot routing is becoming server-owned (a run's residency is decided from its stored state, not a runner-provided field), so the runner-facing route is being removed rather than built into the Runner. This entry, the field, and the pod backends that set it come out together when that lands.",
nextAttemptNumber:
"Not a spec field: it selects the runner's name via getRunnerId, so the attempt lives in the object's name rather than in the spec runnerBodyFor builds.",
deploymentToken:
"Not a spec field: it is written to a per-runner Secret and referenced by name (deployment.token), so the raw token is never a value in the spec.",
snapshotId: "Internal id; the bootstrap snapshot is identified by its friendly id.",
runId: "Internal id; the runner is named for, and reported against, the run's friendly id.",
version: "The deployment version is carried instead; nothing reads this alias.",
traceContext:
"Span context for the pod path's own emission; runnerBodyFor does not build it in.",
dequeueResponseMs: "Producer-side timing for the wide event; not built into the Runner.",
pollingIntervalMs: "Producer-side timing for the wide event; not built into the Runner.",
warmStartCheckMs: "Producer-side timing for the wide event; not built into the Runner.",
};

/**
* A value for each field that differs from createOptions()'s baseline, so a
* field the producer builds in makes the Runner change and one it ignores
* leaves it identical. Total over the keys, so a new option forces a probe
* here too.
*/
const PROBES: Record<ListedKey, Partial<WorkloadManagerCreateOptions>> = {
image: { image: `registry.example.com/other/worker:2@sha256:${"1".repeat(64)}` },
machine: { machine: { name: "micro", cpu: 0.25, memory: 0.25, centsPerMs: 0 } },
version: { version: "99.9.9" },
nextAttemptNumber: { nextAttemptNumber: 5 },
dequeuedAt: { dequeuedAt: new Date("2026-01-01T00:00:00.000Z") },
placementTags: { placementTags: [{ key: "pool", values: ["spot"] }] },
dequeueResponseMs: { dequeueResponseMs: 999 },
pollingIntervalMs: { pollingIntervalMs: 999 },
warmStartCheckMs: { warmStartCheckMs: 999 },
envId: { envId: "env_other" },
envType: { envType: "STAGING" },
orgId: { orgId: "org_other" },
projectId: { projectId: "proj_other" },
deploymentFriendlyId: { deploymentFriendlyId: "deployment_other" },
deploymentVersion: { deploymentVersion: "99.9.9" },
runtime: { runtime: "bun" },
deploymentToken: { deploymentToken: "tok_probe" },
runId: { runId: "run_other_internal" },
runFriendlyId: { runFriendlyId: "run_other" },
snapshotId: { snapshotId: "snapshot_other_internal" },
snapshotFriendlyId: { snapshotFriendlyId: "snapshot_other" },
snapshotRoute: {
snapshotRoute: { version: 1, residency: "redis-primary", organizationId: "org_abc" },
},
traceContext: { traceContext: { traceparent: "00-probe-probe-01" } },
annotations: {
annotations: {
triggerSource: "schedule",
triggerAction: "trigger",
rootTriggerSource: "schedule",
},
},
hasPrivateLink: { hasPrivateLink: true },
};

const excluded = new Set(Object.keys(RUN_CRD_EXCLUDED) as ListedKey[]);
const baseline = JSON.stringify(runnerBodyFor(createOptions(), meta));

/** True when setting the field's probe changes the Runner runnerBodyFor builds. */
function changesRunner(field: ListedKey): boolean {
return JSON.stringify(runnerBodyFor(createOptions(PROBES[field]), meta)) !== baseline;
}

it("lists every create option, so a new field cannot slip past this test", () => {
// Fails to compile, not just at runtime, when a key is missing above.
expect(KEYS_ARE_EXHAUSTIVE).toBe(true);
});

it("gives each field a probe that sets only that field", () => {
// changesRunner reports any output difference, so a probe that also moved a
// second option could pass without its own field being built in. Pinning
// each probe to a single key keeps a difference attributable to that field.
for (const field of CREATE_OPTION_KEYS) {
expect(Object.keys(PROBES[field]), `PROBES.${field} must set only ${field}`).toEqual([field]);
}
});

it("builds every non-excluded create option into the Runner", () => {
const dropped = CREATE_OPTION_KEYS.filter((f) => !excluded.has(f) && !changesRunner(f));
expect(
dropped,
`runnerBodyFor ignores create-options it neither builds in nor excludes: ${
dropped.join(", ") || "(none)"
}. Build each into the Runner, or declare it in RUN_CRD_EXCLUDED with a reason. ` +
`Optionality is not an excuse for a silent drop.`
).toEqual([]);
});

it("excludes only options the Runner genuinely does not carry", () => {
// An excluded field that changes the Runner means the exclusion is a lie:
// the field is built in after all and its reason is stale.
const carried = [...excluded].filter((f) => changesRunner(f)).sort();
expect(
carried,
`RUN_CRD_EXCLUDED lists options runnerBodyFor does build in: ${carried.join(", ")}`
).toEqual([]);
});
});
Loading
Loading