Compare commits

...

51 Commits

Author SHA1 Message Date
Mauricio Siu ff9fa7418f fix(ci): generate OpenAPI spec from the release commit in sync-version
The cli/sdk steps copied the repo-root openapi.json, which was last committed in March, and the mcp step fetched docs.dokploy.com, which canary pushes can overwrite. Generate the spec from the release commit instead so each package release matches its dokploy tag.
2026-08-06 01:28:18 -06:00
Mauricio Siu b6190ff314 fix(ci): pin pnpm and strip v prefix in sync-version job
npm install -g pnpm resolved to pnpm 11, which turns the esbuild ignored-build-scripts warning into a fatal error, so sync-version has failed on every release since v0.29.5 and mcp/cli/sdk were never bumped or published. Pin pnpm to 10.22.0 (same as packageManager) and write the version without the v prefix so npm packages get valid semver.
2026-08-06 01:19:37 -06:00
Mauricio Siu abe774650f Merge pull request #4962 from Dokploy/fix/compose-env-file-special-chars
fix: preserve special characters in Compose-generated .env values
2026-08-06 00:58:04 -06:00
Mauricio Siu 1f04ebf3cd Merge pull request #4983 from Dokploy/fix/release-tag-target-commitish
fix(ci): create release tags on the pushed commit instead of default branch
2026-08-06 00:43:48 -06:00
Mauricio Siu 472ebed06c fix(ci): create release tags on the pushed commit instead of default branch 2026-08-06 00:42:22 -06:00
Mauricio Siu 4ce24dfdd3 Merge pull request #4982 from Dokploy/fix/hotfix-cherry-pick-permissions
fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules
2026-08-06 00:29:02 -06:00
Mauricio Siu 06b67d4519 fix(ci): push hotfix cherry-picks with PAT to satisfy branch rules 2026-08-06 00:26:52 -06:00
Mauricio Siu 5dd2b5beec Merge branch 'main' into canary 2026-08-06 00:12:08 -06:00
Mauricio Siu 35af10ca07 Bump version to v0.30.0 2026-08-06 00:11:16 -06:00
Mauricio Siu 75448f358e Merge pull request #4972 from Dokploy/fix/domain-single-label-hostname
fix(domain): allow single-label hostnames without a TLD
2026-08-06 00:01:57 -06:00
Mauricio Siu 4e675d6f3f Merge pull request #4978 from Dokploy/hotfix/v0.29.14
Hotfix v0.29.14: backport 20 bug fixes
2026-08-06 00:00:20 -06:00
Mauricio Siu 57fae73853 Update packages/server/src/utils/schedules/utils.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-05 23:54:32 -06:00
Mauricio Siu dd542994a4 chore: release v0.29.14 2026-08-05 23:49:59 -06:00
Narciso E. Núñez Arias bcf97f92ab Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation

(cherry picked from commit 3f98208f03)
2026-08-05 23:48:13 -06:00
Mauricio Siu e19d7bbf0e Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs

(cherry picked from commit 3495fc89bf)
2026-08-05 23:48:12 -06:00
Mauricio Siu 3e4ed0c299 Merge pull request #4966 from Dokploy/fix/requests-hostname-filter-crash
fix(requests): guard RequestHost before filtering to avoid crash on malformed logs

(cherry picked from commit a0162ab566)
2026-08-05 23:48:05 -06:00
Narciso E. Núñez Arias 112b2c8358 Merge pull request #4923 from dmtrTm/fix/rollback-environment-variables
fix: resolve environment variables on application rollback
(cherry picked from commit 46c87a22b0)
2026-08-05 23:48:04 -06:00
Mauricio Siu b7fdbd4704 Merge pull request #4557 from rnkp755/fix/env-update-issue
fix: invalidate railpack build cache when env changes
(cherry picked from commit a1230098c9)
2026-08-05 23:48:04 -06:00
Mauricio Siu 13b897c01d Merge pull request #4959 from Dokploy/fix/cleanup-preview-deployments-on-app-delete
fix(application): remove preview deployments when deleting an application

(cherry picked from commit 056b6983db)
2026-08-05 23:48:04 -06:00
Mauricio Siu 83008205b9 Merge pull request #4955 from Dokploy/fix/schedule-run-manually-deployment-metadata
fix(schedule): return deployment metadata from runManually and fail early on missing container

(cherry picked from commit 11e93dde38)
2026-08-05 23:48:03 -06:00
Mauricio Siu 0f0102ff2d Merge pull request #4933 from CyrilBIENNE/fix/preview-deployment-github-credentials
fix(preview-deployment): refetch github provider before authenticating

(cherry picked from commit a058d1f5c7)
2026-08-05 23:48:03 -06:00
Mauricio Siu cbe78950cc Merge pull request #4954 from Dokploy/fix/error-page-status-code
fix(ui): show real status code on error page

(cherry picked from commit 3835a6fe68)
2026-08-05 23:48:03 -06:00
Mauricio Siu 49ee74c89d Merge pull request #4953 from Dokploy/fix/dropdown-dialog-window-blur
fix(ui): keep dropdown menus open on window blur

(cherry picked from commit 51cb7d2287)
2026-08-05 23:48:02 -06:00
Mauricio Siu e88d13f67b Merge pull request #4947 from azizbecha/fix/4945-deployment-delete-loading
fix(ui): scope deployment delete loading

(cherry picked from commit 2be9e1e3df)
2026-08-05 23:48:02 -06:00
Mauricio Siu 2f89981597 Merge pull request #4948 from azizbecha/fix/collapsed-sidebar-avatar
fix(ui): prevent collapsed avatar clipping

(cherry picked from commit 3b0bdd8f74)
2026-08-05 23:48:02 -06:00
Mauricio Siu c7a96ed82c Merge pull request #4782 from imrja8/fix/watchpath-badge-removal
fix(ui): resolve unclickable watch path removal button

(cherry picked from commit 018e56a7b4)
2026-08-05 23:48:01 -06:00
Mauricio Siu d4e087e128 Merge pull request #4937 from Dokploy/fix/persist-trigger-type-provider-forms
fix(ui): persist trigger type selection in GitHub provider forms

(cherry picked from commit bb73c6e6dd)
2026-08-05 23:48:01 -06:00
Mauricio Siu 7f41b098f9 Merge pull request #4911 from AbiRaditya/fix/cloudflare-badge-tooltip-error-title
fix(domains): don't render CDN info message as an error in DNS tooltip

(cherry picked from commit 5df820a740)
2026-08-05 23:48:01 -06:00
Mauricio Siu 43229c89da Merge pull request #4931 from Dokploy/fix/postgres-100-arg-limit-finders
fix: avoid postgres 100-argument limit in schedule, volume backup and port queries
(cherry picked from commit deebf0f107)
2026-08-05 23:48:00 -06:00
Mauricio Siu c6607f3a19 Merge pull request #4924 from dmtrTm/fix/rollback-query-arg-limit
fix: avoid postgres 100-argument limit in findRollbackById
(cherry picked from commit 069939e8f9)
2026-08-05 23:48:00 -06:00
Mauricio Siu 6e84f39a9f Merge pull request #4929 from Dokploy/fix/remove-buttons-not-persisting
fix(ui): make interactive icons inside badges clickable again

(cherry picked from commit 425d478a0b)
2026-08-05 23:47:59 -06:00
Mauricio Siu b024c9c2fd Merge pull request #4626 from veksen/veksen/textarea-resets-while-editing
fix: prevent environment form from resetting while editing
(cherry picked from commit 73e4fdd757)
2026-08-05 23:47:59 -06:00
Mauricio Siu 982f4d70f7 Merge pull request #4890 from SteadEXE/canary
fix(ui): add cursor pointer style for buttons

(cherry picked from commit 0fc75840d2)
2026-08-05 23:47:59 -06:00
Mauricio Siu ba24ae6797 Merge pull request #4977 from Dokploy/feat/hotfix-release-flow
feat(ci): add hotfix cherry-pick and release workflows
2026-08-05 23:34:52 -06:00
Mauricio Siu 2f8af90da4 feat(ci): add hotfix cherry-pick and release workflows 2026-08-05 23:34:01 -06:00
Narciso E. Núñez Arias 3f98208f03 Merge pull request #4772 from rifatdinc/fix-watch-paths-added-removed-files
fix(webhook): include added and removed files in watchPaths validation
2026-08-05 22:21:12 -04:00
Narciso 5d231bf3e3 fix(domain): allow single-label hostnames without a TLD 2026-08-04 17:38:13 -04:00
Mauricio Siu 0124613516 Merge pull request #4944 from Dokploy/feat/add-github-provider-baseurl
feat: add baseUrl to github provider to support github enterprise urls
2026-08-04 11:53:04 -06:00
Mauricio Siu 3495fc89bf Merge pull request #4971 from Dokploy/fix/restore-dialog-width
fix(ui): widen restore backup dialog to match other backup dialogs
2026-08-04 11:51:24 -06:00
Mauricio Siu 5ae289cae3 fix(ui): widen restore backup dialog to match other backup dialogs 2026-08-04 11:47:04 -06:00
autofix-ci[bot] 108541c957 [autofix.ci] apply automated fixes 2026-08-04 02:36:10 +00:00
Narciso 14be318502 feat: remove unnecesary checks for ip and comments 2026-08-03 22:35:41 -04:00
Narciso d6c2e38cb0 Merge remote-tracking branch 'origin/canary' into feat/add-github-provider-baseurl
# Conflicts:
#	apps/dokploy/drizzle/meta/0177_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2026-08-03 19:34:26 -04:00
Narciso ff39cb51e6 fix: escape env values written to the Compose .env file so $, #, and quotes survive literally 2026-08-03 17:57:02 -04:00
Narciso 1cf81e3802 feat: add baseUrl to github provider to support github enterprise url 2026-07-31 11:42:12 -04:00
Mauricio Siu a49615038f Merge pull request #4883 from Dokploy/canary
🚀 Release v0.29.13
2026-07-21 10:00:57 -06:00
Mauricio Siu a376b00a71 Merge pull request #4813 from Dokploy/canary
🚀 Release v0.29.12
2026-07-13 04:35:14 -06:00
Mauricio Siu 1ca03d3dc2 Merge pull request #4767 from Dokploy/canary
🚀 Release v0.29.11
2026-07-09 03:00:31 -06:00
Antigravity AI 2df02f596a fix(webhook): include added and removed files in watchPaths validation
Currently, Dokploy only extracts modified files from webhook payloads.
This causes watchPaths to fail matching commits that only add or remove files (such as adding new routes or deleting files), preventing auto-deployments.

This commit updates the commit parsing logic to aggregate added, modified, and removed files.
2026-07-09 00:06:10 +03:00
Mauricio Siu 1da4db2905 Merge pull request #4746 from Dokploy/canary
🚀 Release v0.29.10
2026-07-06 03:43:48 -06:00
Mauricio Siu b3feeed3e4 Merge pull request #4734 from Dokploy/canary
🚀 Release v0.29.9
2026-07-05 23:52:29 -06:00
33 changed files with 9959 additions and 77 deletions
+27 -13
View File
@@ -140,6 +140,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -151,6 +152,7 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
@@ -164,6 +166,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
@@ -180,71 +183,82 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+36
View File
@@ -0,0 +1,36 @@
name: Hotfix Cherry-Pick
on:
pull_request_target:
types: [closed, labeled]
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
cherry-pick:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
git cherry-pick -x "$SHA"
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
+28
View File
@@ -0,0 +1,28 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Bump patch version and push
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
git commit -am "chore: release ${NEW}"
git push origin main
@@ -0,0 +1,96 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterEach, describe, expect, it } from "vitest";
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
const appName = `env-file-literals-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
try {
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
cwd: codePath,
stdio: "ignore",
});
} catch {
// Project may not have been created (e.g. an earlier assertion failed).
}
rmSync(projectPath, { force: true, recursive: true });
});
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: '!"#$%&/()=?',
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
};
describe("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(inputEncoding)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
cases,
)
.map((key) => ` - ${key}=\${${key}}`)
.join("\n")}\n`;
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
const out = execFileSync(
"docker",
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
{ cwd: codePath, encoding: "utf8" },
);
const parts = out.split("\0");
const actual: Record<string, string> = {};
for (let i = 0; i < parts.length - 1; i += 2) {
actual[parts[i] as string] = parts[i + 1] as string;
}
for (const [key, value] of Object.entries(cases)) {
expect(actual[key], key).toBe(value);
}
}, 60000);
});
@@ -0,0 +1,43 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
import { parse } from "dotenv";
import { afterEach, describe, expect, it } from "vitest";
// Unlike compose's .env, this one is read by the app's own build tooling
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
const appName = `env-file-literals-dockerfile-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
const dockerFilePath = join(codePath, "Dockerfile");
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
NESTED_JSON: '{"nested":{"a":1}}',
QUOTE_INSIDE: 'she said "hi"',
BACKSLASH: "back\\slash",
UNICODE: "héllo wörld 日本語 🚀",
};
describe("createEnvFileCommand", () => {
it("writes special environment values that a generic dotenv parser reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(cases)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
execFileSync("bash", ["-c", command]);
const written = readFileSync(join(codePath, ".env"), "utf8");
const parsed = parse(written);
for (const [key, value] of Object.entries(cases)) {
expect(parsed[key], key).toBe(value);
}
});
});
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// cloneGithubRepository builds a shell command; the only thing under test here
// is which host ends up in the clone URL, so the app auth is stubbed out.
const mockFindGithubById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/github", () => ({
findGithubById: mockFindGithubById,
}));
vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));
vi.mock("octokit", () => ({
Octokit: class {
auth = async () => ({ token: "gh-token" });
},
}));
const { cloneGithubRepository } = await import(
"@dokploy/server/utils/providers/github"
);
const provider = (githubUrl: string) => ({
githubId: "gh-1",
githubUrl,
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
});
const clone = async () => {
const command = await cloneGithubRepository({
appName: "my-app",
owner: "acme",
repository: "web",
branch: "main",
githubId: "gh-1",
enableSubmodules: false,
serverId: null,
});
return command.replace(/\\/g, "");
};
describe("cloneGithubRepository host", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clones from github.com for a default provider", async () => {
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).not.toContain("ghe.com");
});
it("clones from the Enterprise host, not github.com", async () => {
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
);
expect(command).not.toContain("github.com");
});
it("clones from a self-hosted Enterprise Server host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.corp.acme.com"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
);
});
it("keeps an explicit port in the clone host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.acme.com:8443"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
);
});
it("falls back to github.com for a provider stored before this feature", async () => {
mockFindGithubById.mockResolvedValue(provider(""));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
});
});
@@ -0,0 +1,168 @@
import {
DEFAULT_GITHUB_API_URL,
DEFAULT_GITHUB_URL,
deriveGithubApiUrl,
normalizeGithubUrl,
parseGithubBaseUrl,
} from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
"url" in result ? result.url : null;
describe("normalizeGithubUrl", () => {
it("defaults to github.com when empty", () => {
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
});
it("assumes https when no scheme is given", () => {
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
});
it("strips paths, queries and trailing slashes", () => {
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
"https://github.acme.com",
);
});
it("keeps an explicit port", () => {
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443",
);
});
it("falls back to github.com on unusable input", () => {
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("http://github.internal")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
});
});
describe("parseGithubBaseUrl", () => {
it("accepts github.com and Enterprise hosts", () => {
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
"https://acme.ghe.com",
);
// A self-hosted instance behind the corporate network is a valid target.
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
"https://github.corp.acme.com",
);
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
"https://github.acme.com:8443",
);
});
it("treats an absent value as github.com", () => {
// Not specified is different from specified wrong.
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
});
it("rejects plaintext http", () => {
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
});
it("rejects dotless hostnames", () => {
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
});
it("rejects a dotless hostname hidden behind the DNS root label", () => {
// "metadata." resolves like "metadata" but the trailing dot would satisfy
// a naive `includes(".")` check.
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
});
it("never falls back to github.com on a typo", () => {
// The symptom that opened the ticket: a provider silently pointing at
// github.com and reporting that it cannot find the repositories.
for (const typo of [
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
]) {
const result = parseGithubBaseUrl(typo);
expect(result, typo).toHaveProperty("error");
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
}
});
});
describe("deriveGithubApiUrl", () => {
it("maps github.com to api.github.com", () => {
expect(deriveGithubApiUrl("https://github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
});
it("prefixes api. for data residency tenants", () => {
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
"https://api.acme.ghe.com",
);
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
"https://api.americancreditacceptance.ghe.com",
);
});
it("uses /api/v3 for Enterprise Server", () => {
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
"https://github.acme.com/api/v3",
);
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443/api/v3",
);
});
it("does not treat a lookalike host as data residency", () => {
// Must not match the .ghe.com branch just because the string contains it.
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
"https://ghe.com.acme.io/api/v3",
);
});
it("still detects data residency behind the DNS root label", () => {
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
// through to the /api/v3 branch.
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
"https://api.acme.ghe.com",
);
});
it("maps www.github.com, which a user may well type", () => {
// Not dead weight: GitHub never redirects a manifest there, but the value
// comes from a text field. Without this it would derive
// https://www.github.com/api/v3.
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
});
});
describe("providers created before Enterprise support", () => {
it("keeps pointing at github.com", () => {
// The column defaults to https://github.com, but a null must not break it.
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
});
});
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The gh_init branch runs on a GET the user can be linked into, so a rejected
// host must produce a 400 *before* any outbound request is made.
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockCreateGithub = vi.hoisted(() => vi.fn());
const mockOctokitRequest = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
validateRequest: mockValidateRequest,
createGithub: mockCreateGithub,
};
});
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("octokit", () => ({
Octokit: class {
request = mockOctokitRequest;
},
}));
const { default: handler } = await import("@/pages/api/providers/github/setup");
const ORG = "org-1";
const USER = "user-1";
const buildRes = () => {
const res = {
statusCode: 0,
body: undefined as unknown,
redirectedTo: undefined as string | undefined,
status(code: number) {
res.statusCode = code;
return res;
},
json(payload: unknown) {
res.body = payload;
return res;
},
redirect(_code: number, url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
};
const call = async (githubUrl?: string | string[]) => {
const res = buildRes();
const req = {
query: {
code: "manifest-code",
state: `gh_init:${ORG}:${USER}`,
...(githubUrl === undefined ? {} : { githubUrl }),
},
headers: {},
} as unknown as Parameters<typeof handler>[0];
await handler(req, res as unknown as Parameters<typeof handler>[1]);
return res;
};
describe("github setup handler — host validation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockValidateRequest.mockResolvedValue({
user: { id: USER },
session: { activeOrganizationId: ORG },
});
mockHasPermission.mockResolvedValue(true);
mockOctokitRequest.mockResolvedValue({
data: {
name: "Dokploy",
html_url: "https://acme.ghe.com/apps/dokploy",
id: 1,
client_id: "cid",
client_secret: "csecret",
webhook_secret: "wsecret",
pem: "key",
},
});
mockCreateGithub.mockResolvedValue(undefined);
});
it.each([
["http://acme.ghe.com", "plaintext http"],
["http://localhost:2375", "internal service over http"],
["https://metadata", "dotless hostname"],
["htps://acme.ghe.com", "scheme typo"],
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
const res = await call(githubUrl);
expect(res.statusCode).toBe(400);
expect(mockOctokitRequest).not.toHaveBeenCalled();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("throws on a repeated parameter instead of silently picking one", async () => {
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
await expect(
call(["https://acme.ghe.com", "https://evil.com"]),
).rejects.toThrow();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("accepts a data residency tenant", async () => {
await call("https://acme.ghe.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
ORG,
USER,
);
});
it("accepts a self-hosted Enterprise Server host", async () => {
await call("https://github.corp.acme.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
ORG,
USER,
);
});
it("treats an absent parameter as github.com", async () => {
await call(undefined);
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.com" }),
ORG,
USER,
);
});
});
@@ -0,0 +1,75 @@
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
/**
* The client cannot import from @dokploy/server (node-only modules), so
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
* stops the two from drifting apart — and they already did once, when the
* client stripped trailing slashes but not paths and the form action disagreed
* with the persisted row.
*
* The invariant: for the same input, both must make the same accept/reject
* decision, and agree on the URL when they accept.
*/
const INPUTS = [
// Accepted
"https://github.com",
"github.com",
"https://acme.ghe.com",
"acme.ghe.com",
"https://github.corp.acme.com",
"https://github.acme.com:8443",
"https://acme.ghe.com/",
"https://acme.ghe.com///",
"https://acme.ghe.com/enterprises/foo",
"https://acme.ghe.com/some/path?x=1",
" acme.ghe.com ",
"https://acme.ghe.com.",
"https://169.254.169.254",
"https://127.0.0.1",
"https://2130706433",
"https://0x7f.1",
"",
" ",
// Rejected
"http://acme.ghe.com",
"http://localhost:2375",
"https://[::1]",
"https://[::ffff:127.0.0.1]",
"https://metadata",
"https://metadata.",
"https://localhost",
"https://localhost.",
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
"esto no es una url",
];
describe("client and server agree on GitHub base URLs", () => {
it.each(INPUTS)("%j", (input) => {
const client = resolveGithubBaseUrl(input);
const server = parseGithubBaseUrl(input);
const clientAccepted = !client.error;
const serverAccepted = "url" in server;
expect(
clientAccepted,
`accept/reject differs for ${JSON.stringify(input)}`,
).toBe(serverAccepted);
if (clientAccepted && "url" in server) {
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
server.url,
);
}
});
it("both treat an empty value as github.com", () => {
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
});
});
@@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
"example",
"dokploy-server",
"localhost",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
@@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const triggerType = form.watch("triggerType");
const { data: repositories, isPending: isLoadingRepositories } =
@@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -23,6 +24,9 @@ export const AddGithubProvider = () => {
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
const [organizationName, setOrganization] = useState("");
const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL);
const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl);
const randomString = () => Math.random().toString(36).slice(2, 8);
@@ -30,7 +34,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@@ -52,7 +56,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [activeOrganization?.id, session?.user?.id]);
}, [activeOrganization?.id, session?.user?.id, baseUrl]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
below to get started.
</p>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm">GitHub URL</span>
<Input
placeholder={DEFAULT_GITHUB_URL}
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
/>
<span className="text-muted-foreground text-xs">
Leave as is for github.com. For GitHub Enterprise, use your
instance URL (e.g. https://acme.ghe.com or
https://github.acme.com).
</span>
{githubUrlError && (
<span className="text-destructive text-xs">
{githubUrlError}
</span>
)}
</div>
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
@@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@@ -116,8 +139,8 @@ export const AddGithubProvider = () => {
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
? `${baseUrl}/organizations/${organizationName}/settings/installations`
: `${baseUrl}/settings/installations`
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
@@ -131,7 +154,10 @@ export const AddGithubProvider = () => {
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
!!githubUrlError ||
(isOrganization && organizationName.length < 1)
}
type="submit"
className="self-end"
>
@@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">GitHub URL</span>
<Input value={github?.githubUrl ?? ""} readOnly />
<span className="text-muted-foreground text-xs">
Set when the app was created and not editable, the app
credentials belong to this instance.
</span>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
@@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
@@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
<span className="text-sm font-medium">
{gitProvider.name}
</span>
{isGithub &&
gitProvider.github?.githubUrl &&
gitProvider.github.githubUrl !==
DEFAULT_GITHUB_URL && (
<span className="text-xs text-muted-foreground">
{gitProvider.github.githubUrl}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,
@@ -0,0 +1 @@
ALTER TABLE "github" ADD COLUMN "githubUrl" text DEFAULT 'https://github.com' NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1254,6 +1254,13 @@
"when": 1785751631838,
"tag": "0178_silky_randall",
"breakpoints": true
},
{
"idx": 179,
"version": "7",
"when": 1785800152143,
"tag": "0179_foamy_marauders",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.13",
"version": "v0.29.14",
"private": true,
"license": "Apache-2.0",
"type": "module",
+35 -21
View File
@@ -119,9 +119,11 @@ export default async function handler(
}
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
} else if (sourceType === "github") {
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
@@ -150,21 +152,29 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
} else if (provider === "gitlab") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
} else if (provider === "gitea") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
} else if (provider === "soft-serve") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
}
const shouldDeployPaths = shouldDeploy(
@@ -179,9 +189,11 @@ export default async function handler(
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
@@ -225,9 +237,11 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
@@ -54,9 +54,11 @@ export default async function handler(
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -74,9 +76,11 @@ export default async function handler(
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -125,17 +129,23 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
} else if (provider === "gitlab") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
} else if (provider === "gitea") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
}
const shouldDeployPaths = shouldDeploy(
@@ -150,9 +160,11 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
+5 -3
View File
@@ -223,9 +223,11 @@ export default async function handler(
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = getGithubRepositoryOwner(githubBody);
const normalizedCommits = githubBody?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits = githubBody?.commits?.flatMap((commit: any) => [
...(commit.added || []),
...(commit.modified || []),
...(commit.removed || []),
]);
const apps = await db.query.applications.findMany({
where: and(
@@ -1,4 +1,9 @@
import { createGithub, validateRequest } from "@dokploy/server";
import {
createGithub,
deriveGithubApiUrl,
parseGithubBaseUrl,
validateRequest,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm";
@@ -11,13 +16,14 @@ type Query = {
state: string;
installation_id: string;
setup_action: string;
githubUrl?: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { code, state, installation_id }: Query = req.query as Query;
const { code, state, installation_id, githubUrl }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
@@ -38,7 +44,15 @@ export default async function handler(
}
if (action === "gh_init") {
const octokit = new Octokit({});
// Reject before any outbound request: this runs on a GET the user can be
// linked into, so the host is not trusted.
const parsed = parseGithubBaseUrl(githubUrl);
if ("error" in parsed) {
return res.status(400).json({ error: parsed.error });
}
const baseUrl = parsed.url;
const octokit = new Octokit({ baseUrl: deriveGithubApiUrl(baseUrl) });
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
{
@@ -55,6 +69,7 @@ export default async function handler(
githubClientSecret: data.client_secret,
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
githubUrl: baseUrl,
},
session.activeOrganizationId,
user.id,
@@ -49,6 +49,7 @@ export const gitProviderRouter = createTRPCRouter({
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
githubUrl: r.github.githubUrl,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&
@@ -67,6 +67,7 @@ export const githubRouter = createTRPCRouter({
.map((provider) => {
return {
githubId: provider.githubId,
githubUrl: provider.githubUrl,
gitProvider: {
...provider.gitProvider,
},
+49
View File
@@ -0,0 +1,49 @@
// utils/github-utils.ts
// This file contains client-safe utilities for GitHub integration.
// Duplicates parseGithubBaseUrl from @dokploy/server, which cannot be imported
// here (node-only modules). Kept in step by github-url-parity.test.ts.
export const DEFAULT_GITHUB_URL = "https://github.com";
interface ResolvedGithubBaseUrl {
baseUrl: string;
error?: string;
}
export const resolveGithubBaseUrl = (value: string): ResolvedGithubBaseUrl => {
const raw = value.trim() || DEFAULT_GITHUB_URL;
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
// Keep the raw value rather than defaulting: the failure stays visible.
return { baseUrl: withScheme, error: `"${raw}" is not a valid URL` };
}
if (parsed.protocol !== "https:") {
return {
baseUrl: parsed.origin,
error:
"Only https is supported. Creating the app over http would leave it orphaned on your instance.",
};
}
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
// would slip past the checks below.
if (parsed.hostname.endsWith(".")) {
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
}
const { hostname } = parsed;
if (!hostname.includes(".")) {
return {
baseUrl: parsed.origin,
error: `"${hostname}" is not a fully qualified hostname`,
};
}
return { baseUrl: parsed.origin };
};
+5
View File
@@ -16,6 +16,7 @@ export const github = pgTable("github", {
githubInstallationId: text("githubInstallationId"),
githubPrivateKey: text("githubPrivateKey"),
githubWebhookSecret: text("githubWebhookSecret"),
githubUrl: text("githubUrl").default("https://github.com").notNull(),
gitProviderId: text("gitProviderId")
.notNull()
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
@@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
githubInstallationId: z.string().optional(),
githubPrivateKey: z.string().optional(),
githubWebhookSecret: z.string().nullable(),
githubUrl: z
.url()
.startsWith("https://", "Only https is supported for GitHub instances")
.optional(),
gitProviderId: z.string().optional(),
name: z.string().min(1),
});
@@ -7,7 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
} from "../docker/utils";
export type ComposeNested = InferResultType<
@@ -127,7 +127,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
}
const envFileContent = prepareEnvironmentVariables(
const envFileContent = prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
+21
View File
@@ -465,6 +465,27 @@ export const prepareEnvironmentVariablesForShell = (
return envVars.map((env) => quote([env]));
};
export const prepareEnvironmentVariablesForFile = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
): string[] => {
const envVars = prepareEnvironmentVariables(
serviceEnv,
projectEnv,
environmentEnv,
);
return envVars.map((pair) => {
const [key, value] = parseEnvironmentKeyValuePair(pair);
const escapedValue = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\$/g, "\\$");
return `${key}="${escapedValue}"`;
});
};
export const parseEnvironmentKeyValuePair = (
pair: string,
): [string, string] => {
@@ -1,8 +1,8 @@
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected
// because Let's Encrypt refuses to issue certificates for them.
export const VALID_HOSTNAME_REGEX =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
export const INVALID_HOSTNAME_MESSAGE =
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
+68 -2
View File
@@ -9,6 +9,68 @@ import { Octokit } from "octokit";
import { quote } from "shell-quote";
import type { z } from "zod";
export const DEFAULT_GITHUB_URL = "https://github.com";
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
export const parseGithubBaseUrl = (
githubUrl?: string | null,
): { url: string } | { error: string } => {
const raw = githubUrl?.trim();
if (!raw) {
return { url: DEFAULT_GITHUB_URL };
}
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
? raw
: `https://${raw}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
return { error: `"${raw}" is not a valid URL` };
}
if (parsed.protocol !== "https:") {
return { error: "Only https is supported for GitHub instances" };
}
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
// would slip past the checks below.
if (parsed.hostname.endsWith(".")) {
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
}
const { hostname } = parsed;
if (!hostname.includes(".")) {
return { error: `"${hostname}" is not a fully qualified hostname` };
}
return { url: `${parsed.protocol}//${parsed.host}` };
};
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
const result = parseGithubBaseUrl(githubUrl);
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
};
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
const normalized = normalizeGithubUrl(githubUrl);
const { protocol, host } = new URL(normalized);
if (host === "github.com" || host === "www.github.com") {
return DEFAULT_GITHUB_API_URL;
}
// Data residency tenants keep the api. prefix; GHES does not have one.
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
return `${protocol}//api.${host}`;
}
return `${protocol}//${host}/api/v3`;
};
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {
throw new TRPCError({
@@ -24,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
privateKey: githubProvider?.githubPrivateKey || "",
installationId: githubProvider?.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
});
return octokit;
@@ -162,10 +225,11 @@ export const cloneGithubRepository = async ({
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
@@ -187,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const repositories = (await octokit.paginate(
@@ -213,6 +278,7 @@ export const getGithubBranches = async (
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
+2 -1
View File
@@ -118,7 +118,8 @@ export const runCommand = async (scheduleId: string) => {
"This feature is not available in the cloud version.",
);
writeStream.end();
return { ...deployment, status: "running" as const };
await updateDeploymentStatus(deployment.deploymentId, "error");
return { ...deployment, status: "error" as const };
}
writeStream.write(
`docker exec ${containerId} ${shellType} -c ${command}\n`,