Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .teamcity/common/utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package common

/**
* Sanitizes a string to be used as a TeamCity ID.
*
* @param id The string to sanitize
*/
fun sanitizeId(id: String): String = id.replace("[^a-zA-Z0-9_]".toRegex(), "_")
28 changes: 28 additions & 0 deletions .teamcity/landings/LandingConfiguration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package landings

/**
* Configuration for a landing page instance.
* Each landing page is a separate client app.
*
* @param name The landing page name, used as base path
* @param repositoryUrl The GitHub repository URL
* @param branch The branch to build from
* @param autoDeployToProduction Whether to automatically deploy to production after staging
*/
data class LandingConfiguration(
val name: String,
val repositoryUrl: String,
val branch: String = "main",
val autoDeployToProduction: Boolean = false
)

/**
* List of all landing pages to be built.
* Add new landing pages here.
*/
val landingConfigurations = listOf(
LandingConfiguration(
name = "kotlin-spring-ai-tutorial",
repositoryUrl = "git@github.com:jetbrains-lovable/kotlin-ai-tutorial.git"
)
)
20 changes: 20 additions & 0 deletions .teamcity/landings/LandingPagesProject.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package landings

import jetbrains.buildServer.configs.kotlin.Project
import landings.builds.BuildLandingPage
import landings.builds.DeployLandingToStaging
import landings.builds.DeployLandingToProduction

object LandingPagesProject : Project({
name = "Landing Pages"
description = "Builds landing pages from repositories"

landingConfigurations.forEach { config ->
vcsRoot(createVcsRootForLanding(config))

buildType(BuildLandingPage(config))
buildType(DeployLandingToStaging(config))
buildType(DeployLandingToProduction(config))
}
})

76 changes: 76 additions & 0 deletions .teamcity/landings/builds/BuildLandingPage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package landings.builds

import BuildParams.KLANG_NODE_CONTAINER
import jetbrains.buildServer.configs.kotlin.BuildType
import jetbrains.buildServer.configs.kotlin.buildSteps.script
import jetbrains.buildServer.configs.kotlin.triggers.vcs
import landings.LandingConfiguration
import common.sanitizeId
import landings.createVcsRootForLanding
import vcsRoots.KotlinLangOrg

/**
* Build type for building a Vite landing page.
* This build:
* 1. Checks out the landing page repository
* 2. Patches the Vite config to set the correct base path
* 3. Installs npm dependencies
* 4. Builds the static page
* 5. Publishes the dist folder as an artifact
*/
class BuildLandingPage(private val config: LandingConfiguration) : BuildType({
id("build_landing_${sanitizeId(config.name)}")
name = "Build ${config.name} langing page"

params {
param("LANDING_NAME", config.name)
param("AUTO_DEPLOY_TO_PRODUCTION", config.autoDeployToProduction.toString())
}

vcs {
root(createVcsRootForLanding(config))
root(vcsRoots.KotlinLangOrg, "+:scripts => kotlin-web-site-scripts")
cleanCheckout = true
}

triggers {
vcs {
branchFilter = "+:${config.branch}"
}
}

artifactRules = """
dist/** => ${config.name}.zip
""".trimIndent()

requirements {
contains("docker.server.osType", "linux")
}

steps {
script {
name = "Patch Vite config and build"
scriptContent = """
#!/bin/sh
set -e -x -u

# Patch Vite config
node kotlin-web-site-scripts/patch-vite-base.mjs ${config.name}

# Install dependencies
npm i

# Build
npm run build

# Verify dist folder exists
if [ ! -d "dist" ]; then
echo "Error: dist folder not found after build"
exit 1
fi
""".trimIndent()
dockerImage = KLANG_NODE_CONTAINER
dockerPull = true
}
}
})
69 changes: 69 additions & 0 deletions .teamcity/landings/builds/DeployLandingToProduction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package landings.builds

import common.sanitizeId
import jetbrains.buildServer.configs.kotlin.BuildType
import jetbrains.buildServer.configs.kotlin.FailureAction
import jetbrains.buildServer.configs.kotlin.buildSteps.script
import jetbrains.buildServer.configs.kotlin.triggers.finishBuildTrigger
import landings.LandingConfiguration

/**
* Deployment build type for deploying a landing page to production environment.
* Automatically triggered after staging deployment only if autoDeployToProduction = true.
* Can be manually triggered for any landing page.
*/
class DeployLandingToProduction(private val config: LandingConfiguration) : BuildType({
id("deploy_landing_production_${sanitizeId(config.name)}")
name = "Deploy ${config.name} to Production"

type = Type.DEPLOYMENT

params {
param("LANDING_NAME", config.name)
}

requirements {
contains("docker.server.osType", "linux")
}

if (config.autoDeployToProduction) {
triggers {
finishBuildTrigger {
buildType = "deploy_landing_staging_${sanitizeId(config.name)}"
successfulOnly = true
}
}
}

dependencies {
dependency(BuildLandingPage(config)) {
snapshot {
onDependencyFailure = FailureAction.FAIL_TO_START
onDependencyCancel = FailureAction.CANCEL
}
artifacts {
cleanDestination = true
artifactRules = """
${config.name}.zip!** => content/
""".trimIndent()
}
}
}

steps {
script {
name = "Deploy to S3 Production"
scriptContent = """
rclone config create s3 s3 provider=AWS env_auth=true region=us-east-1 && \
rclone sync -P content// s3:kotlinlang-prod-landings.jetbrains.com/lp/%LANDING_NAME%/ \
--delete-after \
--s3-acl private \
--check-first \
--fast-list \
--checksum
""".trimIndent()
dockerImage = "rclone/rclone:latest"
dockerPull = true
}
}
})
66 changes: 66 additions & 0 deletions .teamcity/landings/builds/DeployLandingToStaging.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package landings.builds

import common.sanitizeId
import jetbrains.buildServer.configs.kotlin.BuildType
import jetbrains.buildServer.configs.kotlin.FailureAction
import jetbrains.buildServer.configs.kotlin.buildSteps.script
import jetbrains.buildServer.configs.kotlin.triggers.finishBuildTrigger
import landings.LandingConfiguration

/**
* Deployment build type for deploying a landing page to staging environment.
* Automatically triggered when the landing page build succeeds.
*/
class DeployLandingToStaging(private val config: LandingConfiguration) : BuildType({
id("deploy_landing_staging_${sanitizeId(config.name)}")
name = "Deploy ${config.name} to Staging"

type = Type.DEPLOYMENT

params {
param("LANDING_NAME", config.name)
}

requirements {
contains("docker.server.osType", "linux")
}

triggers {
finishBuildTrigger {
buildType = "build_landing_${sanitizeId(config.name)}"
successfulOnly = true
}
}

dependencies {
dependency(BuildLandingPage(config)) {
snapshot {
onDependencyFailure = FailureAction.FAIL_TO_START
onDependencyCancel = FailureAction.CANCEL
}
artifacts {
cleanDestination = true
artifactRules = """
${config.name}.zip!** => content/
""".trimIndent()
}
}
}

steps {
script {
name = "Deploy to S3 Staging"
scriptContent = """
rclone config create s3 s3 provider=AWS env_auth=true region=us-east-1 && \
rclone sync -P content// s3:kotlinlang-staging-landings.jetbrains.com/lp/%LANDING_NAME%/ \
--delete-after \
--s3-acl private \
--check-first \
--fast-list \
--checksum
""".trimIndent()
dockerImage = "rclone/rclone:latest"
dockerPull = true
}
}
})
16 changes: 16 additions & 0 deletions .teamcity/landings/createVcsRootForLanding.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package landings

import common.sanitizeId
import jetbrains.buildServer.configs.kotlin.vcs.GitVcsRoot

fun createVcsRootForLanding(config: LandingConfiguration): GitVcsRoot {
return GitVcsRoot {
id("landing_vcs_${sanitizeId(config.name)}")
name = "Landing: ${config.name}"
url = config.repositoryUrl
authMethod = uploadedKey {
uploadedKey = "default teamcity key"
}
branch = "refs/heads/${config.branch}"
}
}
1 change: 1 addition & 0 deletions .teamcity/settings.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ project {
references.BuildApiReferencesProject,
tests.TestsProject,
documentation.DocumentationProject,
landings.LandingPagesProject,
).also {
it.forEach { subProject(it) }
}
Expand Down
83 changes: 83 additions & 0 deletions scripts/patch-vite-base.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env node

/**
* Patches Vite config to add the correct base path for deployment.
*/

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';

const landingName = process.argv[2];

if (!landingName) {
console.error('Error: Landing name is required');
process.exit(1);
}

const viteConfigPath = resolve('vite.config.ts');

if (!existsSync(viteConfigPath)) {
console.error('Error: vite.config.ts not found');
process.exit(1);
}

console.log(`Found Vite config: ${viteConfigPath}`);

let content = readFileSync(viteConfigPath, 'utf-8');

const basePath = `/lp/${landingName}/`;

if (content.includes('base:')) {
console.log('Base path already exists, replacing...');
content = content.replace(
/base\s*:\s*['"`][^'"`]*['"`]/g,
`base: '${basePath}'`
);
} else {
console.log('Adding base path to config...');
// Handle defineConfig({ ... }) pattern
if (content.match(/defineConfig\s*\(\s*\{/)) {
content = content.replace(
/defineConfig\s*\(\s*\{/,
`defineConfig({\n base: '${basePath}',`
);
}
// Handle defineConfig(() => ({ ... })) pattern
else if (content.match(/defineConfig\s*\([^)]*\)\s*=>\s*\(\s*\{/)) {
content = content.replace(
/defineConfig\s*\(([^)]*)\)\s*=>\s*\(\s*\{/,
`defineConfig($1) => ({\n base: '${basePath}',`
);
}
else {
console.error('Error: Could not find defineConfig pattern to patch');
process.exit(1);
}
}

writeFileSync(viteConfigPath, content, 'utf-8');

console.log(`Successfully patched ${viteConfigPath} with base: '${basePath}'`);

// Patch BrowserRouter basename
const appFile = 'src/App.tsx';

if (existsSync(appFile)) {
console.log(`Checking ${appFile} for BrowserRouter...`);
let sourceContent = readFileSync(appFile, 'utf-8');

if (sourceContent.includes('BrowserRouter')) {
console.log(`Found BrowserRouter in ${appFile}, patching...`);
sourceContent = sourceContent.replace(
/<BrowserRouter\s*>/g,
`<BrowserRouter basename="${basePath}">`
);
writeFileSync(appFile, sourceContent, 'utf-8');
console.log(`Successfully patched BrowserRouter in ${appFile} with basename: '${basePath}'`);
} else {
console.log('Note: No BrowserRouter found in App.tsx');
}
} else {
console.log('Note: src/App.tsx not found');
}