Merged in chore/release-pipeline (pull request #3352)

Add scripts for handling deployments

Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-12-16 11:42:51 +00:00
parent bf7a2ac2fe
commit f27ba7ccb6
15 changed files with 242 additions and 63 deletions

View File

@@ -47,6 +47,7 @@ For more details see the respective apps and packages' README files.
## More documentation ## More documentation
- [Deploy](./docs/deploy.md)
- [Icons](./docs/icons.md) - [Icons](./docs/icons.md)
- [Payment](./docs/payment.md) - [Payment](./docs/payment.md)
- [Translations (i18n)](./docs/translations.md) - [Translations (i18n)](./docs/translations.md)

View File

@@ -5,11 +5,13 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("development"), NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("development"),
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: z.coerce.number().default(0.001), NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: z.coerce.number().default(0.001),
NEXT_PUBLIC_RELEASE_TAG: z.string().optional(),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE:
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE, process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
}, },
}) })

View File

@@ -36,6 +36,7 @@ export const env = createEnv({
.refine((s) => s === "true" || s === "false") .refine((s) => s === "true" || s === "false")
.transform((s) => s === "true") .transform((s) => s === "true")
.default("false"), .default("false"),
RELEASE_TAG: z.string().optional(),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
@@ -52,5 +53,6 @@ export const env = createEnv({
SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE, SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
REDEMPTION_ENABLED: process.env.REDEMPTION_ENABLED, REDEMPTION_ENABLED: process.env.REDEMPTION_ENABLED,
RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
}, },
}) })

View File

@@ -12,6 +12,7 @@ Sentry.init({
denyUrls: denyUrls, denyUrls: denyUrls,
// Disable logs for clients, will probably give us too much noise // Disable logs for clients, will probably give us too much noise
enableLogs: false, enableLogs: false,
release: env.NEXT_PUBLIC_RELEASE_TAG || undefined,
beforeSendLog(log) { beforeSendLog(log) {
const ignoredLevels: (typeof log.level)[] = ["debug", "trace", "info"] const ignoredLevels: (typeof log.level)[] = ["debug", "trace", "info"]
if (ignoredLevels.includes(log.level)) { if (ignoredLevels.includes(log.level)) {

View File

@@ -21,5 +21,6 @@ async function configureSentry() {
tracesSampleRate: env.SENTRY_SERVER_SAMPLERATE, tracesSampleRate: env.SENTRY_SERVER_SAMPLERATE,
denyUrls: denyUrls, denyUrls: denyUrls,
enableLogs: true, enableLogs: true,
release: env.RELEASE_TAG || undefined,
}) })
} }

View File

@@ -1,14 +1,14 @@
[build] [build]
command = "yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas"
publish = "apps/partner-sas/.next" publish = "apps/partner-sas/.next"
ignore = "if [ -z ${CACHED_COMMIT_REF+x} ] ; then echo 'no CACHED_COMMIT_REF found' && false ; else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF apps/partner-sas packages/booking-flow packages/common packages/trpc packages/design-system packages/typescript-config ; fi" ignore = "if [ -z ${CACHED_COMMIT_REF+x} ] ; then echo 'no CACHED_COMMIT_REF found' && false ; else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF apps/partner-sas packages/booking-flow packages/common packages/trpc packages/design-system packages/typescript-config ; fi"
[context.branch-deploy] [context.branch-deploy]
command = "yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas"
[context.deploy-preview] [context.deploy-preview]
command = "yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/partner-sas && yarn build:sas"
[build.environment] [build.environment]
# set TERM variable for terminal output # set TERM variable for terminal output

View File

@@ -27,11 +27,17 @@ export function EnvironmentWatermark() {
} }
const { environment, name } = getEnvironmentName() const { environment, name } = getEnvironmentName()
const displayValue = name === environment ? name : `${name} (${environment})` const displayValues = [name]
if (name !== environment) {
displayValues.push(`(${environment})`)
}
if (env.NEXT_PUBLIC_RELEASE_TAG) {
displayValues.push(`[${env.NEXT_PUBLIC_RELEASE_TAG}]`)
}
return ( return (
<div className={variants({ environment: environment })}> <div className={variants({ environment: environment })}>
<span>{displayValue}</span> <span>{displayValues.join(" ")}</span>
</div> </div>
) )
} }

View File

@@ -8,6 +8,7 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("development"), NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("development"),
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: z.coerce.number().default(0.001), NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: z.coerce.number().default(0.001),
NEXT_PUBLIC_PUBLIC_URL: z.string().optional(), NEXT_PUBLIC_PUBLIC_URL: z.string().optional(),
NEXT_PUBLIC_RELEASE_TAG: z.string().optional(),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
@@ -17,5 +18,6 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE: NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE:
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE, process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL, NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
}, },
}) })

View File

@@ -112,6 +112,7 @@ export const env = createEnv({
.refine((s) => s === "true" || s === "false") .refine((s) => s === "true" || s === "false")
.transform((s) => s === "true") .transform((s) => s === "true")
.default("false"), .default("false"),
RELEASE_TAG: z.string().optional(),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
@@ -168,5 +169,6 @@ export const env = createEnv({
NEW_STAYS_ON_MY_PAGES: process.env.NEW_STAYS_ON_MY_PAGES, NEW_STAYS_ON_MY_PAGES: process.env.NEW_STAYS_ON_MY_PAGES,
SEO_INERT: process.env.SEO_INERT, SEO_INERT: process.env.SEO_INERT,
ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT, ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT,
RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
}, },
}) })

View File

@@ -12,6 +12,7 @@ Sentry.init({
denyUrls: denyUrls, denyUrls: denyUrls,
// Disable logs for clients, will probably give us too much noise // Disable logs for clients, will probably give us too much noise
enableLogs: false, enableLogs: false,
release: env.NEXT_PUBLIC_RELEASE_TAG || undefined,
beforeSendLog(log) { beforeSendLog(log) {
const ignoredLevels: (typeof log.level)[] = ["debug", "trace", "info"] const ignoredLevels: (typeof log.level)[] = ["debug", "trace", "info"]
if (ignoredLevels.includes(log.level)) { if (ignoredLevels.includes(log.level)) {

View File

@@ -22,5 +22,6 @@ async function configureSentry() {
denyUrls: denyUrls, denyUrls: denyUrls,
enableLogs: true, enableLogs: true,
enableMetrics: true, enableMetrics: true,
release: env.RELEASE_TAG || undefined,
}) })
} }

View File

@@ -1,14 +1,14 @@
[build] [build]
command = "yarn test --filter=@scandic-hotels/scandic-web && yarn build:web" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/scandic-web && yarn build:web"
publish = "apps/scandic-web/.next" publish = "apps/scandic-web/.next"
ignore = "if [ -z ${CACHED_COMMIT_REF+x} ] ; then echo 'no CACHED_COMMIT_REF found' && false ; else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF apps/scandic-web packages/booking-flow packages/common packages/trpc packages/design-system packages/typescript-config ; fi" ignore = "if [ -z ${CACHED_COMMIT_REF+x} ] ; then echo 'no CACHED_COMMIT_REF found' && false ; else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF apps/scandic-web packages/booking-flow packages/common packages/trpc packages/design-system packages/typescript-config ; fi"
[context.branch-deploy] [context.branch-deploy]
command = "yarn test --filter=@scandic-hotels/scandic-web && yarn build:web" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/scandic-web && yarn build:web"
[context.deploy-preview] [context.deploy-preview]
command = "yarn test --filter=@scandic-hotels/scandic-web && yarn build:web" command = "export NEXT_PUBLIC_RELEASE_TAG=$(git tag -l 'release-v*' | sort -V | tail -n 1) && yarn test --filter=@scandic-hotels/scandic-web && yarn build:web"
[[plugins]] [[plugins]]
package = "@netlify/plugin-nextjs" package = "@netlify/plugin-nextjs"

29
docs/deploy.md Normal file
View File

@@ -0,0 +1,29 @@
## Deployment Script
This script automates the deployment process for the application by force-pushing the current branch to specific environment branches.
Deploying to production must be done via a release-branch
### Usage
Be situated on the branch you want to deploy and then run the script using `yarn deploy:<environment>`
```bash
yarn deploy:stage
```
or if you want to explicitly call it by using `yarn dlx tsx scripts/deploy/deploy.ts` (or `bun`/`npx tsx`)
### Environments
| Environment | Target Branch | Description |
| :---------- | :------------ | :----------------------------------------- |
| `test` | `test` | Deploys to the test environment. |
| `stage` | `stage` | Deploys to the staging environment. |
| `preprod` | `prod` | Deploys to the pre-production environment. |
| `prod` | `release` | Deploys to the production environment. |
### Features
- **Branch Validation**: Checks if the target environment is valid.
- **Tagging**: If deploying from a release branch (e.g., `release-v1.2.3`), it automatically creates and pushes a git tag (e.g., `v1.2.3`) if it doesn't exist.
- **Safety Check**: Prompts for confirmation before deploying.

View File

@@ -1,57 +1,60 @@
{ {
"name": "scandic", "name": "scandic",
"packageManager": "yarn@4.6.0", "packageManager": "yarn@4.6.0",
"scripts": { "scripts": {
"build": "turbo run build --env-mode=loose", "build": "turbo run build --env-mode=loose",
"build:web": "turbo run build --filter=@scandic-hotels/scandic-web --env-mode=loose", "build:web": "turbo run build --filter=@scandic-hotels/scandic-web --env-mode=loose",
"build:sas": "turbo run build --filter=@scandic-hotels/partner-sas --env-mode=loose", "build:sas": "turbo run build --filter=@scandic-hotels/partner-sas --env-mode=loose",
"lint": "turbo run lint", "lint": "turbo run lint",
"dev": "turbo run dev --output-logs new-only", "dev": "turbo run dev --output-logs new-only",
"dev:web": "turbo run dev --filter=@scandic-hotels/scandic-web --output-logs new-only", "dev:web": "turbo run dev --filter=@scandic-hotels/scandic-web --output-logs new-only",
"dev:ds": "turbo run dev --filter=@scandic-hotels/design-system --output-logs new-only", "dev:ds": "turbo run dev --filter=@scandic-hotels/design-system --output-logs new-only",
"dev:sas": "turbo run dev --filter=@scandic-hotels/partner-sas --output-logs new-only", "dev:sas": "turbo run dev --filter=@scandic-hotels/partner-sas --output-logs new-only",
"test": "turbo run test", "test": "turbo run test",
"format": "turbo run format", "format": "turbo run format",
"postinstall": "husky", "postinstall": "husky",
"icons:update": "jiti scripts/material-symbols-update.mts", "icons:update": "jiti scripts/material-symbols-update.mts",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"env:web": "node scripts/show-env.mjs scandic-web --missing", "env:web": "node scripts/show-env.mjs scandic-web --missing",
"env:sas": "node scripts/show-env.mjs partner-sas --missing", "env:sas": "node scripts/show-env.mjs partner-sas --missing",
"i18n:extract": "formatjs extract \"{apps/scandic-web,apps/partner-sas,packages/booking-flow,packages/design-system}/{actions,app,components,constants,contexts,env,hooks,i18n,lib,middlewares,netlify,providers,server,services,stores,utils}/**/*.{ts,tsx}\" --format scripts/i18n/formatter.mjs --out-file scripts/i18n/extracted.json", "i18n:extract": "formatjs extract \"{apps/scandic-web,apps/partner-sas,packages/booking-flow,packages/design-system}/{actions,app,components,constants,contexts,env,hooks,i18n,lib,middlewares,netlify,providers,server,services,stores,utils}/**/*.{ts,tsx}\" --format scripts/i18n/formatter.mjs --out-file scripts/i18n/extracted.json",
"i18n:upload": "jiti scripts/i18n/upload.ts", "i18n:upload": "jiti scripts/i18n/upload.ts",
"i18n:download": "jiti scripts/i18n/download.ts", "i18n:download": "jiti scripts/i18n/download.ts",
"i18n:compile": "formatjs compile-folder --ast --format scripts/i18n/formatter.mjs scripts/i18n/translations-all scripts/i18n/dictionaries", "i18n:compile": "formatjs compile-folder --ast --format scripts/i18n/formatter.mjs scripts/i18n/translations-all scripts/i18n/dictionaries",
"i18n:diff": "yarn i18n:extract && yarn i18n:pull && node scripts/i18n/diff.mjs", "i18n:diff": "yarn i18n:extract && yarn i18n:pull && node scripts/i18n/diff.mjs",
"i18n:clean": "jiti scripts/i18n/clean.ts", "i18n:clean": "jiti scripts/i18n/clean.ts",
"i18n:distribute": "jiti scripts/i18n/distribute.ts scandic-web partner-sas", "i18n:distribute": "jiti scripts/i18n/distribute.ts scandic-web partner-sas",
"i18n:push": "yarn i18n:extract && yarn i18n:upload", "i18n:push": "yarn i18n:extract && yarn i18n:upload",
"i18n:pull": "yarn i18n:download && yarn i18n:compile && yarn i18n:distribute", "i18n:pull": "yarn i18n:download && yarn i18n:compile && yarn i18n:distribute",
"i18n:sync": "yarn i18n:push && yarn i18n:pull", "i18n:sync": "yarn i18n:push && yarn i18n:pull",
"i18n:syncDefaultMessage": "yarn i18n:download && bun scripts/i18n/syncDefaultMessage/index.ts scripts/i18n/translations/en.json '{apps,packages}/**/*.{tsx,ts}' && yarn format --force" "i18n:syncDefaultMessage": "yarn i18n:download && bun scripts/i18n/syncDefaultMessage/index.ts scripts/i18n/translations/en.json '{apps,packages}/**/*.{tsx,ts}' && yarn format --force",
}, "deploy:stage": "yarn dlx tsx scripts/deploy/deploy.ts stage",
"workspaces": [ "deploy:preprod": "yarn dlx tsx scripts/deploy/deploy.ts preprod",
"apps/*", "deploy:prod": "yarn dlx tsx scripts/deploy/deploy.ts prod"
"packages/*" },
], "workspaces": [
"devDependencies": { "apps/*",
"@eslint/compat": "^1.2.9", "packages/*"
"@formatjs/cli": "^6.7.1", ],
"@types/react": "19.2.7", "devDependencies": {
"@types/react-dom": "19.2.3", "@eslint/compat": "^1.2.9",
"@typescript/native-preview": "^7.0.0-dev.20251104.1", "@formatjs/cli": "^6.7.1",
"@yarnpkg/types": "^4.0.1", "@types/react": "19.2.7",
"commander": "^14.0.0", "@types/react-dom": "19.2.3",
"husky": "^9.1.7", "@typescript/native-preview": "^7.0.0-dev.20251104.1",
"jiti": "^1.21.0", "@yarnpkg/types": "^4.0.1",
"lint-staged": "^15.2.2", "commander": "^14.0.0",
"prettier": "^3.6.2", "husky": "^9.1.7",
"turbo": "^2.6.1" "jiti": "^1.21.0",
}, "lint-staged": "^15.2.2",
"resolutions": { "prettier": "^3.6.2",
"react": "~19.2.0", "turbo": "^2.6.1"
"react-dom": "~19.2.0", },
"vite": "^7.2.4", "resolutions": {
"import-in-the-middle": "^1.14.2", "react": "~19.2.0",
"@react-aria/overlays": "3.27.3" "react-dom": "~19.2.0",
} "vite": "^7.2.4",
"import-in-the-middle": "^1.14.2",
"@react-aria/overlays": "3.27.3"
}
} }

128
scripts/deploy/deploy.ts Normal file
View File

@@ -0,0 +1,128 @@
import { execSync } from "node:child_process";
import { createInterface } from "node:readline";
// Configuration
const ENV_BRANCH_MAP: Record<string, string> = {
test: "test",
stage: "stage",
preprod: "prod",
prod: "release",
};
const args = process.argv.slice(2);
const targetEnv = args[0];
// Validate input
if (!targetEnv) {
console.error("❌ Error: Please provide an environment.");
console.log(
`Usage: yarn dlx tsx deploy.ts <${Object.keys(ENV_BRANCH_MAP).join("|")}>`
);
process.exit(1);
}
const targetBranch = ENV_BRANCH_MAP[targetEnv];
if (!targetBranch) {
console.error(`❌ Error: Invalid environment '${targetEnv}'.`);
console.log(
`Available environments: ${Object.keys(ENV_BRANCH_MAP).join(", ")}`
);
process.exit(1);
}
// Git helper
const runGit = (
command: string,
options: { stdio?: "inherit" | "ignore" | "pipe" } = {}
) => {
return execSync(command, { encoding: "utf-8", ...options }).trim();
};
async function main() {
try {
// Get current branch
const currentBranch = runGit("git rev-parse --abbrev-ref HEAD");
console.log(
`\n🚀 Preparing to deploy branch '${currentBranch}' to '${targetEnv}' (target branch: '${targetBranch}')`
);
// Check for tagging requirement
let tagToCreate: string | null = null;
const releaseBranchRegex = /^release-(v\d+\.\d+\.\d+)$/;
const releaseBranchMatch = currentBranch.match(releaseBranchRegex);
if (releaseBranchMatch) {
const tagName = releaseBranchMatch[1];
try {
// Check if tag already exists locally or remote
// Checking local first
runGit(`git rev-parse ${tagName}`, { stdio: "ignore" });
console.log(` Tag '${tagName}' already exists.`);
} catch {
// Tag doesn't exist
tagToCreate = tagName;
console.log(`✨ Will create and push tag: '${tagName}'`);
}
}
if (!releaseBranchMatch && targetEnv === "prod") {
console.warn(
"⚠️ Warning: Deploying to prod from a non-release branch. No version tag will be created."
);
}
// Confirmation prompt
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const confirmed = await new Promise<boolean>((resolve) => {
rl.question(
`\n❓ Are you sure you want to deploy ${currentBranch} to ${targetEnv}? (y/n) `,
(answer) => {
rl.close();
resolve(answer.toLowerCase() === "y");
}
);
});
if (!confirmed) {
console.log("🚫 Deployment aborted.");
process.exit(0);
}
console.log("\n🔄 Starting deployment...");
// 1. Create and push tag if needed
if (tagToCreate) {
console.log(`🏷️ Creating tag ${tagToCreate}...`);
runGit(`git tag ${tagToCreate}`);
console.log(`⬆️ Pushing tag ${tagToCreate}...`);
runGit(`git push origin ${tagToCreate}`);
}
// 2. Force push to target branch
console.log(
`🔥 Force pushing '${currentBranch}' to 'origin/${targetBranch}'...`
);
runGit(
`git push origin ${currentBranch}:${targetBranch} --force-with-lease`,
{
stdio: "inherit",
}
);
console.log(`\n✅ Deployment to ${targetEnv} completed successfully!`);
} catch (error) {
console.error("\n❌ Deployment failed.");
if (error instanceof Error) {
console.error(error.message);
}
process.exit(1);
}
}
main();