Skip to content
112 changes: 72 additions & 40 deletions apps/dokploy/components/dashboard/application/build/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,6 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";

// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;

export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
Expand Down Expand Up @@ -91,7 +67,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.15.4"),
railpackVersion: z.string().nullable().default(null),
}),
z.object({
buildType: z.literal(BuildType.static),
Expand Down Expand Up @@ -162,14 +138,16 @@ const resetData = (data: ApplicationData): AddTemplate => {
}
};

const normalizeRailpackVersion = (version: string) =>
version.trim().replace(/^v/i, "");

export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isPending } =
api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);

const form = useForm({
defaultValues: {
buildType: BuildType.nixpacks,
Expand All @@ -178,7 +156,15 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});

const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");

const {
data: railpackVersions,
isLoading: isLoadingRailpackVersions,
isError: isErrorRailpackVersions,
} = api.application.getRailpackVersions.useQuery(undefined, {
enabled: buildType === BuildType.railpack,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);

useEffect(() => {
Expand All @@ -191,28 +177,54 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};

form.reset(resetData(typedData));
}
}, [data, form]);

// Check if railpack version is manual (not in the predefined list)
useEffect(() => {
if (railpackVersions?.length) {
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
data?.railpackVersion &&
!railpackVersions.includes(data.railpackVersion)
) {
setIsManualRailpackVersion(true);
}
if (!form.getValues("railpackVersion")) {
form.setValue("railpackVersion", railpackVersions[0]);
}
}
}, [data, form]);
}, [railpackVersions, data?.railpackVersion, form]);

useEffect(() => {
if (buildType === BuildType.railpack && isErrorRailpackVersions) {
setIsManualRailpackVersion(true);
}
}, [buildType, isErrorRailpackVersions]);

// Hide builder section when Docker provider is selected
if (data?.sourceType === "docker") {
return null;
}

const onSubmit = async (data: AddTemplate) => {
const normalizedRailpackVersion =
data.buildType === BuildType.railpack
? normalizeRailpackVersion(
data.railpackVersion || railpackVersions?.[0] || "",
)
: null;

if (data.buildType === BuildType.railpack && !normalizedRailpackVersion) {
toast.error("Railpack version is required");
return;
}

await mutateAsync({
applicationId,
buildType: data.buildType,
publishDirectory:
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
data.buildType === BuildType.nixpacks
? (data.publishDirectory ?? null)
: null,
dockerfile:
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
dockerContextPath:
Expand All @@ -227,7 +239,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
? normalizedRailpackVersion
: null,
})
.then(async () => {
Expand Down Expand Up @@ -465,7 +477,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
field.onChange(railpackVersions?.[0] ?? "");
}}
>
Use predefined versions
Expand All @@ -481,21 +493,27 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
value={field.value ?? railpackVersions?.[0] ?? ""}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
<SelectTrigger disabled={isLoadingRailpackVersions}>
{isLoadingRailpackVersions ? (
<span className="text-muted-foreground">
Loading versions...
</span>
) : (
<SelectValue placeholder="Select Railpack version" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
✏️ Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
{railpackVersions?.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
{version === railpackVersions?.[0] && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
Expand All @@ -521,14 +539,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
View releases
</a>
</FormDescription>
{isErrorRailpackVersions && !isManualRailpackVersion && (
<p className="text-sm text-destructive">
Failed to load Railpack versions. Switch to manual
mode to set a custom version.
</p>
)}
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isPending} type="submit">
<Button
isLoading={isPending}
type="submit"
disabled={
buildType === BuildType.railpack &&
!isManualRailpackVersion &&
isLoadingRailpackVersions
}
>
Save
</Button>
</div>
Expand Down
36 changes: 36 additions & 0 deletions apps/dokploy/server/api/routers/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ import {
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";

const RAILPACK_VERSIONS_CACHE_TTL = 1000 * 60 * 60 * 24;
let railpackVersionsCache:
| { versions: string[]; fetchedAt: number }
| undefined;

export const applicationRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateApplication)
Expand Down Expand Up @@ -1103,6 +1108,37 @@ export const applicationRouter = createTRPCRouter({
};
}),

getRailpackVersions: protectedProcedure.query(async () => {
if (
railpackVersionsCache &&
Date.now() - railpackVersionsCache.fetchedAt < RAILPACK_VERSIONS_CACHE_TTL
) {
return railpackVersionsCache.versions;
}

const res = await fetch(
"https://api.github.com/repos/railwayapp/railpack/releases",
{
headers: {
Accept: "application/vnd.github+json",
...(process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {}),
},
},
);
if (!res.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Railpack versions from GitHub",
});
}
const releases = (await res.json()) as Array<{ tag_name: string }>;
const versions = releases.map((r) => r.tag_name.replace(/^v/i, ""));
railpackVersionsCache = { versions, fetchedAt: Date.now() };
return versions;
}),

readLogs: protectedProcedure
.input(
apiFindOneApplication.extend({
Expand Down
12 changes: 11 additions & 1 deletion packages/server/src/db/schema/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,19 @@ export const apiSaveBuildType = createSchema
dockerBuildStage: true,
herokuVersion: true,
railpackVersion: true,
publishDirectory: true,
isStaticSpa: true,
})
.required()
.merge(createSchema.pick({ publishDirectory: true, isStaticSpa: true }));
.superRefine((data, ctx) => {
if (data.buildType === "railpack" && !data.railpackVersion?.trim()) {
ctx.addIssue({
code: "custom",
path: ["railpackVersion"],
message: "Railpack version is required",
});
}
});

export const apiSaveGithubProvider = createSchema
.pick({
Expand Down