Skip to content

Commit 1db7e4e

Browse files
committed
feat: add Gitea integration (PR hunvreus#62)
Treat the Git provider as a first-class concept on Project and Deployment with repo_provider and repo_base_url fields. Users can connect Gitea instances via PAT in settings, create projects from Gitea repos, and trigger deployments via Gitea push webhooks. GitHub is now optional -- login and project creation only show configured providers. Migration a1b2c3d4e5f6 adds repo_provider enum, GiteaConnection model, and backfills existing rows to github. Authored By: TDvorak <info@tdvorak.dev>
1 parent 9d438de commit 1db7e4e

35 files changed

Lines changed: 1500 additions & 310 deletions

.env.dev.example

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ DEPLOY_DOMAIN=localhost
1212
SERVER_IP=127.0.0.1
1313
LE_EMAIL=dev@example.com
1414

15+
# Git provider (at least one of GitHub or Gitea must be configured)
1516
# GitHub App (see https://devpu.sh/gh-app)
16-
GITHUB_APP_ID=
17-
GITHUB_APP_NAME=
18-
GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
19-
GITHUB_APP_WEBHOOK_SECRET=
20-
GITHUB_APP_CLIENT_ID=
21-
GITHUB_APP_CLIENT_SECRET=
17+
# GITHUB_APP_ID=
18+
# GITHUB_APP_NAME=
19+
# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
20+
# GITHUB_APP_WEBHOOK_SECRET=
21+
# GITHUB_APP_CLIENT_ID=
22+
# GITHUB_APP_CLIENT_SECRET=
23+
24+
# Gitea
25+
# GITEA_WEBHOOK_SECRET=
2226

2327
# Email (only needed for email login/invites)
2428
EMAIL_SENDER_ADDRESS=

.env.example

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ LE_EMAIL=admin@example.com
1818
CERT_CHALLENGE_PROVIDER=default # default|cloudflare|route53|gcloud|digitalocean|azure
1919
# CF_DNS_API_TOKEN=
2020

21+
# Git provider (at least one of GitHub or Gitea must be configured)
2122
# GitHub App (see https://devpu.sh/gh-app)
22-
GITHUB_APP_ID=
23-
GITHUB_APP_NAME=
24-
GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
25-
GITHUB_APP_WEBHOOK_SECRET=
26-
GITHUB_APP_CLIENT_ID=
27-
GITHUB_APP_CLIENT_SECRET=
23+
# GITHUB_APP_ID=
24+
# GITHUB_APP_NAME=
25+
# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
26+
# GITHUB_APP_WEBHOOK_SECRET=
27+
# GITHUB_APP_CLIENT_ID=
28+
# GITHUB_APP_CLIENT_SECRET=
29+
30+
# Gitea
31+
# GITEA_WEBHOOK_SECRET=
2832

2933
# Email
3034
EMAIL_SENDER_ADDRESS=

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ An open-source and self-hostable alternative to Vercel, Render, Netlify and the
1717

1818
## Key features
1919

20-
- **Git-based deployments**: Push to deploy from GitHub with zero-downtime rollouts and instant rollback.
20+
- **Git-based deployments**: Push to deploy from GitHub or Gitea with zero-downtime rollouts and instant rollback.
2121
- **Multi-language support**: Python, Node.js, PHP... basically anything that can run on Docker.
2222
- **Environment management**: Multiple environments with branch mapping and encrypted environment variables.
2323
- **Real-time monitoring**: Live and searchable build and runtime logs.
@@ -35,7 +35,7 @@ See [devpu.sh/docs](https://devpu.sh/docs) for installation, configuration, and
3535

3636
- **Server**: Ubuntu 20.04+ or Debian 11+ with SSH access and sudo privileges. A [Hetzner CPX31](https://devpu.sh/docs/guides/create-hetzner-server) works well.
3737
- **DNS**: We recommend [Cloudflare](https://cloudflare.com).
38-
- **GitHub account**: You'll create a GitHub App for login and repository access.
38+
- **GitHub account**: You'll create a GitHub App for login and repository access. Gitea instances can also be connected via Personal Access Tokens.
3939
- **Email provider**: A [Resend](https://resend.com) account or SMTP credentials for login emails and invitations.
4040

4141
## Quickstart
@@ -138,6 +138,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for codebase structure.
138138
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook secret. |
139139
| `GITHUB_APP_CLIENT_ID` | GitHub OAuth client ID. |
140140
| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth client secret. |
141+
| `GITEA_WEBHOOK_SECRET` | Shared secret for verifying Gitea webhook payloads (optional, required if using Gitea). |
141142
| `APP_HOSTNAME` | Domain for the app (e.g., `example.com`). |
142143
| `DEPLOY_DOMAIN` | Domain for deployments (wildcard root). No default - set explicitly (e.g., `deploy.example.com`). |
143144
| `LE_EMAIL` | Email for Let's Encrypt notifications. |

app/config.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
class Settings(BaseSettings):
1212
app_name: str = "/dev/push"
1313
app_description: str = (
14-
"An open-source platform to build and deploy any app from GitHub."
14+
"An open-source platform to build and deploy any app from a Git repository."
1515
)
1616
url_scheme: str = "https"
1717
app_hostname: str = ""
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
2222
github_app_webhook_secret: str = ""
2323
github_app_client_id: str = ""
2424
github_app_client_secret: str = ""
25+
gitea_webhook_secret: str = ""
2526
google_client_id: str = ""
2627
google_client_secret: str = ""
2728
resend_api_key: str = ""
@@ -81,6 +82,21 @@ class Settings(BaseSettings):
8182

8283
model_config = SettingsConfigDict(extra="ignore")
8384

85+
@property
86+
def has_github(self) -> bool:
87+
return bool(
88+
self.github_app_id
89+
and self.github_app_name
90+
and self.github_app_private_key
91+
and self.github_app_webhook_secret
92+
and self.github_app_client_id
93+
and self.github_app_client_secret
94+
)
95+
96+
@property
97+
def has_gitea(self) -> bool:
98+
return bool(self.gitea_webhook_secret)
99+
84100
@property
85101
def allow_custom_cpu(self) -> bool:
86102
return self.default_cpus is not None and self.max_cpus is not None

app/dependencies.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ def get_github_installation_service() -> GitHubInstallationService:
4141

4242

4343
@lru_cache
44-
def get_github_oauth_client() -> OAuth:
44+
def get_github_oauth_client() -> OAuth | None:
4545
settings = get_settings()
46+
if not settings.github_app_client_id or not settings.github_app_client_secret:
47+
return None
48+
4649
oauth = OAuth()
4750
oauth.register(
4851
"github",
@@ -534,6 +537,8 @@ def time_ago_filter(value):
534537
templates.env.globals["app_description"] = settings.app_description
535538
templates.env.globals["get_flashed_messages"] = get_flashed_messages
536539
templates.env.globals["toaster_header"] = settings.toaster_header
540+
templates.env.globals["has_github"] = settings.has_github
541+
templates.env.globals["has_gitea"] = settings.has_gitea
537542
templates.env.filters["time_ago"] = time_ago_filter
538543
templates.env.globals["get_access"] = get_access
539544
templates.env.globals["is_superadmin"] = is_superadmin

app/forms/project.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ class ProjectGeneralForm(StarletteForm):
463463
avatar = FileField(_l("Avatar"))
464464
delete_avatar = BooleanField(_l("Delete avatar"), default=False)
465465
repo_id = IntegerField(_l("Repo ID"), validators=[DataRequired()])
466+
repo_full_name = HiddenField()
467+
repo_provider = HiddenField()
468+
repo_base_url = HiddenField()
469+
connection_id = HiddenField()
466470

467471
def validate_avatar(self, field):
468472
if field.data:

app/forms/user.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,20 @@ class UserOAuthAccessRevokeForm(StarletteForm):
101101
choices=["github", "google"],
102102
)
103103
submit = SubmitField(_l("Disconnect"))
104+
105+
106+
class GiteaConnectionCreateForm(StarletteForm):
107+
base_url = StringField(
108+
_l("Instance URL"),
109+
validators=[DataRequired(), Length(max=512)],
110+
)
111+
token = StringField(
112+
_l("Personal access token"),
113+
validators=[DataRequired(), Length(max=512)],
114+
)
115+
submit = SubmitField(_l("Connect"))
116+
117+
118+
class GiteaConnectionDeleteForm(StarletteForm):
119+
connection_id = HiddenField(validators=[DataRequired()])
120+
submit = SubmitField(_l("Remove"))

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from db import get_db, AsyncSessionLocal
1818
from dependencies import get_current_user, TemplateResponse
1919
from models import User, Team, Deployment, Project
20-
from routers import auth, project, github, google, team, user, event, admin
20+
from routers import auth, project, github, gitea, google, team, user, event, admin
2121
from services.loki import LokiService
2222

2323
settings = get_settings()
@@ -179,6 +179,7 @@ async def root(
179179
app.include_router(user.router)
180180
app.include_router(project.router)
181181
app.include_router(github.router)
182+
app.include_router(gitea.router)
182183
app.include_router(google.router)
183184
app.include_router(team.router)
184185
app.include_router(event.router)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Add Gitea provider support
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 6b0c7d2a9e1f
5+
Create Date: 2026-02-22 00:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "a1b2c3d4e5f6"
16+
down_revision: Union[str, Sequence[str], None] = "6b0c7d2a9e1f"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
repo_provider_enum = sa.Enum("github", "gitea", name="repo_provider")
21+
22+
23+
def upgrade() -> None:
24+
repo_provider_enum.create(op.get_bind(), checkfirst=True)
25+
26+
op.create_table(
27+
"gitea_connection",
28+
sa.Column("id", sa.Integer(), primary_key=True),
29+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, index=True),
30+
sa.Column("base_url", sa.String(512), nullable=False),
31+
sa.Column("username", sa.String(255), nullable=False),
32+
sa.Column("token", sa.String(2048), nullable=False),
33+
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
34+
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
35+
sa.UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"),
36+
)
37+
38+
# Project: add repo_provider, repo_base_url, gitea_connection_id; make github_installation_id nullable
39+
op.add_column("project", sa.Column("repo_provider", repo_provider_enum, nullable=True))
40+
op.add_column("project", sa.Column("repo_base_url", sa.String(512), nullable=True))
41+
op.add_column(
42+
"project",
43+
sa.Column("gitea_connection_id", sa.Integer(), sa.ForeignKey("gitea_connection.id"), nullable=True, index=True),
44+
)
45+
46+
op.execute("UPDATE project SET repo_provider = 'github', repo_base_url = 'https://github.com'")
47+
48+
op.alter_column("project", "repo_provider", nullable=False)
49+
op.alter_column("project", "repo_base_url", nullable=False)
50+
op.alter_column("project", "github_installation_id", nullable=True)
51+
52+
# Deployment: add repo_provider, repo_base_url
53+
op.add_column("deployment", sa.Column("repo_provider", repo_provider_enum, nullable=True))
54+
op.add_column("deployment", sa.Column("repo_base_url", sa.String(512), nullable=True))
55+
56+
op.execute("UPDATE deployment SET repo_provider = 'github', repo_base_url = 'https://github.com'")
57+
58+
op.alter_column("deployment", "repo_provider", nullable=False)
59+
op.alter_column("deployment", "repo_base_url", nullable=False)
60+
61+
62+
def downgrade() -> None:
63+
op.drop_column("deployment", "repo_base_url")
64+
op.drop_column("deployment", "repo_provider")
65+
66+
op.alter_column("project", "github_installation_id", nullable=False)
67+
op.drop_column("project", "gitea_connection_id")
68+
op.drop_column("project", "repo_base_url")
69+
op.drop_column("project", "repo_provider")
70+
71+
op.drop_table("gitea_connection")
72+
73+
repo_provider_enum.drop(op.get_bind(), checkfirst=True)

app/models.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,42 @@ class TeamInvite(Base):
273273
inviter: Mapped[User] = relationship()
274274

275275

276+
class GiteaConnection(Base):
277+
__tablename__: str = "gitea_connection"
278+
279+
id: Mapped[int] = mapped_column(primary_key=True)
280+
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
281+
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
282+
username: Mapped[str] = mapped_column(String(255), nullable=False)
283+
_token: Mapped[str] = mapped_column("token", String(2048), nullable=False)
284+
created_at: Mapped[datetime] = mapped_column(default=utc_now)
285+
updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now)
286+
287+
# Relationships
288+
user: Mapped[User] = relationship()
289+
projects: Mapped[list["Project"]] = relationship(
290+
back_populates="gitea_connection"
291+
)
292+
293+
__table_args__ = (
294+
UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"),
295+
)
296+
297+
@property
298+
def token(self) -> str:
299+
fernet = get_fernet()
300+
return fernet.decrypt(self._token.encode()).decode()
301+
302+
@token.setter
303+
def token(self, value: str):
304+
fernet = get_fernet()
305+
self._token = fernet.encrypt(value.encode()).decode()
306+
307+
@override
308+
def __repr__(self):
309+
return f"<GiteaConnection {self.base_url}>"
310+
311+
276312
class GithubInstallation(Base):
277313
__tablename__: str = "github_installation"
278314

@@ -318,17 +354,28 @@ class Project(Base):
318354
)
319355
name: Mapped[str] = mapped_column(String(100), index=True)
320356
has_avatar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
357+
repo_provider: Mapped[str] = mapped_column(
358+
SQLAEnum("github", "gitea", name="repo_provider"),
359+
nullable=False,
360+
default="github",
361+
)
321362
repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
322363
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
364+
repo_base_url: Mapped[str] = mapped_column(
365+
String(512), nullable=False, default="https://github.com"
366+
)
323367
repo_status: Mapped[str] = mapped_column(
324368
SQLAEnum(
325369
"active", "deleted", "removed", "transferred", name="project_github_status"
326370
),
327371
nullable=False,
328372
default="active",
329373
)
330-
github_installation_id: Mapped[int] = mapped_column(
331-
ForeignKey("github_installation.installation_id"), nullable=False, index=True
374+
github_installation_id: Mapped[int | None] = mapped_column(
375+
ForeignKey("github_installation.installation_id"), nullable=True, index=True
376+
)
377+
gitea_connection_id: Mapped[int | None] = mapped_column(
378+
ForeignKey("gitea_connection.id"), nullable=True, index=True
332379
)
333380
environments: Mapped[list[dict[str, str]]] = mapped_column(
334381
JSON, nullable=False, default=list
@@ -355,7 +402,10 @@ class Project(Base):
355402
team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), index=True)
356403

357404
# Relationships
358-
github_installation: Mapped[GithubInstallation] = relationship(
405+
github_installation: Mapped[GithubInstallation | None] = relationship(
406+
back_populates="projects"
407+
)
408+
gitea_connection: Mapped[GiteaConnection | None] = relationship(
359409
back_populates="projects"
360410
)
361411
deployments: Mapped[list["Deployment"]] = relationship(back_populates="project")
@@ -737,8 +787,16 @@ class Deployment(Base):
737787
String(32), primary_key=True, default=lambda: token_hex(16)
738788
)
739789
project_id: Mapped[str] = mapped_column(ForeignKey("project.id"), index=True)
790+
repo_provider: Mapped[str] = mapped_column(
791+
SQLAEnum("github", "gitea", name="repo_provider", create_type=False),
792+
nullable=False,
793+
default="github",
794+
)
740795
repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
741796
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
797+
repo_base_url: Mapped[str] = mapped_column(
798+
String(512), nullable=False, default="https://github.com"
799+
)
742800
environment_id: Mapped[str] = mapped_column(String(8), nullable=False)
743801
branch: Mapped[str] = mapped_column(String(255), index=True)
744802
commit_sha: Mapped[str] = mapped_column(String(40), index=True)
@@ -817,9 +875,10 @@ class Deployment(Base):
817875

818876
def __init__(self, *args, project: "Project", environment_id: str, **kwargs):
819877
super().__init__(project=project, environment_id=environment_id, **kwargs)
820-
# Snapshot repo, config, environments and env_vars from project at time of creation
878+
self.repo_provider = project.repo_provider
821879
self.repo_id = project.repo_id
822880
self.repo_full_name = project.repo_full_name
881+
self.repo_base_url = project.repo_base_url
823882
self.config = project.config
824883
environment = project.get_environment_by_id(environment_id)
825884
self.env_vars = project.get_env_vars(environment["slug"]) if environment else []

0 commit comments

Comments
 (0)